diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc813232..691d6716e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,12 +46,12 @@ jobs: - name: 📦 Build id: build - run: cargo build + run: RUSTFLAGS="-A dead_code" cargo build - name: ⚡️ Check id: check - run: cargo check + run: RUSTFLAGS="-A dead_code" cargo check - name: 🦺 Test id: test - run: cargo test + run: RUSTFLAGS="-A dead_code" cargo test diff --git a/Cargo.lock b/Cargo.lock index 679599471..4b69accab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,9 +40,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" @@ -95,9 +95,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "async-channel" @@ -130,7 +130,7 @@ checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", - "fastrand 2.2.0", + "fastrand 2.3.0", "futures-lite 2.5.0", "slab", ] @@ -183,7 +183,7 @@ dependencies = [ "futures-lite 2.5.0", "parking", "polling 3.7.4", - "rustix 0.38.41", + "rustix 0.38.42", "slab", "tracing", "windows-sys 0.59.0", @@ -249,7 +249,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -275,7 +275,7 @@ checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -328,12 +328,208 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.89", + "syn 2.0.90", "which", ] +[[package]] +name = "biome_console" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c672a9e31e47f8df74549a570ea3245a93ce3404115c724bb16762fcbbfe17e1" +dependencies = [ + "biome_markup", + "biome_text_size", + "schemars", + "serde", + "termcolor", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "biome_deserialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f619dc8ca0595ed8850d729ebc71722d4233aba68c5aec7d9993a53e59f3fe" +dependencies = [ + "biome_console", + "biome_deserialize_macros", + "biome_diagnostics", + "biome_json_parser", + "biome_json_syntax", + "biome_rowan", + "bitflags 2.6.0", + "indexmap 2.7.0", + "serde", +] + +[[package]] +name = "biome_deserialize_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c12826fff87ac09f63bbacf8bdf5225dfdf890da04d426f758cbcacf068e3e" +dependencies = [ + "biome_string_case", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "biome_diagnostics" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1317b6d610541c4e6a0e1f803a946f153ace3468bbc77a8f273dcb04ee526f" +dependencies = [ + "backtrace", + "biome_console", + "biome_diagnostics_categories", + "biome_diagnostics_macros", + "biome_rowan", + "biome_text_edit", + "biome_text_size", + "bitflags 2.6.0", + "bpaf", + "oxc_resolver", + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "biome_diagnostics_categories" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832080d68a2ee2f198d98ff5d26fc0f5c2566907f773d105a4a049ee07664d19" +dependencies = [ + "quote", + "serde", +] + +[[package]] +name = "biome_diagnostics_macros" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "540fec04d2e789fb992128c63d111b650733274afffff1cb3f26c8dff5167d3b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "biome_json_factory" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e409eb289040f3660689dad178b00b6ac8cfa9a7fffd8225f35cb6b3d36437cf" +dependencies = [ + "biome_json_syntax", + "biome_rowan", +] + +[[package]] +name = "biome_json_parser" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6d23fb9b683e6356c094b4a0cb38f8aa0acee60ce9c3ef24628d21a204de4d" +dependencies = [ + "biome_console", + "biome_diagnostics", + "biome_json_factory", + "biome_json_syntax", + "biome_parser", + "biome_rowan", + "biome_unicode_table", + "tracing", + "unicode-bom", +] + +[[package]] +name = "biome_json_syntax" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2645ca57f75680d3d390b2482c35db5850b1d849e1f96151a12f15f4abdb097" +dependencies = [ + "biome_rowan", + "serde", +] + +[[package]] +name = "biome_markup" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7f11cf91599594528e97d216044ef4e410a103327212d909f215cbafe2fd9c" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", +] + +[[package]] +name = "biome_parser" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955dd999f32c086371d5c0e64b4ea1a50f50c98f1f31a3b9fe17ef47198de19b" +dependencies = [ + "biome_console", + "biome_diagnostics", + "biome_rowan", + "bitflags 2.6.0", + "drop_bomb", +] + +[[package]] +name = "biome_rowan" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c2dc25a7ba6ae89526340034abed6c89fac35b79060786771e32ed4aac77e7" +dependencies = [ + "biome_text_edit", + "biome_text_size", + "countme", + "hashbrown 0.12.3", + "memoffset", + "rustc-hash 1.1.0", + "tracing", +] + +[[package]] +name = "biome_string_case" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28b4d0e08c2f13f1c9e0df4e7a8f9bfa03ef3803713d1bcd5110578cc5c67be" + +[[package]] +name = "biome_text_edit" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d486fdd96d5dad6428213ce64e6b9eb5bfb2fce6387fe901e844d386283de509" +dependencies = [ + "biome_text_size", + "serde", + "similar", +] + +[[package]] +name = "biome_text_size" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec604d15cefdced636255400359aeacfdea5d1e79445efc7aa32a0de7f0319b" +dependencies = [ + "serde", +] + +[[package]] +name = "biome_unicode_table" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e8604d34b02180a58af1dbdaac166f1805f27f5370934142a3246f83870952" + [[package]] name = "bitflags" version = "1.3.2" @@ -371,6 +567,39 @@ dependencies = [ "piper", ] +[[package]] +name = "bpaf" +version = "0.9.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50fd5174866dc2fa2ddc96e8fb800852d37f064f32a45c7b7c2f8fa2c64c77fa" +dependencies = [ + "bpaf_derive", + "owo-colors", + "supports-color", +] + +[[package]] +name = "bpaf_derive" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf95d9c7e6aba67f8fc07761091e93254677f4db9e27197adecebc7039a58722" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "bstr" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" +dependencies = [ + "memchr", + "regex-automata 0.4.9", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -385,15 +614,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "27f657647bcff5394bf56c7317665bbf790a137a50eaaa5c6bfbb9e27a518f2d" dependencies = [ "shlex", ] @@ -413,6 +642,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "num-traits", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -455,7 +693,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -515,6 +753,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + [[package]] name = "cpufeatures" version = "0.2.16" @@ -548,6 +792,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -557,6 +814,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -590,7 +866,7 @@ checksum = "8d609e3b8b73dbace666e8a06351fd9062e1ec025e74b27952a932ccb8ec3a25" dependencies = [ "cstree_derive", "fxhash", - "indexmap", + "indexmap 2.7.0", "parking_lot", "sptr", "text-size", @@ -605,7 +881,7 @@ checksum = "84d8f6eaf2917e8bf0173045fe7824c0809e21ef09dc721108da4ee67ce7494b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -621,6 +897,20 @@ dependencies = [ "parking_lot_core", ] +[[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 = "der" version = "0.7.9" @@ -682,7 +972,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -697,6 +987,24 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "drop_bomb" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bda8e21c04aca2ae33ffc2fd8c23134f3cac46db123ba97bd9d3f3b8a4a85e1" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" + [[package]] name = "either" version = "1.13.0" @@ -712,6 +1020,26 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "enumflags2" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -720,12 +1048,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -758,9 +1086,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" dependencies = [ "event-listener 5.3.1", "pin-project-lite", @@ -777,9 +1105,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fixedbitset" @@ -831,6 +1159,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -902,7 +1231,7 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" dependencies = [ - "fastrand 2.2.0", + "fastrand 2.3.0", "futures-core", "futures-io", "parking", @@ -917,7 +1246,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -992,6 +1321,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1ce686646e7f1e19bf7d5533fe443a45dbfb990e00629110797578b42fb19" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1004,6 +1346,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1029,6 +1377,16 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heck" version = "0.4.1" @@ -1207,7 +1565,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1231,14 +1589,42 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.9", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" -version = "2.6.0" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -1273,6 +1659,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1296,13 +1688,23 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ + "once_cell", "wasm-bindgen", ] +[[package]] +name = "json-strip-comments" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b271732a960335e715b6b2ae66a086f115c74eb97360e996d2bd809bfc063bba" +dependencies = [ + "memchr", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1329,15 +1731,15 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.166" +version = "0.2.168" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ccc108bbc0b1331bd061864e7cd823c0cab660bbe6970e66e2c0614decde36" +checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" [[package]] name = "libloading" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", "windows-targets 0.52.6", @@ -1349,6 +1751,16 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +[[package]] +name = "libmimalloc-sys" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libredox" version = "0.1.3" @@ -1458,6 +1870,15 @@ dependencies = [ "url", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1474,6 +1895,24 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memoffset" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mimalloc" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68914350ae34959d83f732418d51e2427a794055d0b9529f48259ac07af65633" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1491,11 +1930,10 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", @@ -1507,6 +1945,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" +[[package]] +name = "newtype-uuid" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c8781e2ef64806278a55ad223f0bc875772fd40e1fe6e73e8adbf027817229d" +dependencies = [ + "uuid", +] + [[package]] name = "nom" version = "7.1.3" @@ -1560,6 +2007,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1650,6 +2106,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + +[[package]] +name = "oxc_resolver" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20bb345f290c46058ba650fef7ca2b579612cf2786b927ebad7b8bec0845a7" +dependencies = [ + "cfg-if", + "dashmap 6.1.0", + "dunce", + "indexmap 2.7.0", + "json-strip-comments", + "once_cell", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "simdutf8", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "parking" version = "2.2.1" @@ -1685,6 +2167,24 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-absolutize" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" +dependencies = [ + "path-dedot", +] + +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1713,7 +2213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.7.0", ] [[package]] @@ -1726,6 +2226,39 @@ dependencies = [ "text-size", ] +[[package]] +name = "pg_cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "bpaf", + "crossbeam", + "dashmap 5.5.3", + "hdrhistogram", + "libc", + "mimalloc", + "path-absolutize", + "pg_configuration", + "pg_console", + "pg_diagnostics", + "pg_flags", + "pg_fs", + "pg_lsp_new", + "pg_text_edit", + "pg_workspace_new", + "quick-junit", + "rayon", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "tikv-jemallocator", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tracing-tree", +] + [[package]] name = "pg_commands" version = "0.0.0" @@ -1750,18 +2283,95 @@ dependencies = [ "tree_sitter_sql", ] +[[package]] +name = "pg_configuration" +version = "0.0.0" +dependencies = [ + "biome_deserialize", + "biome_deserialize_macros", + "bpaf", + "pg_console", + "pg_diagnostics", + "schemars", + "serde", + "serde_json", + "text-size", + "toml", +] + +[[package]] +name = "pg_console" +version = "0.0.0" +dependencies = [ + "pg_markup", + "schemars", + "serde", + "termcolor", + "text-size", + "trybuild", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "pg_diagnostics" version = "0.0.0" dependencies = [ + "backtrace", + "bpaf", + "enumflags2", + "pg_console", + "pg_diagnostics_categories", + "pg_diagnostics_macros", + "pg_text_edit", + "schemars", + "serde", + "serde_json", + "termcolor", "text-size", + "unicode-width", +] + +[[package]] +name = "pg_diagnostics_categories" +version = "0.0.0" +dependencies = [ + "quote", + "schemars", + "serde", +] + +[[package]] +name = "pg_diagnostics_macros" +version = "0.0.0" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pg_flags" +version = "0.0.0" +dependencies = [ + "pg_console", ] [[package]] name = "pg_fs" version = "0.0.0" dependencies = [ + "crossbeam", "directories", + "enumflags2", + "parking_lot", + "pg_diagnostics", + "rayon", + "rustc-hash 2.1.0", + "schemars", + "serde", + "smallvec", "tracing", ] @@ -1835,7 +2445,7 @@ dependencies = [ "async-channel 2.3.1", "async-std", "crossbeam-channel", - "dashmap", + "dashmap 5.5.3", "line_index", "lsp-server", "lsp-types 0.95.1", @@ -1860,6 +2470,48 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "pg_lsp_converters" +version = "0.0.0" +dependencies = [ + "anyhow", + "rustc-hash 2.1.0", + "text-size", + "tower-lsp", +] + +[[package]] +name = "pg_lsp_new" +version = "0.0.0" +dependencies = [ + "anyhow", + "biome_deserialize", + "futures", + "pg_configuration", + "pg_console", + "pg_diagnostics", + "pg_fs", + "pg_lsp_converters", + "pg_text_edit", + "pg_workspace_new", + "rustc-hash 2.1.0", + "serde", + "serde_json", + "text-size", + "tokio", + "tower-lsp", + "tracing", +] + +[[package]] +name = "pg_markup" +version = "0.0.0" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", +] + [[package]] name = "pg_query" version = "0.8.2" @@ -1873,7 +2525,7 @@ dependencies = [ "prost-build", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1950,6 +2602,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "pg_text_edit" +version = "0.0.0" +dependencies = [ + "schemars", + "serde", + "similar", + "text-size", +] + [[package]] name = "pg_type_resolver" version = "0.0.0" @@ -1976,9 +2638,8 @@ name = "pg_workspace" version = "0.0.0" dependencies = [ "async-std", - "dashmap", + "dashmap 5.5.3", "pg_base_db", - "pg_diagnostics", "pg_fs", "pg_hover", "pg_lint", @@ -1992,6 +2653,32 @@ dependencies = [ "tree_sitter_sql", ] +[[package]] +name = "pg_workspace_new" +version = "0.0.0" +dependencies = [ + "biome_deserialize", + "dashmap 5.5.3", + "futures", + "ignore", + "pg_configuration", + "pg_console", + "pg_diagnostics", + "pg_fs", + "pg_query_ext", + "pg_schema_cache", + "pg_statement_splitter", + "serde", + "serde_json", + "sqlx", + "text-size", + "tokio", + "toml", + "tracing", + "tree-sitter", + "tree_sitter_sql", +] + [[package]] name = "pin-project" version = "1.1.7" @@ -2009,7 +2696,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2031,7 +2718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand 2.2.0", + "fastrand 2.3.0", "futures-io", ] @@ -2088,7 +2775,7 @@ dependencies = [ "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", - "rustix 0.38.41", + "rustix 0.38.42", "tracing", "windows-sys 0.59.0", ] @@ -2115,7 +2802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ "proc-macro2", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2127,6 +2814,29 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.92" @@ -2199,7 +2909,7 @@ checksum = "a3a7c64d9bf75b1b8d981124c14c179074e8caa7dfe7b6a12e6222ddcd0c8f72" dependencies = [ "once_cell", "protobuf-support", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2209,12 +2919,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322330e133eab455718444b4e033ebfac7c6528972c784fcde28d2cc783c6257" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.7.0", "log", "protobuf", "protobuf-support", "tempfile", - "thiserror", + "thiserror 1.0.69", "which", ] @@ -2224,7 +2934,31 @@ version = "3.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b088fd20b938a875ea00843b6faf48579462630015c3788d397ad6a786663252" dependencies = [ - "thiserror", + "thiserror 1.0.69", +] + +[[package]] +name = "quick-junit" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed1a693391a16317257103ad06a88c6529ac640846021da7c435a06fffdacd7" +dependencies = [ + "chrono", + "indexmap 2.7.0", + "newtype-uuid", + "quick-xml", + "strip-ansi-escapes", + "thiserror 2.0.6", + "uuid", +] + +[[package]] +name = "quick-xml" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f22f29bdff3987b4d8632ef95fd6424ec7e4e0a57e2f4fc63e489e75357f6a03" +dependencies = [ + "memchr", ] [[package]] @@ -2266,6 +3000,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -2283,7 +3037,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2294,8 +3048,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -2306,9 +3069,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -2362,6 +3131,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" + [[package]] name = "rustix" version = "0.37.27" @@ -2378,22 +3153,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.41" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.18" +version = "0.23.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +checksum = "934b404430bb06b3fae2cba809eb45a1ab1aecd64491213d7c3301b88393f8d1" dependencies = [ "once_cell", "ring", @@ -2435,6 +3210,42 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "indexmap 2.7.0", + "schemars_derive", + "serde", + "serde_json", + "smallvec", +] + +[[package]] +name = "schemars_derive" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.90", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2458,7 +3269,18 @@ checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2467,6 +3289,7 @@ version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ + "indexmap 2.7.0", "itoa", "memchr", "ryu", @@ -2490,7 +3313,16 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", +] + +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", ] [[package]] @@ -2561,11 +3393,21 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +dependencies = [ + "bstr", + "unicode-segmentation", +] [[package]] name = "slab" @@ -2597,9 +3439,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -2676,7 +3518,7 @@ dependencies = [ "hashbrown 0.14.5", "hashlink", "hex", - "indexmap", + "indexmap 2.7.0", "log", "memchr", "once_cell", @@ -2689,7 +3531,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tracing", "url", "webpki-roots", @@ -2705,7 +3547,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2729,7 +3571,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.89", + "syn 2.0.90", "tempfile", "url", ] @@ -2771,7 +3613,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2809,7 +3651,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2854,6 +3696,15 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2866,6 +3717,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "1.0.109" @@ -2879,9 +3739,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -2896,9 +3756,15 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] +[[package]] +name = "target-triple" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a4d50cdb458045afc8131fd91b64904da29548bcb63c7236e0844936c13078" + [[package]] name = "tempfile" version = "3.14.0" @@ -2906,17 +3772,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", - "fastrand 2.2.0", + "fastrand 2.3.0", "once_cell", - "rustix 0.38.41", + "rustix 0.38.42", "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" +dependencies = [ + "serde", +] [[package]] name = "thiserror" @@ -2924,7 +3802,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec2a1820ebd077e2b90c4df007bebf344cd394098a13c563957d0afc83ea47" +dependencies = [ + "thiserror-impl 2.0.6", ] [[package]] @@ -2935,7 +3822,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2957,17 +3855,39 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -2976,6 +3896,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -3003,9 +3933,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.1" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -3014,7 +3944,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.7", + "socket2 0.5.8", "tokio-macros", "windows-sys 0.52.0", ] @@ -3027,14 +3957,14 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" dependencies = [ "bytes", "futures-core", @@ -3043,11 +3973,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -3055,7 +4000,9 @@ version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap", + "indexmap 2.7.0", + "serde", + "serde_spanned", "toml_datetime", "winnow", ] @@ -3089,7 +4036,7 @@ dependencies = [ "async-trait", "auto_impl", "bytes", - "dashmap", + "dashmap 5.5.3", "futures", "httparse", "lsp-types 0.94.1", @@ -3111,7 +4058,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -3122,9 +4069,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -3132,6 +4079,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.28" @@ -3140,7 +4099,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -3164,18 +4123,47 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ - "nu-ansi-term", + "matchers", + "nu-ansi-term 0.46.0", + "once_cell", + "regex", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tracing-tree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f459ca79f1b0d5f71c54ddfde6debfc59c8b6eeb46808ae492077f739dc7b49c" +dependencies = [ + "nu-ansi-term 0.50.1", "tracing-core", "tracing-log", + "tracing-subscriber", ] [[package]] @@ -3205,6 +4193,21 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "trybuild" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dcd332a5496c026f1e14b7f3d2b7bd98e509660c04239c58b0ba38a12daded4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typenum" version = "1.17.0" @@ -3217,6 +4220,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.14" @@ -3244,6 +4253,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -3319,12 +4334,42 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "waker-fn" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -3339,9 +4384,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if", "once_cell", @@ -3350,36 +4395,36 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3387,28 +4432,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" dependencies = [ "js-sys", "wasm-bindgen", @@ -3432,7 +4477,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix 0.38.41", + "rustix 0.38.42", ] [[package]] @@ -3461,6 +4506,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3674,7 +4728,7 @@ checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" [[package]] name = "xtask" -version = "0.1.0" +version = "0.0.0" dependencies = [ "anyhow", "flate2", @@ -3685,6 +4739,14 @@ dependencies = [ "zip", ] +[[package]] +name = "xtask_codegen" +version = "0.0.0" +dependencies = [ + "bpaf", + "xtask", +] + [[package]] name = "yoke" version = "0.7.5" @@ -3705,7 +4767,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "synstructure", ] @@ -3727,7 +4789,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -3747,7 +4809,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "synstructure", ] @@ -3776,7 +4838,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index afaef7ecc..c1306c26e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = [ "crates/*", "lib/*", - "xtask/" + "xtask/codegen" ] resolver = "2" @@ -16,14 +16,49 @@ rust-version = "1.71" line_index = { path = "./lib/line_index", version = "0.0.0" } tree_sitter_sql = { path = "./lib/tree_sitter_sql", version = "0.0.0" } tree-sitter = "0.20.10" -tracing = "0.1.40" +schemars = { version = "0.8.21", features = ["indexmap2", "smallvec"] } +serde = "1.0.195" +serde_json = "1.0.114" +proc-macro2 = "1.0.66" +termcolor = "1.4.1" +unicode-width = "0.1.12" +text-size = "1.1.1" +enumflags2 = "0.7.10" +similar = "2.6.0" +quote = "1.0.33" +syn = "1.0.109" +indexmap = { version = "2.6.0", features = ["serde"] } +crossbeam = "0.8.4" +rayon = "1.10.0" +rustc-hash = "2.0.0" +biome_deserialize = "0.6.0" +biome_deserialize_macros = "0.6.0" +toml = "0.8.19" +tracing = { version = "0.1.40", default-features = false, features = ["std"] } +tracing-subscriber = "0.3.18" +bpaf = { version = "0.9.15", features = ["derive"] } +tokio = "1.40.0" +ignore = "0.4.23" +anyhow = "1.0.92" +smallvec = { version = "1.13.2", features = ["union", "const_new", "serde"] } tower-lsp = "0.20.0" sqlx = { version = "0.8.2", features = [ "runtime-async-std", "tls-rustls", "postgres", "json" ] } # postgres specific crates +pg_flags = { path = "./crates/pg_flags", version = "0.0.0" } pg_lexer = { path = "./crates/pg_lexer", version = "0.0.0" } +pg_lsp_new = { path = "./crates/pg_lsp_new", version = "0.0.0" } +pg_lsp_converters = { path = "./crates/pg_lsp_converters", version = "0.0.0" } +pg_cli = { path = "./crates/pg_cli", version = "0.0.0" } +pg_configuration = { path = "./crates/pg_configuration", version = "0.0.0" } +pg_markup = { path = "./crates/pg_markup", version = "0.0.0" } +pg_console = { path = "./crates/pg_console", version = "0.0.0" } +pg_text_edit = { path = "./crates/pg_text_edit", version = "0.0.0" } +pg_workspace_new = { path = "./crates/pg_workspace_new", version = "0.0.0" } pg_fs = { path = "./crates/pg_fs", version = "0.0.0" } pg_diagnostics = { path = "./crates/pg_diagnostics", version = "0.0.0" } +pg_diagnostics_macros = { path = "./crates/pg_diagnostics_macros", version = "0.0.0" } +pg_diagnostics_categories = { path = "./crates/pg_diagnostics_categories", version = "0.0.0" } pg_lexer_codegen = { path = "./crates/pg_lexer_codegen", version = "0.0.0" } pg_statement_splitter = { path = "./crates/pg_statement_splitter", version = "0.0.0" } pg_query_ext = { path = "./crates/pg_query_ext", version = "0.0.0" } diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..6dae53524 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-dbg-in-tests = true + diff --git a/crates/pg_base_db/Cargo.toml b/crates/pg_base_db/Cargo.toml index 9622e8443..25dbf8e6b 100644 --- a/crates/pg_base_db/Cargo.toml +++ b/crates/pg_base_db/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true line_index.workspace = true pg_statement_splitter.workspace = true diff --git a/crates/pg_base_db/src/document.rs b/crates/pg_base_db/src/document.rs index 6b3c93da9..7e40461fc 100644 --- a/crates/pg_base_db/src/document.rs +++ b/crates/pg_base_db/src/document.rs @@ -162,10 +162,10 @@ impl Document { #[cfg(test)] mod tests { - use text_size::{TextRange, TextSize}; use pg_fs::PgLspPath; + use text_size::{TextRange, TextSize}; - use crate::{Document}; + use crate::Document; #[test] fn test_statements_at_range() { diff --git a/crates/pg_cli/Cargo.toml b/crates/pg_cli/Cargo.toml new file mode 100644 index 000000000..1008a7eaa --- /dev/null +++ b/crates/pg_cli/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "pg_cli" +version = "0.0.0" +edition = "2021" + +[dependencies] +pg_console = { workspace = true } +pg_flags = { workspace = true } +pg_configuration = { workspace = true } +pg_fs = { workspace = true } +pg_lsp_new = { workspace = true } +pg_workspace_new = { workspace = true } +pg_text_edit = { workspace = true } +path-absolutize = { version = "3.1.1", optional = false, features = ["use_unix_paths_on_wasm"] } +pg_diagnostics = { workspace = true } +crossbeam = { workspace = true } +bpaf = { workspace = true, features = ["bright-color"] } +rayon = { workspace = true } +quick-junit = "0.5.0" +tracing = { workspace = true } +tracing-appender = "0.2.3" +tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } +tracing-tree = "0.4.0" +hdrhistogram = { version = "7.5.4", default-features = false } +rustc-hash = { workspace = true } +tokio = { workspace = true, features = ["io-std", "io-util", "net", "time", "rt", "sync", "rt-multi-thread", "macros"] } +anyhow = { workspace = true } +dashmap = "5.5.3" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = "0.2.161" +tokio = { workspace = true, features = ["process"] } + +[target.'cfg(windows)'.dependencies] +mimalloc = "0.1.43" + +[target.'cfg(all(target_family="unix", not(all(target_arch = "aarch64", target_env = "musl"))))'.dependencies] +tikv-jemallocator = "0.6.0" + +[dev-dependencies] + +[lib] +doctest = false + +[features] diff --git a/crates/pg_cli/src/cli_options.rs b/crates/pg_cli/src/cli_options.rs new file mode 100644 index 000000000..5a4300d15 --- /dev/null +++ b/crates/pg_cli/src/cli_options.rs @@ -0,0 +1,230 @@ +use crate::logging::LoggingKind; +use crate::LoggingLevel; +use bpaf::Bpaf; +use pg_configuration::ConfigurationPathHint; +use pg_diagnostics::Severity; +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; +use std::str::FromStr; + +/// Global options applied to all commands +#[derive(Debug, Clone, Bpaf)] +pub struct CliOptions { + /// Set the formatting mode for markup: "off" prints everything as plain text, "force" forces the formatting of markup using ANSI even if the console output is determined to be incompatible + #[bpaf(long("colors"), argument("off|force"))] + pub colors: Option, + + /// Connect to a running instance of the daemon server. + #[bpaf(long("use-server"), switch, fallback(false))] + pub use_server: bool, + + /// Print additional diagnostics, and some diagnostics show more information. Also, print out what files were processed and which ones were modified. + #[bpaf(long("verbose"), switch, fallback(false))] + pub verbose: bool, + + /// Set the file path to the configuration file, or the directory path to find `biome.json` or `biome.jsonc`. + /// If used, it disables the default configuration file resolution. + #[bpaf(long("config-path"), argument("PATH"), optional)] + pub config_path: Option, + + /// Cap the amount of diagnostics displayed. When `none` is provided, the limit is lifted. + #[bpaf( + long("max-diagnostics"), + argument("none|"), + fallback(MaxDiagnostics::default()), + display_fallback + )] + pub max_diagnostics: MaxDiagnostics, + + /// Skip over files containing syntax errors instead of emitting an error diagnostic. + #[bpaf(long("skip-errors"), switch)] + pub skip_errors: bool, + + /// Silence errors that would be emitted in case no files were processed during the execution of the command. + #[bpaf(long("no-errors-on-unmatched"), switch)] + pub no_errors_on_unmatched: bool, + + /// Tell Biome to exit with an error code if some diagnostics emit warnings. + #[bpaf(long("error-on-warnings"), switch)] + pub error_on_warnings: bool, + + /// Allows to change how diagnostics and summary are reported. + #[bpaf( + long("reporter"), + argument("json|json-pretty|github|junit|summary|gitlab"), + fallback(CliReporter::default()) + )] + pub reporter: CliReporter, + + #[bpaf( + long("log-level"), + argument("none|debug|info|warn|error"), + fallback(LoggingLevel::default()), + display_fallback + )] + /// The level of logging. In order, from the most verbose to the least verbose: debug, info, warn, error. + /// + /// The value `none` won't show any logging. + pub log_level: LoggingLevel, + + /// How the log should look like. + #[bpaf( + long("log-kind"), + argument("pretty|compact|json"), + fallback(LoggingKind::default()), + display_fallback + )] + pub log_kind: LoggingKind, + + #[bpaf( + long("diagnostic-level"), + argument("info|warn|error"), + fallback(Severity::default()), + display_fallback + )] + /// The level of diagnostics to show. In order, from the lowest to the most important: info, warn, error. Passing `--diagnostic-level=error` will cause Biome to print only diagnostics that contain only errors. + pub diagnostic_level: Severity, +} + +impl CliOptions { + /// Computes the [ConfigurationPathHint] based on the options passed by the user + pub(crate) fn as_configuration_path_hint(&self) -> ConfigurationPathHint { + match self.config_path.as_ref() { + None => ConfigurationPathHint::default(), + Some(path) => ConfigurationPathHint::FromUser(PathBuf::from(path)), + } + } +} + +#[derive(Debug, Clone)] +pub enum ColorsArg { + Off, + Force, +} + +impl FromStr for ColorsArg { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "off" => Ok(Self::Off), + "force" => Ok(Self::Force), + _ => Err(format!( + "value {s:?} is not valid for the --colors argument" + )), + } + } +} + +#[derive(Debug, Default, Clone)] +pub enum CliReporter { + /// The default reporter + #[default] + Default, + /// Diagnostics are printed for GitHub workflow commands + GitHub, + /// Diagnostics and summary are printed in JUnit format + Junit, + /// Reports linter diagnostics using the [GitLab Code Quality report](https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool). + GitLab, +} + +impl CliReporter { + pub(crate) const fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + +impl FromStr for CliReporter { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "github" => Ok(Self::GitHub), + "junit" => Ok(Self::Junit), + "gitlab" => Ok(Self::GitLab), + _ => Err(format!( + "value {s:?} is not valid for the --reporter argument" + )), + } + } +} + +impl Display for CliReporter { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + CliReporter::Default => f.write_str("default"), + CliReporter::GitHub => f.write_str("github"), + CliReporter::Junit => f.write_str("junit"), + CliReporter::GitLab => f.write_str("gitlab"), + } + } +} + +#[derive(Debug, Clone, Copy, Bpaf)] +pub enum MaxDiagnostics { + None, + Limit(u32), +} + +impl MaxDiagnostics { + pub fn ok(&self) -> Option { + match self { + MaxDiagnostics::None => None, + MaxDiagnostics::Limit(value) => Some(*value), + } + } +} + +impl Default for MaxDiagnostics { + fn default() -> Self { + Self::Limit(20) + } +} + +impl Display for MaxDiagnostics { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + MaxDiagnostics::None => { + write!(f, "none") + } + MaxDiagnostics::Limit(value) => { + write!(f, "{value}") + } + } + } +} + +impl FromStr for MaxDiagnostics { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(MaxDiagnostics::None), + _ => { + if let Ok(value) = s.parse::() { + Ok(MaxDiagnostics::Limit(value)) + } else { + Err(format!("Invalid value provided. Provide 'none' to lift the limit, or a number between 0 and {}.", u32::MAX)) + } + } + } + } +} + +impl From for u64 { + fn from(value: MaxDiagnostics) -> Self { + match value { + MaxDiagnostics::None => u64::MAX, + MaxDiagnostics::Limit(value) => value as u64, + } + } +} + +impl From for u32 { + fn from(value: MaxDiagnostics) -> Self { + match value { + MaxDiagnostics::None => u32::MAX, + MaxDiagnostics::Limit(value) => value, + } + } +} diff --git a/crates/pg_cli/src/commands/clean.rs b/crates/pg_cli/src/commands/clean.rs new file mode 100644 index 000000000..dc2f5daba --- /dev/null +++ b/crates/pg_cli/src/commands/clean.rs @@ -0,0 +1,15 @@ +use crate::commands::daemon::default_pglsp_log_path; +use crate::{CliDiagnostic, CliSession}; +use pg_flags::pglsp_env; +use std::fs::{create_dir, remove_dir_all}; +use std::path::PathBuf; + +/// Runs the clean command +pub fn clean(_cli_session: CliSession) -> Result<(), CliDiagnostic> { + let logs_path = pglsp_env() + .pglsp_log_path + .value() + .map_or(default_pglsp_log_path(), PathBuf::from); + remove_dir_all(logs_path.clone()).and_then(|_| create_dir(logs_path))?; + Ok(()) +} diff --git a/crates/pg_cli/src/commands/daemon.rs b/crates/pg_cli/src/commands/daemon.rs new file mode 100644 index 000000000..a1b2daa1a --- /dev/null +++ b/crates/pg_cli/src/commands/daemon.rs @@ -0,0 +1,279 @@ +use crate::{ + open_transport, + service::{self, ensure_daemon, open_socket, run_daemon}, + CliDiagnostic, CliSession, +}; +use pg_console::{markup, ConsoleExt}; +use pg_lsp_new::ServerFactory; +use pg_workspace_new::{workspace::WorkspaceClient, TransportError, WorkspaceError}; +use std::{env, fs, path::PathBuf}; +use tokio::io; +use tokio::runtime::Runtime; +use tracing::subscriber::Interest; +use tracing::{debug_span, metadata::LevelFilter, Instrument, Metadata}; +use tracing_appender::rolling::Rotation; +use tracing_subscriber::{ + layer::{Context, Filter}, + prelude::*, + registry, Layer, +}; +use tracing_tree::HierarchicalLayer; + +pub(crate) fn start( + session: CliSession, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> Result<(), CliDiagnostic> { + let rt = Runtime::new()?; + let did_spawn = rt.block_on(ensure_daemon( + false, + config_path, + log_path, + log_file_name_prefix, + ))?; + + if did_spawn { + session.app.console.log(markup! { + "The server was successfully started" + }); + } else { + session.app.console.log(markup! { + "The server was already running" + }); + } + + Ok(()) +} + +pub(crate) fn stop(session: CliSession) -> Result<(), CliDiagnostic> { + let rt = Runtime::new()?; + + if let Some(transport) = open_transport(rt)? { + let client = WorkspaceClient::new(transport)?; + match client.shutdown() { + // The `ChannelClosed` error is expected since the server can + // shutdown before sending a response + Ok(()) | Err(WorkspaceError::TransportError(TransportError::ChannelClosed)) => {} + Err(err) => return Err(CliDiagnostic::from(err)), + }; + + session.app.console.log(markup! { + "The server was successfully stopped" + }); + } else { + session.app.console.log(markup! { + "The server was not running" + }); + } + + Ok(()) +} + +pub(crate) fn run_server( + stop_on_disconnect: bool, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> Result<(), CliDiagnostic> { + setup_tracing_subscriber(log_path, log_file_name_prefix); + + let rt = Runtime::new()?; + let factory = ServerFactory::new(stop_on_disconnect); + let cancellation = factory.cancellation(); + let span = debug_span!("Running Server", pid = std::process::id()); + + rt.block_on(async move { + tokio::select! { + res = run_daemon(factory, config_path).instrument(span) => { + match res { + Ok(never) => match never {}, + Err(err) => Err(err.into()), + } + } + _ = cancellation.notified() => { + tracing::info!("Received shutdown signal"); + Ok(()) + } + } + }) +} + +pub(crate) fn print_socket() -> Result<(), CliDiagnostic> { + let rt = Runtime::new()?; + rt.block_on(service::print_socket())?; + Ok(()) +} + +pub(crate) fn lsp_proxy( + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> Result<(), CliDiagnostic> { + let rt = Runtime::new()?; + rt.block_on(start_lsp_proxy( + &rt, + config_path, + log_path, + log_file_name_prefix, + ))?; + + Ok(()) +} + +/// Start a proxy process. +/// Receives a process via `stdin` and then copy the content to the LSP socket. +/// Copy to the process on `stdout` when the LSP responds to a message +async fn start_lsp_proxy( + rt: &Runtime, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> Result<(), CliDiagnostic> { + ensure_daemon(true, config_path, log_path, log_file_name_prefix).await?; + + match open_socket().await? { + Some((mut owned_read_half, mut owned_write_half)) => { + // forward stdin to socket + let mut stdin = io::stdin(); + let input_handle = rt.spawn(async move { + loop { + match io::copy(&mut stdin, &mut owned_write_half).await { + Ok(b) => { + if b == 0 { + return Ok(()); + } + } + Err(err) => return Err(err), + }; + } + }); + + // receive socket response to stdout + let mut stdout = io::stdout(); + let out_put_handle = rt.spawn(async move { + loop { + match io::copy(&mut owned_read_half, &mut stdout).await { + Ok(b) => { + if b == 0 { + return Ok(()); + } + } + Err(err) => return Err(err), + }; + } + }); + + let _ = input_handle.await; + let _ = out_put_handle.await; + Ok(()) + } + None => Ok(()), + } +} + +pub(crate) fn read_most_recent_log_file( + log_path: Option, + log_file_name_prefix: String, +) -> io::Result> { + let biome_log_path = log_path.unwrap_or(default_pglsp_log_path()); + + let most_recent = fs::read_dir(biome_log_path)? + .flatten() + .filter(|file| file.file_type().map_or(false, |ty| ty.is_file())) + .filter_map(|file| { + match file + .file_name() + .to_str()? + .split_once(log_file_name_prefix.as_str()) + { + Some((_, date_part)) if date_part.split('-').count() == 4 => Some(file.path()), + _ => None, + } + }) + .max(); + + match most_recent { + Some(file) => Ok(Some(fs::read_to_string(file)?)), + None => Ok(None), + } +} + +/// Set up the [tracing]-based logging system for the server +/// The events received by the subscriber are filtered at the `info` level, +/// then printed using the [HierarchicalLayer] layer, and the resulting text +/// is written to log files rotated on a hourly basis (in +/// `pglsp-logs/server.log.yyyy-MM-dd-HH` files inside the system temporary +/// directory) +fn setup_tracing_subscriber(log_path: Option, log_file_name_prefix: Option) { + let biome_log_path = log_path.unwrap_or(pg_fs::ensure_cache_dir().join("pglsp-logs")); + let appender_builder = tracing_appender::rolling::RollingFileAppender::builder(); + let file_appender = appender_builder + .filename_prefix(log_file_name_prefix.unwrap_or(String::from("server.log"))) + .max_log_files(7) + .rotation(Rotation::HOURLY) + .build(biome_log_path) + .expect("Failed to start the logger for the daemon."); + + registry() + .with( + HierarchicalLayer::default() + .with_indent_lines(true) + .with_indent_amount(2) + .with_bracketed_fields(true) + .with_targets(true) + .with_ansi(false) + .with_writer(file_appender) + .with_filter(LoggingFilter), + ) + .init(); +} + +pub fn default_pglsp_log_path() -> PathBuf { + match env::var_os("PGLSP_LOG_PATH") { + Some(directory) => PathBuf::from(directory), + None => pg_fs::ensure_cache_dir().join("pglsp-logs"), + } +} + +/// Tracing filter enabling: +/// - All spans and events at level info or higher +/// - All spans and events at level debug in crates whose name starts with `biome` +struct LoggingFilter; + +/// Tracing filter used for spans emitted by `biome*` crates +const SELF_FILTER: LevelFilter = if cfg!(debug_assertions) { + LevelFilter::TRACE +} else { + LevelFilter::DEBUG +}; + +impl LoggingFilter { + fn is_enabled(&self, meta: &Metadata<'_>) -> bool { + let filter = if meta.target().starts_with("biome") { + SELF_FILTER + } else { + LevelFilter::INFO + }; + + meta.level() <= &filter + } +} + +impl Filter for LoggingFilter { + fn enabled(&self, meta: &Metadata<'_>, _cx: &Context<'_, S>) -> bool { + self.is_enabled(meta) + } + + fn callsite_enabled(&self, meta: &'static Metadata<'static>) -> Interest { + if self.is_enabled(meta) { + Interest::always() + } else { + Interest::never() + } + } + + fn max_level_hint(&self) -> Option { + Some(SELF_FILTER) + } +} diff --git a/crates/pg_cli/src/commands/init.rs b/crates/pg_cli/src/commands/init.rs new file mode 100644 index 000000000..77aba0cce --- /dev/null +++ b/crates/pg_cli/src/commands/init.rs @@ -0,0 +1,22 @@ +use crate::{CliDiagnostic, CliSession}; +use pg_configuration::PartialConfiguration; +use pg_console::{markup, ConsoleExt}; +use pg_fs::ConfigName; +use pg_workspace_new::configuration::create_config; + +pub(crate) fn init(mut session: CliSession) -> Result<(), CliDiagnostic> { + let fs = &mut session.app.fs; + create_config(fs, PartialConfiguration::init())?; + let file_created = ConfigName::pglsp_toml(); + session.app.console.log(markup! { +" +Welcome to the Postgres Language Server! Let's get you started... + +""Files created "" + + ""- "{file_created}" + Your project configuration. +" + }); + Ok(()) +} diff --git a/crates/pg_cli/src/commands/mod.rs b/crates/pg_cli/src/commands/mod.rs new file mode 100644 index 000000000..c22c4b283 --- /dev/null +++ b/crates/pg_cli/src/commands/mod.rs @@ -0,0 +1,365 @@ +use crate::cli_options::{cli_options, CliOptions, CliReporter, ColorsArg}; +use crate::diagnostics::DeprecatedConfigurationFile; +use crate::logging::LoggingKind; +use crate::{ + execute_mode, setup_cli_subscriber, CliDiagnostic, CliSession, Execution, LoggingLevel, VERSION, +}; +use bpaf::Bpaf; +use pg_configuration::PartialConfiguration; +use pg_console::{markup, Console, ConsoleExt}; +use pg_diagnostics::{Diagnostic, PrintDiagnostic}; +use pg_fs::FileSystem; +use pg_workspace_new::configuration::{load_configuration, LoadedConfiguration}; +use pg_workspace_new::settings::PartialConfigurationExt; +use pg_workspace_new::workspace::UpdateSettingsParams; +use pg_workspace_new::{DynRef, Workspace, WorkspaceError}; +use std::ffi::OsString; +use std::path::PathBuf; + +pub(crate) mod clean; +pub(crate) mod daemon; +pub(crate) mod init; +pub(crate) mod version; + +#[derive(Debug, Clone, Bpaf)] +#[bpaf(options, version(VERSION))] +/// PgLsp official CLI. Use it to check the health of your project or run it to check single files. +pub enum PgLspCommand { + /// Shows the Biome version information and quit. + #[bpaf(command)] + Version(#[bpaf(external(cli_options), hide_usage)] CliOptions), + + /// Starts the Biome daemon server process. + #[bpaf(command)] + Start { + /// Allows to change the prefix applied to the file name of the logs. + #[bpaf( + env("PGLSP_LOG_PREFIX_NAME"), + long("log-prefix-name"), + argument("STRING"), + hide_usage, + fallback(String::from("server.log")), + display_fallback + )] + log_prefix_name: String, + + /// Allows to change the folder where logs are stored. + #[bpaf( + env("PGLSP_LOG_PATH"), + long("log-path"), + argument("PATH"), + hide_usage, + fallback(pg_fs::ensure_cache_dir().join("pglsp-logs")), + )] + log_path: PathBuf, + /// Allows to set a custom file path to the configuration file, + /// or a custom directory path to find `pglsp.toml` + #[bpaf(env("PGLSP_LOG_PREFIX_NAME"), long("config-path"), argument("PATH"))] + config_path: Option, + }, + + /// Stops the daemon server process. + #[bpaf(command)] + Stop, + + /// Bootstraps a new project. Creates a configuration file with some defaults. + #[bpaf(command)] + Init, + + /// Acts as a server for the Language Server Protocol over stdin/stdout. + #[bpaf(command("lsp-proxy"))] + LspProxy { + /// Allows to change the prefix applied to the file name of the logs. + #[bpaf( + env("PGLSP_LOG_PREFIX_NAME"), + long("log-prefix-name"), + argument("STRING"), + hide_usage, + fallback(String::from("server.log")), + display_fallback + )] + log_prefix_name: String, + /// Allows to change the folder where logs are stored. + #[bpaf( + env("PGLSP_LOG_PATH"), + long("log-path"), + argument("PATH"), + hide_usage, + fallback(pg_fs::ensure_cache_dir().join("pglsp-logs")), + )] + log_path: PathBuf, + /// Allows to set a custom file path to the configuration file, + /// or a custom directory path to find `pglsp.toml` + #[bpaf(env("PGLSP_CONFIG_PATH"), long("config-path"), argument("PATH"))] + config_path: Option, + /// Bogus argument to make the command work with vscode-languageclient + #[bpaf(long("stdio"), hide, hide_usage, switch)] + stdio: bool, + }, + + #[bpaf(command)] + /// Cleans the logs emitted by the daemon. + Clean, + + #[bpaf(command("__run_server"), hide)] + RunServer { + /// Allows to change the prefix applied to the file name of the logs. + #[bpaf( + env("PGLSP_LOG_PREFIX_NAME"), + long("log-prefix-name"), + argument("STRING"), + hide_usage, + fallback(String::from("server.log")), + display_fallback + )] + log_prefix_name: String, + /// Allows to change the folder where logs are stored. + #[bpaf( + env("PGLSP_LOG_PATH"), + long("log-path"), + argument("PATH"), + hide_usage, + fallback(pg_fs::ensure_cache_dir().join("pglsp-logs")), + )] + log_path: PathBuf, + + #[bpaf(long("stop-on-disconnect"), hide_usage)] + stop_on_disconnect: bool, + /// Allows to set a custom file path to the configuration file, + /// or a custom directory path to find `pglsp.toml` + #[bpaf(env("PGLSP_CONFIG_PATH"), long("config-path"), argument("PATH"))] + config_path: Option, + }, + #[bpaf(command("__print_socket"), hide)] + PrintSocket, +} + +impl PgLspCommand { + const fn cli_options(&self) -> Option<&CliOptions> { + match self { + PgLspCommand::Version(cli_options) => Some(cli_options), + PgLspCommand::LspProxy { .. } + | PgLspCommand::Start { .. } + | PgLspCommand::Stop + | PgLspCommand::Init + | PgLspCommand::RunServer { .. } + | PgLspCommand::Clean { .. } + | PgLspCommand::PrintSocket => None, + } + } + + pub const fn get_color(&self) -> Option<&ColorsArg> { + match self.cli_options() { + Some(cli_options) => { + // To properly display GitHub annotations we need to disable colors + if matches!(cli_options.reporter, CliReporter::GitHub) { + return Some(&ColorsArg::Off); + } + // We want force colors in CI, to give e better UX experience + // Unless users explicitly set the colors flag + // if matches!(self, PgLspCommand::Ci { .. }) && cli_options.colors.is_none() { + // return Some(&ColorsArg::Force); + // } + // Normal behaviors + cli_options.colors.as_ref() + } + None => None, + } + } + + pub const fn should_use_server(&self) -> bool { + match self.cli_options() { + Some(cli_options) => cli_options.use_server, + None => false, + } + } + + pub const fn has_metrics(&self) -> bool { + false + } + + pub fn is_verbose(&self) -> bool { + self.cli_options() + .map_or(false, |cli_options| cli_options.verbose) + } + + pub fn log_level(&self) -> LoggingLevel { + self.cli_options() + .map_or(LoggingLevel::default(), |cli_options| cli_options.log_level) + } + + pub fn log_kind(&self) -> LoggingKind { + self.cli_options() + .map_or(LoggingKind::default(), |cli_options| cli_options.log_kind) + } +} + +/// It accepts a [LoadedPartialConfiguration] and it prints the diagnostics emitted during parsing and deserialization. +/// +/// If it contains [errors](Severity::Error) or higher, it returns an error. +pub(crate) fn validate_configuration_diagnostics( + loaded_configuration: &LoadedConfiguration, + console: &mut dyn Console, + verbose: bool, +) -> Result<(), CliDiagnostic> { + if let Some(file_path) = loaded_configuration + .file_path + .as_ref() + .and_then(|f| f.file_name()) + .and_then(|f| f.to_str()) + { + if file_path == "rome.json" { + let diagnostic = DeprecatedConfigurationFile::new(file_path); + if diagnostic.tags().is_verbose() && verbose { + console.error(markup! {{PrintDiagnostic::verbose(&diagnostic)}}) + } else { + console.error(markup! {{PrintDiagnostic::simple(&diagnostic)}}) + } + } + } + + // let diagnostics = loaded_configuration.as_diagnostics_iter(); + // for diagnostic in diagnostics { + // if diagnostic.tags().is_verbose() && verbose { + // console.error(markup! {{PrintDiagnostic::verbose(diagnostic)}}) + // } else { + // console.error(markup! {{PrintDiagnostic::simple(diagnostic)}}) + // } + // } + // + // if loaded_configuration.has_errors() { + // return Err(CliDiagnostic::workspace_error( + // ConfigurationDiagnostic::invalid_configuration( + // "Exited because the configuration resulted in errors. Please fix them.", + // ) + // .into(), + // )); + // } + + Ok(()) +} + +/// Generic interface for executing commands. +/// +/// Consumers must implement the following methods: +/// +/// - [CommandRunner::merge_configuration] +/// - [CommandRunner::get_files_to_process] +/// - [CommandRunner::get_stdin_file_path] +/// - [CommandRunner::should_write] +/// - [CommandRunner::get_execution] +/// +/// Optional methods: +/// - [CommandRunner::check_incompatible_arguments] +pub(crate) trait CommandRunner: Sized { + const COMMAND_NAME: &'static str; + + /// The main command to use. + fn run(&mut self, session: CliSession, cli_options: &CliOptions) -> Result<(), CliDiagnostic> { + setup_cli_subscriber(cli_options.log_level, cli_options.log_kind); + let fs = &session.app.fs; + let console = &mut *session.app.console; + let workspace = &*session.app.workspace; + self.check_incompatible_arguments()?; + let (execution, paths) = self.configure_workspace(fs, console, workspace, cli_options)?; + execute_mode(execution, session, cli_options, paths) + } + + /// This function prepares the workspace with the following: + /// - Loading the configuration file. + /// - Configure the VCS integration + /// - Computes the paths to traverse/handle. This changes based on the VCS arguments that were passed. + /// - Register a project folder using the working directory. + /// - Updates the settings that belong to the project registered + fn configure_workspace( + &mut self, + fs: &DynRef<'_, dyn FileSystem>, + console: &mut dyn Console, + workspace: &dyn Workspace, + cli_options: &CliOptions, + ) -> Result<(Execution, Vec), CliDiagnostic> { + let loaded_configuration = + load_configuration(fs, cli_options.as_configuration_path_hint())?; + if self.should_validate_configuration_diagnostics() { + validate_configuration_diagnostics( + &loaded_configuration, + console, + cli_options.verbose, + )?; + } + let configuration_path = loaded_configuration.directory_path.clone(); + let configuration = self.merge_configuration(loaded_configuration, fs, console)?; + let vcs_base_path = configuration_path.or(fs.working_directory()); + let (vcs_base_path, gitignore_matches) = + configuration.retrieve_gitignore_matches(fs, vcs_base_path.as_deref())?; + let paths = self.get_files_to_process(fs, &configuration)?; + + workspace.update_settings(UpdateSettingsParams { + workspace_directory: fs.working_directory(), + configuration, + vcs_base_path, + gitignore_matches, + })?; + + let execution = self.get_execution(cli_options, console, workspace)?; + Ok((execution, paths)) + } + + // Below, the methods that consumers must implement. + + /// Implements this method if you need to merge CLI arguments to the loaded configuration. + /// + /// The CLI arguments take precedence over the option configured in the configuration file. + fn merge_configuration( + &mut self, + loaded_configuration: LoadedConfiguration, + fs: &DynRef<'_, dyn FileSystem>, + console: &mut dyn Console, + ) -> Result; + + /// It returns the paths that need to be handled/traversed. + fn get_files_to_process( + &self, + fs: &DynRef<'_, dyn FileSystem>, + configuration: &PartialConfiguration, + ) -> Result, CliDiagnostic>; + + /// It returns the file path to use in `stdin` mode. + fn get_stdin_file_path(&self) -> Option<&str>; + + /// Whether the command should write the files. + fn should_write(&self) -> bool; + + /// Returns the [Execution] mode. + fn get_execution( + &self, + cli_options: &CliOptions, + console: &mut dyn Console, + workspace: &dyn Workspace, + ) -> Result; + + // Below, methods that consumers can implement + + /// Optional method that can be implemented to check if some CLI arguments aren't compatible. + /// + /// The method is called before loading the configuration from disk. + fn check_incompatible_arguments(&self) -> Result<(), CliDiagnostic> { + Ok(()) + } + + /// Checks whether the configuration has errors. + fn should_validate_configuration_diagnostics(&self) -> bool { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Tests that all CLI options adhere to the invariants expected by `bpaf`. + #[test] + fn check_options() { + pg_lsp_command().check_invariants(false); + } +} diff --git a/crates/pg_cli/src/commands/version.rs b/crates/pg_cli/src/commands/version.rs new file mode 100644 index 000000000..49900a237 --- /dev/null +++ b/crates/pg_cli/src/commands/version.rs @@ -0,0 +1,42 @@ +use pg_console::fmt::Formatter; +use pg_console::{fmt, markup, ConsoleExt}; +use pg_workspace_new::workspace::ServerInfo; + +use crate::{CliDiagnostic, CliSession, VERSION}; + +/// Handle of the `version` command. Prints a more in detail version. +pub(crate) fn full_version(session: CliSession) -> Result<(), CliDiagnostic> { + session.app.console.log(markup! { + "CLI: "{VERSION} + }); + + match session.app.workspace.server_info() { + None => { + session.app.console.log(markup! { + "Server: ""not connected" + }); + } + Some(info) => { + session.app.console.log(markup! { +"Server: + Name: "{info.name}" + Version: "{DisplayServerVersion(info)} + }); + } + }; + + Ok(()) +} + +pub(super) struct DisplayServerVersion<'a>(pub &'a ServerInfo); + +impl fmt::Display for DisplayServerVersion<'_> { + fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> { + match &self.0.version { + None => markup!("-").fmt(fmt), + Some(version) => { + write!(fmt, "{version}") + } + } + } +} diff --git a/crates/pg_cli/src/diagnostics.rs b/crates/pg_cli/src/diagnostics.rs new file mode 100644 index 000000000..ff00f12fc --- /dev/null +++ b/crates/pg_cli/src/diagnostics.rs @@ -0,0 +1,502 @@ +use pg_console::fmt::Display; +use pg_console::markup; +use pg_diagnostics::adapters::{BpafError, IoError, SerdeJsonError}; +use pg_diagnostics::{ + Advices, Category, Diagnostic, Error, LogCategory, MessageAndDescription, Severity, Visit, +}; +use pg_workspace_new::WorkspaceError; +use std::process::{ExitCode, Termination}; +use std::{env::current_exe, fmt::Debug}; + +fn command_name() -> String { + current_exe() + .ok() + .and_then(|path| Some(path.file_name()?.to_str()?.to_string())) + .unwrap_or_else(|| String::from("biome")) +} + +/// A diagnostic that is emitted when running biome via CLI. +/// +/// When displaying the diagnostic, +#[derive(Debug, Diagnostic)] +pub enum CliDiagnostic { + /// Returned when it is called with a subcommand it doesn't know + UnknownCommand(UnknownCommand), + /// Return by the help command when it is called with a subcommand it doesn't know + UnknownCommandHelp(UnknownCommandHelp), + /// Returned when the value of a command line argument could not be parsed + ParseError(ParseDiagnostic), + /// Returned when the CLI doesn't recognize a command line argument + UnexpectedArgument(UnexpectedArgument), + /// Returned when a required argument is not present in the command line + MissingArgument(MissingArgument), + /// Returned when a subcommand is called without any arguments + EmptyArguments(EmptyArguments), + /// Returned when a subcommand is called with an unsupported combination of arguments + IncompatibleArguments(IncompatibleArguments), + /// Returned by a traversal command when error diagnostics were emitted + CheckError(CheckError), + /// Emitted when a file is fixed, but it still contains diagnostics. + /// + /// This happens when these diagnostics come from rules that don't have a code action. + FileCheck(FileCheck), + /// When an argument is higher than the expected maximum + OverflowNumberArgument(OverflowNumberArgument), + /// Wrapper for an underlying `biome_service` error + WorkspaceError(WorkspaceError), + /// Wrapper for an underlying `std::io` error + IoError(IoDiagnostic), + /// The daemon is not running + ServerNotRunning(ServerNotRunning), + /// The end configuration (`biome.json` + other options) is incompatible with the command + IncompatibleEndConfiguration(IncompatibleEndConfiguration), + /// No files processed during the file system traversal + NoFilesWereProcessed(NoFilesWereProcessed), + /// Emitted during the reporting phase + Report(ReportDiagnostic), + /// Emitted when there's an error emitted when using stdin mode + Stdin(StdinDiagnostic), +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message( + description = "Unknown command {command_name}", + message("Unknown command "{self.command_name}) + ), +)] +pub struct UnknownCommand { + command_name: String, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( +category = "flags/invalid", + severity = Error, + message( + description = "Cannot print help for unknown command {command_name}", + message("Cannot print help for unknown command "{self.command_name}) + ), +)] +pub struct UnknownCommandHelp { + command_name: String, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, +)] +pub struct ParseDiagnostic { + #[message] + #[description] + message: MessageAndDescription, + #[source] + source: Option, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message( + description = "Unrecognized option {argument}", + message("Unrecognized option "{self.argument}".") + ), +)] +pub struct UnexpectedArgument { + argument: String, + #[advice] + help: CliAdvice, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message( + description = "Unrecognized option {argument}", + message("Missing argument "{self.argument}) + ), +)] +pub struct MissingArgument { + argument: String, + #[advice] + advice: CliAdvice, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message = "Empty arguments" +)] +pub struct EmptyArguments; + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message( + description = "Incompatible arguments {first_argument} and {second_argument}", + message("Incompatible arguments "{self.first_argument}" and "{self.second_argument}) + ) +)] +pub struct IncompatibleArguments { + first_argument: String, + second_argument: String, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + severity = Error, +)] +pub struct CheckError { + #[category] + category: &'static Category, + + #[message] + message: MessageAndDescription, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + severity = Error, +)] +pub struct FileCheck { + #[message] + #[description] + pub message: MessageAndDescription, + + #[location(resource)] + pub file_path: String, + + #[category] + pub category: &'static Category, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "flags/invalid", + severity = Error, + message( + description = "The value of the argument {argument} is too high, maximum accepted {maximum}", + message("The value of the argument "{self.argument}" is too high, maximum accepted "{{self.maximum}}) + ) +)] +pub struct OverflowNumberArgument { + argument: String, + maximum: u16, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "internalError/io", + severity = Error, + message = "Errors occurred while executing I/O operations." +)] +pub struct IoDiagnostic { + #[source] + source: Option, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "internalError/io", + severity = Error, + message = "No running instance of the daemon server was found." +)] +pub struct ServerNotRunning; + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "internalError/io", + severity = Error, + message( + description = "The combination of configuration and arguments is invalid: \n{reason}", + message("The combination of configuration and arguments is invalid: \n"{{&self.reason}}) + ) +)] +pub struct IncompatibleEndConfiguration { + reason: String, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "internalError/io", + severity = Error, + message = "No files were processed in the specified paths." +)] +pub struct NoFilesWereProcessed; + +#[derive(Debug, Diagnostic)] +#[diagnostic( + category = "internalError/fs", + severity = Warning, + tags(DEPRECATED_CODE) +)] +pub struct DeprecatedArgument { + #[message] + pub message: MessageAndDescription, +} + +impl DeprecatedArgument { + pub fn new(message: impl Display) -> Self { + Self { + message: MessageAndDescription::from(markup! {{message}}.to_owned()), + } + } +} + +#[derive(Debug, Diagnostic)] +pub enum ReportDiagnostic { + /// Emitted when trying to serialise the report + Serialization(SerdeJsonError), +} + +/// Advices for the [CliDiagnostic] +#[derive(Debug, Default)] +struct CliAdvice { + /// Used to print the help command + sub_command: String, +} + +impl CliAdvice { + fn new_with_help(sub_command: impl Into) -> Self { + Self { + sub_command: sub_command.into(), + } + } +} + +impl Advices for CliAdvice { + fn record(&self, visitor: &mut dyn Visit) -> std::io::Result<()> { + let command_name = command_name(); + let help_sub_command = format!("{} {} --help", command_name, &self.sub_command); + visitor.record_log( + LogCategory::Info, + &markup! { "Type the following command for more information" }, + )?; + visitor.record_command(&help_sub_command)?; + + Ok(()) + } +} + +impl CliDiagnostic { + /// Returned when a subcommand is called with an unsupported combination of arguments + pub fn incompatible_arguments( + first_argument: impl Into, + second_argument: impl Into, + ) -> Self { + Self::IncompatibleArguments(IncompatibleArguments { + first_argument: first_argument.into(), + second_argument: second_argument.into(), + }) + } + + /// To throw when there's been an error while parsing an argument + pub fn parse_error_bpaf(source: bpaf::ParseFailure) -> Self { + Self::ParseError(ParseDiagnostic { + source: Some(Error::from(BpafError::from(source))), + message: MessageAndDescription::from("Failed to parse CLI arguments.".to_string()), + }) + } + + /// Returned when it is called with a subcommand it doesn't know + pub fn unknown_command(command: impl Into) -> Self { + Self::UnknownCommand(UnknownCommand { + command_name: command.into(), + }) + } + + /// Returned when a subcommand is called without any arguments + pub fn empty_arguments() -> Self { + Self::EmptyArguments(EmptyArguments) + } + + /// Returned when a required argument is not present in the command line + pub fn missing_argument(argument: impl Into, subcommand: impl Into) -> Self { + Self::MissingArgument(MissingArgument { + argument: argument.into(), + advice: CliAdvice::new_with_help(subcommand), + }) + } + + /// When no files were processed while traversing the file system + pub fn no_files_processed() -> Self { + Self::NoFilesWereProcessed(NoFilesWereProcessed) + } + + /// Returned when the CLI doesn't recognize a command line argument + pub fn unexpected_argument(argument: impl Into, subcommand: impl Into) -> Self { + Self::UnexpectedArgument(UnexpectedArgument { + argument: argument.into(), + help: CliAdvice::new_with_help(subcommand), + }) + } + + /// When there's been error inside the workspace + pub fn workspace_error(error: WorkspaceError) -> Self { + Self::WorkspaceError(error) + } + + /// An I/O error + pub fn io_error(error: std::io::Error) -> Self { + Self::IoError(IoDiagnostic { + source: Some(Error::from(IoError::from(error))), + }) + } + + /// Emitted when errors were emitted while running `check` command + pub fn check_error(category: &'static Category) -> Self { + Self::CheckError(CheckError { + category, + message: MessageAndDescription::from( + markup! { + "Some ""errors"" were emitted while ""running checks""." + } + .to_owned(), + ), + }) + } + + /// Emitted when warnings were emitted while running `check` command + pub fn check_warnings(category: &'static Category) -> Self { + Self::CheckError(CheckError { + category, + message: MessageAndDescription::from( + markup! { + "Some ""warnings"" were emitted while ""running checks""." + } + .to_owned(), + ), + }) + } + + /// Emitted when errors were emitted while apply code fixes + pub fn apply_error(category: &'static Category) -> Self { + Self::CheckError(CheckError { + category, + message: MessageAndDescription::from( + markup! { + "Some ""errors"" were emitted while ""applying fixes""." + } + .to_owned(), + ), + }) + } + /// Emitted when warnings were emitted while apply code fixes + pub fn apply_warnings(category: &'static Category) -> Self { + Self::CheckError(CheckError { + category, + message: MessageAndDescription::from( + markup! { + "Some ""warnings"" were emitted while ""running checks""." + } + .to_owned(), + ), + }) + } + + pub fn stdin() -> Self { + Self::Stdin(StdinDiagnostic::default()) + } + + /// Emitted when the server is not running + pub fn server_not_running() -> Self { + Self::ServerNotRunning(ServerNotRunning) + } + + /// Emitted when the end configuration (`biome.json` file + CLI arguments + LSP configuration) + /// results in a combination of options that doesn't allow to run the command correctly. + /// + /// A reason needs to be provided + pub fn incompatible_end_configuration(reason: impl Into) -> Self { + Self::IncompatibleEndConfiguration(IncompatibleEndConfiguration { + reason: reason.into(), + }) + } + + /// Emitted when an argument value is greater than the allowed value + pub fn overflown_argument(argument: impl Into, maximum: u16) -> Self { + Self::OverflowNumberArgument(OverflowNumberArgument { + argument: argument.into(), + maximum, + }) + } + + /// Return by the help command when it is called with a subcommand it doesn't know + pub fn new_unknown_help(command: impl Into) -> Self { + Self::UnknownCommandHelp(UnknownCommandHelp { + command_name: command.into(), + }) + } +} + +impl From for CliDiagnostic { + fn from(error: WorkspaceError) -> Self { + CliDiagnostic::workspace_error(error) + } +} + +impl From for CliDiagnostic { + fn from(error: std::io::Error) -> Self { + CliDiagnostic::io_error(error) + } +} + +impl Termination for CliDiagnostic { + fn report(self) -> ExitCode { + let severity = self.severity(); + if severity >= Severity::Error { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } + } +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( +category = "internalError/fs", + severity = Warning, + message( + description = "The configuration file {path} is deprecated. Use biome.json instead.", + message("The configuration file "{self.path}" is deprecated. Use ""biome.json"" instead."), + ) +)] +pub struct DeprecatedConfigurationFile { + #[location(resource)] + pub path: String, +} + +impl DeprecatedConfigurationFile { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } +} + +#[derive(Debug, Default, Diagnostic)] +#[diagnostic( + severity = Error, + category = "stdin", + message = "The contents aren't fixed. Use the `--fix` flag to fix them." +)] +pub struct StdinDiagnostic {} + +#[cfg(test)] +mod test { + use crate::CliDiagnostic; + + #[test] + fn termination_diagnostic_size() { + assert_eq!( + std::mem::size_of::(), + 80, + "you successfully decreased the size of the diagnostic!" + ) + } +} diff --git a/crates/pg_cli/src/execute/diagnostics.rs b/crates/pg_cli/src/execute/diagnostics.rs new file mode 100644 index 000000000..232709159 --- /dev/null +++ b/crates/pg_cli/src/execute/diagnostics.rs @@ -0,0 +1,72 @@ +use pg_diagnostics::adapters::{IoError, StdError}; +use pg_diagnostics::{Category, Diagnostic, DiagnosticExt, DiagnosticTags, Error}; +use std::io; + +#[derive(Debug, Diagnostic)] +#[diagnostic(category = "internalError/panic", tags(INTERNAL))] +pub(crate) struct PanicDiagnostic { + #[description] + #[message] + pub(crate) message: String, +} + +/// Extension trait for turning [Display]-able error types into [TraversalError] +pub(crate) trait ResultExt { + type Result; + fn with_file_path_and_code( + self, + file_path: String, + code: &'static Category, + ) -> Result; + + fn with_file_path_and_code_and_tags( + self, + file_path: String, + code: &'static Category, + tags: DiagnosticTags, + ) -> Result; +} + +impl ResultExt for Result +where + E: std::error::Error + Send + Sync + 'static, +{ + type Result = T; + + fn with_file_path_and_code_and_tags( + self, + file_path: String, + code: &'static Category, + diagnostic_tags: DiagnosticTags, + ) -> Result { + self.map_err(move |err| { + StdError::from(err) + .with_category(code) + .with_file_path(file_path) + .with_tags(diagnostic_tags) + }) + } + + fn with_file_path_and_code( + self, + file_path: String, + code: &'static Category, + ) -> Result { + self.map_err(move |err| { + StdError::from(err) + .with_category(code) + .with_file_path(file_path) + }) + } +} + +/// Extension trait for turning [io::Error] into [Error] +pub(crate) trait ResultIoExt: ResultExt { + fn with_file_path(self, file_path: String) -> Result; +} + +impl ResultIoExt for io::Result { + fn with_file_path(self, file_path: String) -> Result { + self.map_err(|error| IoError::from(error).with_file_path(file_path)) + } +} diff --git a/crates/pg_cli/src/execute/mod.rs b/crates/pg_cli/src/execute/mod.rs new file mode 100644 index 000000000..f96f330b3 --- /dev/null +++ b/crates/pg_cli/src/execute/mod.rs @@ -0,0 +1,302 @@ +mod diagnostics; +mod process_file; +mod std_in; +pub(crate) mod traverse; + +use crate::cli_options::{CliOptions, CliReporter}; +use crate::execute::traverse::{traverse, TraverseResult}; +use crate::reporter::github::{GithubReporter, GithubReporterVisitor}; +use crate::reporter::gitlab::{GitLabReporter, GitLabReporterVisitor}; +use crate::reporter::junit::{JunitReporter, JunitReporterVisitor}; +use crate::reporter::terminal::{ConsoleReporter, ConsoleReporterVisitor}; +use crate::{CliDiagnostic, CliSession, DiagnosticsPayload, Reporter}; +use pg_diagnostics::{category, Category}; +use pg_fs::PgLspPath; +use std::borrow::Borrow; +use std::ffi::OsString; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use tracing::info; + +/// Useful information during the traversal of files and virtual content +#[derive(Debug, Clone)] +pub struct Execution { + /// How the information should be collected and reported + report_mode: ReportMode, + + /// The modality of execution of the traversal + traversal_mode: TraversalMode, + + /// The maximum number of diagnostics that can be printed in console + max_diagnostics: u32, +} + +impl Execution { + pub fn report_mode(&self) -> &ReportMode { + &self.report_mode + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ExecutionEnvironment { + GitHub, +} + +/// A type that holds the information to execute the CLI via `stdin +#[derive(Debug, Clone)] +pub struct Stdin( + /// The virtual path to the file + PathBuf, + /// The content of the file + String, +); + +impl Stdin { + fn as_path(&self) -> &Path { + self.0.as_path() + } + + fn as_content(&self) -> &str { + self.1.as_str() + } +} + +impl From<(PathBuf, String)> for Stdin { + fn from((path, content): (PathBuf, String)) -> Self { + Self(path, content) + } +} + +#[derive(Debug, Clone)] +pub struct VcsTargeted { + pub staged: bool, + pub changed: bool, +} + +impl From<(bool, bool)> for VcsTargeted { + fn from((staged, changed): (bool, bool)) -> Self { + Self { staged, changed } + } +} + +#[derive(Debug, Clone)] +pub enum TraversalMode { + /// A dummy mode to be used when the CLI is not running any command + Dummy, +} + +impl Display for TraversalMode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + TraversalMode::Dummy { .. } => write!(f, "dummy"), + } + } +} + +/// Tells to the execution of the traversal how the information should be reported +#[derive(Copy, Clone, Debug)] +pub enum ReportMode { + /// Reports information straight to the console, it's the default mode + Terminal, + /// Reports information for GitHub + GitHub, + /// JUnit output + /// Ref: https://github.com/testmoapp/junitxml?tab=readme-ov-file#basic-junit-xml-structure + Junit, + /// Reports information in the [GitLab Code Quality](https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool) format. + GitLab, +} + +impl Default for ReportMode { + fn default() -> Self { + Self::Terminal {} + } +} + +impl From for ReportMode { + fn from(value: CliReporter) -> Self { + match value { + CliReporter::Default => Self::Terminal, + CliReporter::GitHub => Self::GitHub, + CliReporter::Junit => Self::Junit, + CliReporter::GitLab => Self::GitLab {}, + } + } +} + +impl Execution { + pub(crate) fn new(mode: TraversalMode) -> Self { + Self { + report_mode: ReportMode::default(), + traversal_mode: mode, + max_diagnostics: 20, + } + } + + /// It sets the reporting mode by reading the [CliOptions] + pub(crate) fn set_report(mut self, cli_options: &CliOptions) -> Self { + self.report_mode = cli_options.reporter.clone().into(); + self + } + + pub(crate) fn traversal_mode(&self) -> &TraversalMode { + &self.traversal_mode + } + + pub(crate) fn get_max_diagnostics(&self) -> u32 { + self.max_diagnostics + } + + pub(crate) fn as_diagnostic_category(&self) -> &'static Category { + match self.traversal_mode { + TraversalMode::Dummy { .. } => category!("dummy"), + } + } + + pub(crate) const fn is_dummy(&self) -> bool { + matches!(self.traversal_mode, TraversalMode::Dummy { .. }) + } + + /// Whether the traversal mode requires write access to files + pub(crate) const fn requires_write_access(&self) -> bool { + match self.traversal_mode { + TraversalMode::Dummy { .. } => false, + } + } + + pub(crate) fn as_stdin_file(&self) -> Option<&Stdin> { + match &self.traversal_mode { + TraversalMode::Dummy { .. } => None, + } + } + + pub(crate) fn is_vcs_targeted(&self) -> bool { + match &self.traversal_mode { + TraversalMode::Dummy { .. } => false, + } + } + + pub(crate) const fn is_check_apply(&self) -> bool { + false + } + + /// Returns [true] if the user used the `--write`/`--fix` option + pub(crate) fn is_write(&self) -> bool { + match self.traversal_mode { + TraversalMode::Dummy { .. } => false, + } + } +} + +/// Based on the [mode](TraversalMode), the function might launch a traversal of the file system +/// or handles the stdin file. +pub fn execute_mode( + mut execution: Execution, + mut session: CliSession, + cli_options: &CliOptions, + paths: Vec, +) -> Result<(), CliDiagnostic> { + // If a custom reporter was provided, let's lift the limit so users can see all of them + execution.max_diagnostics = if cli_options.reporter.is_default() { + cli_options.max_diagnostics.into() + } else { + info!("Removing the limit of --max-diagnostics, because of a reporter different from the default one: {}", cli_options.reporter); + u32::MAX + }; + + // don't do any traversal if there's some content coming from stdin + if let Some(stdin) = execution.as_stdin_file() { + let biome_path = PgLspPath::new(stdin.as_path()); + std_in::run( + session, + &execution, + biome_path, + stdin.as_content(), + cli_options.verbose, + ) + } else { + let TraverseResult { + summary, + evaluated_paths, + diagnostics, + } = traverse(&execution, &mut session, cli_options, paths)?; + let console = session.app.console; + let errors = summary.errors; + let skipped = summary.skipped; + let processed = summary.changed + summary.unchanged; + let should_exit_on_warnings = summary.warnings > 0 && cli_options.error_on_warnings; + + match execution.report_mode { + ReportMode::Terminal => { + let reporter = ConsoleReporter { + summary, + diagnostics_payload: DiagnosticsPayload { + verbose: cli_options.verbose, + diagnostic_level: cli_options.diagnostic_level, + diagnostics, + }, + execution: execution.clone(), + evaluated_paths, + }; + reporter.write(&mut ConsoleReporterVisitor(console))?; + } + ReportMode::GitHub => { + let reporter = GithubReporter { + diagnostics_payload: DiagnosticsPayload { + verbose: cli_options.verbose, + diagnostic_level: cli_options.diagnostic_level, + diagnostics, + }, + execution: execution.clone(), + }; + reporter.write(&mut GithubReporterVisitor(console))?; + } + ReportMode::GitLab => { + let reporter = GitLabReporter { + diagnostics: DiagnosticsPayload { + verbose: cli_options.verbose, + diagnostic_level: cli_options.diagnostic_level, + diagnostics, + }, + execution: execution.clone(), + }; + reporter.write(&mut GitLabReporterVisitor::new( + console, + session.app.fs.borrow().working_directory(), + ))?; + } + ReportMode::Junit => { + let reporter = JunitReporter { + summary, + diagnostics_payload: DiagnosticsPayload { + verbose: cli_options.verbose, + diagnostic_level: cli_options.diagnostic_level, + diagnostics, + }, + execution: execution.clone(), + }; + reporter.write(&mut JunitReporterVisitor::new(console))?; + } + } + + // Processing emitted error diagnostics, exit with a non-zero code + if processed.saturating_sub(skipped) == 0 && !cli_options.no_errors_on_unmatched { + Err(CliDiagnostic::no_files_processed()) + } else if errors > 0 || should_exit_on_warnings { + let category = execution.as_diagnostic_category(); + if should_exit_on_warnings { + if execution.is_check_apply() { + Err(CliDiagnostic::apply_warnings(category)) + } else { + Err(CliDiagnostic::check_warnings(category)) + } + } else if execution.is_check_apply() { + Err(CliDiagnostic::apply_error(category)) + } else { + Err(CliDiagnostic::check_error(category)) + } + } else { + Ok(()) + } + } +} diff --git a/crates/pg_cli/src/execute/process_file.rs b/crates/pg_cli/src/execute/process_file.rs new file mode 100644 index 000000000..90bf20330 --- /dev/null +++ b/crates/pg_cli/src/execute/process_file.rs @@ -0,0 +1,122 @@ +pub(crate) mod workspace_file; + +use crate::execute::traverse::TraversalOptions; +use crate::execute::TraversalMode; +use pg_diagnostics::Error; +use pg_fs::PgLspPath; +use std::marker::PhantomData; +use std::ops::Deref; + +#[derive(Debug)] +pub(crate) enum FileStatus { + /// File changed and it was a success + Changed, + /// File unchanged, and it was a success + Unchanged, + /// While handling the file, something happened + Message(Message), + /// A match was found while searching a file + SearchResult(usize, Message), + /// File ignored, it should not be count as "handled" + Ignored, + /// Files that belong to other tools and shouldn't be touched + Protected(String), +} + +impl FileStatus { + pub const fn is_changed(&self) -> bool { + matches!(self, Self::Changed) + } +} + +/// Wrapper type for messages that can be printed during the traversal process +#[derive(Debug)] +pub(crate) enum Message { + SkippedFixes { + /// Suggested fixes skipped during the lint traversal + skipped_suggested_fixes: u32, + }, + Failure, + Error(Error), + Diagnostics { + name: String, + content: String, + diagnostics: Vec, + skipped_diagnostics: u32, + }, +} + +impl Message { + pub(crate) const fn is_failure(&self) -> bool { + matches!(self, Message::Failure) + } +} + +#[derive(Debug)] +pub(crate) enum DiffKind { + Format, + OrganizeImports, + Assists, +} + +impl From for Message +where + Error: From, + D: std::fmt::Debug, +{ + fn from(err: D) -> Self { + Self::Error(Error::from(err)) + } +} + +/// The return type for [process_file], with the following semantics: +/// - `Ok(Success)` means the operation was successful (the file is added to +/// the `processed` counter) +/// - `Ok(Message(_))` means the operation was successful but a message still +/// needs to be printed (eg. the diff when not in CI or write mode) +/// - `Ok(Ignored)` means the file was ignored (the file is not added to the +/// `processed` or `skipped` counters) +/// - `Err(_)` means the operation failed and the file should be added to the +/// `skipped` counter +pub(crate) type FileResult = Result; + +/// Data structure that allows to pass [TraversalOptions] to multiple consumers, bypassing the +/// compiler constraints set by the lifetimes of the [TraversalOptions] +pub(crate) struct SharedTraversalOptions<'ctx, 'app> { + inner: &'app TraversalOptions<'ctx, 'app>, + _p: PhantomData<&'app ()>, +} + +impl<'ctx, 'app> SharedTraversalOptions<'ctx, 'app> { + fn new(t: &'app TraversalOptions<'ctx, 'app>) -> Self { + Self { + _p: PhantomData, + inner: t, + } + } +} + +impl<'ctx, 'app> Deref for SharedTraversalOptions<'ctx, 'app> { + type Target = TraversalOptions<'ctx, 'app>; + + fn deref(&self) -> &Self::Target { + self.inner + } +} + +/// This function performs the actual processing: it reads the file from disk +/// and parse it; analyze and / or format it; then it either fails if error +/// diagnostics were emitted, or compare the formatted code with the original +/// content of the file and emit a diff or write the new content to the disk if +/// write mode is enabled +pub(crate) fn process_file(ctx: &TraversalOptions, pglsp_path: &PgLspPath) -> FileResult { + tracing::trace_span!("process_file", path = ?pglsp_path).in_scope(move || { + let shared_context = &SharedTraversalOptions::new(ctx); + + match ctx.execution.traversal_mode { + TraversalMode::Dummy => { + unreachable!("The dummy mode should not be called for this file") + } + } + }) +} diff --git a/crates/pg_cli/src/execute/process_file/workspace_file.rs b/crates/pg_cli/src/execute/process_file/workspace_file.rs new file mode 100644 index 000000000..9be76c9aa --- /dev/null +++ b/crates/pg_cli/src/execute/process_file/workspace_file.rs @@ -0,0 +1,79 @@ +use crate::execute::diagnostics::{ResultExt, ResultIoExt}; +use crate::execute::process_file::SharedTraversalOptions; +use pg_diagnostics::{category, Error}; +use pg_fs::{File, OpenOptions, PgLspPath}; +use pg_workspace_new::workspace::{ChangeParams, FileGuard, OpenFileParams}; +use pg_workspace_new::{Workspace, WorkspaceError}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// Small wrapper that holds information and operations around the current processed file +pub(crate) struct WorkspaceFile<'ctx, 'app> { + guard: FileGuard<'app, dyn Workspace + 'ctx>, + file: Box, + pub(crate) path: PathBuf, +} + +impl<'ctx, 'app> WorkspaceFile<'ctx, 'app> { + /// It attempts to read the file from disk, creating a [FileGuard] and + /// saving these information internally + pub(crate) fn new( + ctx: &SharedTraversalOptions<'ctx, 'app>, + path: &Path, + ) -> Result { + let biome_path = PgLspPath::new(path); + let open_options = OpenOptions::default() + .read(true) + .write(ctx.execution.requires_write_access()); + let mut file = ctx + .fs + .open_with_options(path, open_options) + .with_file_path(path.display().to_string())?; + + let mut input = String::new(); + file.read_to_string(&mut input) + .with_file_path(path.display().to_string())?; + + let guard = FileGuard::open( + ctx.workspace, + OpenFileParams { + path: biome_path, + version: 0, + content: input.clone(), + }, + ) + .with_file_path_and_code(path.display().to_string(), category!("internalError/fs"))?; + + Ok(Self { + file, + guard, + path: PathBuf::from(path), + }) + } + + pub(crate) fn guard(&self) -> &FileGuard<'app, dyn Workspace + 'ctx> { + &self.guard + } + + pub(crate) fn input(&self) -> Result { + self.guard().get_file_content() + } + + pub(crate) fn as_extension(&self) -> Option<&OsStr> { + self.path.extension() + } + + /// It updates the workspace file with `new_content` + pub(crate) fn update_file(&mut self, new_content: impl Into) -> Result<(), Error> { + let new_content = new_content.into(); + + self.file + .set_content(new_content.as_bytes()) + .with_file_path(self.path.display().to_string())?; + self.guard.change_file( + self.file.file_version(), + vec![ChangeParams::overwrite(new_content)], + )?; + Ok(()) + } +} diff --git a/crates/pg_cli/src/execute/std_in.rs b/crates/pg_cli/src/execute/std_in.rs new file mode 100644 index 000000000..e9dbff1cf --- /dev/null +++ b/crates/pg_cli/src/execute/std_in.rs @@ -0,0 +1,21 @@ +//! In here, there are the operations that run via standard input +//! +use crate::execute::Execution; +use crate::{CliDiagnostic, CliSession}; +use pg_console::{markup, ConsoleExt}; +use pg_fs::PgLspPath; + +pub(crate) fn run<'a>( + session: CliSession, + mode: &'a Execution, + biome_path: PgLspPath, + content: &'a str, + verbose: bool, +) -> Result<(), CliDiagnostic> { + let workspace = &*session.app.workspace; + let console = &mut *session.app.console; + let version = 0; + + console.append(markup! {{content}}); + Ok(()) +} diff --git a/crates/pg_cli/src/execute/traverse.rs b/crates/pg_cli/src/execute/traverse.rs new file mode 100644 index 000000000..68e2ff26c --- /dev/null +++ b/crates/pg_cli/src/execute/traverse.rs @@ -0,0 +1,544 @@ +use super::process_file::{process_file, FileStatus, Message}; +use super::{Execution, TraversalMode}; +use crate::cli_options::CliOptions; +use crate::execute::diagnostics::PanicDiagnostic; +use crate::reporter::TraversalSummary; +use crate::{CliDiagnostic, CliSession}; +use crossbeam::channel::{unbounded, Receiver, Sender}; +use pg_diagnostics::DiagnosticTags; +use pg_diagnostics::{DiagnosticExt, Error, Resource, Severity}; +use pg_fs::{FileSystem, PathInterner, PgLspPath}; +use pg_fs::{TraversalContext, TraversalScope}; +use pg_workspace_new::dome::Dome; +use pg_workspace_new::workspace::IsPathIgnoredParams; +use pg_workspace_new::{Workspace, WorkspaceError}; +use rustc_hash::FxHashSet; +use std::collections::BTreeSet; +use std::sync::atomic::AtomicU32; +use std::sync::RwLock; +use std::{ + env::current_dir, + ffi::OsString, + panic::catch_unwind, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Once, + }, + thread, + time::{Duration, Instant}, +}; + +pub(crate) struct TraverseResult { + pub(crate) summary: TraversalSummary, + pub(crate) evaluated_paths: BTreeSet, + pub(crate) diagnostics: Vec, +} + +pub(crate) fn traverse( + execution: &Execution, + session: &mut CliSession, + cli_options: &CliOptions, + mut inputs: Vec, +) -> Result { + init_thread_pool(); + + if inputs.is_empty() { + match &execution.traversal_mode { + TraversalMode::Dummy => { + // If `--staged` or `--changed` is specified, it's acceptable for them to be empty, so ignore it. + if !execution.is_vcs_targeted() { + match current_dir() { + Ok(current_dir) => inputs.push(current_dir.into_os_string()), + Err(err) => return Err(CliDiagnostic::io_error(err)), + } + } + } + _ => { + if execution.as_stdin_file().is_none() && !cli_options.no_errors_on_unmatched { + return Err(CliDiagnostic::missing_argument( + "", + format!("{}", execution.traversal_mode), + )); + } + } + } + } + + let (interner, recv_files) = PathInterner::new(); + let (sender, receiver) = unbounded(); + + let changed = AtomicUsize::new(0); + let unchanged = AtomicUsize::new(0); + let matches = AtomicUsize::new(0); + let skipped = AtomicUsize::new(0); + + let fs = &*session.app.fs; + let workspace = &*session.app.workspace; + + let max_diagnostics = execution.get_max_diagnostics(); + let remaining_diagnostics = AtomicU32::new(max_diagnostics); + + let printer = DiagnosticsPrinter::new(execution) + .with_verbose(cli_options.verbose) + .with_diagnostic_level(cli_options.diagnostic_level) + .with_max_diagnostics(max_diagnostics); + + let (duration, evaluated_paths, diagnostics) = thread::scope(|s| { + let handler = thread::Builder::new() + .name(String::from("pglsp::console")) + .spawn_scoped(s, || printer.run(receiver, recv_files)) + .expect("failed to spawn console thread"); + + // The traversal context is scoped to ensure all the channels it + // contains are properly closed once the traversal finishes + let (elapsed, evaluated_paths) = traverse_inputs( + fs, + inputs, + &TraversalOptions { + fs, + workspace, + execution, + interner, + matches: &matches, + changed: &changed, + unchanged: &unchanged, + skipped: &skipped, + messages: sender, + remaining_diagnostics: &remaining_diagnostics, + evaluated_paths: RwLock::default(), + }, + ); + // wait for the main thread to finish + let diagnostics = handler.join().unwrap(); + + (elapsed, evaluated_paths, diagnostics) + }); + + let errors = printer.errors(); + let warnings = printer.warnings(); + let changed = changed.load(Ordering::Relaxed); + let unchanged = unchanged.load(Ordering::Relaxed); + let matches = matches.load(Ordering::Relaxed); + let skipped = skipped.load(Ordering::Relaxed); + let suggested_fixes_skipped = printer.skipped_fixes(); + let diagnostics_not_printed = printer.not_printed_diagnostics(); + Ok(TraverseResult { + summary: TraversalSummary { + changed, + unchanged, + duration, + errors, + matches, + warnings, + skipped, + suggested_fixes_skipped, + diagnostics_not_printed, + }, + evaluated_paths, + diagnostics, + }) +} + +/// This function will setup the global Rayon thread pool the first time it's called +/// +/// This is currently only used to assign friendly debug names to the threads of the pool +fn init_thread_pool() { + static INIT_ONCE: Once = Once::new(); + INIT_ONCE.call_once(|| { + rayon::ThreadPoolBuilder::new() + .thread_name(|index| format!("pglsp::worker_{index}")) + .build_global() + .expect("failed to initialize the global thread pool"); + }); +} + +/// Initiate the filesystem traversal tasks with the provided input paths and +/// run it to completion, returning the duration of the process and the evaluated paths +fn traverse_inputs( + fs: &dyn FileSystem, + inputs: Vec, + ctx: &TraversalOptions, +) -> (Duration, BTreeSet) { + let start = Instant::now(); + fs.traversal(Box::new(move |scope: &dyn TraversalScope| { + for input in inputs { + scope.evaluate(ctx, PathBuf::from(input)); + } + })); + + let paths = ctx.evaluated_paths(); + let dome = Dome::new(paths); + let mut iter = dome.iter(); + fs.traversal(Box::new(|scope: &dyn TraversalScope| { + while let Some(path) = iter.next_config() { + scope.handle(ctx, path.to_path_buf()); + } + + for path in iter { + scope.handle(ctx, path.to_path_buf()); + } + })); + + (start.elapsed(), ctx.evaluated_paths()) +} + +// struct DiagnosticsReporter<'ctx> {} + +struct DiagnosticsPrinter<'ctx> { + /// Execution of the traversal + execution: &'ctx Execution, + /// The maximum number of diagnostics the console thread is allowed to print + max_diagnostics: u32, + /// The approximate number of diagnostics the console will print before + /// folding the rest into the "skipped diagnostics" counter + remaining_diagnostics: AtomicU32, + /// Mutable reference to a boolean flag tracking whether the console thread + /// printed any error-level message + errors: AtomicU32, + /// Mutable reference to a boolean flag tracking whether the console thread + /// printed any warnings-level message + warnings: AtomicU32, + /// Whether the console thread should print diagnostics in verbose mode + verbose: bool, + /// The diagnostic level the console thread should print + diagnostic_level: Severity, + + not_printed_diagnostics: AtomicU32, + printed_diagnostics: AtomicU32, + total_skipped_suggested_fixes: AtomicU32, +} + +impl<'ctx> DiagnosticsPrinter<'ctx> { + fn new(execution: &'ctx Execution) -> Self { + Self { + errors: AtomicU32::new(0), + warnings: AtomicU32::new(0), + remaining_diagnostics: AtomicU32::new(0), + execution, + diagnostic_level: Severity::Hint, + verbose: false, + max_diagnostics: 20, + not_printed_diagnostics: AtomicU32::new(0), + printed_diagnostics: AtomicU32::new(0), + total_skipped_suggested_fixes: AtomicU32::new(0), + } + } + + fn with_verbose(mut self, verbose: bool) -> Self { + self.verbose = verbose; + self + } + + fn with_max_diagnostics(mut self, value: u32) -> Self { + self.max_diagnostics = value; + self + } + + fn with_diagnostic_level(mut self, value: Severity) -> Self { + self.diagnostic_level = value; + self + } + + fn errors(&self) -> u32 { + self.errors.load(Ordering::Relaxed) + } + + fn warnings(&self) -> u32 { + self.warnings.load(Ordering::Relaxed) + } + + fn not_printed_diagnostics(&self) -> u32 { + self.not_printed_diagnostics.load(Ordering::Relaxed) + } + + fn skipped_fixes(&self) -> u32 { + self.total_skipped_suggested_fixes.load(Ordering::Relaxed) + } + + /// Checks if the diagnostic we received from the thread should be considered or not. Logic: + /// - it should not be considered if its severity level is lower than the one provided via CLI; + /// - it should not be considered if it's a verbose diagnostic and the CLI **didn't** request a `--verbose` option. + fn should_skip_diagnostic(&self, severity: Severity, diagnostic_tags: DiagnosticTags) -> bool { + if severity < self.diagnostic_level { + return true; + } + + if diagnostic_tags.is_verbose() && !self.verbose { + return true; + } + + false + } + + /// Count the diagnostic, and then returns a boolean that tells if it should be printed + fn should_print(&self) -> bool { + let printed_diagnostics = self.printed_diagnostics.load(Ordering::Relaxed); + let should_print = printed_diagnostics < self.max_diagnostics; + if should_print { + self.printed_diagnostics.fetch_add(1, Ordering::Relaxed); + self.remaining_diagnostics.store( + self.max_diagnostics.saturating_sub(printed_diagnostics), + Ordering::Relaxed, + ); + } else { + self.not_printed_diagnostics.fetch_add(1, Ordering::Relaxed); + } + + should_print + } + + fn run(&self, receiver: Receiver, interner: Receiver) -> Vec { + let mut paths: FxHashSet = FxHashSet::default(); + + let mut diagnostics_to_print = vec![]; + + while let Ok(msg) = receiver.recv() { + match msg { + Message::SkippedFixes { + skipped_suggested_fixes, + } => { + self.total_skipped_suggested_fixes + .fetch_add(skipped_suggested_fixes, Ordering::Relaxed); + } + + Message::Failure => { + self.errors.fetch_add(1, Ordering::Relaxed); + } + + Message::Error(mut err) => { + let location = err.location(); + if self.should_skip_diagnostic(err.severity(), err.tags()) { + continue; + } + if err.severity() == Severity::Warning { + // *warnings += 1; + self.warnings.fetch_add(1, Ordering::Relaxed); + // self.warnings.set(self.warnings.get() + 1) + } + if let Some(Resource::File(file_path)) = location.resource.as_ref() { + // Retrieves the file name from the file ID cache, if it's a miss + // flush entries from the interner channel until it's found + let file_name = match paths.get(*file_path) { + Some(path) => Some(path), + None => loop { + match interner.recv() { + Ok(path) => { + paths.insert(path.display().to_string()); + if path.display().to_string() == *file_path { + break paths.get(&path.display().to_string()); + } + } + // In case the channel disconnected without sending + // the path we need, print the error without a file + // name (normally this should never happen) + Err(_) => break None, + } + }, + }; + + if let Some(path) = file_name { + err = err.with_file_path(path.as_str()); + } + } + + let should_print = self.should_print(); + + if should_print { + diagnostics_to_print.push(err); + } + } + + Message::Diagnostics { + name, + content, + diagnostics, + skipped_diagnostics, + } => { + self.not_printed_diagnostics + .fetch_add(skipped_diagnostics, Ordering::Relaxed); + + // is CI mode we want to print all the diagnostics + for diag in diagnostics { + let severity = diag.severity(); + if self.should_skip_diagnostic(severity, diag.tags()) { + continue; + } + if severity == Severity::Error { + self.errors.fetch_add(1, Ordering::Relaxed); + } + if severity == Severity::Warning { + self.warnings.fetch_add(1, Ordering::Relaxed); + } + + let should_print = self.should_print(); + + if should_print { + let diag = diag.with_file_path(&name).with_file_source_code(&content); + diagnostics_to_print.push(diag) + } + } + } + } + } + diagnostics_to_print + } +} + +/// Context object shared between directory traversal tasks +pub(crate) struct TraversalOptions<'ctx, 'app> { + /// Shared instance of [FileSystem] + pub(crate) fs: &'app dyn FileSystem, + /// Instance of [Workspace] used by this instance of the CLI + pub(crate) workspace: &'ctx dyn Workspace, + /// Determines how the files should be processed + pub(crate) execution: &'ctx Execution, + /// File paths interner cache used by the filesystem traversal + interner: PathInterner, + /// Shared atomic counter storing the number of changed files + changed: &'ctx AtomicUsize, + /// Shared atomic counter storing the number of unchanged files + unchanged: &'ctx AtomicUsize, + /// Shared atomic counter storing the number of unchanged files + matches: &'ctx AtomicUsize, + /// Shared atomic counter storing the number of skipped files + skipped: &'ctx AtomicUsize, + /// Channel sending messages to the display thread + pub(crate) messages: Sender, + /// The approximate number of diagnostics the console will print before + /// folding the rest into the "skipped diagnostics" counter + pub(crate) remaining_diagnostics: &'ctx AtomicU32, + + /// List of paths that should be processed + pub(crate) evaluated_paths: RwLock>, +} + +impl<'ctx, 'app> TraversalOptions<'ctx, 'app> { + pub(crate) fn increment_changed(&self, path: &PgLspPath) { + self.changed.fetch_add(1, Ordering::Relaxed); + self.evaluated_paths + .write() + .unwrap() + .replace(path.to_written()); + } + pub(crate) fn increment_unchanged(&self) { + self.unchanged.fetch_add(1, Ordering::Relaxed); + } + + pub(crate) fn increment_matches(&self, num_matches: usize) { + self.matches.fetch_add(num_matches, Ordering::Relaxed); + } + + /// Send a message to the display thread + pub(crate) fn push_message(&self, msg: impl Into) { + self.messages.send(msg.into()).ok(); + } + + pub(crate) fn protected_file(&self, pglsp_path: &PgLspPath) { + self.push_diagnostic( + WorkspaceError::protected_file(pglsp_path.display().to_string()).into(), + ) + } +} + +impl<'ctx, 'app> TraversalContext for TraversalOptions<'ctx, 'app> { + fn interner(&self) -> &PathInterner { + &self.interner + } + + fn evaluated_paths(&self) -> BTreeSet { + self.evaluated_paths.read().unwrap().clone() + } + + fn push_diagnostic(&self, error: Error) { + self.push_message(error); + } + + fn can_handle(&self, pglsp_path: &PgLspPath) -> bool { + let path = pglsp_path.as_path(); + if self.fs.path_is_dir(path) || self.fs.path_is_symlink(path) { + // handle: + // - directories + // - symlinks + // - unresolved symlinks + // e.g `symlink/subdir` where symlink points to a directory that includes `subdir`. + // Note that `symlink/subdir` is not an existing file. + let can_handle = !self + .workspace + .is_path_ignored(IsPathIgnoredParams { + pglsp_path: pglsp_path.clone(), + }) + .unwrap_or_else(|err| { + self.push_diagnostic(err.into()); + false + }); + return can_handle; + } + + // bail on fifo and socket files + if !self.fs.path_is_file(path) { + return false; + } + + match self.execution.traversal_mode() { + TraversalMode::Dummy { .. } => true, + } + } + + fn handle_path(&self, path: PgLspPath) { + handle_file(self, &path) + } + + fn store_path(&self, path: PgLspPath) { + self.evaluated_paths + .write() + .unwrap() + .insert(PgLspPath::new(path.as_path())); + } +} + +/// This function wraps the [process_file] function implementing the traversal +/// in a [catch_unwind] block and emit diagnostics in case of error (either the +/// traversal function returns Err or panics) +fn handle_file(ctx: &TraversalOptions, path: &PgLspPath) { + match catch_unwind(move || process_file(ctx, path)) { + Ok(Ok(FileStatus::Changed)) => { + ctx.increment_changed(path); + } + Ok(Ok(FileStatus::Unchanged)) => { + ctx.increment_unchanged(); + } + Ok(Ok(FileStatus::SearchResult(num_matches, msg))) => { + ctx.increment_unchanged(); + ctx.increment_matches(num_matches); + ctx.push_message(msg); + } + Ok(Ok(FileStatus::Message(msg))) => { + ctx.increment_unchanged(); + ctx.push_message(msg); + } + Ok(Ok(FileStatus::Protected(file_path))) => { + ctx.increment_unchanged(); + ctx.push_diagnostic(WorkspaceError::protected_file(file_path).into()); + } + Ok(Ok(FileStatus::Ignored)) => {} + Ok(Err(err)) => { + ctx.increment_unchanged(); + ctx.skipped.fetch_add(1, Ordering::Relaxed); + ctx.push_message(err); + } + Err(err) => { + let message = match err.downcast::() { + Ok(msg) => format!("processing panicked: {msg}"), + Err(err) => match err.downcast::<&'static str>() { + Ok(msg) => format!("processing panicked: {msg}"), + Err(_) => String::from("processing panicked"), + }, + }; + + ctx.push_message( + PanicDiagnostic { message }.with_file_path(path.display().to_string()), + ); + } + } +} diff --git a/crates/pg_cli/src/lib.rs b/crates/pg_cli/src/lib.rs new file mode 100644 index 000000000..2a4ae9a5c --- /dev/null +++ b/crates/pg_cli/src/lib.rs @@ -0,0 +1,107 @@ +//! # Module +//! +//! This is where the main CLI session starts. The module is responsible +//! to parse commands and arguments, redirect the execution of the commands and +//! execute the traversal of directory and files, based on the command that was passed. + +use pg_console::{ColorMode, Console}; +use pg_fs::OsFileSystem; +use pg_workspace_new::{App, DynRef, Workspace, WorkspaceRef}; +use std::env; + +mod cli_options; +mod commands; +mod diagnostics; +mod execute; +mod logging; +mod metrics; +mod panic; +mod reporter; +mod service; + +use crate::cli_options::ColorsArg; +pub use crate::commands::{pg_lsp_command, PgLspCommand}; +pub use crate::logging::{setup_cli_subscriber, LoggingLevel}; +pub use diagnostics::CliDiagnostic; +pub use execute::{execute_mode, Execution, TraversalMode, VcsTargeted}; +pub use panic::setup_panic_handler; +pub use reporter::{DiagnosticsPayload, Reporter, ReporterVisitor, TraversalSummary}; +pub use service::{open_transport, SocketTransport}; + +pub(crate) const VERSION: &str = match option_env!("PGLSP_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +/// Global context for an execution of the CLI +pub struct CliSession<'app> { + /// Instance of [App] used by this run of the CLI + pub app: App<'app>, +} + +impl<'app> CliSession<'app> { + pub fn new( + workspace: &'app dyn Workspace, + console: &'app mut dyn Console, + ) -> Result { + Ok(Self { + app: App::new( + DynRef::Owned(Box::::default()), + console, + WorkspaceRef::Borrowed(workspace), + ), + }) + } + + /// Main function to run the CLI + pub fn run(self, command: PgLspCommand) -> Result<(), CliDiagnostic> { + let has_metrics = command.has_metrics(); + if has_metrics { + crate::metrics::init_metrics(); + } + + let result = match command { + PgLspCommand::Version(_) => commands::version::full_version(self), + PgLspCommand::Clean => commands::clean::clean(self), + PgLspCommand::Start { + config_path, + log_path, + log_prefix_name, + } => commands::daemon::start(self, config_path, Some(log_path), Some(log_prefix_name)), + PgLspCommand::Stop => commands::daemon::stop(self), + PgLspCommand::Init => commands::init::init(self), + PgLspCommand::LspProxy { + config_path, + log_path, + log_prefix_name, + .. + } => commands::daemon::lsp_proxy(config_path, Some(log_path), Some(log_prefix_name)), + PgLspCommand::RunServer { + stop_on_disconnect, + config_path, + log_path, + log_prefix_name, + } => commands::daemon::run_server( + stop_on_disconnect, + config_path, + Some(log_path), + Some(log_prefix_name), + ), + PgLspCommand::PrintSocket => commands::daemon::print_socket(), + }; + + if has_metrics { + metrics::print_metrics(); + } + + result + } +} + +pub fn to_color_mode(color: Option<&ColorsArg>) -> ColorMode { + match color { + Some(ColorsArg::Off) => ColorMode::Disabled, + Some(ColorsArg::Force) => ColorMode::Enabled, + None => ColorMode::Auto, + } +} diff --git a/crates/pg_cli/src/logging.rs b/crates/pg_cli/src/logging.rs new file mode 100644 index 000000000..fc8d4e664 --- /dev/null +++ b/crates/pg_cli/src/logging.rs @@ -0,0 +1,173 @@ +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use tracing::subscriber::Interest; +use tracing::Metadata; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::layer::{Context, Filter, SubscriberExt}; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{registry, Layer}; + +pub fn setup_cli_subscriber(level: LoggingLevel, kind: LoggingKind) { + if level == LoggingLevel::None { + return; + } + let format = tracing_subscriber::fmt::layer() + .with_level(true) + .with_target(false) + .with_thread_names(true) + .with_file(true) + .with_ansi(true); + match kind { + LoggingKind::Pretty => { + let format = format.pretty(); + registry() + .with(format.with_filter(LoggingFilter { level })) + .init() + } + LoggingKind::Compact => { + let format = format.compact(); + registry() + .with(format.with_filter(LoggingFilter { level })) + .init() + } + LoggingKind::Json => { + let format = format.json().flatten_event(true); + + registry() + .with(format.with_filter(LoggingFilter { level })) + .init() + } + }; +} + +#[derive(Copy, Debug, Default, Clone, Ord, PartialOrd, Eq, PartialEq)] +pub enum LoggingLevel { + /// No logs should be shown + #[default] + None, + Debug, + Info, + Warn, + Error, +} + +impl LoggingLevel { + fn to_filter_level(self) -> Option { + match self { + LoggingLevel::None => None, + LoggingLevel::Info => Some(LevelFilter::INFO), + LoggingLevel::Warn => Some(LevelFilter::WARN), + LoggingLevel::Error => Some(LevelFilter::ERROR), + LoggingLevel::Debug => Some(LevelFilter::DEBUG), + } + } +} + +impl FromStr for LoggingLevel { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "none" => Ok(Self::None), + "info" => Ok(Self::Info), + "warn" => Ok(Self::Warn), + "error" => Ok(Self::Error), + "debug" => Ok(Self::Debug), + _ => Err("Unexpected value".to_string()), + } + } +} + +impl Display for LoggingLevel { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LoggingLevel::None => write!(f, "none"), + LoggingLevel::Debug => write!(f, "debug"), + LoggingLevel::Info => write!(f, "info"), + LoggingLevel::Warn => write!(f, "warn"), + LoggingLevel::Error => write!(f, "error"), + } + } +} + +/// Tracing filter enabling: +/// - All spans and events at level info or higher +/// - All spans and events at level debug in crates whose name starts with `biome` +struct LoggingFilter { + level: LoggingLevel, +} + +/// Tracing filter used for spans emitted by `biome*` crates +const SELF_FILTER: LevelFilter = if cfg!(debug_assertions) { + LevelFilter::TRACE +} else { + LevelFilter::DEBUG +}; + +impl LoggingFilter { + fn is_enabled(&self, meta: &Metadata<'_>) -> bool { + let filter = if meta.target().starts_with("biome") { + if let Some(level) = self.level.to_filter_level() { + level + } else { + return false; + } + } else { + LevelFilter::INFO + }; + + meta.level() <= &filter + } +} + +impl Filter for LoggingFilter { + fn enabled(&self, meta: &Metadata<'_>, _cx: &Context<'_, S>) -> bool { + self.is_enabled(meta) + } + + fn callsite_enabled(&self, meta: &'static Metadata<'static>) -> Interest { + if self.is_enabled(meta) { + Interest::always() + } else { + Interest::never() + } + } + + fn max_level_hint(&self) -> Option { + Some(SELF_FILTER) + } +} + +/// The kind of logging +#[derive(Copy, Debug, Default, Clone, Eq, PartialEq)] +pub enum LoggingKind { + /// A pretty log on multiple lines with nice colours + #[default] + Pretty, + /// A more cluttered logging + Compact, + /// Logs are emitted in JSON format + Json, +} + +impl Display for LoggingKind { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LoggingKind::Pretty => write!(f, "pretty"), + LoggingKind::Compact => write!(f, "compact"), + LoggingKind::Json => write!(f, "json"), + } + } +} + +impl FromStr for LoggingKind { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "compact" => Ok(Self::Compact), + "pretty" => Ok(Self::Pretty), + "json" => Ok(Self::Json), + _ => Err("This log kind doesn't exist".to_string()), + } + } +} diff --git a/crates/pg_cli/src/main.rs b/crates/pg_cli/src/main.rs new file mode 100644 index 000000000..e52b4cd1d --- /dev/null +++ b/crates/pg_cli/src/main.rs @@ -0,0 +1,68 @@ +//! This is the main binary + +use pg_cli::{ + open_transport, pg_lsp_command, setup_panic_handler, to_color_mode, CliDiagnostic, CliSession, + PgLspCommand, +}; +use pg_console::{markup, ConsoleExt, EnvConsole}; +use pg_diagnostics::{set_bottom_frame, Diagnostic, PrintDiagnostic}; +use pg_workspace_new::workspace; +use std::process::{ExitCode, Termination}; +use tokio::runtime::Runtime; + +#[cfg(target_os = "windows")] +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[cfg(all( + any(target_os = "macos", target_os = "linux"), + not(target_env = "musl") +))] +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +// Jemallocator does not work on aarch64 with musl, so we'll use the system allocator instead +#[cfg(all(target_env = "musl", target_os = "linux", target_arch = "aarch64"))] +#[global_allocator] +static GLOBAL: std::alloc::System = std::alloc::System; + +fn main() -> ExitCode { + setup_panic_handler(); + set_bottom_frame(main as usize); + + let mut console = EnvConsole::default(); + let command = pg_lsp_command().fallback_to_usage().run(); + + console.set_color(to_color_mode(command.get_color())); + + let is_verbose = command.is_verbose(); + let result = run_workspace(&mut console, command); + match result { + Err(termination) => { + if termination.tags().is_verbose() && is_verbose { + console.error(markup! {{PrintDiagnostic::verbose(&termination)}}) + } else { + console.error(markup! {{PrintDiagnostic::simple(&termination)}}) + } + termination.report() + } + Ok(_) => ExitCode::SUCCESS, + } +} + +fn run_workspace(console: &mut EnvConsole, command: PgLspCommand) -> Result<(), CliDiagnostic> { + // If the `--use-server` CLI flag is set, try to open a connection to an + // existing Biome server socket + let workspace = if command.should_use_server() { + let runtime = Runtime::new()?; + match open_transport(runtime)? { + Some(transport) => workspace::client(transport)?, + None => return Err(CliDiagnostic::server_not_running()), + } + } else { + workspace::server() + }; + + let session = CliSession::new(&*workspace, console)?; + session.run(command) +} diff --git a/crates/pg_cli/src/metrics.rs b/crates/pg_cli/src/metrics.rs new file mode 100644 index 000000000..03c88cb41 --- /dev/null +++ b/crates/pg_cli/src/metrics.rs @@ -0,0 +1,410 @@ +use std::{ + borrow::Cow, + hash::Hash, + ops::Sub, + ptr, + time::{Duration, Instant}, +}; + +use hdrhistogram::Histogram; +use rustc_hash::FxHashMap; +use std::sync::{LazyLock, Mutex, RwLock}; +use tracing::{span, subscriber::Interest, Level, Metadata, Subscriber}; +use tracing_subscriber::{ + layer::Context, + prelude::*, + registry::{LookupSpan, SpanRef}, + Layer, +}; + +/// Implementation of a tracing [Layer] that collects timing information for spans into [Histogram]s +struct MetricsLayer; + +static METRICS: LazyLock>>> = + LazyLock::new(RwLock::default); + +/// Static pointer to the metadata of a callsite, used as a unique identifier +/// for collecting spans created from there in the global metrics map +struct CallsiteKey(&'static Metadata<'static>); + +impl PartialEq for CallsiteKey { + fn eq(&self, other: &Self) -> bool { + ptr::eq(self.0, other.0) + } +} + +impl Eq for CallsiteKey {} + +impl Hash for CallsiteKey { + fn hash(&self, state: &mut H) { + ptr::hash(self.0, state); + } +} + +/// Single entry in the global callsite storage, containing handles to the +/// histograms associated with this callsite +enum CallsiteEntry { + /// Spans with the debug level only count their total duration + Debug { total: Histogram }, + /// Spans with the trace level count their total duration as well as + /// individual busy and idle times + Trace { + total: Histogram, + busy: Histogram, + idle: Histogram, + }, +} + +impl CallsiteEntry { + fn from_level(level: &Level) -> Self { + /// Number of significant figures retained by the histogram + const SIGNIFICANT_FIGURES: u8 = 3; + + match level { + &Level::TRACE => Self::Trace { + // SAFETY: Histogram::new only returns an error if the value of + // SIGNIFICANT_FIGURES is invalid, 3 is statically known to work + total: Histogram::new(SIGNIFICANT_FIGURES).unwrap(), + busy: Histogram::new(SIGNIFICANT_FIGURES).unwrap(), + idle: Histogram::new(SIGNIFICANT_FIGURES).unwrap(), + }, + _ => Self::Debug { + total: Histogram::new(SIGNIFICANT_FIGURES).unwrap(), + }, + } + } + + fn into_histograms(self, name: &str) -> Vec<(Cow, Histogram)> { + match self { + CallsiteEntry::Debug { total } => vec![(Cow::Borrowed(name), total)], + CallsiteEntry::Trace { total, busy, idle } => vec![ + (Cow::Borrowed(name), total), + (Cow::Owned(format!("{name}.busy")), busy), + (Cow::Owned(format!("{name}.idle")), idle), + ], + } + } +} + +/// Extension data attached to tracing spans to keep track of their idle and busy time +/// +/// Most of the associated code is based on the similar logic found in `tracing-subscriber` +/// for printing span timings to the console: +/// https://github.com/tokio-rs/tracing/blob/6f23c128fced6409008838a3223d76d7332d79e9/tracing-subscriber/src/fmt/fmt_subscriber.rs#L973 +struct Timings { + idle: u64, + busy: u64, + last: I, +} + +trait Timepoint: Sub + Copy + Sized { + fn now() -> Self; +} + +impl Timepoint for Instant { + fn now() -> Self { + Instant::now() + } +} + +impl Timings { + fn new() -> Self { + Self { + idle: 0, + busy: 0, + last: I::now(), + } + } + + /// Count the time between the last update and now as idle + fn enter(&mut self, now: I) { + self.idle += (now - self.last).as_nanos() as u64; + self.last = now; + } + + /// Count the time between the last update and now as busy + fn exit(&mut self, now: I) { + self.busy += (now - self.last).as_nanos() as u64; + self.last = now; + } + + /// Exit the timing for this span, and record it into a callsite entry + fn record(mut self, now: I, entry: &mut CallsiteEntry) { + self.exit(now); + + match entry { + CallsiteEntry::Debug { total } => { + total.record(self.busy + self.idle).unwrap(); + } + CallsiteEntry::Trace { total, busy, idle } => { + busy.record(self.busy).unwrap(); + idle.record(self.idle).unwrap(); + total.record(self.busy + self.idle).unwrap(); + } + } + } +} + +fn read_span<'ctx, S>(ctx: &'ctx Context<'_, S>, id: &span::Id) -> SpanRef<'ctx, S> +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + ctx.span(id) + .expect("Span not found, it should have been stored in the registry") +} + +impl Layer for MetricsLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + /// Only express interest in span callsites, disabling collection of events, + /// and create new histogram for the spans created by this callsite + fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest { + if !metadata.is_span() { + return Interest::never(); + } + + let entry = CallsiteEntry::from_level(metadata.level()); + + METRICS + .write() + .unwrap() + .insert(CallsiteKey(metadata), Mutex::new(entry)); + + Interest::always() + } + + /// When a new span is created, attach the timing data extension to it + fn on_new_span(&self, _attrs: &span::Attributes<'_>, id: &span::Id, ctx: Context<'_, S>) { + let span = read_span(&ctx, id); + let mut extensions = span.extensions_mut(); + + if extensions.get_mut::().is_none() { + extensions.insert(Timings::::new()); + } + } + + /// When a span is entered, start counting idle time for the parent span if + /// it exists and busy time for the entered span itself + fn on_enter(&self, id: &span::Id, ctx: Context<'_, S>) { + let span = read_span(&ctx, id); + + let now = Instant::now(); + if let Some(parent) = span.parent() { + let mut extensions = parent.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + // The parent span was busy until now + timings.exit(now); + } + } + + let mut extensions = span.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + // The child span was idle until now + timings.enter(now); + } + } + + /// When a span is exited, stop it from counting busy time and start + /// counting the parent as busy instead + fn on_exit(&self, id: &span::Id, ctx: Context<'_, S>) { + let span = read_span(&ctx, id); + + let now = Instant::now(); + let mut extensions = span.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + // Child span was busy until now + timings.exit(now); + } + + // Re-enter parent + if let Some(parent) = span.parent() { + let mut extensions = parent.extensions_mut(); + if let Some(timings) = extensions.get_mut::() { + // Parent span was idle until now + timings.enter(now); + } + } + } + + /// When a span is closed, extract its timing information and write it to + /// the associated histograms + fn on_close(&self, id: span::Id, ctx: Context<'_, S>) { + let span = read_span(&ctx, &id); + let mut extensions = span.extensions_mut(); + if let Some(timing) = extensions.remove::() { + let now = Instant::now(); + + // Acquire a read lock on the metrics storage, access the metrics entry + // associated with this call site and acquire a write lock on it + let metrics = METRICS.read().unwrap(); + let entry = metrics + .get(&CallsiteKey(span.metadata())) + .expect("callsite not found, it should have been registered in register_callsite"); + + let mut entry = entry.lock().unwrap(); + timing.record(now, &mut entry); + } + } +} + +/// Initializes metrics recording +pub fn init_metrics() { + // Create and injects the metrics recording layer with the tracing library + tracing_subscriber::registry().with(MetricsLayer).init(); +} + +/// Flush and print the recorded metrics to the console +pub fn print_metrics() { + let mut write_guard = METRICS.write().unwrap(); + let mut histograms: Vec<_> = write_guard + .drain() + .flat_map(|(key, entry)| entry.into_inner().unwrap().into_histograms(key.0.name())) + .collect(); + + histograms.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); + + for (key, histogram) in histograms { + // Print the header line for the histogram with its name, mean sample + // duration and standard deviation + println!( + "{}: mean = {:.1?}, stdev = {:.1?}", + key, + Duration::from_nanos(histogram.mean().round() as u64), + Duration::from_nanos(histogram.stdev().round() as u64), + ); + + // For each quantile bucket in the histogram print out the associated + // duration, a bar corresponding to the percentage of the total number + // of samples falling within this bucket and the percentile + // corresponding to this bucket + let total = histogram.len() as f64; + for v in histogram.iter_quantiles(1) { + let duration = Duration::from_nanos(v.value_iterated_to()); + + let count = v.count_since_last_iteration() as f64; + let bar_length = (count * 40.0 / total).ceil() as usize; + + println!( + "{: >7.1?} | {:40} | {:5.1}%", + duration, + "*".repeat(bar_length), + v.quantile_iterated_to() * 100.0, + ); + } + + // Print an empty line after each histogram + println!(); + } +} + +#[cfg(test)] +mod tests { + use std::{ops::Sub, thread, time::Duration}; + + use tracing::Level; + use tracing_subscriber::prelude::*; + + use super::{CallsiteEntry, CallsiteKey, MetricsLayer, Timepoint, Timings, METRICS}; + + #[derive(Clone, Copy)] + struct TestTime(u64); + + impl Sub for TestTime { + type Output = Duration; + + fn sub(self, rhs: Self) -> Self::Output { + Duration::from_nanos(self.0 - rhs.0) + } + } + + impl Timepoint for TestTime { + fn now() -> Self { + Self(0) + } + } + + #[test] + fn test_timing() { + let mut entry = CallsiteEntry::from_level(&Level::TRACE); + + for i in 1..=5 { + let mut timing = Timings::::new(); + + timing.enter(TestTime(i)); + + timing.record(TestTime(i * 2), &mut entry); + } + + let histograms = entry.into_histograms("test"); + for (name, histogram) in histograms { + let scale = match name.as_ref() { + "test" => 2.0, + "test.idle" | "test.busy" => 1.0, + _ => unreachable!(), + }; + + let sample_count = 5; + assert_eq!(histogram.len(), sample_count); + + let mean = 3.0 * scale; + assert_eq!(histogram.mean(), mean); + + let sum = (1..=5).fold(0.0, |sum, i| { + let sample = i as f64 * scale; + sum + (sample - mean).powi(2) + }); + + let stddev = (sum / sample_count as f64).sqrt(); + assert_eq!(histogram.stdev(), stddev); + + let s = scale as u64 - 1; + let expected_buckets = [ + (0, s, 0.0), + (1, 2 * s + 1, 0.2), + (1, 3 * s + 2, 0.4), + (1, 4 * s + 3, 0.6), + (1, 5 * s + 4, 0.8), + (1, 6 * s + 5, 1.0), + ]; + + for (bucket, expected) in histogram.iter_linear(scale as u64).zip(&expected_buckets) { + let (count, value, quantile) = *expected; + + assert_eq!(bucket.count_since_last_iteration(), count); + assert_eq!(bucket.value_iterated_to(), value); + assert_eq!(bucket.quantile_iterated_to(), quantile); + } + } + } + + #[test] + fn test_layer() { + let _guard = tracing_subscriber::registry() + .with(MetricsLayer) + .set_default(); + + let key = { + let span = tracing::trace_span!("test_layer"); + span.in_scope(|| { + thread::sleep(Duration::from_millis(1)); + }); + + span.metadata().expect("span is disabled") + }; + + let entry = { + let mut metrics = METRICS.write().unwrap(); + metrics.remove(&CallsiteKey(key)) + }; + + let entry = entry.expect("callsite does not exist in metrics storage"); + + let entry = entry.into_inner().unwrap(); + let histograms = entry.into_histograms(key.name()); + + for (_, histogram) in histograms { + assert_eq!(histogram.len(), 1); + } + } +} diff --git a/crates/pg_cli/src/panic.rs b/crates/pg_cli/src/panic.rs new file mode 100644 index 000000000..3e066bd23 --- /dev/null +++ b/crates/pg_cli/src/panic.rs @@ -0,0 +1,47 @@ +use std::{ + fmt::Write, + panic::{set_hook, PanicHookInfo}, + thread, +}; + +/// Installs a global panic handler to show a user-friendly error message +/// in case the CLI panics +pub fn setup_panic_handler() { + set_hook(Box::new(panic_handler)) +} + +fn panic_handler(info: &PanicHookInfo) { + // Buffer the error message to a string before printing it at once + // to prevent it from getting mixed with other errors if multiple threads + // panic at the same time + let mut error = String::new(); + + writeln!(error, "Encountered an unexpected error").unwrap(); + writeln!(error).unwrap(); + + writeln!(error, "This is a bug in PgLsp, not an error in your code, and we would appreciate it if you could report it along with the following information to help us fixing the issue:").unwrap(); + writeln!(error).unwrap(); + + if let Some(location) = info.location() { + writeln!(error, "Source Location: {location}").unwrap(); + } + + if let Some(thread) = thread::current().name() { + writeln!(error, "Thread Name: {thread}").unwrap(); + } + + let payload = info.payload(); + if let Some(msg) = payload.downcast_ref::<&'static str>() { + writeln!(error, "Message: {msg}").unwrap(); + } else if let Some(msg) = payload.downcast_ref::() { + writeln!(error, "Message: {msg}").unwrap(); + } + + // Write the panic to stderr + eprintln!("{error}"); + + // Write the panic to the log file, this is done last since the `tracing` + // infrastructure could panic a second time and abort the process, so we + // want to ensure the error has at least been logged to stderr beforehand + tracing::error!("{error}"); +} diff --git a/crates/pg_cli/src/reporter/github.rs b/crates/pg_cli/src/reporter/github.rs new file mode 100644 index 000000000..783ed758e --- /dev/null +++ b/crates/pg_cli/src/reporter/github.rs @@ -0,0 +1,45 @@ +use crate::{DiagnosticsPayload, Execution, Reporter, ReporterVisitor, TraversalSummary}; +use pg_console::{markup, Console, ConsoleExt}; +use pg_diagnostics::PrintGitHubDiagnostic; +use std::io; + +pub(crate) struct GithubReporter { + pub(crate) diagnostics_payload: DiagnosticsPayload, + pub(crate) execution: Execution, +} + +impl Reporter for GithubReporter { + fn write(self, visitor: &mut dyn ReporterVisitor) -> io::Result<()> { + visitor.report_diagnostics(&self.execution, self.diagnostics_payload)?; + Ok(()) + } +} +pub(crate) struct GithubReporterVisitor<'a>(pub(crate) &'a mut dyn Console); + +impl<'a> ReporterVisitor for GithubReporterVisitor<'a> { + fn report_summary( + &mut self, + _execution: &Execution, + _summary: TraversalSummary, + ) -> io::Result<()> { + Ok(()) + } + + fn report_diagnostics( + &mut self, + _execution: &Execution, + diagnostics_payload: DiagnosticsPayload, + ) -> io::Result<()> { + for diagnostic in &diagnostics_payload.diagnostics { + if diagnostic.severity() >= diagnostics_payload.diagnostic_level { + if diagnostic.tags().is_verbose() && diagnostics_payload.verbose { + self.0.log(markup! {{PrintGitHubDiagnostic(diagnostic)}}); + } else if !diagnostics_payload.verbose { + self.0.log(markup! {{PrintGitHubDiagnostic(diagnostic)}}); + } + } + } + + Ok(()) + } +} diff --git a/crates/pg_cli/src/reporter/gitlab.rs b/crates/pg_cli/src/reporter/gitlab.rs new file mode 100644 index 000000000..2948ddb34 --- /dev/null +++ b/crates/pg_cli/src/reporter/gitlab.rs @@ -0,0 +1,245 @@ +use crate::{DiagnosticsPayload, Execution, Reporter, ReporterVisitor, TraversalSummary}; +use path_absolutize::Absolutize; +use pg_console::fmt::{Display, Formatter}; +use pg_console::{markup, Console, ConsoleExt}; +use pg_diagnostics::display::SourceFile; +use pg_diagnostics::{Error, PrintDescription, Resource, Severity}; +use serde::Serialize; +use std::sync::RwLock; +use std::{ + collections::HashSet, + hash::{DefaultHasher, Hash, Hasher}, + path::{Path, PathBuf}, +}; + +pub struct GitLabReporter { + pub execution: Execution, + pub diagnostics: DiagnosticsPayload, +} + +impl Reporter for GitLabReporter { + fn write(self, visitor: &mut dyn ReporterVisitor) -> std::io::Result<()> { + visitor.report_diagnostics(&self.execution, self.diagnostics)?; + Ok(()) + } +} + +pub(crate) struct GitLabReporterVisitor<'a> { + console: &'a mut dyn Console, + repository_root: Option, +} + +#[derive(Default)] +struct GitLabHasher(HashSet); + +impl GitLabHasher { + /// Enforces uniqueness of generated fingerprints in the context of a + /// single report. + fn rehash_until_unique(&mut self, fingerprint: u64) -> u64 { + let mut current = fingerprint; + while self.0.contains(¤t) { + let mut hasher = DefaultHasher::new(); + current.hash(&mut hasher); + current = hasher.finish(); + } + + self.0.insert(current); + current + } +} + +impl<'a> GitLabReporterVisitor<'a> { + pub fn new(console: &'a mut dyn Console, repository_root: Option) -> Self { + Self { + console, + repository_root, + } + } +} + +impl<'a> ReporterVisitor for GitLabReporterVisitor<'a> { + fn report_summary(&mut self, _: &Execution, _: TraversalSummary) -> std::io::Result<()> { + Ok(()) + } + + fn report_diagnostics( + &mut self, + _execution: &Execution, + payload: DiagnosticsPayload, + ) -> std::io::Result<()> { + let hasher = RwLock::default(); + let diagnostics = GitLabDiagnostics(payload, &hasher, self.repository_root.as_deref()); + self.console.log(markup!({ diagnostics })); + Ok(()) + } +} + +struct GitLabDiagnostics<'a>( + DiagnosticsPayload, + &'a RwLock, + Option<&'a Path>, +); + +impl<'a> GitLabDiagnostics<'a> { + fn attempt_to_relativize(&self, subject: &str) -> Option { + let Ok(resolved) = Path::new(subject).absolutize() else { + return None; + }; + + let Ok(relativized) = resolved.strip_prefix(self.2?) else { + return None; + }; + + Some(relativized.to_path_buf()) + } + + fn compute_initial_fingerprint(&self, diagnostic: &Error, path: &str) -> u64 { + let location = diagnostic.location(); + let code = match location.span { + Some(span) => match location.source_code { + Some(source_code) => &source_code.text[span], + None => "", + }, + None => "", + }; + + let check_name = diagnostic + .category() + .map(|category| category.name()) + .unwrap_or_default(); + + calculate_hash(&Fingerprint { + check_name, + path, + code, + }) + } +} + +impl<'a> Display for GitLabDiagnostics<'a> { + fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> { + let mut hasher = self.1.write().unwrap(); + let gitlab_diagnostics: Vec<_> = self + .0 + .diagnostics + .iter() + .filter(|d| d.severity() >= self.0.diagnostic_level) + .filter(|d| { + if self.0.verbose { + d.tags().is_verbose() + } else { + true + } + }) + .filter_map(|biome_diagnostic| { + let absolute_path = match biome_diagnostic.location().resource { + Some(Resource::File(file)) => Some(file), + _ => None, + } + .unwrap_or_default(); + let path_buf = self.attempt_to_relativize(absolute_path); + let path = match path_buf { + Some(buf) => buf.to_str().unwrap_or(absolute_path).to_owned(), + None => absolute_path.to_owned(), + }; + + let initial_fingerprint = self.compute_initial_fingerprint(biome_diagnostic, &path); + let fingerprint = hasher.rehash_until_unique(initial_fingerprint); + + GitLabDiagnostic::try_from_diagnostic( + biome_diagnostic, + path.to_string(), + fingerprint, + ) + }) + .collect(); + let serialized = serde_json::to_string_pretty(&gitlab_diagnostics)?; + fmt.write_str(serialized.as_str())?; + Ok(()) + } +} + +/// An entry in the GitLab Code Quality report. +/// See https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool +#[derive(Serialize)] +pub struct GitLabDiagnostic<'a> { + /// A description of the code quality violation. + description: String, + /// A unique name representing the static analysis check that emitted this issue. + check_name: &'a str, + /// A unique fingerprint to identify the code quality violation. For example, an MD5 hash. + fingerprint: String, + /// A severity string (can be info, minor, major, critical, or blocker). + severity: &'a str, + /// The location where the code quality violation occurred. + location: Location, +} + +impl<'a> GitLabDiagnostic<'a> { + pub fn try_from_diagnostic( + diagnostic: &'a Error, + path: String, + fingerprint: u64, + ) -> Option { + let location = diagnostic.location(); + let span = location.span?; + let source_code = location.source_code?; + let description = PrintDescription(diagnostic).to_string(); + let begin = match SourceFile::new(source_code).location(span.start()) { + Ok(start) => start.line_number.get(), + Err(_) => return None, + }; + let check_name = diagnostic + .category() + .map(|category| category.name()) + .unwrap_or_default(); + + Some(GitLabDiagnostic { + severity: match diagnostic.severity() { + Severity::Hint => "info", + Severity::Information => "minor", + Severity::Warning => "major", + Severity::Error => "critical", + Severity::Fatal => "blocker", + }, + description, + check_name, + // A u64 does not fit into a JSON number, so we serialize this as a + // string + fingerprint: fingerprint.to_string(), + location: Location { + path, + lines: Lines { begin }, + }, + }) + } +} + +#[derive(Serialize)] +struct Location { + /// The relative path to the file containing the code quality violation. + path: String, + lines: Lines, +} + +#[derive(Serialize)] +struct Lines { + /// The line on which the code quality violation occurred. + begin: usize, +} + +#[derive(Hash)] +struct Fingerprint<'a> { + // Including the source code in our hash leads to more stable + // fingerprints. If you instead rely on e.g. the line number and change + // the first line of a file, all of its fingerprint would change. + code: &'a str, + check_name: &'a str, + path: &'a str, +} + +fn calculate_hash(t: &T) -> u64 { + let mut s = DefaultHasher::new(); + t.hash(&mut s); + s.finish() +} diff --git a/crates/pg_cli/src/reporter/junit.rs b/crates/pg_cli/src/reporter/junit.rs new file mode 100644 index 000000000..a313b1289 --- /dev/null +++ b/crates/pg_cli/src/reporter/junit.rs @@ -0,0 +1,121 @@ +use crate::{DiagnosticsPayload, Execution, Reporter, ReporterVisitor, TraversalSummary}; +use pg_console::{markup, Console, ConsoleExt}; +use pg_diagnostics::display::SourceFile; +use pg_diagnostics::{Error, Resource}; +use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite}; +use std::fmt::{Display, Formatter}; +use std::io; + +pub(crate) struct JunitReporter { + pub(crate) diagnostics_payload: DiagnosticsPayload, + pub(crate) execution: Execution, + pub(crate) summary: TraversalSummary, +} + +impl Reporter for JunitReporter { + fn write(self, visitor: &mut dyn ReporterVisitor) -> io::Result<()> { + visitor.report_summary(&self.execution, self.summary)?; + visitor.report_diagnostics(&self.execution, self.diagnostics_payload)?; + Ok(()) + } +} + +struct JunitDiagnostic<'a> { + diagnostic: &'a Error, +} + +impl<'a> Display for JunitDiagnostic<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.diagnostic.description(f) + } +} + +pub(crate) struct JunitReporterVisitor<'a>(pub(crate) Report, pub(crate) &'a mut dyn Console); + +impl<'a> JunitReporterVisitor<'a> { + pub(crate) fn new(console: &'a mut dyn Console) -> Self { + let report = Report::new("Biome"); + Self(report, console) + } +} + +impl<'a> ReporterVisitor for JunitReporterVisitor<'a> { + fn report_summary( + &mut self, + _execution: &Execution, + summary: TraversalSummary, + ) -> io::Result<()> { + self.0.time = Some(summary.duration); + self.0.errors = summary.errors as usize; + + Ok(()) + } + + fn report_diagnostics( + &mut self, + _execution: &Execution, + payload: DiagnosticsPayload, + ) -> io::Result<()> { + let diagnostics = payload.diagnostics.iter().filter(|diagnostic| { + if diagnostic.tags().is_verbose() { + payload.verbose + } else { + true + } + }); + + for diagnostic in diagnostics { + let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure); + let message = format!("{}", JunitDiagnostic { diagnostic }); + status.set_message(message.clone()); + + let location = diagnostic.location(); + + if let (Some(span), Some(source_code), Some(resource)) = + (location.span, location.source_code, location.resource) + { + let source = SourceFile::new(source_code); + let start = source.location(span.start())?; + + status.set_description(format!( + "line {row:?}, col {col:?}, {body}", + row = start.line_number.to_zero_indexed(), + col = start.column_number.to_zero_indexed(), + body = message + )); + let mut case = TestCase::new( + format!( + "org.pglsp.{}", + diagnostic + .category() + .map(|c| c.name()) + .unwrap_or_default() + .replace('/', ".") + ), + status, + ); + + if let Resource::File(path) = resource { + let mut test_suite = TestSuite::new(path); + case.extra + .insert("line".into(), start.line_number.get().to_string().into()); + case.extra.insert( + "column".into(), + start.column_number.get().to_string().into(), + ); + test_suite + .extra + .insert("package".into(), "org.pglsp".into()); + test_suite.add_test_case(case); + self.0.add_test_suite(test_suite); + } + } + } + + self.1.log(markup! { + {self.0.to_string().unwrap()} + }); + + Ok(()) + } +} diff --git a/crates/pg_cli/src/reporter/mod.rs b/crates/pg_cli/src/reporter/mod.rs new file mode 100644 index 000000000..adc7c0235 --- /dev/null +++ b/crates/pg_cli/src/reporter/mod.rs @@ -0,0 +1,63 @@ +pub(crate) mod github; +pub(crate) mod gitlab; +pub(crate) mod junit; +pub(crate) mod terminal; + +use crate::execute::Execution; +use pg_diagnostics::{Error, Severity}; +use pg_fs::PgLspPath; +use serde::Serialize; +use std::collections::BTreeSet; +use std::io; +use std::time::Duration; + +pub struct DiagnosticsPayload { + pub diagnostics: Vec, + pub verbose: bool, + pub diagnostic_level: Severity, +} + +/// A type that holds the result of the traversal +#[derive(Debug, Default, Serialize, Copy, Clone)] +pub struct TraversalSummary { + pub changed: usize, + pub unchanged: usize, + pub matches: usize, + // We skip it during testing because the time isn't predictable + #[cfg_attr(debug_assertions, serde(skip))] + pub duration: Duration, + pub errors: u32, + pub warnings: u32, + pub skipped: usize, + pub suggested_fixes_skipped: u32, + pub diagnostics_not_printed: u32, +} + +/// When using this trait, the type that implements this trait is the one that holds the read-only information to pass around +pub trait Reporter: Sized { + /// Writes the summary using the underling visitor + fn write(self, visitor: &mut dyn ReporterVisitor) -> io::Result<()>; +} + +/// When using this trait, the type that implements this trait is the one that will **write** the data, ideally inside a buffer +pub trait ReporterVisitor { + /// Writes the summary in the underling writer + fn report_summary( + &mut self, + execution: &Execution, + summary: TraversalSummary, + ) -> io::Result<()>; + + /// Writes the paths that were handled during a run. + fn report_handled_paths(&mut self, evaluated_paths: BTreeSet) -> io::Result<()> { + let _ = evaluated_paths; + Ok(()) + } + + /// Writes a diagnostics + fn report_diagnostics( + &mut self, + execution: &Execution, + payload: DiagnosticsPayload, + ) -> io::Result<()>; +} diff --git a/crates/pg_cli/src/reporter/terminal.rs b/crates/pg_cli/src/reporter/terminal.rs new file mode 100644 index 000000000..1aba58d9f --- /dev/null +++ b/crates/pg_cli/src/reporter/terminal.rs @@ -0,0 +1,187 @@ +use crate::execute::{Execution, TraversalMode}; +use crate::reporter::{DiagnosticsPayload, ReporterVisitor, TraversalSummary}; +use crate::Reporter; +use pg_console::fmt::Formatter; +use pg_console::{fmt, markup, Console, ConsoleExt}; +use pg_diagnostics::advice::ListAdvice; +use pg_diagnostics::{Diagnostic, PrintDiagnostic}; +use pg_fs::PgLspPath; +use std::collections::BTreeSet; +use std::io; +use std::time::Duration; + +pub(crate) struct ConsoleReporter { + pub(crate) summary: TraversalSummary, + pub(crate) diagnostics_payload: DiagnosticsPayload, + pub(crate) execution: Execution, + pub(crate) evaluated_paths: BTreeSet, +} + +impl Reporter for ConsoleReporter { + fn write(self, visitor: &mut dyn ReporterVisitor) -> io::Result<()> { + let verbose = self.diagnostics_payload.verbose; + visitor.report_diagnostics(&self.execution, self.diagnostics_payload)?; + visitor.report_summary(&self.execution, self.summary)?; + if verbose { + visitor.report_handled_paths(self.evaluated_paths)?; + } + Ok(()) + } +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + tags(VERBOSE), + severity = Information, + message = "Files processed:" +)] +struct EvaluatedPathsDiagnostic { + #[advice] + advice: ListAdvice, +} + +#[derive(Debug, Diagnostic)] +#[diagnostic( + tags(VERBOSE), + severity = Information, + message = "Files fixed:" +)] +struct FixedPathsDiagnostic { + #[advice] + advice: ListAdvice, +} + +pub(crate) struct ConsoleReporterVisitor<'a>(pub(crate) &'a mut dyn Console); + +impl<'a> ReporterVisitor for ConsoleReporterVisitor<'a> { + fn report_summary( + &mut self, + execution: &Execution, + summary: TraversalSummary, + ) -> io::Result<()> { + self.0.log(markup! { + {ConsoleTraversalSummary(execution.traversal_mode(), &summary)} + }); + + Ok(()) + } + + fn report_handled_paths(&mut self, evaluated_paths: BTreeSet) -> io::Result<()> { + let evaluated_paths_diagnostic = EvaluatedPathsDiagnostic { + advice: ListAdvice { + list: evaluated_paths + .iter() + .map(|p| p.display().to_string()) + .collect(), + }, + }; + + let fixed_paths_diagnostic = FixedPathsDiagnostic { + advice: ListAdvice { + list: evaluated_paths + .iter() + .filter(|p| p.was_written()) + .map(|p| p.display().to_string()) + .collect(), + }, + }; + + self.0.log(markup! { + {PrintDiagnostic::verbose(&evaluated_paths_diagnostic)} + }); + self.0.log(markup! { + {PrintDiagnostic::verbose(&fixed_paths_diagnostic)} + }); + + Ok(()) + } + + fn report_diagnostics( + &mut self, + _execution: &Execution, + diagnostics_payload: DiagnosticsPayload, + ) -> io::Result<()> { + for diagnostic in &diagnostics_payload.diagnostics { + if diagnostic.severity() >= diagnostics_payload.diagnostic_level { + if diagnostic.tags().is_verbose() && diagnostics_payload.verbose { + self.0 + .error(markup! {{PrintDiagnostic::verbose(diagnostic)}}); + } else { + self.0 + .error(markup! {{PrintDiagnostic::simple(diagnostic)}}); + } + } + } + + Ok(()) + } +} + +struct Files(usize); + +impl fmt::Display for Files { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_markup(markup!({self.0} " "))?; + if self.0 == 1 { + fmt.write_str("file") + } else { + fmt.write_str("files") + } + } +} + +struct SummaryDetail<'a>(pub(crate) &'a TraversalMode, usize); + +impl<'a> fmt::Display for SummaryDetail<'a> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + if self.1 > 0 { + fmt.write_markup(markup! { + " Fixed "{Files(self.1)}"." + }) + } else { + fmt.write_markup(markup! { + " No fixes applied." + }) + } + } +} +struct SummaryTotal<'a>(&'a TraversalMode, usize, &'a Duration); + +impl<'a> fmt::Display for SummaryTotal<'a> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + let files = Files(self.1); + match self.0 { + TraversalMode::Dummy { .. } => fmt.write_markup(markup! { + "Dummy "{files}" in "{self.2}"." + }), + } + } +} + +pub(crate) struct ConsoleTraversalSummary<'a>( + pub(crate) &'a TraversalMode, + pub(crate) &'a TraversalSummary, +); +impl<'a> fmt::Display for ConsoleTraversalSummary<'a> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + let summary = SummaryTotal(self.0, self.1.changed + self.1.unchanged, &self.1.duration); + let detail = SummaryDetail(self.0, self.1.changed); + fmt.write_markup(markup!({summary}{detail}))?; + + if self.1.errors > 0 { + if self.1.errors == 1 { + fmt.write_markup(markup!("\n""Found "{self.1.errors}" error."))?; + } else { + fmt.write_markup(markup!("\n""Found "{self.1.errors}" errors."))?; + } + } + if self.1.warnings > 0 { + if self.1.warnings == 1 { + fmt.write_markup(markup!("\n""Found "{self.1.warnings}" warning."))?; + } else { + fmt.write_markup(markup!("\n""Found "{self.1.warnings}" warnings."))?; + } + } + Ok(()) + } +} diff --git a/crates/pg_cli/src/service/mod.rs b/crates/pg_cli/src/service/mod.rs new file mode 100644 index 000000000..6b137c051 --- /dev/null +++ b/crates/pg_cli/src/service/mod.rs @@ -0,0 +1,474 @@ +//! Implements the OS dependent transport layer for the server protocol. This +//! uses a domain socket created in the global temporary directory on Unix +//! systems, and a named pipe on Windows. The protocol used for message frames +//! is based on the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#baseProtocol), +//! a simplified derivative of the HTTP protocol + +use std::{ + any::type_name, + borrow::Cow, + io, + ops::Deref, + panic::RefUnwindSafe, + str::{from_utf8, FromStr}, + sync::Arc, + time::Duration, +}; + +use anyhow::{bail, ensure, Context, Error}; +use dashmap::DashMap; +use pg_workspace_new::{ + workspace::{TransportRequest, WorkspaceTransport}, + TransportError, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{ + from_slice, from_str, to_vec, + value::{to_raw_value, RawValue}, + Value, +}; +use tokio::{ + io::{ + AsyncBufRead, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, + BufReader, BufWriter, + }, + runtime::Runtime, + sync::{ + mpsc::{channel, Receiver, Sender}, + oneshot, Notify, + }, + time::sleep, +}; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use self::windows::{ + ensure_daemon, enumerate_pipes, open_socket, print_socket, run_daemon, +}; + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use self::unix::{ensure_daemon, open_socket, print_socket, run_daemon}; + +/// Tries to open a connection to a running daemon instance, returning a +/// [WorkspaceTransport] instance if the socket is currently active +pub fn open_transport(runtime: Runtime) -> io::Result> { + match runtime.block_on(open_socket()) { + Ok(Some((read, write))) => Ok(Some(SocketTransport::open(runtime, read, write))), + Ok(None) => Ok(None), + Err(err) => Err(err), + } +} + +type JsonRpcResult = Result, TransportError>; + +/// Implementation of [WorkspaceTransport] for types implementing [AsyncRead] +/// and [AsyncWrite] +/// +/// This structs holds an instance of the `tokio` runtime, as well as the +/// following fields: +/// - `write_send` is a sender handle to the "write channel", an MPSC channel +/// that's used to queue up requests to be sent to the server (for simplicity +/// the requests are pushed to the channel as serialized byte buffers) +/// - `pending_requests` is handle to a shared hashmap where the keys are `u64` +/// corresponding to request IDs, and the values are sender handles to oneshot +/// channel instances that can be consumed to fulfill the associated request +/// +/// Creating a new `SocketTransport` instance requires providing a `tokio` +/// runtime instance as well as the "read half" and "write half" of the socket +/// object to be used by this transport instance. These two objects implement +/// [AsyncRead] and [AsyncWrite] respectively, and should generally map to the +/// same underlying I/O object but are represented as separate so they can be +/// used concurrently +/// +/// This concurrent handling of I/O is implemented using two "background tasks": +/// - the `write_task` pulls outgoing messages from the "write channel" and +/// writes them to the "write half" of the socket +/// - the `read_task` reads incoming messages from the "read half" of the +/// socket, then looks up a request with an ID corresponding to the received +/// message in the "pending requests" map. If a pending request is found, it's +/// fulfilled with the content of the message that was just received +/// +/// In addition to these, a new "foreground task" is created for each request. +/// Each foreground task creates a oneshot channel and stores it in the pending +/// requests map using the request ID as a key, then serialize the content of +/// the request and send it over the write channel. Finally, the task blocks +/// the current thread until a response is received over the oneshot channel +/// from the read task, or the request times out +pub struct SocketTransport { + runtime: Runtime, + write_send: Sender<(Vec, bool)>, + pending_requests: PendingRequests, +} + +/// Stores a handle to the map of pending requests, and clears the map +/// automatically when the handle is dropped +#[derive(Clone, Default)] +struct PendingRequests { + inner: Arc>>, +} + +impl Deref for PendingRequests { + type Target = DashMap>; + + fn deref(&self) -> &Self::Target { + self.inner.as_ref() + } +} + +/// There are two live handles to the pending requests map: one is in the +/// `SocketTransport` and the other in the `read_task`. The `SocketTransport` +/// instance can only be dropped if it's empty (since the `request` method +/// blocks until the request is resolved, `&self` will always outlive any +/// pending request), but the `read_task` may abort if it encounters an error +/// or receives a shutdown broadcast while there are still pending requests. In +/// this case the `Drop` implementation will ensure that all pending requests +/// are cancelled immediately instead of timing out. +impl Drop for PendingRequests { + fn drop(&mut self) { + self.inner.clear(); + } +} + +impl SocketTransport { + pub fn open(runtime: Runtime, socket_read: R, socket_write: W) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + /// Capacity of the "write channel", once this many requests have been + /// queued up, calls to `write_send.send` will block the sending task + /// until enough capacity is available again + /// + /// Note that this does not limit how many requests can be in flight at + /// a given time, it only serves as a loose rate-limit on how many new + /// requests can be sent to the server within a given time frame + const WRITE_CHANNEL_CAPACITY: usize = 16; + + let (write_send, write_recv) = channel(WRITE_CHANNEL_CAPACITY); + + let pending_requests = PendingRequests::default(); + let pending_requests_2 = pending_requests.clone(); + + let socket_read = BufReader::new(socket_read); + let socket_write = BufWriter::new(socket_write); + + let broadcast_shutdown = Arc::new(Notify::new()); + + runtime.spawn(write_task( + broadcast_shutdown.clone(), + write_recv, + socket_write, + )); + + runtime.spawn(async move { + tokio::select! { + _ = read_task(socket_read, &pending_requests) => {} + _ = broadcast_shutdown.notified() => {} + } + }); + + Self { + runtime, + write_send, + pending_requests: pending_requests_2, + } + } +} + +// Allow the socket to be recovered across panic boundaries +impl RefUnwindSafe for SocketTransport {} + +impl WorkspaceTransport for SocketTransport { + fn request(&self, request: TransportRequest

) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + let (send, recv) = oneshot::channel(); + + self.pending_requests.insert(request.id, send); + + let is_shutdown = request.method == "pglsp/shutdown"; + + let request = JsonRpcRequest { + jsonrpc: Cow::Borrowed("2.0"), + id: request.id, + method: Cow::Borrowed(request.method), + params: request.params, + }; + + let request = to_vec(&request).map_err(|err| { + TransportError::SerdeError(format!( + "failed to serialize {} into byte buffer: {err}", + type_name::

() + )) + })?; + + let response = self.runtime.block_on(async move { + self.write_send + .send((request, is_shutdown)) + .await + .map_err(|_| TransportError::ChannelClosed)?; + + tokio::select! { + result = recv => { + match result { + Ok(Ok(response)) => Ok(response), + Ok(Err(error)) => Err(error), + Err(_) => Err(TransportError::ChannelClosed), + } + } + _ = sleep(Duration::from_secs(15)) => { + Err(TransportError::Timeout) + } + } + })?; + + let response = response.get(); + let result = from_str(response).map_err(|err| { + TransportError::SerdeError(format!( + "failed to deserialize {} from {response:?}: {err}", + type_name::() + )) + })?; + + Ok(result) + } +} + +async fn read_task(mut socket_read: BufReader, pending_requests: &PendingRequests) +where + R: AsyncRead + Unpin, +{ + loop { + let message = read_message(&mut socket_read).await; + let message = match message { + Ok(message) => { + let response = from_slice(&message).with_context(|| { + if let Ok(message) = from_utf8(&message) { + format!("failed to deserialize JSON-RPC response from {message:?}") + } else { + format!("failed to deserialize JSON-RPC response from {message:?}") + } + }); + + response.map(|response| (message, response)) + } + Err(err) => Err(err), + }; + + let (message, response): (_, JsonRpcResponse) = match message { + Ok(message) => message, + Err(err) => { + eprintln!( + "{:?}", + err.context("remote connection read task exited with an error") + ); + break; + } + }; + + if let Some((_, channel)) = pending_requests.remove(&response.id) { + let response = match (response.result, response.error) { + (Some(result), None) => Ok(result), + (None, Some(err)) => Err(TransportError::RPCError(err.message)), + + // Both result and error will be None if the request + // returns a null-ish result, in this case create a + // "null" RawValue as the result + // + // SAFETY: Calling `to_raw_value` with a static "null" + // JSON Value will always succeed + (None, None) => Ok(to_raw_value(&Value::Null).unwrap()), + + _ => { + let message = if let Ok(message) = from_utf8(&message) { + format!("invalid response {message:?}") + } else { + format!("invalid response {message:?}") + }; + + Err(TransportError::SerdeError(message)) + } + }; + + channel.send(response).ok(); + } + } +} + +async fn read_message(mut socket_read: R) -> Result, Error> +where + R: AsyncBufRead + Unpin, +{ + let mut length = None; + let mut line = String::new(); + + loop { + match socket_read + .read_line(&mut line) + .await + .context("failed to read header line from the socket")? + { + // A read of 0 bytes means the connection was closed + 0 => { + bail!("the connection to the remote workspace was unexpectedly closed"); + } + // A read of two bytes corresponds to the "\r\n" sequence + // that indicates the end of the header section + 2 => { + if line != "\r\n" { + bail!("unexpected byte sequence received from the remote workspace, got {line:?} expected \"\\r\\n\""); + } + + break; + } + _ => { + let header: TransportHeader = line + .parse() + .context("failed to parse header from the remote workspace")?; + + match header { + TransportHeader::ContentLength(value) => { + length = Some(value); + } + TransportHeader::ContentType => {} + TransportHeader::Unknown(name) => { + eprintln!("ignoring unknown header {name:?}"); + } + } + + line.clear(); + } + } + } + + let length = length.context( + "incoming response from the remote workspace is missing the Content-Length header", + )?; + + let mut result = vec![0u8; length]; + socket_read + .read_exact(&mut result) + .await + .with_context(|| format!("failed to read message of {length} bytes from the socket"))?; + + Ok(result) +} + +async fn write_task( + broadcast_shutdown: Arc, + mut write_recv: Receiver<(Vec, bool)>, + mut socket_write: BufWriter, +) where + W: AsyncWrite + Unpin, +{ + while let Some((message, is_shutdown)) = write_recv.recv().await { + if is_shutdown { + broadcast_shutdown.notify_waiters(); + } + + if let Err(err) = write_message(&mut socket_write, message).await { + eprintln!( + "{:?}", + err.context("remote connection write task exited with an error") + ); + break; + } + + if is_shutdown { + break; + } + } +} + +async fn write_message(mut socket_write: W, message: Vec) -> Result<(), Error> +where + W: AsyncWrite + Unpin, +{ + socket_write.write_all(b"Content-Length: ").await?; + + let length = message.len().to_string(); + socket_write.write_all(length.as_bytes()).await?; + socket_write.write_all(b"\r\n").await?; + + socket_write + .write_all(b"Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n") + .await?; + + socket_write.write_all(b"\r\n").await?; + + socket_write.write_all(&message).await?; + + socket_write.flush().await?; + + Ok(()) +} + +#[derive(Debug, Serialize)] +struct JsonRpcRequest

{ + jsonrpc: Cow<'static, str>, + id: u64, + method: Cow<'static, str>, + params: P, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct JsonRpcResponse { + #[allow(dead_code)] + jsonrpc: Cow<'static, str>, + id: u64, + result: Option>, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcError { + #[allow(dead_code)] + code: i64, + message: String, + #[allow(dead_code)] + data: Option>, +} + +enum TransportHeader { + ContentLength(usize), + ContentType, + Unknown(String), +} + +impl FromStr for TransportHeader { + type Err = Error; + + fn from_str(line: &str) -> Result { + let colon = line + .find(':') + .with_context(|| format!("could not find colon token in {line:?}"))?; + + let (name, value) = line.split_at(colon); + let value = value[1..].trim(); + + match name { + "Content-Length" => { + let value = value.parse().with_context(|| { + format!("could not parse Content-Length header value {value:?}") + })?; + + Ok(TransportHeader::ContentLength(value)) + } + "Content-Type" => { + ensure!( + value.starts_with( "application/vscode-jsonrpc"), + "invalid value for Content-Type expected \"application/vscode-jsonrpc\", got {value:?}" + ); + + Ok(TransportHeader::ContentType) + } + _ => Ok(TransportHeader::Unknown(name.into())), + } + } +} diff --git a/crates/pg_cli/src/service/unix.rs b/crates/pg_cli/src/service/unix.rs new file mode 100644 index 000000000..9a529b0cf --- /dev/null +++ b/crates/pg_cli/src/service/unix.rs @@ -0,0 +1,233 @@ +use std::{ + convert::Infallible, + env, fs, + io::{self, ErrorKind}, + path::PathBuf, + time::Duration, +}; + +use pg_lsp_new::{ServerConnection, ServerFactory}; +use tokio::{ + io::Interest, + net::{ + unix::{OwnedReadHalf, OwnedWriteHalf}, + UnixListener, UnixStream, + }, + process::{Child, Command}, + time, +}; +use tracing::{debug, info, Instrument}; + +/// Returns the filesystem path of the global socket used to communicate with +/// the server daemon +fn get_socket_name() -> PathBuf { + pg_fs::ensure_cache_dir().join(format!("pglsp-socket-{}", pg_configuration::VERSION)) +} + +pub(crate) fn enumerate_pipes() -> io::Result> { + fs::read_dir(pg_fs::ensure_cache_dir()).map(|iter| { + iter.filter_map(|entry| { + let entry = entry.ok()?.path(); + let file_name = entry.file_name()?; + let file_name = file_name.to_str()?; + + let version = file_name.strip_prefix("pglsp-socket")?; + if version.is_empty() { + Some(String::new()) + } else { + Some(version.strip_prefix('-')?.to_string()) + } + }) + }) +} + +/// Try to connect to the global socket and wait for the connection to become ready +async fn try_connect() -> io::Result { + let socket_name = get_socket_name(); + info!("Trying to connect to socket {}", socket_name.display()); + let stream = UnixStream::connect(socket_name).await?; + stream + .ready(Interest::READABLE | Interest::WRITABLE) + .await?; + Ok(stream) +} + +/// Spawn the daemon server process in the background +fn spawn_daemon( + stop_on_disconnect: bool, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> io::Result { + let binary = env::current_exe()?; + + let mut cmd = Command::new(binary); + debug!("command {:?}", &cmd); + cmd.arg("__run_server"); + + if stop_on_disconnect { + cmd.arg("--stop-on-disconnect"); + } + if let Some(config_path) = config_path { + cmd.arg(format!("--config-path={}", config_path.display())); + } + if let Some(log_path) = log_path { + cmd.arg(format!("--log-path={}", log_path.display())); + } + + if let Some(log_file_name_prefix) = log_file_name_prefix { + cmd.arg(format!("--log-prefix-name={}", log_file_name_prefix)); + } + + // Create a new session for the process and make it the leader, this will + // ensures that the child process is fully detached from its parent and will + // continue running in the background even after the parent process exits + // + // SAFETY: This closure runs in the forked child process before it starts + // executing, this is a highly unsafe environment because the process isn't + // running yet so seemingly innocuous operation like allocating memory may + // hang indefinitely. + // The only thing we do here is issuing a syscall, which is safe to do in + // this state but still "unsafe" in Rust semantics because it's technically + // mutating the shared global state of the process + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + + let child = cmd.spawn()?; + Ok(child) +} + +/// Open a connection to the daemon server process, returning [None] if the +/// server is not running +pub(crate) async fn open_socket() -> io::Result> { + match try_connect().await { + Ok(socket) => Ok(Some(socket.into_split())), + Err(err) + // The OS will return `ConnectionRefused` if the socket file exists + // but no server process is listening on it + if matches!( + err.kind(), + ErrorKind::NotFound | ErrorKind::ConnectionRefused + ) => + { + Ok(None) + } + Err(err) => Err(err), + } +} + +/// Ensure the server daemon is running and ready to receive connections +/// +/// Returns false if the daemon process was already running or true if it had +/// to be started +pub(crate) async fn ensure_daemon( + stop_on_disconnect: bool, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> io::Result { + let mut current_child: Option = None; + let mut last_error = None; + + // Try to initialize the connection a few times + for _ in 0..10 { + // Try to open a connection on the global socket + match try_connect().await { + // The connection is open and ready + Ok(_) => { + return Ok(current_child.is_some()); + } + + // There's no process listening on the global socket + Err(err) + if matches!( + err.kind(), + ErrorKind::NotFound | ErrorKind::ConnectionRefused + ) => + { + last_error = Some(err); + + if let Some(current_child) = &mut current_child { + // If we have a handle to the daemon process, wait for a few + // milliseconds for it to exit, or retry the connection + tokio::select! { + result = current_child.wait() => { + let _status = result?; + return Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "the server process exited before the connection could be established", + )); + } + _ = time::sleep(Duration::from_millis(50)) => {} + } + } else { + // Spawn the daemon process and wait a few milliseconds for + // it to become ready then retry the connection + current_child = Some(spawn_daemon( + stop_on_disconnect, + config_path.clone(), + log_path.clone(), + log_file_name_prefix.clone(), + )?); + time::sleep(Duration::from_millis(50)).await; + } + } + + Err(err) => return Err(err), + } + } + + // If the connection couldn't be opened after 10 tries fail with the last + // error message from the OS, or a generic error message otherwise + Err(last_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::Other, + "could not connect to the daemon socket", + ) + })) +} + +/// Ensure the server daemon is running and ready to receive connections and +/// print the global socket name in the standard output +pub(crate) async fn print_socket() -> io::Result<()> { + ensure_daemon(true, None, None, None).await?; + println!("{}", get_socket_name().display()); + Ok(()) +} + +/// Start listening on the global socket and accepting connections with the +/// provided [ServerFactory] +pub(crate) async fn run_daemon( + factory: ServerFactory, + config_path: Option, +) -> io::Result { + let path = get_socket_name(); + + info!("Trying to connect to socket {}", path.display()); + + // Try to remove the socket file if it already exists + if path.exists() { + info!("Remove socket folder {}", path.display()); + fs::remove_file(&path)?; + } + + let listener = UnixListener::bind(path)?; + + loop { + let (stream, _) = listener.accept().await?; + let connection = factory.create(config_path.clone()); + let span = tracing::trace_span!("run_server"); + info!("Accepted connection"); + tokio::spawn(run_server(connection, stream).instrument(span.or_current())); + } +} + +/// Async task driving a single client connection +async fn run_server(connection: ServerConnection, stream: UnixStream) { + let (read, write) = stream.into_split(); + connection.accept(read, write).await; +} diff --git a/crates/pg_cli/src/service/windows.rs b/crates/pg_cli/src/service/windows.rs new file mode 100644 index 000000000..ed2fe1d62 --- /dev/null +++ b/crates/pg_cli/src/service/windows.rs @@ -0,0 +1,313 @@ +use std::{ + convert::Infallible, + env, + fs::read_dir, + io::{self, ErrorKind}, + mem::swap, + os::windows::process::CommandExt, + path::PathBuf, + pin::Pin, + process::Command, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; + +use pg_lsp_new::{ServerConnection, ServerFactory}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::windows::named_pipe::{ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions}, + time, +}; +use tracing::Instrument; + +/// Returns the name of the global named pipe used to communicate with the +/// server daemon +fn get_pipe_name() -> String { + format!(r"\\.\pipe\pglsp-service-{}", pg_configuration::VERSION) +} + +pub(crate) fn enumerate_pipes() -> io::Result> { + read_dir(r"\\.\pipe").map(|iter| { + iter.filter_map(|entry| { + let entry = entry.ok()?.path(); + let file_name = entry.file_name()?; + let file_name = file_name.to_str()?; + + let version = file_name.strip_prefix("pglsp-service")?; + if version.is_empty() { + Some(String::new()) + } else { + Some(version.strip_prefix('-')?.to_string()) + } + }) + }) +} + +/// Error code from the Win32 API +const ERROR_PIPE_BUSY: i32 = 231; + +/// Try to connect to the global pipe and wait for the connection to become ready +async fn try_connect() -> io::Result { + loop { + match ClientOptions::new().open(get_pipe_name()) { + Ok(client) => return Ok(client), + // If the connection failed with ERROR_PIPE_BUSY, wait a few + // milliseconds then retry the connection (we should be using + // WaitNamedPipe here but that's not exposed by tokio / mio) + Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY) => {} + Err(e) => return Err(e), + } + + time::sleep(Duration::from_millis(50)).await; + } +} + +/// Process creation flag from the Win32 API, ensures the process is created +/// in its own group and will not be killed when the parent process exits +const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + +/// Spawn the daemon server process in the background +fn spawn_daemon( + stop_on_disconnect: bool, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> io::Result<()> { + let binary = env::current_exe()?; + + let mut cmd = Command::new(binary); + cmd.arg("__run_server"); + + if stop_on_disconnect { + cmd.arg("--stop-on-disconnect"); + } + + if let Some(config_path) = config_path { + cmd.arg(format!("--config-path={}", config_path.display())); + } + if let Some(log_path) = log_path { + cmd.arg(format!("--log-path={}", log_path.display())); + } + if let Some(log_file_name_prefix) = log_file_name_prefix { + cmd.arg(format!("--log-prefix-name={}", log_file_name_prefix)); + } + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + + cmd.spawn()?; + + Ok(()) +} + +/// Open a connection to the daemon server process, returning [None] if the +/// server is not running +pub(crate) async fn open_socket() -> io::Result> { + match try_connect().await { + Ok(socket) => { + let inner = Arc::new(socket); + Ok(Some(( + ClientReadHalf { + inner: inner.clone(), + }, + ClientWriteHalf { inner }, + ))) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +pub(crate) struct ClientReadHalf { + inner: Arc, +} + +impl AsyncRead for ClientReadHalf { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + match self.inner.poll_read_ready(cx) { + Poll::Ready(Ok(())) => match self.inner.try_read(buf.initialize_unfilled()) { + Ok(count) => { + buf.advance(count); + return Poll::Ready(Ok(())); + } + + Err(err) if err.kind() == io::ErrorKind::WouldBlock => continue, + Err(err) => return Poll::Ready(Err(err)), + }, + + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending => return Poll::Pending, + }; + } + } +} + +pub(crate) struct ClientWriteHalf { + inner: Arc, +} + +impl AsyncWrite for ClientWriteHalf { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + loop { + match self.inner.poll_write_ready(cx) { + Poll::Ready(Ok(())) => match self.inner.try_write(buf) { + Ok(count) => return Poll::Ready(Ok(count)), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => continue, + Err(err) => return Poll::Ready(Err(err)), + }, + + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending => return Poll::Pending, + } + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.poll_flush(cx) + } +} + +/// Ensure the server daemon is running and ready to receive connections +/// +/// Returns false if the daemon process was already running or true if it had +/// to be started +pub(crate) async fn ensure_daemon( + stop_on_disconnect: bool, + config_path: Option, + log_path: Option, + log_file_name_prefix: Option, +) -> io::Result { + let mut did_spawn = false; + + loop { + match open_socket().await { + Ok(Some(_)) => break, + Ok(None) => { + spawn_daemon( + stop_on_disconnect, + config_path.clone(), + log_path.clone(), + log_file_name_prefix.clone(), + )?; + did_spawn = true; + time::sleep(Duration::from_millis(50)).await; + } + Err(err) => return Err(err), + } + } + + Ok(did_spawn) +} + +/// Ensure the server daemon is running and ready to receive connections and +/// print the global pipe name in the standard output +pub(crate) async fn print_socket() -> io::Result<()> { + ensure_daemon(true, None, None, None).await?; + println!("{}", get_pipe_name()); + Ok(()) +} + +/// Start listening on the global pipe and accepting connections with the +/// provided [ServerFactory] +pub(crate) async fn run_daemon( + factory: ServerFactory, + config_path: Option, +) -> io::Result { + let mut prev_server = ServerOptions::new() + .first_pipe_instance(true) + .create(get_pipe_name())?; + + loop { + prev_server.connect().await?; + let mut next_server = ServerOptions::new().create(get_pipe_name())?; + swap(&mut prev_server, &mut next_server); + + let connection = factory.create(config_path.clone()); + let span = tracing::trace_span!("run_server"); + tokio::spawn(run_server(connection, next_server).instrument(span.or_current())); + } +} + +/// Async task driving a single client connection +async fn run_server(connection: ServerConnection, stream: NamedPipeServer) { + let inner = Arc::new(stream); + let read = ServerReadHalf { + inner: inner.clone(), + }; + let write = ServerWriteHalf { inner }; + connection.accept(read, write).await; +} + +struct ServerReadHalf { + inner: Arc, +} + +impl AsyncRead for ServerReadHalf { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + match self.inner.poll_read_ready(cx) { + Poll::Ready(Ok(())) => match self.inner.try_read(buf.initialize_unfilled()) { + Ok(count) => { + buf.advance(count); + return Poll::Ready(Ok(())); + } + + Err(err) if err.kind() == io::ErrorKind::WouldBlock => continue, + Err(err) => return Poll::Ready(Err(err)), + }, + + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending => return Poll::Pending, + }; + } + } +} + +struct ServerWriteHalf { + inner: Arc, +} + +impl AsyncWrite for ServerWriteHalf { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + loop { + match self.inner.poll_write_ready(cx) { + Poll::Ready(Ok(())) => match self.inner.try_write(buf) { + Ok(count) => return Poll::Ready(Ok(count)), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => continue, + Err(err) => return Poll::Ready(Err(err)), + }, + + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending => return Poll::Pending, + } + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.poll_flush(cx) + } +} diff --git a/crates/pg_commands/Cargo.toml b/crates/pg_commands/Cargo.toml index ad01ce925..b5a24250e 100644 --- a/crates/pg_commands/Cargo.toml +++ b/crates/pg_commands/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true async-std = "1.12.0" anyhow = "1.0.62" sqlx.workspace = true diff --git a/crates/pg_commands/src/execute_statement.rs b/crates/pg_commands/src/execute_statement.rs index 74e9d544e..619791a58 100644 --- a/crates/pg_commands/src/execute_statement.rs +++ b/crates/pg_commands/src/execute_statement.rs @@ -16,11 +16,11 @@ impl ExecuteStatementCommand { match conn.execute(self.statement.as_str()).await { Ok(res) => Ok(res), Err(e) => { - return Err(anyhow::anyhow!(e.to_string())); + Err(anyhow::anyhow!(e.to_string())) } } } else { - return Err(anyhow::anyhow!("No connection to database".to_string())); + Err(anyhow::anyhow!("No connection to database".to_string())) } } diff --git a/crates/pg_completions/Cargo.toml b/crates/pg_completions/Cargo.toml index 7ff13e509..ba0e981c7 100644 --- a/crates/pg_completions/Cargo.toml +++ b/crates/pg_completions/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies] async-std = "1.12.0" -text-size = "1.1.1" +text-size.workspace = true tree-sitter.workspace = true tree_sitter_sql.workspace = true diff --git a/crates/pg_completions/src/builder.rs b/crates/pg_completions/src/builder.rs index c5a898893..9ada2466f 100644 --- a/crates/pg_completions/src/builder.rs +++ b/crates/pg_completions/src/builder.rs @@ -30,7 +30,7 @@ impl CompletionBuilder { if idx == 0 { item.preselected = Some(should_preselect_first_item); } - item.into() + item }) .collect(); diff --git a/crates/pg_completions/src/complete.rs b/crates/pg_completions/src/complete.rs index 58c08897a..7118281a2 100644 --- a/crates/pg_completions/src/complete.rs +++ b/crates/pg_completions/src/complete.rs @@ -83,7 +83,7 @@ mod tests { let result = complete(p); - assert!(result.items.len() > 0); + assert!(!result.items.is_empty()); let best_match = &result.items[0]; @@ -142,7 +142,7 @@ mod tests { let result = complete(p); - assert!(result.items.len() > 0); + assert!(!result.items.is_empty()); let best_match = &result.items[0]; @@ -205,7 +205,7 @@ mod tests { let result = complete(p); - assert!(result.items.len() > 0); + assert!(!result.items.is_empty()); let best_match = &result.items[0]; diff --git a/crates/pg_completions/src/context.rs b/crates/pg_completions/src/context.rs index 92cfc5594..e13a256e9 100644 --- a/crates/pg_completions/src/context.rs +++ b/crates/pg_completions/src/context.rs @@ -18,7 +18,7 @@ impl<'a> CompletionContext<'a> { pub fn new(params: &'a CompletionParams) -> Self { let mut tree = Self { tree: params.tree, - text: ¶ms.text, + text: params.text, schema_cache: params.schema, position: usize::from(params.position), diff --git a/crates/pg_configuration/Cargo.toml b/crates/pg_configuration/Cargo.toml new file mode 100644 index 000000000..2a1151e8d --- /dev/null +++ b/crates/pg_configuration/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pg_configuration" +version = "0.0.0" +edition = "2021" + +[dependencies] +schemars = { workspace = true, features = ["indexmap1"], optional = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["raw_value"] } +biome_deserialize = { workspace = true } +text-size = { workspace = true } +biome_deserialize_macros = { workspace = true } +bpaf = { workspace = true } +pg_diagnostics = { workspace = true } +pg_console = { workspace = true } +toml = { workspace = true } + +[lib] +doctest = false + +[features] +schema = [ + "dep:schemars" +] + diff --git a/crates/pg_configuration/src/database.rs b/crates/pg_configuration/src/database.rs new file mode 100644 index 000000000..dcbbabfb5 --- /dev/null +++ b/crates/pg_configuration/src/database.rs @@ -0,0 +1,45 @@ +use biome_deserialize_macros::Partial; +use bpaf::Bpaf; +use serde::{Deserialize, Serialize}; + +/// The configuration of the database connection. +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, PartialEq))] +#[partial(serde(rename_all = "snake_case", default, deny_unknown_fields))] +pub struct DatabaseConfiguration { + #[partial(bpaf(long("host")))] + pub host: String, + + #[partial(bpaf(long("port")))] + pub port: u16, + + #[partial(bpaf(long("username")))] + pub username: String, + + #[partial(bpaf(long("password")))] + pub password: String, + + #[partial(bpaf(long("database")))] + pub database: String, +} + +impl Default for DatabaseConfiguration { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 5432, + username: "postgres".to_string(), + password: "postgres".to_string(), + database: "postgres".to_string(), + } + } +} + +impl DatabaseConfiguration { + pub fn to_connection_string(&self) -> String { + format!( + "postgres://{}:{}@{}:{}/{}", + self.username, self.password, self.host, self.port, self.database + ) + } +} diff --git a/crates/pg_configuration/src/diagnostics.rs b/crates/pg_configuration/src/diagnostics.rs new file mode 100644 index 000000000..e62f080f5 --- /dev/null +++ b/crates/pg_configuration/src/diagnostics.rs @@ -0,0 +1,170 @@ +use pg_console::fmt::Display; +use pg_console::{markup, MarkupBuf}; +use pg_diagnostics::{Advices, Diagnostic, Error, LogCategory, MessageAndDescription, Visit}; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Formatter}; + +/// Series of errors that can be thrown while computing the configuration. +#[derive(Deserialize, Diagnostic, Serialize)] +pub enum ConfigurationDiagnostic { + /// Thrown when the program can't serialize the configuration, while saving it + SerializationError(SerializationError), + + /// Error thrown when de-serialising the configuration from file + DeserializationError(DeserializationError), + + /// Thrown when trying to **create** a new configuration file, but it exists already + ConfigAlreadyExists(ConfigAlreadyExists), + + /// When something is wrong with the configuration + InvalidConfiguration(InvalidConfiguration), + + /// Thrown when the pattern inside the `ignore` field errors + InvalidIgnorePattern(InvalidIgnorePattern), +} + +impl ConfigurationDiagnostic { + pub fn new_deserialization_error(error: toml::de::Error) -> Self { + Self::DeserializationError(DeserializationError { + message: error.message().to_string(), + }) + } + + pub fn new_serialization_error() -> Self { + Self::SerializationError(SerializationError) + } + + pub fn new_invalid_ignore_pattern( + pattern: impl Into, + reason: impl Into, + ) -> Self { + Self::InvalidIgnorePattern(InvalidIgnorePattern { + message: format!( + "Couldn't parse the pattern \"{}\". Reason: {}", + pattern.into(), + reason.into() + ), + file_path: None, + }) + } + + pub fn new_invalid_ignore_pattern_with_path( + pattern: impl Into, + reason: impl Into, + file_path: Option>, + ) -> Self { + Self::InvalidIgnorePattern(InvalidIgnorePattern { + message: format!( + "Couldn't parse the pattern \"{}\". Reason: {}", + pattern.into(), + reason.into() + ), + file_path: file_path.map(|f| f.into()), + }) + } + + pub fn new_already_exists() -> Self { + Self::ConfigAlreadyExists(ConfigAlreadyExists {}) + } + + pub fn invalid_configuration(message: impl Display) -> Self { + Self::InvalidConfiguration(InvalidConfiguration { + message: MessageAndDescription::from(markup! {{message}}.to_owned()), + }) + } +} + +impl Debug for ConfigurationDiagnostic { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, f) + } +} + +impl std::fmt::Display for ConfigurationDiagnostic { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.description(f) + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ConfigurationAdvices { + messages: Vec, +} + +impl Advices for ConfigurationAdvices { + fn record(&self, visitor: &mut dyn Visit) -> std::io::Result<()> { + for message in &self.messages { + visitor.record_log(LogCategory::Info, message)?; + } + + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + message = "Failed to deserialize", + category = "configuration", + severity = Error +)] +pub struct DeserializationError { + #[message] + #[description] + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + message = "Failed to serialize", + category = "configuration", + severity = Error +)] +pub struct SerializationError; + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + message = "It seems that a configuration file already exists", + category = "configuration", + severity = Error +)] +pub struct ConfigAlreadyExists {} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "configuration", + severity = Error, +)] +pub struct InvalidIgnorePattern { + #[message] + #[description] + pub message: String, + + #[location(resource)] + pub file_path: Option, +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "configuration", + severity = Error, +)] +pub struct InvalidConfiguration { + #[message] + #[description] + message: MessageAndDescription, +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "configuration", + severity = Error, +)] +pub struct CantResolve { + #[message] + #[description] + message: MessageAndDescription, + + #[serde(skip)] + #[source] + source: Option, +} diff --git a/crates/pg_configuration/src/files.rs b/crates/pg_configuration/src/files.rs new file mode 100644 index 000000000..5ae70dab1 --- /dev/null +++ b/crates/pg_configuration/src/files.rs @@ -0,0 +1,42 @@ +use std::num::NonZeroU64; + +use biome_deserialize::StringSet; +use biome_deserialize_macros::Partial; +use bpaf::Bpaf; +use serde::{Deserialize, Serialize}; + +/// Limit the size of files to 1.0 MiB by default +pub const DEFAULT_FILE_SIZE_LIMIT: NonZeroU64 = + // SAFETY: This constant is initialized with a non-zero value + unsafe { NonZeroU64::new_unchecked(1024 * 1024) }; + +/// The configuration of the filesystem +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, PartialEq))] +#[partial(serde(rename_all = "snake_case", default, deny_unknown_fields))] +pub struct FilesConfiguration { + /// The maximum allowed size for source code files in bytes. Files above + /// this limit will be ignored for performance reasons. Defaults to 1 MiB + #[partial(bpaf(long("files-max-size"), argument("NUMBER")))] + pub max_size: NonZeroU64, + + /// A list of Unix shell style patterns. Will ignore files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub ignore: StringSet, + + /// A list of Unix shell style patterns. Will handle only those files/folders that will + /// match these patterns. + #[partial(bpaf(hide))] + pub include: StringSet, +} + +impl Default for FilesConfiguration { + fn default() -> Self { + Self { + max_size: DEFAULT_FILE_SIZE_LIMIT, + ignore: Default::default(), + include: Default::default(), + } + } +} diff --git a/crates/pg_configuration/src/lib.rs b/crates/pg_configuration/src/lib.rs new file mode 100644 index 000000000..4a089e59d --- /dev/null +++ b/crates/pg_configuration/src/lib.rs @@ -0,0 +1,117 @@ +//! This module contains the configuration of `pg.json` +//! +//! The configuration is divided by "tool", and then it's possible to further customise it +//! by language. The language might further options divided by tool. + +pub mod database; +pub mod diagnostics; +pub mod files; +pub mod vcs; + +pub use crate::diagnostics::ConfigurationDiagnostic; + +use std::path::PathBuf; + +use crate::vcs::{partial_vcs_configuration, PartialVcsConfiguration, VcsConfiguration}; +use biome_deserialize_macros::Partial; +use bpaf::Bpaf; +use database::{ + partial_database_configuration, DatabaseConfiguration, PartialDatabaseConfiguration, +}; +use files::{partial_files_configuration, FilesConfiguration, PartialFilesConfiguration}; +use serde::{Deserialize, Serialize}; +use vcs::VcsClientKind; + +pub const VERSION: &str = match option_env!("PGLSP_VERSION") { + Some(version) => version, + None => "0.0.0", +}; + +/// The configuration that is contained inside the configuration file. +#[derive(Clone, Debug, Default, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, PartialEq))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(deny_unknown_fields, rename_all = "snake_case"))] +pub struct Configuration { + /// The configuration of the VCS integration + #[partial(type, bpaf(external(partial_vcs_configuration), optional, hide_usage))] + pub vcs: VcsConfiguration, + + /// The configuration of the filesystem + #[partial( + type, + bpaf(external(partial_files_configuration), optional, hide_usage) + )] + pub files: FilesConfiguration, + + /// The configuration of the database connection + #[partial( + type, + bpaf(external(partial_database_configuration), optional, hide_usage) + )] + pub db: DatabaseConfiguration, +} + +impl PartialConfiguration { + /// Returns the initial configuration. + pub fn init() -> Self { + Self { + files: Some(PartialFilesConfiguration { + ignore: Some(Default::default()), + ..Default::default() + }), + vcs: Some(PartialVcsConfiguration { + enabled: Some(false), + client_kind: Some(VcsClientKind::Git), + use_ignore_file: Some(false), + ..Default::default() + }), + db: Some(PartialDatabaseConfiguration { + host: Some("127.0.0.1".to_string()), + port: Some(5432), + username: Some("postgres".to_string()), + password: Some("postgres".to_string()), + database: Some("postgres".to_string()), + }), + } + } +} + +pub struct ConfigurationPayload { + /// The result of the deserialization + pub deserialized: PartialConfiguration, + /// The path of where the configuration file that was found. This contains the file name. + pub configuration_file_path: PathBuf, + /// The base path where the external configuration in a package should be resolved from + pub external_resolution_base_path: PathBuf, +} + +#[derive(Debug, Default, PartialEq, Clone)] +pub enum ConfigurationPathHint { + /// The default mode, not having a configuration file is not an error. + /// The path will be filled with the working directory if it is not filled at the time of usage. + #[default] + None, + + /// Very similar to [ConfigurationPathHint::None]. However, the path provided by this variant + /// will be used as **working directory**, which means that all globs defined in the configuration + /// will use **this path** as base path. + FromWorkspace(PathBuf), + + /// The configuration path provided by the LSP, not having a configuration file is not an error. + /// The path will always be a directory path. + FromLsp(PathBuf), + /// The configuration path provided by the user, not having a configuration file is an error. + /// The path can either be a directory path or a file path. + /// Throws any kind of I/O errors. + FromUser(PathBuf), +} + +impl ConfigurationPathHint { + pub const fn is_from_user(&self) -> bool { + matches!(self, Self::FromUser(_)) + } + pub const fn is_from_lsp(&self) -> bool { + matches!(self, Self::FromLsp(_)) + } +} diff --git a/crates/pg_configuration/src/vcs.rs b/crates/pg_configuration/src/vcs.rs new file mode 100644 index 000000000..7f9b049ea --- /dev/null +++ b/crates/pg_configuration/src/vcs.rs @@ -0,0 +1,118 @@ +use biome_deserialize::{DeserializableValidator, DeserializationDiagnostic}; +use biome_deserialize_macros::{Deserializable, Merge, Partial}; +use bpaf::Bpaf; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; + +const GIT_IGNORE_FILE_NAME: &str = ".gitignore"; + +/// Set of properties to integrate with a VCS software. +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Deserializable, Eq, Merge, PartialEq))] +#[partial(deserializable(with_validator))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(deny_unknown_fields))] +pub struct VcsConfiguration { + /// Whether we should integrate itself with the VCS client + #[partial(bpaf(long("vcs-enabled"), argument("true|false")))] + pub enabled: bool, + + /// The kind of client. + #[partial(bpaf(long("vcs-client-kind"), argument("git"), optional))] + #[partial(deserializable(bail_on_error))] + pub client_kind: VcsClientKind, + + /// Whether we should use the VCS ignore file. When [true], we will ignore the files + /// specified in the ignore file. + #[partial(bpaf(long("vcs-use-ignore-file"), argument("true|false")))] + pub use_ignore_file: bool, + + /// The folder where we should check for VCS files. By default, we will use the same + /// folder where `pglsp.toml` was found. + /// + /// If we can't find the configuration, it will attempt to use the current working directory. + /// If no current working directory can't be found, we won't use the VCS integration, and a diagnostic + /// will be emitted + #[partial(bpaf(long("vcs-root"), argument("PATH"), optional))] + pub root: String, + + /// The main branch of the project + #[partial(bpaf(long("vcs-default-branch"), argument("BRANCH"), optional))] + pub default_branch: String, +} + +impl Default for VcsConfiguration { + fn default() -> Self { + Self { + client_kind: VcsClientKind::Git, + enabled: false, + use_ignore_file: true, + root: Default::default(), + default_branch: Default::default(), + } + } +} + +impl PartialVcsConfiguration { + pub const fn is_enabled(&self) -> bool { + matches!(self.enabled, Some(true)) + } + pub const fn is_disabled(&self) -> bool { + !self.is_enabled() + } + pub const fn ignore_file_disabled(&self) -> bool { + matches!(self.use_ignore_file, Some(false)) + } +} + +impl DeserializableValidator for PartialVcsConfiguration { + fn validate( + &mut self, + _name: &str, + range: biome_deserialize::TextRange, + diagnostics: &mut Vec, + ) -> bool { + if self.client_kind.is_none() && self.is_enabled() { + diagnostics.push( + DeserializationDiagnostic::new( + "You enabled the VCS integration, but you didn't specify a client.", + ) + .with_range(range) + .with_note("We will disable the VCS integration until the issue is fixed."), + ); + return false; + } + + true + } +} + +#[derive( + Clone, Copy, Debug, Default, Deserialize, Deserializable, Eq, Merge, PartialEq, Serialize, +)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum VcsClientKind { + #[default] + /// Integration with the git client as VCS + Git, +} + +impl VcsClientKind { + pub const fn ignore_file(&self) -> &'static str { + match self { + VcsClientKind::Git => GIT_IGNORE_FILE_NAME, + } + } +} + +impl FromStr for VcsClientKind { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "git" => Ok(Self::Git), + _ => Err("Value not supported for VcsClientKind"), + } + } +} diff --git a/crates/pg_console/Cargo.toml b/crates/pg_console/Cargo.toml new file mode 100644 index 000000000..03f6905c2 --- /dev/null +++ b/crates/pg_console/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "pg_console" +version = "0.0.0" +edition = "2021" + +[dependencies] +pg_markup = { workspace = true } +text-size = { workspace = true } + +schemars = { workspace = true, optional = true } +serde = { workspace = true, optional = true, features = ["derive"] } +termcolor = { workspace = true } +unicode-segmentation = "1.12.0" +unicode-width = { workspace = true } + +[dev-dependencies] +trybuild = "1.0.99" + +[features] +serde_markup = ["serde", "schemars"] + +[lib] +doctest = false + diff --git a/crates/pg_console/README.md b/crates/pg_console/README.md new file mode 100644 index 000000000..7baceba9e --- /dev/null +++ b/crates/pg_console/README.md @@ -0,0 +1,8 @@ +# `pg_console` + +The crate contains a general abstraction over printing messages (formatted with markup) and diagnostics to a console. + +## Acknowledgement + +This crate was initially forked from [biome](https://github.com/biomejs/biome). + diff --git a/crates/pg_console/src/fmt.rs b/crates/pg_console/src/fmt.rs new file mode 100644 index 000000000..82a83a0e6 --- /dev/null +++ b/crates/pg_console/src/fmt.rs @@ -0,0 +1,299 @@ +use std::{borrow::Cow, fmt, io, time::Duration}; + +pub use crate::write::{Termcolor, Write, HTML}; +use crate::{markup, Markup, MarkupElement}; + +/// A stack-allocated linked-list of [MarkupElement] slices +#[derive(Clone, Copy)] +pub enum MarkupElements<'a> { + Root, + Node(&'a Self, &'a [MarkupElement<'a>]), +} + +impl<'a> MarkupElements<'a> { + /// Iterates on all the element slices depth-first + pub fn for_each( + &self, + func: &mut impl FnMut(&'a [MarkupElement]) -> io::Result<()>, + ) -> io::Result<()> { + if let Self::Node(parent, elem) = self { + parent.for_each(func)?; + func(elem)?; + } + + Ok(()) + } + + /// Iterates on all the element slices breadth-first + pub fn for_each_rev( + &self, + func: &mut impl FnMut(&'a [MarkupElement]) -> io::Result<()>, + ) -> io::Result<()> { + if let Self::Node(parent, elem) = self { + func(elem)?; + parent.for_each(func)?; + } + + Ok(()) + } +} + +/// The [Formatter] is the `pg_console` equivalent to [std::fmt::Formatter]: +/// it's never constructed directly by consumers, and can only be used through +/// the mutable reference passed to implementations of the [Display] trait). +/// It manages the state of the markup to print, and implementations of +/// [Display] can call into its methods to append content into the current +/// printing session +pub struct Formatter<'fmt> { + /// Stack of markup elements currently applied to the text being printed + state: MarkupElements<'fmt>, + /// Inner IO writer this [Formatter] will print text into + writer: &'fmt mut dyn Write, +} + +impl<'fmt> Formatter<'fmt> { + /// Create a new instance of the [Formatter] using the provided `writer` for printing + pub fn new(writer: &'fmt mut dyn Write) -> Self { + Self { + state: MarkupElements::Root, + writer, + } + } + + pub fn wrap_writer<'b: 'c, 'c>( + &'b mut self, + wrap: impl FnOnce(&'b mut dyn Write) -> &'c mut dyn Write, + ) -> Formatter<'c> { + Formatter { + state: self.state, + writer: wrap(self.writer), + } + } + + /// Return a new instance of the [Formatter] with `elements` appended to its element stack + fn with_elements<'b>(&'b mut self, elements: &'b [MarkupElement]) -> Formatter<'b> { + Formatter { + state: MarkupElements::Node(&self.state, elements), + writer: self.writer, + } + } + + /// Write a piece of markup into this formatter + pub fn write_markup(&mut self, markup: Markup) -> io::Result<()> { + for node in markup.0 { + let mut fmt = self.with_elements(node.elements); + node.content.fmt(&mut fmt)?; + } + + Ok(()) + } + + /// Write a slice of text into this formatter + pub fn write_str(&mut self, content: &str) -> io::Result<()> { + self.writer.write_str(&self.state, content) + } + + /// Write formatted text into this formatter + pub fn write_fmt(&mut self, content: fmt::Arguments) -> io::Result<()> { + self.writer.write_fmt(&self.state, content) + } +} + +/// Formatting trait for types to be displayed as markup, the `pg_console` +/// equivalent to [std::fmt::Display] +/// +/// # Example +/// Implementing `Display` on a custom struct +/// ``` +/// use pg_console::{ +/// fmt::{Display, Formatter}, +/// markup, +/// }; +/// use std::io; +/// +/// struct Warning(String); +/// +/// impl Display for Warning { +/// fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { +/// fmt.write_markup(markup! { +/// {self.0} +/// }) +/// } +/// } +/// +/// let warning = Warning(String::from("content")); +/// markup! { +/// {warning} +/// }; +/// ``` +pub trait Display { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()>; +} + +// Blanket implementations of Display for reference types +impl Display for &T +where + T: Display + ?Sized, +{ + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + T::fmt(self, fmt) + } +} + +impl Display for Cow<'_, T> +where + T: Display + ToOwned + ?Sized, +{ + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + T::fmt(self, fmt) + } +} + +// Simple implementations of Display calling through to write_str for types +// that implement Deref +impl Display for str { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_str(self) + } +} + +impl Display for String { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_str(self) + } +} + +// Implement Display for Markup and Rust format Arguments +impl Display for Markup<'_> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_markup(*self) + } +} + +impl Display for std::fmt::Arguments<'_> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_fmt(*self) + } +} + +/// Implement [Display] for types that implement [std::fmt::Display] by calling +/// through to [Formatter::write_fmt] +macro_rules! impl_std_display { + ($ty:ty) => { + impl Display for $ty { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + write!(fmt, "{self}") + } + } + }; +} + +impl_std_display!(char); +impl_std_display!(i8); +impl_std_display!(i16); +impl_std_display!(i32); +impl_std_display!(i64); +impl_std_display!(i128); +impl_std_display!(isize); +impl_std_display!(u8); +impl_std_display!(u16); +impl_std_display!(u32); +impl_std_display!(u64); +impl_std_display!(u128); +impl_std_display!(usize); + +impl Display for Duration { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + use crate as pg_console; + + let secs = self.as_secs(); + if secs > 1 { + return fmt.write_markup(markup! { + {secs}"s" + }); + } + + let millis = self.as_millis(); + if millis > 1 { + return fmt.write_markup(markup! { + {millis}"ms" + }); + } + + let micros = self.as_micros(); + if micros > 1 { + return fmt.write_markup(markup! { + {micros}"µs" + }); + } + + let nanos = self.as_nanos(); + fmt.write_markup(markup! { + {nanos}"ns" + }) + } +} + +#[repr(transparent)] +#[derive(Clone, Copy, Debug)] +pub struct Bytes(pub usize); + +impl std::fmt::Display for Bytes { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(mut value) = *self; + + if value < 1024 { + return write!(fmt, "{value} B"); + } + + const PREFIX: [char; 4] = ['K', 'M', 'G', 'T']; + let prefix = PREFIX + .into_iter() + .find(|_| { + let next_value = value / 1024; + if next_value < 1024 { + return true; + } + + value = next_value; + false + }) + .unwrap_or('T'); + + write!(fmt, "{:.1} {prefix}iB", value as f32 / 1024.0) + } +} + +impl Display for Bytes { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + write!(fmt, "{self}") + } +} + +#[cfg(test)] +mod tests { + use crate::fmt::Bytes; + + #[test] + fn display_bytes() { + // Examples taken from https://stackoverflow.com/a/3758880 + assert_eq!(Bytes(0).to_string(), "0 B"); + assert_eq!(Bytes(27).to_string(), "27 B"); + assert_eq!(Bytes(999).to_string(), "999 B"); + assert_eq!(Bytes(1_000).to_string(), "1000 B"); + assert_eq!(Bytes(1_023).to_string(), "1023 B"); + assert_eq!(Bytes(1_024).to_string(), "1.0 KiB"); + assert_eq!(Bytes(1_728).to_string(), "1.7 KiB"); + assert_eq!(Bytes(110_592).to_string(), "108.0 KiB"); + assert_eq!(Bytes(999_999).to_string(), "976.6 KiB"); + assert_eq!(Bytes(7_077_888).to_string(), "6.8 MiB"); + assert_eq!(Bytes(452_984_832).to_string(), "432.0 MiB"); + assert_eq!(Bytes(28_991_029_248).to_string(), "27.0 GiB"); + assert_eq!(Bytes(1_855_425_871_872).to_string(), "1.7 TiB"); + + #[cfg(target_pointer_width = "32")] + assert_eq!(Bytes(usize::MAX).to_string(), "4.0 GiB"); + #[cfg(target_pointer_width = "64")] + assert_eq!(Bytes(usize::MAX).to_string(), "16384.0 TiB"); + } +} diff --git a/crates/pg_console/src/lib.rs b/crates/pg_console/src/lib.rs new file mode 100644 index 000000000..7830e347e --- /dev/null +++ b/crates/pg_console/src/lib.rs @@ -0,0 +1,234 @@ +//! # pg_console + +use std::io; +use std::io::{IsTerminal, Read, Write}; +use std::panic::RefUnwindSafe; +use termcolor::{ColorChoice, StandardStream}; +use write::Termcolor; + +pub mod fmt; +mod markup; +mod utils; +mod write; + +pub use self::markup::{Markup, MarkupBuf, MarkupElement, MarkupNode}; +pub use pg_markup::markup; +pub use utils::*; + +/// Determines the "output stream" a message should get printed to +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevel { + /// Print the message to the `Error` stream of the console, for instance + /// "stderr" for the [EnvConsole] + Error, + /// Print the message to the `Log` stream of the console, for instance + /// "stdout" for the [EnvConsole] + Log, +} + +/// Generic abstraction over printing markup and diagnostics to an output, +/// which can be a terminal, a file, a memory buffer ... +pub trait Console: Send + Sync + RefUnwindSafe { + /// Prints a message (formatted using [markup!]) to the console. + /// + /// It adds a new line at the end. + fn println(&mut self, level: LogLevel, args: Markup); + + /// Prints a message (formatted using [markup!]) to the console. + fn print(&mut self, level: LogLevel, args: Markup); + + /// It reads from a source, and if this source contains something, it's converted into a [String] + fn read(&mut self) -> Option; +} + +/// Extension trait for [Console] providing convenience printing methods +pub trait ConsoleExt: Console { + /// Prints a piece of markup with level [LogLevel::Error] + fn error(&mut self, args: Markup); + + /// Prints a piece of markup with level [LogLevel::Log] + /// + /// Logs a message, adds a new line at the end. + fn log(&mut self, args: Markup); + + /// Prints a piece of markup with level [LogLevel::Log] + /// + /// It doesn't add any line + fn append(&mut self, args: Markup); +} + +impl ConsoleExt for T { + fn error(&mut self, args: Markup) { + self.println(LogLevel::Error, args); + } + + fn log(&mut self, args: Markup) { + self.println(LogLevel::Log, args); + } + + fn append(&mut self, args: Markup) { + self.print(LogLevel::Log, args); + } +} + +/// Implementation of [Console] printing messages to the standard output and standard error +pub struct EnvConsole { + /// Channel to print messages + out: StandardStream, + /// Channel to print errors + err: StandardStream, + /// Channel to read arbitrary input + r#in: io::Stdin, +} + +#[derive(Debug, Clone)] +pub enum ColorMode { + /// Always print color using either ANSI or the Windows Console API + Enabled, + /// Never print colors + Disabled, + /// Print colors if stdout / stderr are determined to be TTY / Console + /// streams, and the `TERM=dumb` and `NO_COLOR` environment variables are + /// not set + Auto, +} + +impl EnvConsole { + fn compute_color(colors: ColorMode) -> (ColorChoice, ColorChoice) { + match colors { + ColorMode::Enabled => (ColorChoice::Always, ColorChoice::Always), + ColorMode::Disabled => (ColorChoice::Never, ColorChoice::Never), + ColorMode::Auto => { + let stdout = if io::stdout().is_terminal() { + ColorChoice::Auto + } else { + ColorChoice::Never + }; + + let stderr = if io::stderr().is_terminal() { + ColorChoice::Auto + } else { + ColorChoice::Never + }; + + (stdout, stderr) + } + } + } + + pub fn new(colors: ColorMode) -> Self { + let (out_mode, err_mode) = Self::compute_color(colors); + + Self { + out: StandardStream::stdout(out_mode), + err: StandardStream::stderr(err_mode), + r#in: io::stdin(), + } + } + + pub fn set_color(&mut self, colors: ColorMode) { + let (out_mode, err_mode) = Self::compute_color(colors); + self.out = StandardStream::stdout(out_mode); + self.err = StandardStream::stderr(err_mode); + } +} + +impl Default for EnvConsole { + fn default() -> Self { + Self::new(ColorMode::Auto) + } +} + +impl Console for EnvConsole { + fn println(&mut self, level: LogLevel, args: Markup) { + let mut out = match level { + LogLevel::Error => self.err.lock(), + LogLevel::Log => self.out.lock(), + }; + + fmt::Formatter::new(&mut Termcolor(&mut out)) + .write_markup(args) + .unwrap(); + + writeln!(out).unwrap(); + } + + fn print(&mut self, level: LogLevel, args: Markup) { + let mut out = match level { + LogLevel::Error => self.err.lock(), + LogLevel::Log => self.out.lock(), + }; + + fmt::Formatter::new(&mut Termcolor(&mut out)) + .write_markup(args) + .unwrap(); + + write!(out, "").unwrap(); + } + + fn read(&mut self) -> Option { + // Here we check if stdin is redirected. If not, we bail. + // + // Doing this check allows us to pipe stdin to rome, without expecting + // user content when we call `read_to_string` + if io::stdin().is_terminal() { + return None; + } + let mut handle = self.r#in.lock(); + let mut buffer = String::new(); + let result = handle.read_to_string(&mut buffer); + // Skipping the error for now + if result.is_ok() { + Some(buffer) + } else { + None + } + } +} + +/// Implementation of [Console] storing all printed messages to a memory buffer +#[derive(Default, Debug)] +pub struct BufferConsole { + pub out_buffer: Vec, + pub in_buffer: Vec, + pub print_json: bool, +} + +impl BufferConsole { + pub fn with_json(mut self) -> Self { + self.print_json = true; + self + } +} + +/// Individual message entry printed to a [BufferConsole] +#[derive(Debug)] +pub struct Message { + pub level: LogLevel, + pub content: MarkupBuf, +} + +impl Console for BufferConsole { + fn println(&mut self, level: LogLevel, args: Markup) { + self.out_buffer.push(Message { + level, + content: args.to_owned(), + }); + } + + fn print(&mut self, level: LogLevel, args: Markup) { + self.out_buffer.push(Message { + level, + content: args.to_owned(), + }); + } + fn read(&mut self) -> Option { + if self.in_buffer.is_empty() { + None + } else { + // for the time being we simple return the first message, as we don't + // particular use case for multiple prompts + Some(self.in_buffer[0].clone()) + } + } +} diff --git a/crates/pg_console/src/markup.rs b/crates/pg_console/src/markup.rs new file mode 100644 index 000000000..a6781c231 --- /dev/null +++ b/crates/pg_console/src/markup.rs @@ -0,0 +1,273 @@ +use std::{ + borrow::Cow, + fmt::{self, Debug}, + io, +}; + +use termcolor::{Color, ColorSpec}; +use text_size::TextSize; + +use crate::fmt::{Display, Formatter, MarkupElements, Write}; + +/// Enumeration of all the supported markup elements +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub enum MarkupElement<'fmt> { + Emphasis, + Dim, + Italic, + Underline, + Error, + Success, + Warn, + Info, + Debug, + Trace, + Inverse, + Hyperlink { href: Cow<'fmt, str> }, +} + +impl fmt::Display for MarkupElement<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Self::Hyperlink { href } = self { + if fmt.alternate() { + write!(fmt, "Hyperlink href={:?}", href.as_ref()) + } else { + fmt.write_str("Hyperlink") + } + } else { + write!(fmt, "{self:?}") + } + } +} + +impl MarkupElement<'_> { + /// Mutate a [ColorSpec] object in place to apply this element's associated + /// style to it + pub(crate) fn update_color(&self, color: &mut ColorSpec) { + match self { + // Text Styles + MarkupElement::Emphasis => { + color.set_bold(true); + } + MarkupElement::Dim => { + color.set_dimmed(true); + } + MarkupElement::Italic => { + color.set_italic(true); + } + MarkupElement::Underline => { + color.set_underline(true); + } + + // Text Colors + MarkupElement::Error => { + color.set_fg(Some(Color::Red)); + } + MarkupElement::Success => { + color.set_fg(Some(Color::Green)); + } + MarkupElement::Warn => { + color.set_fg(Some(Color::Yellow)); + } + MarkupElement::Trace => { + color.set_fg(Some(Color::Magenta)); + } + MarkupElement::Info | MarkupElement::Debug => { + // Blue is really difficult to see on the standard windows command line + #[cfg(windows)] + const BLUE: Color = Color::Cyan; + #[cfg(not(windows))] + const BLUE: Color = Color::Blue; + + color.set_fg(Some(BLUE)); + } + + MarkupElement::Inverse | MarkupElement::Hyperlink { .. } => {} + } + } + + fn to_owned(&self) -> MarkupElement<'static> { + match self { + MarkupElement::Emphasis => MarkupElement::Emphasis, + MarkupElement::Dim => MarkupElement::Dim, + MarkupElement::Italic => MarkupElement::Italic, + MarkupElement::Underline => MarkupElement::Underline, + MarkupElement::Error => MarkupElement::Error, + MarkupElement::Success => MarkupElement::Success, + MarkupElement::Warn => MarkupElement::Warn, + MarkupElement::Info => MarkupElement::Info, + MarkupElement::Debug => MarkupElement::Debug, + MarkupElement::Trace => MarkupElement::Trace, + MarkupElement::Inverse => MarkupElement::Inverse, + MarkupElement::Hyperlink { href } => MarkupElement::Hyperlink { + href: Cow::Owned(match href { + Cow::Borrowed(href) => (*href).to_string(), + Cow::Owned(href) => href.clone(), + }), + }, + } + } +} + +/// Implementation of a single "markup node": a piece of text with a number of +/// associated styles applied to it +#[derive(Copy, Clone)] +pub struct MarkupNode<'fmt> { + pub elements: &'fmt [MarkupElement<'fmt>], + pub content: &'fmt dyn Display, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub struct MarkupNodeBuf { + pub elements: Vec>, + pub content: String, +} + +impl Debug for MarkupNodeBuf { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + for element in &self.elements { + write!(fmt, "<{element:#}>")?; + } + + if fmt.alternate() { + let mut content = self.content.as_str(); + while let Some(index) = content.find('\n') { + let (before, after) = content.split_at(index + 1); + if !before.is_empty() { + writeln!(fmt, "{before:?}")?; + } + content = after; + } + + if !content.is_empty() { + write!(fmt, "{content:?}")?; + } + } else { + write!(fmt, "{:?}", self.content)?; + } + + for element in self.elements.iter().rev() { + write!(fmt, "")?; + } + + Ok(()) + } +} + +/// Root type returned by the `markup` macro: this is simply a container for a +/// list of markup nodes +/// +/// Text nodes are formatted lazily by storing an [fmt::Arguments] struct, this +/// means [Markup] shares the same restriction as the values returned by +/// [format_args] and can't be stored in a `let` binding for instance +#[derive(Copy, Clone)] +pub struct Markup<'fmt>(pub &'fmt [MarkupNode<'fmt>]); + +impl Markup<'_> { + pub fn to_owned(&self) -> MarkupBuf { + let mut result = MarkupBuf(Vec::new()); + // SAFETY: The implementation of Write for MarkupBuf below always returns Ok + Formatter::new(&mut result).write_markup(*self).unwrap(); + result + } +} + +#[derive(Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +pub struct MarkupBuf(pub Vec); + +impl MarkupBuf { + pub fn is_empty(&self) -> bool { + self.0.iter().all(|node| node.content.is_empty()) + } + + pub fn len(&self) -> TextSize { + self.0.iter().map(|node| TextSize::of(&node.content)).sum() + } + + pub fn text_len(&self) -> usize { + self.0 + .iter() + .fold(0, |acc, string| acc + string.content.len()) + } +} + +impl Write for MarkupBuf { + fn write_str(&mut self, elements: &MarkupElements, content: &str) -> io::Result<()> { + let mut styles = Vec::new(); + elements.for_each(&mut |elements| { + styles.extend(elements.iter().map(MarkupElement::to_owned)); + Ok(()) + })?; + + if let Some(last) = self.0.last_mut() { + if last.elements == styles { + last.content.push_str(content); + return Ok(()); + } + } + + self.0.push(MarkupNodeBuf { + elements: styles, + content: content.into(), + }); + + Ok(()) + } + + fn write_fmt(&mut self, elements: &MarkupElements, content: fmt::Arguments) -> io::Result<()> { + let mut styles = Vec::new(); + elements.for_each(&mut |elements| { + styles.extend(elements.iter().map(MarkupElement::to_owned)); + Ok(()) + })?; + + if let Some(last) = self.0.last_mut() { + if last.elements == styles { + last.content.push_str(&content.to_string()); + return Ok(()); + } + } + + self.0.push(MarkupNodeBuf { + elements: styles, + content: content.to_string(), + }); + Ok(()) + } +} + +impl Display for MarkupBuf { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + let nodes: Vec<_> = self + .0 + .iter() + .map(|node| MarkupNode { + elements: &node.elements, + content: &node.content, + }) + .collect(); + + fmt.write_markup(Markup(&nodes)) + } +} + +impl Debug for MarkupBuf { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + for node in &self.0 { + Debug::fmt(node, fmt)?; + } + Ok(()) + } +} diff --git a/crates/pg_console/src/utils.rs b/crates/pg_console/src/utils.rs new file mode 100644 index 000000000..6524b3dae --- /dev/null +++ b/crates/pg_console/src/utils.rs @@ -0,0 +1,114 @@ +use crate::fmt::{Display, Formatter}; +use crate::{markup, Markup}; +use std::io; + +/// It displays a type that implements [std::fmt::Display] + +pub struct DebugDisplay(pub T); + +impl Display for DebugDisplay +where + T: std::fmt::Debug, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> io::Result<()> { + write!(f, "{:?}", self.0) + } +} + +/// It displays a `Option`, where `T` implements [std::fmt::Display] +pub struct DebugDisplayOption(pub Option); + +impl Display for DebugDisplayOption +where + T: std::fmt::Debug, +{ + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + use crate as pg_console; + + if let Some(value) = &self.0 { + markup!({ DebugDisplay(value) }).fmt(fmt)?; + } else { + markup!("unset").fmt(fmt)?; + } + Ok(()) + } +} + +/// A horizontal line with the given print width +pub struct HorizontalLine { + width: usize, +} + +impl HorizontalLine { + pub fn new(width: usize) -> Self { + Self { width } + } +} + +impl Display for HorizontalLine { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_str(&"\u{2501}".repeat(self.width)) + } +} + +// It prints `\n` +pub struct Softline; + +pub const SOFT_LINE: Softline = Softline; + +impl Display for Softline { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_str("\n") + } +} + +// It prints `\n\n` +pub struct Hardline; + +pub const HARD_LINE: Hardline = Hardline; + +impl Display for Hardline { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + fmt.write_str("\n\n") + } +} + +/// It prints N whitespaces, where N is the `width` provided by [Padding::new] +pub struct Padding { + width: usize, +} + +impl Padding { + pub fn new(width: usize) -> Self { + Self { width } + } +} + +impl Display for Padding { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + for _ in 0..self.width { + fmt.write_str(" ")?; + } + Ok(()) + } +} + +/// It writes a pair of key-value, with the given padding +pub struct KeyValuePair<'a>(pub &'a str, pub Markup<'a>); + +impl Display for KeyValuePair<'_> { + fn fmt(&self, fmt: &mut Formatter) -> io::Result<()> { + let KeyValuePair(key, value) = self; + write!(fmt, " {key}:")?; + + let padding_width = 30usize.saturating_sub(key.len() + 1); + + for _ in 0..padding_width { + fmt.write_str(" ")?; + } + + value.fmt(fmt)?; + + fmt.write_str("\n") + } +} diff --git a/crates/pg_console/src/write.rs b/crates/pg_console/src/write.rs new file mode 100644 index 000000000..a11d79d58 --- /dev/null +++ b/crates/pg_console/src/write.rs @@ -0,0 +1,13 @@ +mod html; +mod termcolor; + +use std::{fmt, io}; + +use crate::fmt::MarkupElements; + +pub use self::{html::HTML, termcolor::Termcolor}; + +pub trait Write { + fn write_str(&mut self, elements: &MarkupElements, content: &str) -> io::Result<()>; + fn write_fmt(&mut self, elements: &MarkupElements, content: fmt::Arguments) -> io::Result<()>; +} diff --git a/crates/pg_console/src/write/html.rs b/crates/pg_console/src/write/html.rs new file mode 100644 index 000000000..244ea4b8e --- /dev/null +++ b/crates/pg_console/src/write/html.rs @@ -0,0 +1,268 @@ +use std::{ + fmt, + io::{self, Write as _}, +}; + +use crate::{fmt::MarkupElements, MarkupElement}; + +use super::Write; + +/// Adapter struct implementing [Write] over types implementing [io::Write], +/// renders markup as UTF-8 strings of HTML code +pub struct HTML(pub W, bool); + +impl HTML { + pub fn new(writer: W) -> Self { + Self(writer, false) + } + + pub fn with_mdx(mut self) -> Self { + self.1 = true; + self + } +} + +impl Write for HTML +where + W: io::Write, +{ + fn write_str(&mut self, elements: &MarkupElements, content: &str) -> io::Result<()> { + push_styles(&mut self.0, elements)?; + HtmlAdapter(&mut self.0, self.1).write_all(content.as_bytes())?; + pop_styles(&mut self.0, elements) + } + + fn write_fmt(&mut self, elements: &MarkupElements, content: fmt::Arguments) -> io::Result<()> { + push_styles(&mut self.0, elements)?; + HtmlAdapter(&mut self.0, self.1).write_fmt(content)?; + pop_styles(&mut self.0, elements) + } +} + +fn push_styles(fmt: &mut W, elements: &MarkupElements) -> io::Result<()> { + elements.for_each(&mut |styles| { + for style in styles { + match style { + MarkupElement::Emphasis => write!(fmt, "")?, + MarkupElement::Dim => write!(fmt, "")?, + MarkupElement::Italic => write!(fmt, "")?, + MarkupElement::Underline => write!(fmt, "")?, + MarkupElement::Error => write!(fmt, "")?, + MarkupElement::Success => write!(fmt, "")?, + MarkupElement::Warn => write!(fmt, "")?, + MarkupElement::Debug => write!(fmt, "")?, + MarkupElement::Info => write!(fmt, "")?, + MarkupElement::Trace => write!(fmt, "")?, + MarkupElement::Inverse => { + write!(fmt, "")? + } + MarkupElement::Hyperlink { href } => write!(fmt, "")?, + } + } + + Ok(()) + }) +} + +fn pop_styles(fmt: &mut W, elements: &MarkupElements) -> io::Result<()> { + elements.for_each_rev(&mut |styles| { + for style in styles.iter().rev() { + match style { + MarkupElement::Emphasis => write!(fmt, "")?, + MarkupElement::Italic => write!(fmt, "")?, + MarkupElement::Underline => write!(fmt, "")?, + MarkupElement::Dim + | MarkupElement::Error + | MarkupElement::Success + | MarkupElement::Warn + | MarkupElement::Debug + | MarkupElement::Trace + | MarkupElement::Info + | MarkupElement::Inverse => write!(fmt, "")?, + MarkupElement::Hyperlink { .. } => write!(fmt, "")?, + } + } + + Ok(()) + }) +} + +/// Adapter wrapping a type implementing [io::Write]. It's responsible for: +/// - and adding HTML special characters escaping to the written byte sequence +/// - and adding HTML line breaks for newline characters +struct HtmlAdapter(W, bool); + +impl HtmlAdapter { + fn write_escapes(&mut self, current_byte: &u8) -> io::Result { + match *current_byte { + b'"' => self.0.write_all(b""")?, + b'&' => self.0.write_all(b"&")?, + b'<' => self.0.write_all(b"<")?, + b'>' => self.0.write_all(b">")?, + _ => return Ok(false), + }; + + Ok(true) + } + + fn write_mdx_escapes(&mut self, current_byte: &u8) -> io::Result { + if !self.1 { + return Ok(false); + } else { + match current_byte { + b'\n' => self.0.write_all(b"
")?, + b'\r' => self.0.write_all(b"
")?, + b'{' => self.0.write_all(b"{")?, + b'}' => self.0.write_all(b"}")?, + b'*' => self.0.write_all(b"*")?, + b'_' => self.0.write_all(b"_")?, + b'\\' => self.0.write_all(b"\")?, + _ => return Ok(false), + } + } + + Ok(true) + } +} + +impl io::Write for HtmlAdapter { + fn write(&mut self, buf: &[u8]) -> io::Result { + for byte in buf { + let escaped = self.write_escapes(byte)?; + let mdx_escaped = self.write_mdx_escapes(byte)?; + if !escaped && !mdx_escaped { + self.0.write_all(&[*byte])?; + } + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} + +#[cfg(test)] +mod test { + use crate as pg_console; + use crate::fmt::Formatter; + use pg_markup::markup; + + #[test] + fn test_mdx_new_lines() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, true); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "Hello" + }) + .unwrap(); + + formatter + .write_markup(markup! { + "\n" + }) + .unwrap(); + + formatter + .write_markup(markup! { + "World" + }) + .unwrap(); + + assert_eq!(String::from_utf8(buf).unwrap(), "Hello
World"); + } + + #[test] + fn test_escapes() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, false); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "\"" + }) + .unwrap(); + formatter + .write_markup(markup! { + "\"" + }) + .unwrap(); + + assert_eq!(String::from_utf8(buf).unwrap(), """"); + } + + #[test] + fn test_escapes_and_new_lines() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, true); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "New rules that are still under development.\n\n." + }) + .unwrap(); + + assert_eq!( + String::from_utf8(buf).unwrap(), + "New rules that are still under development.

." + ); + } + + #[test] + fn does_not_escape_curly_braces() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, false); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "New rules that are {still} under development." + }) + .unwrap(); + + assert_eq!( + String::from_utf8(buf).unwrap(), + "New rules that are {still} under development." + ); + } + + #[test] + fn escape_curly_braces() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, false).with_mdx(); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "New rules that are {still} under development.\n\n." + }) + .unwrap(); + + assert_eq!( + String::from_utf8(buf).unwrap(), + "New rules that are {still} under development.

." + ); + } + #[test] + fn test_from_website() { + let mut buf = Vec::new(); + let mut writer = super::HTML(&mut buf, false).with_mdx(); + let mut formatter = Formatter::new(&mut writer); + + formatter + .write_markup(markup! { + "Rules focused on preventing accessibility problems." + }) + .unwrap(); + + assert_eq!( + String::from_utf8(buf).unwrap(), + "Rules focused on preventing accessibility problems." + ); + } +} diff --git a/crates/pg_console/src/write/termcolor.rs b/crates/pg_console/src/write/termcolor.rs new file mode 100644 index 000000000..fc94e7d3a --- /dev/null +++ b/crates/pg_console/src/write/termcolor.rs @@ -0,0 +1,253 @@ +use std::{ + fmt::{self, Write as _}, + io, +}; + +use termcolor::{Color, ColorSpec, WriteColor}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::{fmt::MarkupElements, MarkupElement}; + +use super::Write; + +/// Adapter struct implementing [Write] over types implementing [WriteColor] +pub struct Termcolor(pub W); + +impl Write for Termcolor +where + W: WriteColor, +{ + fn write_str(&mut self, elements: &MarkupElements, content: &str) -> io::Result<()> { + with_format(&mut self.0, elements, |writer| { + let mut adapter = SanitizeAdapter { + writer, + error: Ok(()), + }; + + match adapter.write_str(content) { + Ok(()) => Ok(()), + Err(..) => { + if adapter.error.is_err() { + adapter.error + } else { + // SanitizeAdapter can only fail if the underlying + // writer returns an error + unreachable!() + } + } + } + }) + } + + fn write_fmt(&mut self, elements: &MarkupElements, content: fmt::Arguments) -> io::Result<()> { + with_format(&mut self.0, elements, |writer| { + let mut adapter = SanitizeAdapter { + writer, + error: Ok(()), + }; + + match adapter.write_fmt(content) { + Ok(()) => Ok(()), + Err(..) => { + if adapter.error.is_err() { + adapter.error + } else { + Err(io::Error::new( + io::ErrorKind::Other, + "a Display formatter returned an error", + )) + } + } + } + }) + } +} + +/// Applies the current format in `state` to `writer`, calls `func` to +/// print a piece of text, then reset the printing format +fn with_format( + writer: &mut W, + state: &MarkupElements, + func: impl FnOnce(&mut W) -> io::Result<()>, +) -> io::Result<()> +where + W: WriteColor, +{ + let mut color = ColorSpec::new(); + let mut link = None; + let mut inverse = false; + + state.for_each(&mut |elements| { + for element in elements { + match element { + MarkupElement::Inverse => { + inverse = !inverse; + } + MarkupElement::Hyperlink { href } => { + link = Some(href); + } + _ => { + element.update_color(&mut color); + } + } + } + + Ok(()) + })?; + + if inverse { + let fg = color.fg().map_or(Color::White, |c| *c); + let bg = color.bg().map_or(Color::Black, |c| *c); + color.set_bg(Some(fg)); + color.set_fg(Some(bg)); + } + + if let Err(err) = writer.set_color(&color) { + writer.reset()?; + return Err(err); + } + + let mut reset_link = false; + if let Some(href) = link { + // `is_synchronous` is used to check if the underlying writer + // is using the Windows Console API, that does not support ANSI + // escape codes. Generally this would only be true when running + // in the legacy `cmd.exe` terminal emulator, since in modern + // clients like the Windows Terminal ANSI is used instead + if writer.supports_color() && !writer.is_synchronous() { + write!(writer, "\x1b]8;;{href}\x1b\\")?; + reset_link = true; + } + } + + let result = func(writer); + + if reset_link { + write!(writer, "\x1b]8;;\x1b\\")?; + } + + writer.reset()?; + result +} + +/// Adapter [fmt::Write] calls to [io::Write] with sanitization, +/// implemented as an internal struct to avoid exposing [fmt::Write] on +/// [Termcolor] +struct SanitizeAdapter { + writer: W, + error: io::Result<()>, +} + +impl fmt::Write for SanitizeAdapter +where + W: WriteColor, +{ + fn write_str(&mut self, content: &str) -> fmt::Result { + let mut buffer = [0; 4]; + + for grapheme in content.graphemes(true) { + let width = UnicodeWidthStr::width(grapheme); + let is_whitespace = grapheme_is_whitespace(grapheme); + + if !is_whitespace && width == 0 { + let char_to_write = char::REPLACEMENT_CHARACTER; + char_to_write.encode_utf8(&mut buffer); + + if let Err(err) = self.writer.write_all(&buffer[..char_to_write.len_utf8()]) { + self.error = Err(err); + return Err(fmt::Error); + } + + continue; + } + + // Unicode is currently poorly supported on most Windows + // terminal clients, so we always strip emojis in Windows + if cfg!(windows) || !self.writer.supports_color() { + let is_ascii = grapheme.is_ascii(); + + if !is_ascii { + let replacement = unicode_to_ascii(grapheme.chars().nth(0).unwrap()); + + replacement.encode_utf8(&mut buffer); + + if let Err(err) = self.writer.write_all(&buffer[..replacement.len_utf8()]) { + self.error = Err(err); + return Err(fmt::Error); + } + + continue; + } + }; + + for char in grapheme.chars() { + char.encode_utf8(&mut buffer); + + if let Err(err) = self.writer.write_all(&buffer[..char.len_utf8()]) { + self.error = Err(err); + return Err(fmt::Error); + } + } + } + + Ok(()) + } +} + +/// Determines if a unicode grapheme consists only of code points +/// which are considered whitespace characters in ASCII +fn grapheme_is_whitespace(grapheme: &str) -> bool { + grapheme.chars().all(|c| c.is_whitespace()) +} + +/// Replace emoji characters with similar but more widely supported ASCII +/// characters +fn unicode_to_ascii(c: char) -> char { + match c { + '\u{2714}' => '\u{221a}', + '\u{2139}' => 'i', + '\u{26a0}' => '!', + '\u{2716}' => '\u{00d7}', + _ => c, + } +} + +#[cfg(test)] +mod tests { + use std::{fmt::Write, str::from_utf8}; + + use pg_markup::markup; + use termcolor::Ansi; + + use crate as pg_console; + use crate::fmt::Formatter; + + use super::{SanitizeAdapter, Termcolor}; + + #[test] + fn test_printing_complex_emojis() { + const INPUT: &str = "⚠️1️⃣ℹ️"; + const OUTPUT: &str = "⚠️1️⃣ℹ️"; + const WINDOWS_OUTPUT: &str = "!1i"; + + let mut buffer = Vec::new(); + + { + let writer = termcolor::Ansi::new(&mut buffer); + let mut adapter = SanitizeAdapter { + writer, + error: Ok(()), + }; + + adapter.write_str(INPUT).unwrap(); + adapter.error.unwrap(); + } + + if cfg!(windows) { + assert_eq!(from_utf8(&buffer).unwrap(), WINDOWS_OUTPUT); + } else { + assert_eq!(from_utf8(&buffer).unwrap(), OUTPUT); + } + } +} diff --git a/crates/pg_console/tests/macro.rs b/crates/pg_console/tests/macro.rs new file mode 100644 index 000000000..daa02332e --- /dev/null +++ b/crates/pg_console/tests/macro.rs @@ -0,0 +1,37 @@ +use pg_console::{Markup, MarkupElement}; + +#[test] +fn test_macro() { + let category = "test"; + + match + // Due to how MarkupNode is implemented, the result of the markup macro + // cannot be stored in a binding and must be matched upon immediately + pg_markup::markup! { + {category}" Commands" + } + { + Markup(markup) => { + let node_0 = &markup[0]; + assert_eq!(&node_0.elements, &[MarkupElement::Info, MarkupElement::Emphasis]); + // assert_eq!(node_0.content.to_string(), category.to_string()); + + let node_1 = &markup[1]; + assert_eq!(&node_1.elements, &[MarkupElement::Info]); + // assert_eq!(node_1.content.to_string(), " Commands".to_string()); + } + } +} + +#[test] +fn test_macro_attributes() { + pg_markup::markup! { + "link" + }; +} + +#[test] +fn test_macro_errors() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/markup/*.rs"); +} diff --git a/crates/pg_console/tests/markup/closing_element_standalone.rs b/crates/pg_console/tests/markup/closing_element_standalone.rs new file mode 100644 index 000000000..1e448f1a9 --- /dev/null +++ b/crates/pg_console/tests/markup/closing_element_standalone.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + + } +} diff --git a/crates/pg_console/tests/markup/closing_element_standalone.stderr b/crates/pg_console/tests/markup/closing_element_standalone.stderr new file mode 100644 index 000000000..005060893 --- /dev/null +++ b/crates/pg_console/tests/markup/closing_element_standalone.stderr @@ -0,0 +1,5 @@ +error: unexpected closing element + --> tests/markup/closing_element_standalone.rs:3:11 + | +3 | + | ^^^^^^^^ diff --git a/crates/pg_console/tests/markup/element_non_ident_name.rs b/crates/pg_console/tests/markup/element_non_ident_name.rs new file mode 100644 index 000000000..43813d9d6 --- /dev/null +++ b/crates/pg_console/tests/markup/element_non_ident_name.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + <"Literal" /> + } +} diff --git a/crates/pg_console/tests/markup/element_non_ident_name.stderr b/crates/pg_console/tests/markup/element_non_ident_name.stderr new file mode 100644 index 000000000..b3fbf301e --- /dev/null +++ b/crates/pg_console/tests/markup/element_non_ident_name.stderr @@ -0,0 +1,5 @@ +error: unexpected token + --> tests/markup/element_non_ident_name.rs:3:10 + | +3 | <"Literal" /> + | ^^^^^^^^^ diff --git a/crates/pg_console/tests/markup/invalid_group.rs b/crates/pg_console/tests/markup/invalid_group.rs new file mode 100644 index 000000000..2228e2804 --- /dev/null +++ b/crates/pg_console/tests/markup/invalid_group.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + [] + } +} diff --git a/crates/pg_console/tests/markup/invalid_group.stderr b/crates/pg_console/tests/markup/invalid_group.stderr new file mode 100644 index 000000000..f37bbe521 --- /dev/null +++ b/crates/pg_console/tests/markup/invalid_group.stderr @@ -0,0 +1,5 @@ +error: unexpected token + --> tests/markup/invalid_group.rs:3:9 + | +3 | [] + | ^^ diff --git a/crates/pg_console/tests/markup/invalid_punct.rs b/crates/pg_console/tests/markup/invalid_punct.rs new file mode 100644 index 000000000..5857a3ec5 --- /dev/null +++ b/crates/pg_console/tests/markup/invalid_punct.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + ! + } +} diff --git a/crates/pg_console/tests/markup/invalid_punct.stderr b/crates/pg_console/tests/markup/invalid_punct.stderr new file mode 100644 index 000000000..29b6be34c --- /dev/null +++ b/crates/pg_console/tests/markup/invalid_punct.stderr @@ -0,0 +1,5 @@ +error: unexpected token + --> tests/markup/invalid_punct.rs:3:9 + | +3 | ! + | ^ diff --git a/crates/pg_console/tests/markup/open_element_improper_close_1.rs b/crates/pg_console/tests/markup/open_element_improper_close_1.rs new file mode 100644 index 000000000..291809695 --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_improper_close_1.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + tests/markup/open_element_improper_close_1.rs:3:20 + | +3 | tests/markup/open_element_improper_close_2.rs:3:20 + | +3 | + } +} diff --git a/crates/pg_console/tests/markup/open_element_improper_prop_value.stderr b/crates/pg_console/tests/markup/open_element_improper_prop_value.stderr new file mode 100644 index 000000000..ceecc4469 --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_improper_prop_value.stderr @@ -0,0 +1,5 @@ +error: unexpected token + --> tests/markup/open_element_improper_prop_value.rs:3:28 + | +3 | + | ^^^^^ diff --git a/crates/pg_console/tests/markup/open_element_missing_prop_value.rs b/crates/pg_console/tests/markup/open_element_missing_prop_value.rs new file mode 100644 index 000000000..38547643c --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_missing_prop_value.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + + } +} diff --git a/crates/pg_console/tests/markup/open_element_missing_prop_value.stderr b/crates/pg_console/tests/markup/open_element_missing_prop_value.stderr new file mode 100644 index 000000000..3b96a306d --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_missing_prop_value.stderr @@ -0,0 +1,5 @@ +error: unexpected token + --> tests/markup/open_element_missing_prop_value.rs:3:28 + | +3 | + | ^ diff --git a/crates/pg_console/tests/markup/open_element_unfinished_1.rs b/crates/pg_console/tests/markup/open_element_unfinished_1.rs new file mode 100644 index 000000000..13939e522 --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_unfinished_1.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + < + } +} diff --git a/crates/pg_console/tests/markup/open_element_unfinished_1.stderr b/crates/pg_console/tests/markup/open_element_unfinished_1.stderr new file mode 100644 index 000000000..8bbd3f187 --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_unfinished_1.stderr @@ -0,0 +1,9 @@ +error: unexpected end of input + --> tests/markup/open_element_unfinished_1.rs:2:5 + | +2 | / pg_console::markup! { +3 | | < +4 | | } + | |_____^ + | + = note: this error originates in the macro `pg_console::markup` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/pg_console/tests/markup/open_element_unfinished_2.rs b/crates/pg_console/tests/markup/open_element_unfinished_2.rs new file mode 100644 index 000000000..40552a6ea --- /dev/null +++ b/crates/pg_console/tests/markup/open_element_unfinished_2.rs @@ -0,0 +1,5 @@ +fn main() { + pg_console::markup! { + tests/markup/open_element_unfinished_2.rs:2:5 + | +2 | / pg_console::markup! { +3 | | tests/markup/open_element_unfinished_3.rs:2:5 + | +2 | / pg_console::markup! { +3 | | tests/markup/open_element_unfinished_4.rs:2:5 + | +2 | / pg_console::markup! { +3 | | tests/markup/open_element_unfinished_5.rs:2:5 + | +2 | / pg_console::markup! { +3 | | tests/markup/open_element_unfinished_6.rs:2:5 + | +2 | / pg_console::markup! { +3 | | tests/markup/open_element_unfinished_7.rs:2:5 + | +2 | / pg_console::markup! { +3 | | + } +} diff --git a/crates/pg_console/tests/markup/unclosed_element.stderr b/crates/pg_console/tests/markup/unclosed_element.stderr new file mode 100644 index 000000000..47adce5b0 --- /dev/null +++ b/crates/pg_console/tests/markup/unclosed_element.stderr @@ -0,0 +1,5 @@ +error: unclosed element + --> tests/markup/unclosed_element.rs:3:10 + | +3 | + | ^^^^^^^^ diff --git a/crates/pg_diagnostics/Cargo.toml b/crates/pg_diagnostics/Cargo.toml index 9dc967504..626291d25 100644 --- a/crates/pg_diagnostics/Cargo.toml +++ b/crates/pg_diagnostics/Cargo.toml @@ -4,11 +4,25 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +backtrace = "0.3.74" +text-size.workspace = true +pg_diagnostics_macros = { workspace = true } +pg_diagnostics_categories = { workspace = true, features = ["serde"] } +pg_console = { workspace = true, features = ["serde_markup"] } +pg_text_edit = { workspace = true } +bpaf = { workspace = true } +serde = { workspace = true, features = ["derive"] } +enumflags2 = { workspace = true } +termcolor = { workspace = true } +unicode-width = { workspace = true } +serde_json = { workspace = true } +schemars = { workspace = true, optional = true } + +[features] +schema = ["schemars", "pg_text_edit/schemars", "pg_diagnostics_categories/schemars"] [dev-dependencies] [lib] doctest = false -[features] diff --git a/crates/pg_diagnostics/src/adapters.rs b/crates/pg_diagnostics/src/adapters.rs new file mode 100644 index 000000000..1521b1390 --- /dev/null +++ b/crates/pg_diagnostics/src/adapters.rs @@ -0,0 +1,136 @@ +//! This modules exposes a number of "adapter diagnostics" that wrap error types +//! such as [std::error::Error] or [std::io::Error] in newtypes implementing the +//! [Diagnostic] trait + +use std::io; + +use pg_console::{ + fmt::{self}, + markup, +}; + +use crate::{category, Category, Diagnostic, DiagnosticTags}; + +/// Implements [Diagnostic] over types implementing [std::error::Error]. +#[derive(Debug)] +pub struct StdError { + error: Box, +} + +impl From for StdError { + fn from(error: E) -> Self { + Self { + error: Box::new(error), + } + } +} + +impl Diagnostic for StdError { + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "{}", self.error) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt.write_markup(markup!({ AsConsoleDisplay(&self.error) })) + } +} + +struct AsConsoleDisplay<'a, T>(&'a T); + +impl fmt::Display for AsConsoleDisplay<'_, T> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt.write_fmt(format_args!("{}", self.0)) + } +} + +/// Implements [Diagnostic] over for [io::Error]. +#[derive(Debug)] +pub struct IoError { + error: io::Error, +} + +impl From for IoError { + fn from(error: io::Error) -> Self { + Self { error } + } +} + +impl Diagnostic for IoError { + fn category(&self) -> Option<&'static Category> { + Some(category!("internalError/io")) + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "{}", self.error) + } + + fn tags(&self) -> DiagnosticTags { + DiagnosticTags::INTERNAL + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt.write_markup(markup!({ AsConsoleDisplay(&self.error) })) + } +} + +/// Implements [Diagnostic] over for [bpaf::ParseFailure]. +#[derive(Debug)] +pub struct BpafError { + error: bpaf::ParseFailure, +} + +impl From for BpafError { + fn from(error: bpaf::ParseFailure) -> Self { + Self { error } + } +} + +impl Diagnostic for BpafError { + fn category(&self) -> Option<&'static Category> { + Some(category!("flags/invalid")) + } + + fn tags(&self) -> DiagnosticTags { + DiagnosticTags::FIXABLE + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let bpaf::ParseFailure::Stderr(reason) = &self.error { + write!(fmt, "{reason}")?; + } + Ok(()) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + if let bpaf::ParseFailure::Stderr(reason) = &self.error { + let error = reason.to_string(); + fmt.write_str(&error)?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct SerdeJsonError { + error: serde_json::Error, +} + +impl From for SerdeJsonError { + fn from(error: serde_json::Error) -> Self { + Self { error } + } +} + +impl Diagnostic for SerdeJsonError { + fn category(&self) -> Option<&'static Category> { + Some(category!("internalError/io")) + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "{}", self.error) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt.write_markup(markup!({ AsConsoleDisplay(&self.error) })) + } +} diff --git a/crates/pg_diagnostics/src/advice.rs b/crates/pg_diagnostics/src/advice.rs new file mode 100644 index 000000000..1488ed81a --- /dev/null +++ b/crates/pg_diagnostics/src/advice.rs @@ -0,0 +1,226 @@ +use crate::Applicability; +use crate::{ + display::Backtrace, + location::{AsResource, AsSourceCode, AsSpan}, + Location, +}; +use pg_console::fmt::{self, Display}; +use pg_console::{markup, MarkupBuf}; +use pg_text_edit::TextEdit; +use serde::{Deserialize, Serialize}; +use std::io; + +/// Trait implemented by types that support emitting advices into a diagnostic +pub trait Advices { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()>; +} + +/// The `Visit` trait is used to collect advices from a diagnostic: a visitor +/// instance is provided to the [Diagnostic::advices](super::Diagnostic::advices) +/// and [Diagnostic::verbose_advices](super::Diagnostic::verbose_advices) methods, +/// and the diagnostic implementation is expected to call into the various `record_*` +/// methods to communicate advices to the user. +pub trait Visit { + /// Prints a single log entry with the provided category and markup. + fn record_log(&mut self, category: LogCategory, text: &dyn fmt::Display) -> io::Result<()> { + let _ = (category, text); + Ok(()) + } + + /// Prints an unordered list of items. + fn record_list(&mut self, list: &[&dyn fmt::Display]) -> io::Result<()> { + let _ = list; + Ok(()) + } + + /// Prints a code frame outlining the provided source location. + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + let _ = location; + Ok(()) + } + + /// Prints the diff between the `prev` and `next` strings. + fn record_diff(&mut self, diff: &TextEdit) -> io::Result<()> { + let _ = diff; + Ok(()) + } + + /// Prints a Rust backtrace. + fn record_backtrace( + &mut self, + title: &dyn fmt::Display, + backtrace: &Backtrace, + ) -> io::Result<()> { + let _ = (title, backtrace); + Ok(()) + } + + /// Prints a command to the user. + fn record_command(&mut self, command: &str) -> io::Result<()> { + let _ = command; + Ok(()) + } + + /// Prints a group of advices under a common title. + fn record_group(&mut self, title: &dyn fmt::Display, advice: &dyn Advices) -> io::Result<()> { + let _ = (title, advice); + Ok(()) + } + + /// ## Warning + /// + /// The implementation of the table, for now, is tailored for two columns, and it assumes that + /// the longest cell is on top. + fn record_table( + &mut self, + padding: usize, + headers: &[MarkupBuf], + columns: &[&[MarkupBuf]], + ) -> io::Result<()> { + let _ = (headers, columns, padding); + Ok(()) + } +} + +/// The category for a log advice, defines how the message should be presented +/// to the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum LogCategory { + /// The advice doesn't have any specific category, the message will be + /// printed as plain markup. + None, + /// Print the advices with the information style. + Info, + /// Print the advices with the warning style. + Warn, + /// Print the advices with the error style. + Error, +} + +/// Utility type implementing [Advices] that emits a single log advice with +/// the provided category and text. +#[derive(Debug)] +pub struct LogAdvice { + pub category: LogCategory, + pub text: T, +} + +impl Advices for LogAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_log(self.category, &self.text) + } +} + +/// Utility advice that prints a list of items. +#[derive(Debug)] +pub struct ListAdvice { + pub list: Vec, +} + +impl Advices for ListAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + if self.list.is_empty() { + visitor.record_log(LogCategory::Warn, &"The list is empty.") + } else { + let pattern_list: Vec<_> = self + .list + .iter() + .map(|pattern| pattern as &dyn Display) + .collect(); + + visitor.record_list(&pattern_list) + } + } +} + +/// Utility type implementing [Advices] that emits a single code frame +/// advice with the provided path, span and source code. +#[derive(Debug)] +pub struct CodeFrameAdvice { + pub path: Path, + pub span: Span, + pub source_code: SourceCode, +} + +impl Advices for CodeFrameAdvice +where + Path: AsResource, + Span: AsSpan, + SourceCode: AsSourceCode, +{ + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let location = Location::builder() + .resource(&self.path) + .span(&self.span) + .source_code(&self.source_code) + .build(); + + visitor.record_frame(location)?; + + Ok(()) + } +} + +/// Utility type implementing [Advices] that emits a diff advice with the +/// provided prev and next text. +#[derive(Debug)] +pub struct DiffAdvice { + pub diff: D, +} + +impl Advices for DiffAdvice +where + D: AsRef, +{ + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_diff(self.diff.as_ref()) + } +} + +/// Utility type implementing [Advices] that emits a command advice with +/// the provided text. +#[derive(Debug)] +pub struct CommandAdvice { + pub command: T, +} + +impl Advices for CommandAdvice +where + T: AsRef, +{ + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_command(self.command.as_ref()) + } +} + +#[derive(Debug)] +/// Utility type implementing [Advices] that emits a +/// code suggestion with the provided text +pub struct CodeSuggestionAdvice { + pub applicability: Applicability, + pub msg: M, + pub suggestion: TextEdit, +} + +impl Advices for CodeSuggestionAdvice +where + M: Display, +{ + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let applicability = match self.applicability { + Applicability::Always => "Safe fix", + Applicability::MaybeIncorrect => "Unsafe fix", + }; + + visitor.record_log( + LogCategory::Info, + &markup! { + {applicability}": "{self.msg} + }, + )?; + + visitor.record_diff(&self.suggestion) + } +} diff --git a/crates/pg_diagnostics/src/context.rs b/crates/pg_diagnostics/src/context.rs new file mode 100644 index 000000000..c9811aeea --- /dev/null +++ b/crates/pg_diagnostics/src/context.rs @@ -0,0 +1,761 @@ +use pg_console::fmt; + +use crate::context::internal::{SeverityDiagnostic, TagsDiagnostic}; +use crate::{ + diagnostic::internal::AsDiagnostic, + location::{AsResource, AsSourceCode, AsSpan}, + Category, DiagnosticTags, Error, Resource, Severity, SourceCode, +}; + +/// This trait is implemented for all types implementing [Diagnostic](super::Diagnostic) +/// and the [Error] struct, and exposes various combinator methods to enrich +/// existing diagnostics with additional information. +pub trait DiagnosticExt: internal::Sealed + Sized { + /// Returns a new diagnostic with the provided `message` as a message and + /// description, and `self` as a source diagnostic. This is useful to + /// create chains of diagnostics, where high level errors wrap lower level + /// causes. + fn context(self, message: M) -> Error + where + Self: 'static, + M: fmt::Display + 'static, + Error: From>; + + /// Returns a new diagnostic using the provided `category` if `self` + /// doesn't already have one. + fn with_category(self, category: &'static Category) -> Error + where + Error: From>; + + /// Returns a new diagnostic using the provided `path` if `self` + /// doesn't already have one. + fn with_file_path(self, path: impl AsResource) -> Error + where + Error: From>; + + /// Returns a new diagnostic using the provided `span` instead of the one in `self`. + fn with_file_span(self, span: impl AsSpan) -> Error + where + Error: From>; + + /// Returns a new diagnostic using the provided `source_code` if `self` + /// doesn't already have one. + fn with_file_source_code(self, source_code: impl AsSourceCode) -> Error + where + Error: From>; + + /// Returns a new diagnostic with additional `tags` + fn with_tags(self, tags: DiagnosticTags) -> Error + where + Error: From>; + + /// Returns a new diagnostic with additional `severity` + fn with_severity(self, severity: Severity) -> Error + where + Error: From>; +} + +impl internal::Sealed for E {} + +impl DiagnosticExt for E { + fn context(self, message: M) -> Error + where + E: 'static, + M: fmt::Display + 'static, + Error: From>, + { + Error::from(internal::ContextDiagnostic { + message, + source: self, + }) + } + + fn with_category(self, category: &'static Category) -> Error + where + Error: From>, + { + Error::from(internal::CategoryDiagnostic { + category, + source: self, + }) + } + + fn with_file_path(self, path: impl AsResource) -> Error + where + Error: From>, + { + Error::from(internal::FilePathDiagnostic { + path: path.as_resource().map(Resource::to_owned), + source: self, + }) + } + + fn with_file_span(self, span: impl AsSpan) -> Error + where + Error: From>, + { + Error::from(internal::FileSpanDiagnostic { + span: span.as_span(), + source: self, + }) + } + + fn with_file_source_code(self, source_code: impl AsSourceCode) -> Error + where + Error: From>, + { + Error::from(internal::FileSourceCodeDiagnostic { + source_code: source_code.as_source_code().map(SourceCode::to_owned), + source: self, + }) + } + + fn with_tags(self, tags: DiagnosticTags) -> Error + where + Error: From>, + { + Error::from(internal::TagsDiagnostic { tags, source: self }) + } + + fn with_severity(self, severity: Severity) -> Error + where + Error: From>, + { + Error::from(internal::SeverityDiagnostic { + severity, + source: self, + }) + } +} + +pub trait Context: internal::Sealed { + /// If `self` is an error, returns a new diagnostic with the provided + /// `message` as a message and description, and `self` as a source + /// diagnostic. This is useful to create chains of diagnostics, where high + /// level errors wrap lower level causes. + fn context(self, message: M) -> Result + where + E: 'static, + M: fmt::Display + 'static, + Error: From>; + + /// If `self` is an error, returns a new diagnostic using the provided + /// `category` if `self` doesn't already have one. + fn with_category(self, category: &'static Category) -> Result + where + Error: From>; + + /// If `self` is an error, returns a new diagnostic using the provided + /// `path` if `self` doesn't already have one. + fn with_file_path(self, path: impl AsResource) -> Result + where + Error: From>; + + /// If `self` is an error, returns a new diagnostic using the provided + /// `severity` if `self` doesn't already have one. + fn with_severity(self, severity: Severity) -> Result + where + Error: From>; + + /// If `self` is an error, returns a new diagnostic using the provided + /// `tags` if `self` doesn't already have one. + fn with_tags(self, tags: DiagnosticTags) -> Result + where + Error: From>; + + /// If `self` is an error, returns a new diagnostic using the provided + /// `span` instead of the one returned by `self`. + /// + /// This is useful in multi-language documents, where a given diagnostic + /// may be originally emitted with a span relative to a specific substring + /// of a larger document, and later needs to have its position remapped to + /// be relative to the entire file instead. + fn with_file_span(self, span: impl AsSpan) -> Result + where + Error: From>; +} + +impl internal::Sealed for Result {} + +impl Context for Result { + fn context(self, message: M) -> Result + where + E: 'static, + M: fmt::Display + 'static, + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.context(message)), + } + } + + fn with_category(self, category: &'static Category) -> Result + where + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.with_category(category)), + } + } + + fn with_file_path(self, path: impl AsResource) -> Result + where + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.with_file_path(path)), + } + } + + fn with_severity(self, severity: Severity) -> Result + where + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.with_severity(severity)), + } + } + + fn with_tags(self, tags: DiagnosticTags) -> Result + where + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.with_tags(tags)), + } + } + + fn with_file_span(self, span: impl AsSpan) -> Result + where + Error: From>, + { + match self { + Ok(value) => Ok(value), + Err(source) => Err(source.with_file_span(span)), + } + } +} + +mod internal { + //! These types need to be declared as public as they're referred to in the + //! `where` clause of other public items, but as they're not part of the + //! public API they are declared in a private module so they're not + //! accessible outside of the crate + + use std::{fmt::Debug, io}; + + use pg_console::{fmt, markup}; + use pg_text_edit::TextEdit; + use text_size::TextRange; + + use crate::{ + diagnostic::internal::AsDiagnostic, Advices, Backtrace, Category, Diagnostic, + DiagnosticTags, LineIndex, LineIndexBuf, Location, LogCategory, Resource, Severity, + SourceCode, Visit, + }; + + /// This trait is inherited by `DiagnosticExt` and `Context`, since it's + /// not visible outside of `pg_diagnostics` this prevents these extension + /// traits from being implemented on other types outside of this module + /// + /// Making these traits "sealed" is mainly intended as a stability + /// guarantee, if these traits were simply public any change to their + /// signature or generic implementations would be a breaking change for + /// downstream implementations, so preventing these traits from ever being + /// implemented in downstream crates ensures this doesn't happen. + pub trait Sealed {} + + /// Diagnostic type returned by [super::DiagnosticExt::context], uses + /// `message` as its message and description, and `source` as its source + /// diagnostic. + pub struct ContextDiagnostic { + pub(super) message: M, + pub(super) source: E, + } + + impl Diagnostic for ContextDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut writer = DisplayMarkup(fmt); + let mut fmt = fmt::Formatter::new(&mut writer); + fmt.write_markup(markup!({ self.message })) + .map_err(|_| std::fmt::Error) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt::Display::fmt(&self.message, fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + self.source.as_diagnostic().location() + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + + fn source(&self) -> Option<&dyn Diagnostic> { + Some(self.source.as_dyn()) + } + } + + impl Debug for ContextDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("message", &DebugMarkup(&self.message)) + .field("source", &self.source) + .finish() + } + } + + /// Helper wrapper implementing [Debug] for types implementing [fmt::Display], + /// prints a debug representation of the markup generated by printing `T`. + struct DebugMarkup(T); + + impl Debug for DebugMarkup { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let buffer = markup!({ self.0 }).to_owned(); + Debug::fmt(&buffer, fmt) + } + } + + /// Helper wrapper implementing [fmt::Write] for [std::fmt::Formatter]. + struct DisplayMarkup<'a, 'b>(&'a mut std::fmt::Formatter<'b>); + + impl fmt::Write for DisplayMarkup<'_, '_> { + fn write_str(&mut self, _: &fmt::MarkupElements<'_>, content: &str) -> io::Result<()> { + self.0 + .write_str(content) + .map_err(|error| io::Error::new(io::ErrorKind::Other, error)) + } + + fn write_fmt( + &mut self, + _: &fmt::MarkupElements<'_>, + content: std::fmt::Arguments<'_>, + ) -> io::Result<()> { + self.0 + .write_fmt(content) + .map_err(|error| io::Error::new(io::ErrorKind::Other, error)) + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_category], + /// uses `category` as its category if `source` doesn't return one. + pub struct CategoryDiagnostic { + pub(super) category: &'static Category, + pub(super) source: E, + } + + impl Debug for CategoryDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("category", &self.category) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for CategoryDiagnostic { + fn category(&self) -> Option<&'static Category> { + Some( + self.source + .as_diagnostic() + .category() + .unwrap_or(self.category), + ) + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + self.source.as_diagnostic().location() + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_file_path], + /// uses `path` as its location path if `source` doesn't return one. + pub struct FilePathDiagnostic { + pub(super) path: Option>, + pub(super) source: E, + } + + impl Debug for FilePathDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("path", &self.path) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for FilePathDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + let loc = self.source.as_diagnostic().location(); + Location { + resource: match loc.resource { + Some(Resource::Argv) => Some(Resource::Argv), + Some(Resource::Memory) => Some(Resource::Memory), + Some(Resource::File(file)) => { + if let Some(Resource::File(path)) = &self.path { + Some(Resource::File(path.as_ref())) + } else { + Some(Resource::File(file)) + } + } + None => self.path.as_ref().map(Resource::as_deref), + }, + span: loc.span, + source_code: loc.source_code, + } + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_file_span], + /// uses `span` as its location span instead of the one returned by `source`. + pub struct FileSpanDiagnostic { + pub(super) span: Option, + pub(super) source: E, + } + + impl Debug for FileSpanDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("span", &self.span) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for FileSpanDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + let loc = self.source.as_diagnostic().location(); + Location { + resource: loc.resource, + span: self.span.or(loc.span), + source_code: loc.source_code, + } + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_file_source_code], + /// uses `source_code` as its location source code if `source` doesn't + /// return one. + pub struct FileSourceCodeDiagnostic { + pub(super) source_code: Option>, + pub(super) source: E, + } + + impl Debug for FileSourceCodeDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("source_code", &self.source_code) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for FileSourceCodeDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + if let Some(source_code) = &self.source_code { + let mut visitor = FileSourceCodeVisitor { + visitor, + source_code: source_code.as_deref(), + }; + + self.source.as_diagnostic().advices(&mut visitor) + } else { + self.source.as_diagnostic().advices(visitor) + } + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + if let Some(source_code) = &self.source_code { + let mut visitor = FileSourceCodeVisitor { + visitor, + source_code: source_code.as_deref(), + }; + + self.source.as_diagnostic().verbose_advices(&mut visitor) + } else { + self.source.as_diagnostic().verbose_advices(visitor) + } + } + + fn location(&self) -> Location<'_> { + let location = self.source.as_diagnostic().location(); + Location { + source_code: location + .source_code + .or_else(|| Some(self.source_code.as_ref()?.as_deref())), + ..location + } + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + } + + /// Helper wrapper for a [Visitor], automatically inject `source_code` into + /// the location of code frame advices if they don't have one already. + struct FileSourceCodeVisitor<'a> { + visitor: &'a mut dyn Visit, + source_code: SourceCode<&'a str, &'a LineIndex>, + } + + impl Visit for FileSourceCodeVisitor<'_> { + fn record_log(&mut self, category: LogCategory, text: &dyn fmt::Display) -> io::Result<()> { + self.visitor.record_log(category, text) + } + + fn record_list(&mut self, list: &[&dyn fmt::Display]) -> io::Result<()> { + self.visitor.record_list(list) + } + + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + self.visitor.record_frame(Location { + source_code: Some(location.source_code.unwrap_or(self.source_code)), + ..location + }) + } + + fn record_diff(&mut self, diff: &TextEdit) -> io::Result<()> { + self.visitor.record_diff(diff) + } + + fn record_backtrace( + &mut self, + title: &dyn fmt::Display, + backtrace: &Backtrace, + ) -> io::Result<()> { + self.visitor.record_backtrace(title, backtrace) + } + + fn record_command(&mut self, command: &str) -> io::Result<()> { + self.visitor.record_command(command) + } + + fn record_group( + &mut self, + title: &dyn fmt::Display, + advice: &dyn Advices, + ) -> io::Result<()> { + self.visitor.record_group(title, advice) + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_tags], + /// merges `tags` with the tags of its source + pub struct TagsDiagnostic { + pub(super) tags: DiagnosticTags, + pub(super) source: E, + } + + impl Debug for TagsDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("tags", &self.tags) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for TagsDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.source.as_diagnostic().severity() + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + self.source.as_diagnostic().location() + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() | self.tags + } + } + + /// Diagnostic type returned by [super::DiagnosticExt::with_severity], + /// replaces `severity` with the severity of its source + pub struct SeverityDiagnostic { + pub(super) severity: Severity, + pub(super) source: E, + } + + impl Debug for SeverityDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Diagnostic") + .field("severity", &self.severity) + .field("source", &self.source) + .finish() + } + } + + impl Diagnostic for SeverityDiagnostic { + fn category(&self) -> Option<&'static Category> { + self.source.as_diagnostic().category() + } + + fn severity(&self) -> Severity { + self.severity + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.source.as_diagnostic().description(fmt) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.source.as_diagnostic().message(fmt) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().advices(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.source.as_diagnostic().verbose_advices(visitor) + } + + fn location(&self) -> Location<'_> { + self.source.as_diagnostic().location() + } + + fn tags(&self) -> DiagnosticTags { + self.source.as_diagnostic().tags() + } + } +} diff --git a/crates/pg_diagnostics/src/diagnostic.rs b/crates/pg_diagnostics/src/diagnostic.rs new file mode 100644 index 000000000..e50690691 --- /dev/null +++ b/crates/pg_diagnostics/src/diagnostic.rs @@ -0,0 +1,269 @@ +use std::{ + convert::Infallible, + fmt::{Debug, Display}, + io, + ops::{BitOr, BitOrAssign}, + str::FromStr, +}; + +use enumflags2::{bitflags, make_bitflags, BitFlags}; +use serde::{Deserialize, Serialize}; + +use pg_console::fmt; + +use crate::{Category, Location, Visit}; + +/// The `Diagnostic` trait defines the metadata that can be exposed by error +/// types in order to print details diagnostics in the console of the editor +/// +/// ## Implementation +/// +/// Most types should not have to implement this trait manually, and should +/// instead rely on the `Diagnostic` derive macro also provided by this crate: +/// +/// ``` +/// # use pg_diagnostics::Diagnostic; +/// #[derive(Debug, Diagnostic)] +/// #[diagnostic(category = "lint/style/noShoutyConstants", tags(FIXABLE))] +/// struct ExampleDiagnostic { +/// #[message] +/// #[description] +/// message: String, +/// } +/// ``` +pub trait Diagnostic: Debug { + /// The category of a diagnostic uniquely identifying this + /// diagnostic type, such as `lint/correctness/noArguments`, `args/invalid` + /// or `format/disabled`. + fn category(&self) -> Option<&'static Category> { + None + } + + /// The severity defines whether this diagnostic reports an error, a + /// warning, an information or a hint to the user. + fn severity(&self) -> Severity { + Severity::Error + } + + /// The description is a text-only explanation of the issue this diagnostic + /// is reporting, intended for display contexts that do not support rich + /// markup such as in-editor popovers + /// + /// The description should generally be as exhaustive as possible, since + /// the clients that do not support rendering markup will not render the + /// advices for the diagnostic either. + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let _ = fmt; + Ok(()) + } + + /// An explanation of the issue this diagnostic is reporting + /// + /// In general it's better to keep this message as short as possible, and + /// instead rely on advices to better convey contextual explanations to the + /// user. + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let _ = fmt; + Ok(()) + } + + /// Advices are the main building blocks used compose rich errors. They are + /// implemented using a visitor pattern, where consumers of a diagnostic + /// can visit the object and collect the advices that make it up for the + /// purpose of display or introspection. + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let _ = visitor; + Ok(()) + } + + /// Diagnostics can defines additional advices to be printed if the user + /// requires more detail about the diagnostic. + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let _ = visitor; + Ok(()) + } + + /// A diagnostic can be tied to a specific "location": this can be a file, + /// memory buffer, command line argument, etc. It may also be tied to a + /// specific text range within the content of that location. Finally, it + /// may also provide the source string for that location (this is required + /// in order to display a code frame advice for the diagnostic). + fn location(&self) -> Location<'_> { + Location::builder().build() + } + + /// Tags convey additional boolean metadata about the nature of a diagnostic: + /// - If the diagnostic can be automatically fixed + /// - If the diagnostic resulted from and internal error + /// - If the diagnostic is being emitted as part of a crash / fatal error + /// - If the diagnostic is a warning about a piece of unused or unnecessary code + /// - If the diagnostic is a warning about a piece of deprecated or obsolete code. + /// - If the diagnostic is meant to provide more information + fn tags(&self) -> DiagnosticTags { + DiagnosticTags::empty() + } + + /// Similarly to the `source` method of the [std::error::Error] trait, this + /// returns another diagnostic that's the logical "cause" for this issue. + /// For instance, a "request failed" diagnostic may have been cause by a + /// "deserialization error". This allows low-level error to be wrapped in + /// higher level concepts, while retaining enough information to display + /// and fix the underlying issue. + fn source(&self) -> Option<&dyn Diagnostic> { + None + } +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] +#[serde(rename_all = "snake_case")] +/// The severity to associate to a diagnostic. +pub enum Severity { + /// Reports a hint. + Hint, + /// Reports an information. + #[default] + Information, + /// Reports a warning. + Warning, + /// Reports an error. + Error, + /// Reports a crash. + Fatal, +} + +impl FromStr for Severity { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "hint" => Ok(Self::Information), + "info" => Ok(Self::Information), + "warn" => Ok(Self::Warning), + "error" => Ok(Self::Error), + v => Err(format!( + "Found unexpected value ({v}), valid values are: info, warn, error." + )), + } + } +} + +impl Display for Severity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Hint => write!(f, "info"), + Self::Information => write!(f, "info"), + Self::Warning => write!(f, "warn"), + Self::Error => write!(f, "error"), + Self::Fatal => write!(f, "fatal"), + } + } +} + +/// Internal enum used to automatically generate bit offsets for [DiagnosticTags] +/// and help with the implementation of `serde` and `schemars` for tags. +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[bitflags] +#[repr(u8)] +pub(super) enum DiagnosticTag { + Fixable = 1 << 0, + Internal = 1 << 1, + UnnecessaryCode = 1 << 2, + DeprecatedCode = 1 << 3, + Verbose = 1 << 4, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct DiagnosticTags(BitFlags); +impl DiagnosticTags { + /// This diagnostic has a fix suggestion. + pub const FIXABLE: Self = Self(make_bitflags!(DiagnosticTag::{Fixable})); + /// This diagnostic results from an internal error. + pub const INTERNAL: Self = Self(make_bitflags!(DiagnosticTag::{Internal})); + /// This diagnostic tags unused or unnecessary code, this may change + /// how the diagnostic is render in editors. + pub const UNNECESSARY_CODE: Self = Self(make_bitflags!(DiagnosticTag::{UnnecessaryCode})); + /// This diagnostic tags deprecated or obsolete code, this may change + /// how the diagnostic is render in editors. + pub const DEPRECATED_CODE: Self = Self(make_bitflags!(DiagnosticTag::{DeprecatedCode})); + /// This diagnostic is verbose and should be printed only if the `--verbose` option is provided + pub const VERBOSE: Self = Self(make_bitflags!(DiagnosticTag::{Verbose})); + pub const fn all() -> Self { + Self(BitFlags::ALL) + } + pub const fn empty() -> Self { + Self(BitFlags::EMPTY) + } + pub fn insert(&mut self, other: DiagnosticTags) { + self.0 |= other.0; + } + pub fn contains(self, other: impl Into) -> bool { + self.0.contains(other.into().0) + } + pub const fn union(self, other: Self) -> Self { + Self(self.0.union_c(other.0)) + } + pub fn is_empty(self) -> bool { + self.0.is_empty() + } + pub fn is_verbose(&self) -> bool { + self.contains(DiagnosticTag::Verbose) + } +} + +impl BitOr for DiagnosticTags { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self::Output { + DiagnosticTags(self.0 | rhs.0) + } +} + +impl BitOrAssign for DiagnosticTags { + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0; + } +} + +// Implement the `Diagnostic` on the `Infallible` error type from the standard +// library as a utility for implementing signatures that require a diagnostic +// type when the operation can never fail +impl Diagnostic for Infallible {} + +pub(crate) mod internal { + //! The `AsDiagnostic` trait needs to be declared as public as its referred + //! to in the `where` clause of other public items, but as it's not part of + //! the public API it's declared in a private module so it's not accessible + //! outside of the crate + + use std::fmt::Debug; + + use crate::Diagnostic; + + /// Since [Error](crate::Error) must implement `From` to + /// be used with the `?` operator, it cannot implement the [Diagnostic] + /// trait (as that would conflict with the implementation of `From for T` + /// in the standard library). The [AsDiagnostic] exists as an internal + /// implementation detail to bridge this gap and allow various types and + /// functions in `pg_diagnostics` to be generic over all diagnostics + + /// `Error`. + pub trait AsDiagnostic: Debug { + type Diagnostic: Diagnostic + ?Sized; + fn as_diagnostic(&self) -> &Self::Diagnostic; + fn as_dyn(&self) -> &dyn Diagnostic; + } + + impl AsDiagnostic for D { + type Diagnostic = D; + + fn as_diagnostic(&self) -> &Self::Diagnostic { + self + } + + fn as_dyn(&self) -> &dyn Diagnostic { + self + } + } +} diff --git a/crates/pg_diagnostics/src/display.rs b/crates/pg_diagnostics/src/display.rs new file mode 100644 index 000000000..1707b777f --- /dev/null +++ b/crates/pg_diagnostics/src/display.rs @@ -0,0 +1,1081 @@ +use pg_console::fmt::MarkupElements; +use pg_console::{ + fmt, markup, HorizontalLine, Markup, MarkupBuf, MarkupElement, MarkupNode, Padding, +}; +use pg_text_edit::TextEdit; +use std::path::Path; +use std::{env, io, iter}; +use unicode_width::UnicodeWidthStr; + +mod backtrace; +mod diff; +pub(super) mod frame; +mod message; + +pub use crate::display::frame::{SourceFile, SourceLocation}; +use crate::{ + diagnostic::internal::AsDiagnostic, Advices, Diagnostic, DiagnosticTags, Location, LogCategory, + Resource, Severity, Visit, +}; + +pub use self::backtrace::{set_bottom_frame, Backtrace}; +pub use self::message::MessageAndDescription; + +/// Helper struct from printing the description of a diagnostic into any +/// formatter implementing [std::fmt::Write]. +pub struct PrintDescription<'fmt, D: ?Sized>(pub &'fmt D); + +impl std::fmt::Display for PrintDescription<'_, D> { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0 + .as_diagnostic() + .description(fmt) + .map_err(|_| std::fmt::Error) + } +} + +/// Helper struct for printing a diagnostic as markup into any formatter +/// implementing [pg_console::fmt::Write]. +pub struct PrintDiagnostic<'fmt, D: ?Sized> { + diag: &'fmt D, + verbose: bool, + search: bool, +} + +impl<'fmt, D: AsDiagnostic + ?Sized> PrintDiagnostic<'fmt, D> { + pub fn simple(diag: &'fmt D) -> Self { + Self { + diag, + verbose: false, + search: false, + } + } + + pub fn verbose(diag: &'fmt D) -> Self { + Self { + diag, + verbose: true, + search: false, + } + } + + pub fn search(diag: &'fmt D) -> Self { + Self { + diag, + verbose: false, + search: true, + } + } +} + +impl fmt::Display for PrintDiagnostic<'_, D> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let diagnostic = self.diag.as_diagnostic(); + + // Print the header for the diagnostic + fmt.write_markup(markup! { + {PrintHeader(diagnostic)}"\n\n" + })?; + // Wrap the formatter with an indentation level and print the advices + let mut slot = None; + let mut fmt = IndentWriter::wrap(fmt, &mut slot, true, " "); + + if self.search { + let mut visitor = PrintSearch(&mut fmt); + print_advices(&mut visitor, diagnostic, self.verbose) + } else { + let mut visitor = PrintAdvices(&mut fmt); + print_advices(&mut visitor, diagnostic, self.verbose) + } + } +} + +/// Display struct implementing the formatting of a diagnostic header. +pub(crate) struct PrintHeader<'fmt, D: ?Sized>(pub(crate) &'fmt D); + +impl fmt::Display for PrintHeader<'_, D> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> io::Result<()> { + let Self(diagnostic) = *self; + + // Wrap the formatter with a counter to measure the width of the printed text + let mut slot = None; + let mut fmt = CountWidth::wrap(f, &mut slot); + + // Print the diagnostic location if it has a file path + let location = diagnostic.location(); + let file_name = match &location.resource { + Some(Resource::File(file)) => Some(file), + _ => None, + }; + + let is_vscode = env::var("TERM_PROGRAM").unwrap_or_default() == "vscode"; + + if let Some(name) = file_name { + if is_vscode { + fmt.write_str(name)?; + } else { + let path_name = Path::new(name); + if path_name.is_absolute() { + let link = format!("file://{name}"); + fmt.write_markup(markup! { + {name} + })?; + } else { + fmt.write_str(name)?; + } + } + + // Print the line and column position if the location has a span and source code + // (the source code is necessary to convert a byte offset into a line + column) + if let (Some(span), Some(source_code)) = (location.span, location.source_code) { + let file = SourceFile::new(source_code); + if let Ok(location) = file.location(span.start()) { + fmt.write_markup(markup! { + ":"{location.line_number.get()}":"{location.column_number.get()} + })?; + } + } + + fmt.write_str(" ")?; + } + + // Print the category of the diagnostic, with a hyperlink if + // the category has an associated link + if let Some(category) = diagnostic.category() { + if let Some(link) = category.link() { + fmt.write_markup(markup! { + {category.name()}" " + })?; + } else { + fmt.write_markup(markup! { + {category.name()}" " + })?; + } + } + + // Print the internal, fixable and fatal tags + let tags = diagnostic.tags(); + + if tags.contains(DiagnosticTags::INTERNAL) { + fmt.write_markup(markup! { + " INTERNAL "" " + })?; + } + + if tags.contains(DiagnosticTags::FIXABLE) { + fmt.write_markup(markup! { + " FIXABLE "" " + })?; + } + + if tags.contains(DiagnosticTags::DEPRECATED_CODE) { + fmt.write_markup(markup! { + " DEPRECATED "" " + })?; + } + + if tags.contains(DiagnosticTags::VERBOSE) { + fmt.write_markup(markup! { + " VERBOSE "" " + })?; + } + if diagnostic.severity() == Severity::Fatal { + fmt.write_markup(markup! { + " FATAL "" " + })?; + } + + // Load the printed width for the header, and fill the rest of the line + // with the '━' line character up to 100 columns with at least 10 characters + const HEADER_WIDTH: usize = 100; + const MIN_WIDTH: usize = 10; + + let text_width = slot.map_or(0, |writer| writer.width); + let line_width = HEADER_WIDTH.saturating_sub(text_width).max(MIN_WIDTH); + HorizontalLine::new(line_width).fmt(f) + } +} + +/// Wrapper for a type implementing [fmt::Write] that counts the total width of +/// all printed characters. +struct CountWidth<'a, W: ?Sized> { + writer: &'a mut W, + width: usize, +} + +impl<'write> CountWidth<'write, dyn fmt::Write + 'write> { + /// Wrap the writer in an existing [fmt::Formatter] with an instance of [CountWidth]. + fn wrap<'slot, 'fmt: 'write + 'slot>( + fmt: &'fmt mut fmt::Formatter<'_>, + slot: &'slot mut Option, + ) -> fmt::Formatter<'slot> { + fmt.wrap_writer(|writer| slot.get_or_insert(Self { writer, width: 0 })) + } +} + +impl fmt::Write for CountWidth<'_, W> { + fn write_str(&mut self, elements: &fmt::MarkupElements<'_>, content: &str) -> io::Result<()> { + self.writer.write_str(elements, content)?; + self.width += UnicodeWidthStr::width(content); + Ok(()) + } + + fn write_fmt( + &mut self, + elements: &fmt::MarkupElements<'_>, + content: std::fmt::Arguments<'_>, + ) -> io::Result<()> { + if let Some(content) = content.as_str() { + self.write_str(elements, content) + } else { + let content = content.to_string(); + self.write_str(elements, &content) + } + } +} + +/// Write the advices for `diagnostic` into `visitor`. +fn print_advices(visitor: &mut V, diagnostic: &D, verbose: bool) -> io::Result<()> +where + V: Visit, + D: Diagnostic + ?Sized, +{ + // Visit the advices of the diagnostic with a lightweight visitor that + // detects if the diagnostic has any frame or backtrace advice + let mut frame_visitor = FrameVisitor { + location: diagnostic.location(), + skip_frame: false, + }; + + diagnostic.advices(&mut frame_visitor)?; + + let skip_frame = frame_visitor.skip_frame; + + // Print the message for the diagnostic as a log advice + print_message_advice(visitor, diagnostic, skip_frame)?; + + // Print the other advices for the diagnostic + diagnostic.advices(visitor)?; + + // Print the tags of the diagnostic as advices + print_tags_advices(visitor, diagnostic)?; + + // If verbose printing is enabled, print the verbose advices in a nested group + if verbose { + // Count the number of verbose advices in the diagnostic + let mut counter = CountAdvices(0); + diagnostic.verbose_advices(&mut counter)?; + + // If the diagnostic has any verbose advice, print the group + if !counter.is_empty() { + let verbose_advices = PrintVerboseAdvices(diagnostic); + visitor.record_group(&"Verbose advice", &verbose_advices)?; + } + } + + Ok(()) +} + +/// Advice visitor used to detect if the diagnostic contains any frame or backtrace diagnostic. +#[derive(Debug)] +struct FrameVisitor<'diag> { + location: Location<'diag>, + skip_frame: bool, +} + +impl Visit for FrameVisitor<'_> { + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + if location == self.location { + self.skip_frame = true; + } + Ok(()) + } + + fn record_backtrace(&mut self, _: &dyn fmt::Display, _: &Backtrace) -> io::Result<()> { + self.skip_frame = true; + Ok(()) + } +} + +/// Print the message and code frame for the diagnostic as advices. +fn print_message_advice(visitor: &mut V, diagnostic: &D, skip_frame: bool) -> io::Result<()> +where + V: Visit, + D: Diagnostic + ?Sized, +{ + // Print the entire message / cause chain for the diagnostic to a MarkupBuf + let message = { + let mut message = MarkupBuf::default(); + let mut fmt = fmt::Formatter::new(&mut message); + fmt.write_markup(markup!({ PrintCauseChain(diagnostic) }))?; + message + }; + + // Print a log advice for the message, with a special fallback if the buffer is empty + if message.is_empty() { + visitor.record_log( + LogCategory::None, + &markup! { + "no diagnostic message provided" + }, + )?; + } else { + let category = match diagnostic.severity() { + Severity::Fatal | Severity::Error => LogCategory::Error, + Severity::Warning => LogCategory::Warn, + Severity::Information | Severity::Hint => LogCategory::Info, + }; + + visitor.record_log(category, &message)?; + } + + // If the diagnostic has no explicit code frame or backtrace advice, print + // a code frame advice with the location of the diagnostic + if !skip_frame { + let location = diagnostic.location(); + if location.span.is_some() { + visitor.record_frame(location)?; + } + } + + Ok(()) +} + +/// Display wrapper for printing the "cause chain" of a diagnostic, with the +/// message of this diagnostic and all of its sources. +struct PrintCauseChain<'fmt, D: ?Sized>(&'fmt D); + +impl fmt::Display for PrintCauseChain<'_, D> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let Self(diagnostic) = *self; + + diagnostic.message(fmt)?; + + let chain = iter::successors(diagnostic.source(), |prev| prev.source()); + for diagnostic in chain { + fmt.write_str("\n\nCaused by:\n")?; + + let mut slot = None; + let mut fmt = IndentWriter::wrap(fmt, &mut slot, true, " "); + diagnostic.message(&mut fmt)?; + } + + Ok(()) + } +} + +struct PrintSearch<'a, 'b>(&'a mut fmt::Formatter<'b>); + +impl Visit for PrintSearch<'_, '_> { + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + frame::print_highlighted_frame(self.0, location) + } +} + +/// Implementation of [Visitor] that prints the advices for a diagnostic. +struct PrintAdvices<'a, 'b>(&'a mut fmt::Formatter<'b>); + +impl PrintAdvices<'_, '_> { + fn print_log( + &mut self, + kind: MarkupElement<'_>, + prefix: char, + text: &dyn fmt::Display, + ) -> io::Result<()> { + self.0.write_markup(Markup(&[MarkupNode { + elements: &[MarkupElement::Emphasis, kind.clone()], + content: &prefix as &dyn fmt::Display, + }]))?; + + self.0.write_str(" ")?; + + let mut slot = None; + let mut fmt = IndentWriter::wrap(self.0, &mut slot, false, " "); + fmt.write_markup(Markup(&[MarkupNode { + elements: &[kind], + content: text, + }]))?; + + self.0.write_str("\n\n") + } +} + +impl Visit for PrintAdvices<'_, '_> { + fn record_log(&mut self, category: LogCategory, text: &dyn fmt::Display) -> io::Result<()> { + match category { + LogCategory::None => self.0.write_markup(markup! { {text}"\n\n" }), + LogCategory::Info => self.print_log(MarkupElement::Info, '\u{2139}', text), + LogCategory::Warn => self.print_log(MarkupElement::Warn, '\u{26a0}', text), + LogCategory::Error => self.print_log(MarkupElement::Error, '\u{2716}', text), + } + } + + fn record_list(&mut self, list: &[&dyn fmt::Display]) -> io::Result<()> { + for item in list { + let mut slot = None; + let mut fmt = IndentWriter::wrap(self.0, &mut slot, false, " "); + fmt.write_markup(markup! { + "- "{*item}"\n" + })?; + } + + if list.is_empty() { + Ok(()) + } else { + self.0.write_str("\n") + } + } + + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + frame::print_frame(self.0, location) + } + + fn record_diff(&mut self, diff: &TextEdit) -> io::Result<()> { + diff::print_diff(self.0, diff) + } + + fn record_backtrace( + &mut self, + title: &dyn fmt::Display, + backtrace: &Backtrace, + ) -> io::Result<()> { + let mut backtrace = backtrace.clone(); + backtrace.resolve(); + + if backtrace.is_empty() { + return Ok(()); + } + + self.record_log(LogCategory::Info, title)?; + + backtrace::print_backtrace(self.0, &backtrace) + } + + fn record_command(&mut self, command: &str) -> io::Result<()> { + self.0.write_markup(markup! { + "$"" "{command}"\n\n" + }) + } + + fn record_group(&mut self, title: &dyn fmt::Display, advice: &dyn Advices) -> io::Result<()> { + self.0.write_markup(markup! { + {title}"\n\n" + })?; + + let mut slot = None; + let mut fmt = IndentWriter::wrap(self.0, &mut slot, true, " "); + let mut visitor = PrintAdvices(&mut fmt); + advice.record(&mut visitor) + } + + fn record_table( + &mut self, + padding: usize, + headers: &[MarkupBuf], + columns: &[&[MarkupBuf]], + ) -> io::Result<()> { + debug_assert_eq!( + headers.len(), + columns.len(), + "headers and columns must have the same number length" + ); + + if columns.is_empty() { + return Ok(()); + } + + let mut headers_iter = headers.iter().enumerate(); + let rows_number = columns[0].len(); + let columns_number = columns.len(); + + let mut longest_cell = 0; + for current_row_index in 0..rows_number { + for current_column_index in 0..columns_number { + let cell = columns + .get(current_column_index) + .and_then(|c| c.get(current_row_index)); + if let Some(cell) = cell { + if current_column_index == 0 && current_row_index == 0 { + longest_cell = cell.text_len(); + for (index, header_cell) in headers_iter.by_ref() { + self.0.write_markup(markup!({ header_cell }))?; + if index < headers.len() - 1 { + self.0.write_markup( + markup! {{Padding::new(padding + longest_cell - header_cell.text_len())}}, + )?; + } + } + + self.0.write_markup(markup! {"\n\n"})?; + } + let extra_padding = longest_cell.saturating_sub(cell.text_len()); + + self.0.write_markup(markup!({ cell }))?; + if columns_number != current_column_index + 1 { + self.0 + .write_markup(markup! {{Padding::new(padding + extra_padding)}})?; + } + } + } + self.0.write_markup(markup!("\n"))?; + } + + Ok(()) + } +} + +/// Print the fatal and internal tags for the diagnostic as log advices. +fn print_tags_advices(visitor: &mut V, diagnostic: &D) -> io::Result<()> +where + V: Visit, + D: Diagnostic + ?Sized, +{ + if diagnostic.severity() == Severity::Fatal { + visitor.record_log(LogCategory::Warn, &"Exited as this error could not be handled and resulted in a fatal error. Please report it if necessary.")?; + } + + if diagnostic.tags().contains(DiagnosticTags::INTERNAL) { + visitor.record_log(LogCategory::Warn, &"This diagnostic was derived from an internal error. Potential bug, please report it if necessary.")?; + } + + Ok(()) +} + +/// Advice visitor that counts how many advices are visited. +struct CountAdvices(usize); + +impl CountAdvices { + fn is_empty(&self) -> bool { + self.0 == 0 + } +} + +impl Visit for CountAdvices { + fn record_log(&mut self, _: LogCategory, _: &dyn fmt::Display) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_list(&mut self, _: &[&dyn fmt::Display]) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_frame(&mut self, _: Location<'_>) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_diff(&mut self, _: &TextEdit) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_backtrace(&mut self, _: &dyn fmt::Display, _: &Backtrace) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_command(&mut self, _: &str) -> io::Result<()> { + self.0 += 1; + Ok(()) + } + + fn record_group(&mut self, _: &dyn fmt::Display, _: &dyn Advices) -> io::Result<()> { + self.0 += 1; + Ok(()) + } +} + +/// Implements [Advices] for verbose advices of a diagnostic. +struct PrintVerboseAdvices<'a, D: ?Sized>(&'a D); + +impl Advices for PrintVerboseAdvices<'_, D> { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.0.verbose_advices(visitor) + } +} + +/// Wrapper type over [fmt::Write] that injects `ident_text` at the start of +/// every line. +struct IndentWriter<'a, W: ?Sized> { + writer: &'a mut W, + pending_indent: bool, + ident_text: &'static str, +} + +impl<'write> IndentWriter<'write, dyn fmt::Write + 'write> { + fn wrap<'slot, 'fmt: 'write + 'slot>( + fmt: &'fmt mut fmt::Formatter<'_>, + slot: &'slot mut Option, + pending_indent: bool, + ident_text: &'static str, + ) -> fmt::Formatter<'slot> { + fmt.wrap_writer(|writer| { + slot.get_or_insert(Self { + writer, + pending_indent, + ident_text, + }) + }) + } +} + +impl fmt::Write for IndentWriter<'_, W> { + fn write_str( + &mut self, + elements: &fmt::MarkupElements<'_>, + mut content: &str, + ) -> io::Result<()> { + while !content.is_empty() { + if self.pending_indent { + self.writer + .write_str(&MarkupElements::Root, self.ident_text)?; + self.pending_indent = false; + } + + if let Some(index) = content.find('\n') { + let (start, end) = content.split_at(index + 1); + self.writer.write_str(elements, start)?; + self.pending_indent = true; + content = end; + } else { + return self.writer.write_str(elements, content); + } + } + + Ok(()) + } + + fn write_fmt( + &mut self, + elements: &fmt::MarkupElements<'_>, + content: std::fmt::Arguments<'_>, + ) -> io::Result<()> { + if let Some(content) = content.as_str() { + self.write_str(elements, content) + } else { + let content = content.to_string(); + self.write_str(elements, &content) + } + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use pg_console::{fmt, markup}; + use pg_diagnostics::{DiagnosticTags, Severity}; + use pg_diagnostics_categories::{category, Category}; + use pg_text_edit::TextEdit; + use text_size::{TextRange, TextSize}; + use serde_json::{from_value, json}; + + use crate::{self as pg_diagnostics}; + use crate::{ + Advices, Diagnostic, Location, LogCategory, PrintDiagnostic, Resource, SourceCode, Visit, + }; + + #[derive(Debug)] + struct TestDiagnostic { + path: Option, + span: Option, + source_code: Option, + advice: Option, + verbose_advice: Option, + source: Option>, + } + + impl TestDiagnostic { + fn empty() -> Self { + Self { + path: None, + span: None, + source_code: None, + advice: None, + verbose_advice: None, + source: None, + } + } + + fn with_location() -> Self { + Self { + path: Some(String::from("path")), + span: Some(TextRange::at(TextSize::from(0), TextSize::from(6))), + source_code: Some(String::from("source code")), + advice: None, + verbose_advice: None, + source: None, + } + } + } + + impl Diagnostic for TestDiagnostic { + fn category(&self) -> Option<&'static Category> { + Some(category!("internalError/io")) + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "diagnostic message") + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + write!(fmt, "diagnostic message") + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + if let Some(advice) = &self.advice { + advice.record(visitor)?; + } + + Ok(()) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + if let Some(advice) = &self.verbose_advice { + advice.record(visitor)?; + } + + Ok(()) + } + + fn location(&self) -> Location<'_> { + Location::builder() + .resource(&self.path) + .span(&self.span) + .source_code(&self.source_code) + .build() + } + + fn tags(&self) -> DiagnosticTags { + DiagnosticTags::FIXABLE + } + + fn source(&self) -> Option<&dyn Diagnostic> { + self.source.as_deref() + } + } + + #[derive(Debug)] + struct LogAdvices; + + impl Advices for LogAdvices { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_log(LogCategory::Error, &"error")?; + visitor.record_log(LogCategory::Warn, &"warn")?; + visitor.record_log(LogCategory::Info, &"info")?; + visitor.record_log(LogCategory::None, &"none") + } + } + + #[derive(Debug)] + struct ListAdvice; + + impl Advices for ListAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_list(&[&"item 1", &"item 2"]) + } + } + + #[derive(Debug)] + struct FrameAdvice; + + impl Advices for FrameAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_frame(Location { + resource: Some(Resource::File("other_path")), + span: Some(TextRange::new(TextSize::from(8), TextSize::from(16))), + source_code: Some(SourceCode { + text: "context location context", + line_starts: None, + }), + }) + } + } + + #[derive(Debug)] + struct DiffAdvice; + + impl Advices for DiffAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let diff = + TextEdit::from_unicode_words("context before context", "context after context"); + visitor.record_diff(&diff) + } + } + + #[derive(Debug)] + struct BacktraceAdvice; + + impl Advices for BacktraceAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + let backtrace = from_value(json!([ + { + "ip": 0x0f0f_0f0f, + "symbols": [ + { + "name": "crate::module::function", + "filename": "crate/src/module.rs", + "lineno": 8, + "colno": 16 + } + ] + } + ])); + + visitor.record_backtrace(&"Backtrace Title", &backtrace.unwrap()) + } + } + + #[derive(Debug)] + struct CommandAdvice; + + impl Advices for CommandAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_command("pg command --argument") + } + } + + #[derive(Debug)] + struct GroupAdvice; + + impl Advices for GroupAdvice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_group(&"Group Title", &LogAdvices) + } + } + + #[test] + fn test_header() { + let diag = TestDiagnostic::::with_location(); + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "path:1:1 internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + ">"" ""1 │ ""source code\n" + " "" │ ""^^^^^^""\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + #[test] + fn test_log_advices() { + let diag = TestDiagnostic { + advice: Some(LogAdvices), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + "✖"" ""error""\n" + " \n" + " " + "⚠"" ""warn""\n" + " \n" + " " + "ℹ"" ""info""\n" + " \n" + " none\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_list_advice() { + let diag = TestDiagnostic { + advice: Some(ListAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " - item 1\n" + " - item 2\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_frame_advice() { + let diag = TestDiagnostic { + advice: Some(FrameAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + ">"" ""1 │ ""context location context\n" + " "" │ "" ""^^^^^^^^""\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_diff_advice() { + let diag = TestDiagnostic { + advice: Some(DiffAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + "-"" ""context""·""before""·""context""\n" + " " + "+"" ""context""·""after""·""context""\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_backtrace_advice() { + let diag = TestDiagnostic { + advice: Some(BacktraceAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + "ℹ"" ""Backtrace Title""\n" + " \n" + " 0: crate::module::function\n" + " at crate/src/module.rs:8:16\n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_command_advice() { + let diag = TestDiagnostic { + advice: Some(CommandAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + "$"" pg command --argument\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } + + #[test] + fn test_group_advice() { + let diag = TestDiagnostic { + advice: Some(GroupAdvice), + ..TestDiagnostic::empty() + }; + + let diag = markup!({ PrintDiagnostic::verbose(&diag) }).to_owned(); + + let expected = markup!{ + "internalError/io "" FIXABLE "" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "\n" + " " + "✖"" ""diagnostic message""\n" + " \n" + " " + "Group Title""\n" + " \n" + " " + "✖"" ""error""\n" + " \n" + " " + "⚠"" ""warn""\n" + " \n" + " " + "ℹ"" ""info""\n" + " \n" + " none\n" + " \n" + }.to_owned(); + + assert_eq!( + diag, expected, + "\nactual:\n{diag:#?}\nexpected:\n{expected:#?}" + ); + } +} diff --git a/crates/pg_diagnostics/src/display/backtrace.rs b/crates/pg_diagnostics/src/display/backtrace.rs new file mode 100644 index 000000000..c5ba1a1ee --- /dev/null +++ b/crates/pg_diagnostics/src/display/backtrace.rs @@ -0,0 +1,452 @@ +use std::{borrow::Cow, path::PathBuf}; +use std::{cell::Cell, fmt::Write as _, io, os::raw::c_void, path::Path, slice}; + +use pg_console::{fmt, markup}; +use serde::{Deserialize, Serialize}; + +use super::IndentWriter; + +/// The [Backtrace] type can be used to capture a native Rust stack trace, to +/// be displayed a diagnostic advice for native errors. +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct Backtrace { + inner: BacktraceKind, +} + +impl Default for Backtrace { + // Do not inline this function to ensure it creates a stack frame, so that + // internal functions above it in the backtrace can be hidden when the + // backtrace is printed + #[inline(never)] + fn default() -> Self { + Self::capture(Backtrace::default as usize) + } +} + +impl Backtrace { + /// Take a snapshot of the current state of the stack and return it as a [Backtrace]. + pub fn capture(top_frame: usize) -> Self { + Self { + inner: BacktraceKind::Native(NativeBacktrace::new(top_frame)), + } + } + + /// Since the `capture` function only takes a lightweight snapshot of the + /// stack, it's necessary to perform an additional resolution step to map + /// the list of instruction pointers on the stack to actual symbol + /// information (like function name and file location) before printing the + /// backtrace. + pub(super) fn resolve(&mut self) { + if let BacktraceKind::Native(inner) = &mut self.inner { + inner.resolve(); + } + } + + fn frames(&self) -> BacktraceFrames<'_> { + match &self.inner { + BacktraceKind::Native(inner) => BacktraceFrames::Native(inner.frames()), + BacktraceKind::Serialized(inner) => BacktraceFrames::Serialized(inner), + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.frames().is_empty() + } +} + +impl serde::Serialize for Backtrace { + fn serialize(&self, serializer: S) -> Result + where + S: serde::ser::Serializer, + { + let frames = match &self.inner { + BacktraceKind::Native(backtrace) => { + let mut backtrace = backtrace.clone(); + backtrace.resolve(); + + let frames: Vec<_> = backtrace + .frames() + .iter() + .map(SerializedFrame::from) + .collect(); + + Cow::Owned(frames) + } + BacktraceKind::Serialized(frames) => Cow::Borrowed(frames), + }; + + frames.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Backtrace { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + Ok(Self { + inner: BacktraceKind::Serialized(>::deserialize(deserializer)?), + }) + } +} + +/// Internal representation of a [Backtrace], can be either a native backtrace +/// instance or a vector of serialized frames. +#[derive(Clone, Debug)] +enum BacktraceKind { + Native(NativeBacktrace), + Serialized(Vec), +} + +#[cfg(test)] +impl PartialEq for BacktraceKind { + fn eq(&self, _other: &Self) -> bool { + if let (BacktraceKind::Serialized(this), BacktraceKind::Serialized(other)) = (self, _other) + { + return this == other; + } + + false + } +} + +#[cfg(test)] +impl Eq for BacktraceKind {} + +/// Wrapper type for a native backtrace instance. +#[derive(Clone, Debug)] +struct NativeBacktrace { + backtrace: ::backtrace::Backtrace, + /// Pointer to the top frame, this frame and every entry above it on the + /// stack will not be displayed in the printed stack trace. + top_frame: usize, + /// Pointer to the bottom frame, this frame and every entry below it on the + /// stack will not be displayed in the printed stack trace. + bottom_frame: usize, +} + +impl NativeBacktrace { + fn new(top_frame: usize) -> Self { + Self { + backtrace: ::backtrace::Backtrace::new_unresolved(), + top_frame, + bottom_frame: bottom_frame(), + } + } + + fn resolve(&mut self) { + self.backtrace.resolve(); + } + + /// Returns the list of frames for this backtrace, truncated to the + /// `top_frame` and `bottom_frame`. + fn frames(&self) -> &'_ [::backtrace::BacktraceFrame] { + let mut frames = self.backtrace.frames(); + + let top_frame = frames.iter().position(|frame| { + frame.symbols().iter().any(|symbol| { + symbol + .addr() + .map_or(false, |addr| addr as usize == self.top_frame) + }) + }); + + if let Some(top_frame) = top_frame { + if let Some(bottom_frames) = frames.get(top_frame + 1..) { + frames = bottom_frames; + } + } + + let bottom_frame = frames.iter().position(|frame| { + frame.symbols().iter().any(|symbol| { + symbol + .addr() + .map_or(false, |addr| addr as usize == self.bottom_frame) + }) + }); + + if let Some(bottom_frame) = bottom_frame { + if let Some(top_frames) = frames.get(..bottom_frame + 1) { + frames = top_frames; + } + } + + frames + } +} + +thread_local! { + /// This cell holds the address of the function that conceptually sits at the + /// "bottom" of the backtraces created on the current thread (all the frames + /// below this will be hidden when the backtrace is printed) + /// + /// This value is thread-local since different threads will generally have + /// different values for the bottom frame address: for the main thread this + /// will be the address of the `main` function, while on worker threads + /// this will be the start function for the thread (see the documentation + /// of [set_bottom_frame] for examples of where to set the bottom frame). + static BOTTOM_FRAME: Cell> = const { Cell::new(None) }; +} + +/// Registers a function pointer as the "bottom frame" for this thread: all +/// instances of [Backtrace] created on this thread will omit this function and +/// all entries below it on the stack +/// +/// ## Examples +/// +/// On the main thread: +/// ``` +/// # use pg_diagnostics::set_bottom_frame; +/// # #[allow(clippy::needless_doctest_main)] +/// pub fn main() { +/// set_bottom_frame(main as usize); +/// +/// // ... +/// } +/// ``` +/// +/// On worker threads: +/// ``` +/// # use pg_diagnostics::set_bottom_frame; +/// fn worker_thread() { +/// set_bottom_frame(worker_thread as usize); +/// +/// // ... +/// } +/// +/// std::thread::spawn(worker_thread); +/// ``` +pub fn set_bottom_frame(ptr: usize) { + BOTTOM_FRAME.with(|cell| { + cell.set(Some(ptr)); + }); +} + +fn bottom_frame() -> usize { + BOTTOM_FRAME.with(|cell| cell.get().unwrap_or(0)) +} + +pub(super) fn print_backtrace( + fmt: &mut fmt::Formatter<'_>, + backtrace: &Backtrace, +) -> io::Result<()> { + for (frame_index, frame) in backtrace.frames().iter().enumerate() { + if frame.ip().is_null() { + continue; + } + + fmt.write_fmt(format_args!("{frame_index:4}: "))?; + + let mut slot = None; + let mut fmt = IndentWriter::wrap(fmt, &mut slot, false, " "); + + for symbol in frame.symbols().iter() { + if let Some(name) = symbol.name() { + fmt.write_fmt(format_args!("{name:#}"))?; + } + + fmt.write_str("\n")?; + + if let Some(filename) = symbol.filename() { + let mut slot = None; + let mut fmt = IndentWriter::wrap(&mut fmt, &mut slot, true, " "); + + // Print a hyperlink if the file exists on disk + let href = if filename.exists() { + Some(format!("file:///{}", filename.display())) + } else { + None + }; + + // Build up the text of the link from the file path, the line number and column number + let mut text = filename.display().to_string(); + + if let Some(lineno) = symbol.lineno() { + // SAFETY: Writing a `u32` to a string should not fail + write!(text, ":{lineno}").unwrap(); + + if let Some(colno) = symbol.colno() { + // SAFETY: Writing a `u32` to a string should not fail + write!(text, ":{colno}").unwrap(); + } + } + + if let Some(href) = href { + fmt.write_markup(markup! { + "at " + {text} + "\n" + })?; + } else { + fmt.write_markup(markup! { + "at "{text}"\n" + })?; + } + } + } + } + + Ok(()) +} + +/// Serializable representation of a backtrace frame. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(test, derive(Eq, PartialEq))] +struct SerializedFrame { + ip: u64, + symbols: Vec, +} + +impl From<&'_ backtrace::BacktraceFrame> for SerializedFrame { + fn from(frame: &'_ backtrace::BacktraceFrame) -> Self { + Self { + ip: frame.ip() as u64, + symbols: frame.symbols().iter().map(SerializedSymbol::from).collect(), + } + } +} + +/// Serializable representation of a backtrace frame symbol. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(test, derive(Eq, PartialEq))] +struct SerializedSymbol { + name: Option, + filename: Option, + lineno: Option, + colno: Option, +} + +impl From<&'_ backtrace::BacktraceSymbol> for SerializedSymbol { + fn from(symbol: &'_ backtrace::BacktraceSymbol) -> Self { + Self { + name: symbol.name().map(|name| format!("{name:#}")), + filename: symbol.filename().map(ToOwned::to_owned), + lineno: symbol.lineno(), + colno: symbol.colno(), + } + } +} + +enum BacktraceFrames<'a> { + Native(&'a [::backtrace::BacktraceFrame]), + Serialized(&'a [SerializedFrame]), +} + +impl BacktraceFrames<'_> { + fn iter(&self) -> BacktraceFramesIter<'_> { + match self { + Self::Native(inner) => BacktraceFramesIter::Native(inner.iter()), + Self::Serialized(inner) => BacktraceFramesIter::Serialized(inner.iter()), + } + } + + fn is_empty(&self) -> bool { + match self { + Self::Native(inner) => inner.is_empty(), + Self::Serialized(inner) => inner.is_empty(), + } + } +} + +enum BacktraceFramesIter<'a> { + Native(slice::Iter<'a, ::backtrace::BacktraceFrame>), + Serialized(slice::Iter<'a, SerializedFrame>), +} + +impl<'a> Iterator for BacktraceFramesIter<'a> { + type Item = BacktraceFrame<'a>; + + fn next(&mut self) -> Option { + match self { + Self::Native(inner) => inner.next().map(BacktraceFrame::Native), + Self::Serialized(inner) => inner.next().map(BacktraceFrame::Serialized), + } + } +} + +enum BacktraceFrame<'a> { + Native(&'a ::backtrace::BacktraceFrame), + Serialized(&'a SerializedFrame), +} + +impl BacktraceFrame<'_> { + fn ip(&self) -> *mut c_void { + match self { + Self::Native(inner) => inner.ip(), + Self::Serialized(inner) => inner.ip as *mut c_void, + } + } + + fn symbols(&self) -> BacktraceSymbols<'_> { + match self { + Self::Native(inner) => BacktraceSymbols::Native(inner.symbols()), + Self::Serialized(inner) => BacktraceSymbols::Serialized(&inner.symbols), + } + } +} + +enum BacktraceSymbols<'a> { + Native(&'a [::backtrace::BacktraceSymbol]), + Serialized(&'a [SerializedSymbol]), +} + +impl BacktraceSymbols<'_> { + fn iter(&self) -> BacktraceSymbolsIter<'_> { + match self { + Self::Native(inner) => BacktraceSymbolsIter::Native(inner.iter()), + Self::Serialized(inner) => BacktraceSymbolsIter::Serialized(inner.iter()), + } + } +} + +enum BacktraceSymbolsIter<'a> { + Native(slice::Iter<'a, ::backtrace::BacktraceSymbol>), + Serialized(slice::Iter<'a, SerializedSymbol>), +} + +impl<'a> Iterator for BacktraceSymbolsIter<'a> { + type Item = BacktraceSymbol<'a>; + + fn next(&mut self) -> Option { + match self { + Self::Native(inner) => inner.next().map(BacktraceSymbol::Native), + Self::Serialized(inner) => inner.next().map(BacktraceSymbol::Serialized), + } + } +} + +enum BacktraceSymbol<'a> { + Native(&'a ::backtrace::BacktraceSymbol), + Serialized(&'a SerializedSymbol), +} + +impl BacktraceSymbol<'_> { + fn name(&self) -> Option { + match self { + Self::Native(inner) => inner.name().map(|name| format!("{name:#}")), + Self::Serialized(inner) => inner.name.clone(), + } + } + + fn filename(&self) -> Option<&Path> { + match self { + Self::Native(inner) => inner.filename(), + Self::Serialized(inner) => inner.filename.as_deref(), + } + } + + fn lineno(&self) -> Option { + match self { + Self::Native(inner) => inner.lineno(), + Self::Serialized(inner) => inner.lineno, + } + } + + fn colno(&self) -> Option { + match self { + Self::Native(inner) => inner.colno(), + Self::Serialized(inner) => inner.colno, + } + } +} diff --git a/crates/pg_diagnostics/src/display/diff.rs b/crates/pg_diagnostics/src/display/diff.rs new file mode 100644 index 000000000..f98396475 --- /dev/null +++ b/crates/pg_diagnostics/src/display/diff.rs @@ -0,0 +1,986 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + io, slice, +}; + +use pg_console::{fmt, markup, MarkupElement}; +use pg_text_edit::{ChangeTag, CompressedOp, TextEdit}; + +use super::frame::{ + calculate_print_width, print_invisibles, text_width, IntoIter, OneIndexed, + PrintInvisiblesOptions, CODE_FRAME_CONTEXT_LINES, +}; + +const MAX_PATCH_LINES: usize = 150; + +pub(super) fn print_diff(fmt: &mut fmt::Formatter<'_>, diff: &TextEdit) -> io::Result<()> { + // Before printing, we need to preprocess the list of DiffOps it's made of to classify them by line + let mut modified_lines = BTreeSet::new(); + let mut inserted_lines = BTreeMap::new(); + let mut before_line_to_after = BTreeMap::new(); + + let mut before_line = OneIndexed::MIN; + let mut after_line = OneIndexed::MIN; + + process_diff_ops( + diff, + PushToLineState { + modified_lines: &mut modified_lines, + inserted_lines: &mut inserted_lines, + before_line_to_after: &mut before_line_to_after, + }, + &mut after_line, + &mut before_line, + ); + + let before_line_count = before_line; + let after_line_count = after_line; + + // If only a single line was modified, print a "short diff" + let modified_line = if before_line_count == after_line_count { + let mut iter = modified_lines.iter().filter_map(|key| { + let line = inserted_lines.get(key)?; + + // A line has been modified if its diff list is empty (the line was + // either fully inserted or fully removed) or if its diff list has + // any delete or insert operation + let has_edits = line.diffs.is_empty() + || line.diffs.iter().any(|(tag, text)| { + matches!(tag, ChangeTag::Delete | ChangeTag::Insert) && !text.is_empty() + }); + + if has_edits { + Some((key, line)) + } else { + None + } + }); + + iter.next().and_then(|(key, line)| { + if iter.next().is_some() { + return None; + } + + // Disallow fully empty lines from being displayed in short mode + if !line.diffs.is_empty() { + Some((key, line)) + } else { + None + } + }) + } else { + None + }; + + if let Some((key, entry)) = modified_line { + return print_short_diff(fmt, key, entry); + } + + // Otherwise if multiple lines were modified we need to perform more preprocessing, + // to merge identical line numbers and calculate how many context lines need to be rendered + let mut diffs_by_line = Vec::new(); + let mut shown_line_indexes = BTreeSet::new(); + + process_diff_lines( + &mut inserted_lines, + &mut before_line_to_after, + &mut diffs_by_line, + &mut shown_line_indexes, + before_line_count, + after_line_count, + ); + + // Finally when have a flat list of lines we can now print + print_full_diff( + fmt, + &diffs_by_line, + &shown_line_indexes, + before_line_count, + after_line_count, + ) +} + +/// This function scans the list of DiffOps that make up the `diff` and derives +/// the following data structures: +/// - `modified_lines` is the set of [LineKey] that contain at least one insert +/// or delete operation +/// - `inserted_lines` maps a [LineKey] to the list of diff operations that +/// happen on the corresponding line +/// - `before_line_to_after` maps line numbers in the old revision of the text +/// to line numbers in the new revision +/// - `after_line` counts the number of lines in the new revision of the document +/// - `before_line` counts the number of lines in the old revision of the document +fn process_diff_ops<'diff>( + diff: &'diff TextEdit, + mut state: PushToLineState<'_, 'diff>, + after_line: &mut OneIndexed, + before_line: &mut OneIndexed, +) { + for (op_index, op) in diff.iter().enumerate() { + let op = match op { + CompressedOp::DiffOp(op) => op, + CompressedOp::EqualLines { line_count } => { + let is_first_op = op_index == 0; + for line_index in 0..=line_count.get() { + // Don't increment the first line if we are the first tuple marking the beginning of the file + if !(is_first_op && line_index == 0) { + *after_line = after_line.saturating_add(1); + *before_line = before_line.saturating_add(1); + } + + state.before_line_to_after.insert(*before_line, *after_line); + + push_to_line(&mut state, *before_line, *after_line, ChangeTag::Equal, ""); + } + + continue; + } + }; + + let tag = op.tag(); + let text = op.text(diff); + + // Get all the lines + let mut parts = text.split('\n'); + + // Deconstruct each text chunk + let current_line = parts.next(); + + // The first chunk belongs to the current line + if let Some(current_line) = current_line { + push_to_line(&mut state, *before_line, *after_line, tag, current_line); + } + + // Create unique lines for each other chunk + for new_line in parts { + match tag { + ChangeTag::Equal => { + *after_line = after_line.saturating_add(1); + *before_line = before_line.saturating_add(1); + } + + ChangeTag::Delete => { + *before_line = before_line.saturating_add(1); + } + ChangeTag::Insert => { + *after_line = after_line.saturating_add(1); + } + } + + state.before_line_to_after.insert(*before_line, *after_line); + + push_to_line(&mut state, *before_line, *after_line, tag, new_line); + } + } +} + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] +struct LineKey { + before_line: Option, + after_line: Option, +} + +impl LineKey { + const fn before(before_line: OneIndexed) -> Self { + Self { + before_line: Some(before_line), + after_line: None, + } + } + + const fn after(after_line: OneIndexed) -> Self { + Self { + before_line: None, + after_line: Some(after_line), + } + } +} + +#[derive(Debug, Clone)] +struct GroupDiffsLine<'a> { + before_line: Option, + after_line: Option, + diffs: Vec<(ChangeTag, &'a str)>, +} + +impl<'a> GroupDiffsLine<'a> { + fn insert( + inserted_lines: &mut BTreeMap, + key: LineKey, + tag: ChangeTag, + text: &'a str, + ) { + inserted_lines + .entry(key) + .and_modify(|line| { + if !text.is_empty() { + line.diffs.push((tag, text)); + } + }) + .or_insert_with_key(|key| GroupDiffsLine { + before_line: key.before_line, + after_line: key.after_line, + diffs: if text.is_empty() { + Vec::new() + } else { + vec![(tag, text)] + }, + }); + } +} + +struct PushToLineState<'a, 'b> { + modified_lines: &'a mut BTreeSet, + inserted_lines: &'a mut BTreeMap>, + before_line_to_after: &'a mut BTreeMap, +} + +fn push_to_line<'b>( + state: &mut PushToLineState<'_, 'b>, + before_line: OneIndexed, + after_line: OneIndexed, + tag: ChangeTag, + text: &'b str, +) { + let PushToLineState { + modified_lines, + inserted_lines, + before_line_to_after, + } = state; + + match tag { + ChangeTag::Insert => { + GroupDiffsLine::insert(inserted_lines, LineKey::after(after_line), tag, text); + modified_lines.insert(LineKey::after(after_line)); + } + ChangeTag::Delete => { + GroupDiffsLine::insert(inserted_lines, LineKey::before(before_line), tag, text); + modified_lines.insert(LineKey::before(before_line)); + } + ChangeTag::Equal => { + if before_line == OneIndexed::MIN && after_line == OneIndexed::MIN { + before_line_to_after.insert(before_line, after_line); + } + + GroupDiffsLine::insert(inserted_lines, LineKey::after(after_line), tag, text); + GroupDiffsLine::insert(inserted_lines, LineKey::before(before_line), tag, text); + } + } +} + +fn process_diff_lines<'lines, 'diff>( + inserted_lines: &'lines mut BTreeMap>, + before_line_to_after: &mut BTreeMap, + diffs_by_line: &mut Vec<&'lines GroupDiffsLine<'diff>>, + shown_line_indexes: &mut BTreeSet, + before_line_count: OneIndexed, + after_line_count: OneIndexed, +) { + // Merge identical lines + for before_line in IntoIter::new(OneIndexed::MIN..=before_line_count) { + let after_line = match before_line_to_after.get(&before_line) { + Some(after_line) => *after_line, + None => continue, + }; + + let inserted_before_line = inserted_lines.get(&LineKey::before(before_line)); + let inserted_after_line = inserted_lines.get(&LineKey::after(after_line)); + + if let (Some(inserted_before_line), Some(inserted_after_line)) = + (inserted_before_line, inserted_after_line) + { + if inserted_before_line.diffs == inserted_after_line.diffs { + let line = inserted_lines + .remove(&LineKey::before(before_line)) + .unwrap(); + + inserted_lines.remove(&LineKey::after(after_line)).unwrap(); + + inserted_lines.insert( + LineKey { + before_line: Some(before_line), + after_line: Some(after_line), + }, + GroupDiffsLine { + before_line: Some(before_line), + after_line: Some(after_line), + diffs: line.diffs, + }, + ); + } + } + } + + let mut diffs_by_line_with_before_and_shared = Vec::new(); + + // Print before lines, including those that are shared + for before_line in IntoIter::new(OneIndexed::MIN..=before_line_count) { + let line = inserted_lines.get(&LineKey::before(before_line)); + + if let Some(line) = line { + diffs_by_line_with_before_and_shared.push(line); + } + + // If we have a shared line then add it + if let Some(after_line) = before_line_to_after.get(&before_line) { + let line = inserted_lines.get(&LineKey { + before_line: Some(before_line), + after_line: Some(*after_line), + }); + + if let Some(line) = line { + diffs_by_line_with_before_and_shared.push(line); + } + } + } + + // Calculate the parts of the diff we should show + let mut last_printed_after = 0; + + for line in diffs_by_line_with_before_and_shared { + if let Some(after_line) = line.after_line { + catch_up_after( + inserted_lines, + diffs_by_line, + shown_line_indexes, + last_printed_after, + after_line, + ); + + last_printed_after = after_line.get(); + } + + push_displayed_line(diffs_by_line, shown_line_indexes, line); + } + + catch_up_after( + inserted_lines, + diffs_by_line, + shown_line_indexes, + last_printed_after, + after_line_count, + ); +} + +fn push_displayed_line<'input, 'group>( + diffs_by_line: &mut Vec<&'group GroupDiffsLine<'input>>, + shown_line_indexes: &mut BTreeSet, + line: &'group GroupDiffsLine<'input>, +) { + let i = diffs_by_line.len(); + diffs_by_line.push(line); + + if line.before_line.is_none() || line.after_line.is_none() { + let first = i.saturating_sub(CODE_FRAME_CONTEXT_LINES.get()); + let last = i + CODE_FRAME_CONTEXT_LINES.get(); + shown_line_indexes.extend(first..=last); + } +} + +fn catch_up_after<'input, 'lines>( + inserted_lines: &'lines BTreeMap>, + diffs_by_line: &mut Vec<&'lines GroupDiffsLine<'input>>, + shown_line_indexes: &mut BTreeSet, + last_printed_after: usize, + after_line: OneIndexed, +) { + let iter = IntoIter::new(OneIndexed::from_zero_indexed(last_printed_after)..=after_line); + + for i in iter { + let key = LineKey::after(i); + if let Some(line) = inserted_lines.get(&key) { + push_displayed_line(diffs_by_line, shown_line_indexes, line); + } + } +} + +fn print_short_diff( + fmt: &mut fmt::Formatter<'_>, + key: &LineKey, + entry: &GroupDiffsLine<'_>, +) -> io::Result<()> { + let index = match (key.before_line, key.after_line) { + (None, Some(index)) | (Some(index), None) => index, + (None, None) | (Some(_), Some(_)) => unreachable!( + "the key of a modified line should have exactly one index in one of the two revisions" + ), + }; + + fmt.write_markup(markup! { + + {format_args!(" {} \u{2502} ", index.get())} + + })?; + + let mut at_line_start = true; + let last_index = entry.diffs.len().saturating_sub(1); + + for (i, (tag, text)) in entry.diffs.iter().enumerate() { + let is_changed = *tag != ChangeTag::Equal; + let options = PrintInvisiblesOptions { + ignore_leading_tabs: false, + ignore_lone_spaces: false, + ignore_trailing_carriage_return: is_changed, + at_line_start, + at_line_end: i == last_index, + }; + + let element = match tag { + ChangeTag::Equal => None, + ChangeTag::Delete => Some(MarkupElement::Error), + ChangeTag::Insert => Some(MarkupElement::Success), + }; + + let has_non_whitespace = if let Some(element) = element { + let mut slot = None; + let mut fmt = ElementWrapper::wrap(fmt, &mut slot, element); + print_invisibles(&mut fmt, text, options)? + } else { + print_invisibles(fmt, text, options)? + }; + + if has_non_whitespace { + at_line_start = false; + } + } + + fmt.write_str("\n")?; + + let no_length = calculate_print_width(index); + fmt.write_markup(markup! { + + {format_args!(" {: >1$} \u{2502} ", "", no_length.get())} + + })?; + + for (tag, text) in &entry.diffs { + let marker = match tag { + ChangeTag::Equal => markup! { " " }, + ChangeTag::Delete => markup! { "-" }, + ChangeTag::Insert => markup! { "+" }, + }; + + for _ in 0..text_width(text) { + fmt.write_markup(marker)?; + } + } + + fmt.write_str("\n") +} + +fn print_full_diff( + fmt: &mut fmt::Formatter<'_>, + diffs_by_line: &[&'_ GroupDiffsLine<'_>], + shown_line_indexes: &BTreeSet, + before_line_count: OneIndexed, + after_line_count: OneIndexed, +) -> io::Result<()> { + // Calculate width of line no column + let before_no_length = calculate_print_width(before_line_count); + let after_no_length = calculate_print_width(after_line_count); + let line_no_length = before_no_length.get() + 1 + after_no_length.get(); + + // Skip displaying the gutter if the file only has a single line + let single_line = before_line_count == OneIndexed::MIN && after_line_count == OneIndexed::MIN; + + let mut displayed_lines = 0; + let mut truncated = false; + let mut last_displayed_line = None; + + // Print the actual frame + for (i, line) in diffs_by_line.iter().enumerate() { + if !shown_line_indexes.contains(&i) { + continue; + } + + displayed_lines += 1; + + if displayed_lines > MAX_PATCH_LINES { + truncated = true; + continue; + } + + let mut line_type = ChangeTag::Equal; + let mut marker = markup! { " " }; + + if line.before_line.is_none() { + marker = markup! { "+" }; + line_type = ChangeTag::Insert; + } + + if line.after_line.is_none() { + marker = markup! { "-" }; + line_type = ChangeTag::Delete; + } + + if let Some(last_displayed_line) = last_displayed_line { + if last_displayed_line + 1 != i { + fmt.write_markup(markup! { + " "{"\u{b7}".repeat(line_no_length)}" \u{2502} \n" + })?; + } + } + + last_displayed_line = Some(i); + + if single_line { + let line = FormatDiffLine { + is_equal: line_type == ChangeTag::Equal, + ops: &line.diffs, + }; + + match line_type { + ChangeTag::Equal => fmt.write_markup(markup! { + " "{line}"\n" + })?, + ChangeTag::Delete => fmt.write_markup(markup! { + {marker}" "{line}"\n" + })?, + ChangeTag::Insert => fmt.write_markup(markup! { + {marker}" "{line}"\n" + })?, + } + } else { + fmt.write_str(" ")?; + + if let Some(before_line) = line.before_line { + fmt.write_markup(markup! { + + {format_args!("{: >1$}", before_line.get(), before_no_length.get())} + + })?; + } else { + for _ in 0..before_no_length.get() { + fmt.write_str(" ")?; + } + } + + fmt.write_str(" ")?; + + if let Some(after_line) = line.after_line { + fmt.write_markup(markup! { + + {format_args!("{: >1$}", after_line.get(), after_no_length.get())} + + })?; + } else { + for _ in 0..after_no_length.get() { + fmt.write_str(" ")?; + } + } + + fmt.write_markup(markup! { + " \u{2502} "{marker}' ' + })?; + + let line = FormatDiffLine { + is_equal: line_type == ChangeTag::Equal, + ops: &line.diffs, + }; + + match line_type { + ChangeTag::Equal => fmt.write_markup(markup! { + {line}"\n" + })?, + ChangeTag::Delete => fmt.write_markup(markup! { + {line}"\n" + })?, + ChangeTag::Insert => fmt.write_markup(markup! { + {line}"\n" + })?, + } + } + } + + if truncated { + fmt.write_markup(markup! { + {displayed_lines.saturating_sub(MAX_PATCH_LINES)}" more lines truncated\n" + })?; + } + + fmt.write_str("\n") +} + +struct FormatDiffLine<'a> { + is_equal: bool, + ops: &'a [(ChangeTag, &'a str)], +} + +impl fmt::Display for FormatDiffLine<'_> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let mut at_line_start = true; + let last_index = self.ops.len().saturating_sub(1); + + for (i, (tag, text)) in self.ops.iter().enumerate() { + let is_changed = *tag != ChangeTag::Equal; + let options = PrintInvisiblesOptions { + ignore_leading_tabs: self.is_equal, + ignore_lone_spaces: self.is_equal, + ignore_trailing_carriage_return: is_changed, + at_line_start, + at_line_end: i == last_index, + }; + + let has_non_whitespace = if is_changed { + let mut slot = None; + let mut fmt = ElementWrapper::wrap(fmt, &mut slot, MarkupElement::Emphasis); + print_invisibles(&mut fmt, text, options)? + } else { + print_invisibles(fmt, text, options)? + }; + + if has_non_whitespace { + at_line_start = false; + } + } + + Ok(()) + } +} + +struct ElementWrapper<'a, W: ?Sized>(&'a mut W, MarkupElement<'static>); + +impl<'write> ElementWrapper<'write, dyn fmt::Write + 'write> { + fn wrap<'slot, 'fmt: 'write + 'slot>( + fmt: &'fmt mut fmt::Formatter<'_>, + slot: &'slot mut Option, + element: MarkupElement<'static>, + ) -> fmt::Formatter<'slot> { + fmt.wrap_writer(|writer| slot.get_or_insert(Self(writer, element))) + } +} + +impl fmt::Write for ElementWrapper<'_, W> { + fn write_str(&mut self, elements: &fmt::MarkupElements<'_>, content: &str) -> io::Result<()> { + let elements = fmt::MarkupElements::Node(elements, slice::from_ref(&self.1)); + self.0.write_str(&elements, content) + } + + fn write_fmt( + &mut self, + elements: &fmt::MarkupElements<'_>, + content: std::fmt::Arguments<'_>, + ) -> io::Result<()> { + let elements = fmt::MarkupElements::Node(elements, slice::from_ref(&self.1)); + self.0.write_fmt(&elements, content) + } +} + +#[cfg(test)] +mod tests { + use super::print_diff; + use pg_console::{fmt, markup, MarkupBuf}; + use pg_text_edit::TextEdit; + use termcolor::Buffer; + + fn assert_eq_markup(actual: &MarkupBuf, expected: &MarkupBuf) { + if actual != expected { + let mut buffer = Buffer::ansi(); + let mut writer = fmt::Termcolor(&mut buffer); + let mut output = fmt::Formatter::new(&mut writer); + + output + .write_markup(markup! { + "assertion failed: (actual == expected)\n" + "actual:\n" + {actual}"\n" + {format_args!("{actual:#?}")}"\n" + "expected:\n" + {expected}"\n" + {format_args!("{expected:#?}")}"\n" + }) + .unwrap(); + + let buffer = buffer.into_inner(); + let buffer = String::from_utf8(buffer).unwrap(); + panic!("{buffer}"); + } + } + + #[test] + fn test_inline() { + let diff = TextEdit::from_unicode_words("before", "after"); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + "-"" ""before""\n" + "+"" ""after""\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn test_single_line() { + let diff = TextEdit::from_unicode_words("start before end\n", "start after end \n"); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" "" │ ""-"" ""start""·""before""·""end""\n" + " ""1 │ ""+"" ""start""·""after""·""end""·""\n" + " ""2"" ""2 │ "" \n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn test_ellipsis() { + const SOURCE_LEFT: &str = "Lorem +ipsum +dolor +sit +amet, +function +name( + args +) {} +consectetur +adipiscing +elit, +sed +do +eiusmod + +incididunt +function +name( + args +) {}"; + + const SOURCE_RIGHT: &str = "Lorem +ipsum +dolor +sit +amet, +function name(args) { +} +consectetur +adipiscing +elit, +sed +do +eiusmod + +incididunt +function name(args) { +}"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " "" 4"" "" 4 │ "" sit\n" + " "" 5"" "" 5 │ "" amet,\n" + " "" 6"" "" │ ""-"" ""function""\n" + " "" 7"" "" │ ""-"" ""name(""\n" + " "" 8"" "" │ ""-"" ""····""args""\n" + " "" 9"" "" │ ""-"" "")""·""{}""\n" + " "" 6 │ ""+"" ""function""·""name(args)""·""{""\n" + " "" 7 │ ""+"" ""}""\n" + " ""10"" "" 8 │ "" consectetur\n" + " ""11"" "" 9 │ "" adipiscing\n" + " ····· │ \n" + " ""16"" ""14 │ "" \n" + " ""17"" ""15 │ "" incididunt\n" + " ""18"" "" │ ""-"" ""function""\n" + " ""19"" "" │ ""-"" ""name(""\n" + " ""20"" "" │ ""-"" ""····""args""\n" + " ""21"" "" │ ""-"" "")""·""{}""\n" + " ""16 │ ""+"" ""function""·""name(args)""·""{""\n" + " ""17 │ ""+"" ""}""\n" + "\n" + }.to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn remove_single_line() { + const SOURCE_LEFT: &str = "declare module \"test\" { + interface A { + + prop: string; + } +} +"; + + const SOURCE_RIGHT: &str = "declare module \"test\" { + interface A { + prop: string; + } +} +"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" ""1 │ "" declare module \"test\" {\n" + " ""2"" ""2 │ "" \tinterface A {\n" + " ""3"" "" │ ""-"" \n" + " ""4"" ""3 │ "" \t\tprop: string;\n" + " ""5"" ""4 │ "" \t}\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn remove_many_lines() { + const SOURCE_LEFT: &str = "declare module \"test\" { + interface A { + + + + prop: string; + } +} +"; + + const SOURCE_RIGHT: &str = "declare module \"test\" { + interface A { + prop: string; + } +} +"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" ""1 │ "" declare module \"test\" {\n" + " ""2"" ""2 │ "" \tinterface A {\n" + " ""3"" "" │ ""-"" \n" + " ""4"" "" │ ""-"" \n" + " ""5"" "" │ ""-"" \n" + " ""6"" ""3 │ "" \t\tprop: string;\n" + " ""7"" ""4 │ "" \t}\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn insert_single_line() { + const SOURCE_LEFT: &str = "declare module \"test\" { + interface A { + prop: string; + } +} +"; + + const SOURCE_RIGHT: &str = "declare module \"test\" { + interface A { + + prop: string; + } +} +"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" ""1 │ "" declare module \"test\" {\n" + " ""2"" ""2 │ "" \tinterface A {\n" + " ""3 │ ""+"" \n" + " ""3"" ""4 │ "" \t\tprop: string;\n" + " ""4"" ""5 │ "" \t}\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn insert_many_lines() { + const SOURCE_LEFT: &str = "declare module \"test\" { + interface A { + prop: string; + } +} +"; + + const SOURCE_RIGHT: &str = "declare module \"test\" { + interface A { + + + + prop: string; + } +} +"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" ""1 │ "" declare module \"test\" {\n" + " ""2"" ""2 │ "" \tinterface A {\n" + " ""3 │ ""+"" \n" + " ""4 │ ""+"" \n" + " ""5 │ ""+"" \n" + " ""3"" ""6 │ "" \t\tprop: string;\n" + " ""4"" ""7 │ "" \t}\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } + + #[test] + fn remove_empty_line() { + const SOURCE_LEFT: &str = "for (; ;) { +} + +console.log(\"test\"); +"; + + const SOURCE_RIGHT: &str = "for (;;) {} + +console.log(\"test\"); +"; + + let diff = TextEdit::from_unicode_words(SOURCE_LEFT, SOURCE_RIGHT); + + let mut output = MarkupBuf::default(); + print_diff(&mut fmt::Formatter::new(&mut output), &diff).unwrap(); + + let expected = markup! { + " ""1"" "" │ ""-"" ""for""·""(;""·"";)""·""{""\n" + " ""2"" "" │ ""-"" ""}""\n" + " ""1 │ ""+"" ""for""·""(;;)""·""{}""\n" + " ""3"" ""2 │ "" \n" + " ""4"" ""3 │ "" console.log(\"test\");\n" + "\n" + } + .to_owned(); + + assert_eq_markup(&output, &expected); + } +} diff --git a/crates/pg_diagnostics/src/display/frame.rs b/crates/pg_diagnostics/src/display/frame.rs new file mode 100644 index 000000000..8013148e9 --- /dev/null +++ b/crates/pg_diagnostics/src/display/frame.rs @@ -0,0 +1,755 @@ +use std::{ + borrow::Cow, + io, + iter::FusedIterator, + num::NonZeroUsize, + ops::{Bound, RangeBounds}, +}; + +use pg_console::{fmt, markup}; +use text_size::{TextLen, TextRange, TextSize}; +use unicode_width::UnicodeWidthChar; + +use crate::{ + location::{BorrowedSourceCode, LineIndex}, + LineIndexBuf, Location, +}; + +/// A const Option::unwrap without nightly features: +/// https://github.com/rust-lang/rust/issues/67441 +const fn unwrap(option: Option) -> T { + match option { + Some(value) => value, + None => panic!("unwrapping None"), + } +} + +const ONE: NonZeroUsize = unwrap(NonZeroUsize::new(1)); +pub(super) const CODE_FRAME_CONTEXT_LINES: NonZeroUsize = unwrap(NonZeroUsize::new(2)); + +const MAX_CODE_FRAME_LINES: usize = 8; +const HALF_MAX_CODE_FRAME_LINES: usize = MAX_CODE_FRAME_LINES / 2; + +/// Prints a code frame advice +pub(super) fn print_frame(fmt: &mut fmt::Formatter<'_>, location: Location<'_>) -> io::Result<()> { + let source_span = location + .source_code + .and_then(|source_code| Some((source_code, location.span?))); + + let (source_code, span) = match source_span { + Some(source_span) => source_span, + None => return Ok(()), + }; + + let source_file = SourceFile::new(source_code); + + let start_index = span.start(); + let start_location = match source_file.location(start_index) { + Ok(location) => location, + Err(_) => return Ok(()), + }; + + let end_index = span.end(); + let end_location = match source_file.location(end_index) { + Ok(location) => location, + Err(_) => return Ok(()), + }; + + // Increase the amount of lines we should show for "context" + let context_start = start_location + .line_number + .saturating_sub(CODE_FRAME_CONTEXT_LINES.get()); + + let mut context_end = end_location + .line_number + .saturating_add(CODE_FRAME_CONTEXT_LINES.get()) + .min(OneIndexed::new(source_file.line_starts.len()).unwrap_or(OneIndexed::MIN)); + + // Remove trailing blank lines + for line_index in IntoIter::new(context_start..=context_end).rev() { + if line_index == end_location.line_number { + break; + } + + let line_start = match source_file.line_start(line_index.to_zero_indexed()) { + Ok(index) => index, + Err(_) => continue, + }; + let line_end = match source_file.line_start(line_index.to_zero_indexed() + 1) { + Ok(index) => index, + Err(_) => continue, + }; + + let line_range = TextRange::new(line_start, line_end); + let line_text = source_file.source[line_range].trim(); + if !line_text.is_empty() { + break; + } + + context_end = line_index; + } + + // If we have too many lines in our selection, then collapse them to an ellipsis + let range_len = (context_end.get() + 1).saturating_sub(context_start.get()); + let ellipsis_range = if range_len > MAX_CODE_FRAME_LINES + 2 { + let ellipsis_start = context_start.saturating_add(HALF_MAX_CODE_FRAME_LINES); + let ellipsis_end = context_end.saturating_sub(HALF_MAX_CODE_FRAME_LINES); + Some(ellipsis_start..=ellipsis_end) + } else { + None + }; + + // Calculate the maximum width of the line number + let max_gutter_len = calculate_print_width(context_end); + let mut printed_lines = false; + + for line_index in IntoIter::new(context_start..=context_end) { + if let Some(ellipsis_range) = &ellipsis_range { + if ellipsis_range.contains(&line_index) { + if *ellipsis_range.start() == line_index { + for _ in 0..max_gutter_len.get() { + fmt.write_str(" ")?; + } + + fmt.write_markup(markup! { " ...\n" })?; + printed_lines = true; + } + continue; + } + } + + let line_start = match source_file.line_start(line_index.to_zero_indexed()) { + Ok(index) => index, + Err(_) => continue, + }; + let line_end = match source_file.line_start(line_index.to_zero_indexed() + 1) { + Ok(index) => index, + Err(_) => continue, + }; + + let line_range = TextRange::new(line_start, line_end); + let line_text = source_file.source[line_range].trim_end_matches(['\r', '\n']); + + // Ensure that the frame doesn't start with whitespace + if !printed_lines && line_index != start_location.line_number && line_text.trim().is_empty() + { + continue; + } + + printed_lines = true; + + // If this is within the highlighted line range + let should_highlight = + line_index >= start_location.line_number && line_index <= end_location.line_number; + + let padding_width = max_gutter_len + .get() + .saturating_sub(calculate_print_width(line_index).get()); + + for _ in 0..padding_width { + fmt.write_str(" ")?; + } + + if should_highlight { + fmt.write_markup(markup! { + '>'' ' + })?; + } else { + fmt.write_str(" ")?; + } + + fmt.write_markup(markup! { + {format_args!("{line_index} \u{2502} ")} + })?; + + // Show invisible characters + print_invisibles( + fmt, + line_text, + PrintInvisiblesOptions { + ignore_trailing_carriage_return: true, + ignore_leading_tabs: true, + ignore_lone_spaces: true, + at_line_start: true, + at_line_end: true, + }, + )?; + + fmt.write_str("\n")?; + + if should_highlight { + let is_first_line = line_index == start_location.line_number; + let is_last_line = line_index == end_location.line_number; + + let start_index_relative_to_line = + start_index.max(line_range.start()) - line_range.start(); + let end_index_relative_to_line = end_index.min(line_range.end()) - line_range.start(); + + let marker = if is_first_line && is_last_line { + // Only line in the selection + Some(TextRange::new( + start_index_relative_to_line, + end_index_relative_to_line, + )) + } else if is_first_line { + // First line in selection + Some(TextRange::new( + start_index_relative_to_line, + line_text.text_len(), + )) + } else if is_last_line { + // Last line in selection + let start_index = line_text + .text_len() + .checked_sub(line_text.trim_start().text_len()) + // SAFETY: The length of `line_text.trim_start()` should + // never be larger than `line_text` itself + .expect("integer overflow"); + Some(TextRange::new(start_index, end_index_relative_to_line)) + } else { + None + }; + + if let Some(marker) = marker { + for _ in 0..max_gutter_len.get() { + fmt.write_str(" ")?; + } + + fmt.write_markup(markup! { + " \u{2502} " + })?; + + // Align the start of the marker with the line above by a + // number of space characters equal to the unicode print width + // of the leading part of the line (before the start of the + // marker), with a special exception for tab characters that + // still get printed as tabs to respect the user-defined tab + // display width + let leading_range = TextRange::new(TextSize::from(0), marker.start()); + for c in line_text[leading_range].chars() { + match c { + '\t' => fmt.write_str("\t")?, + _ => { + for _ in 0..char_width(c) { + fmt.write_str(" ")?; + } + } + } + } + + let marker_width = text_width(&line_text[marker]); + for _ in 0..marker_width { + fmt.write_markup(markup! { + '^' + })?; + } + + fmt.write_str("\n")?; + } + } + } + + fmt.write_str("\n") +} + +pub(super) fn print_highlighted_frame( + fmt: &mut fmt::Formatter<'_>, + location: Location<'_>, +) -> io::Result<()> { + let Some(span) = location.span else { + return Ok(()); + }; + let Some(source_code) = location.source_code else { + return Ok(()); + }; + + // TODO: instead of calculating lines for every match, + // check if the Grit engine is able to pull them out + let source = SourceFile::new(source_code); + + let start = source.location(span.start())?; + let end = source.location(span.end())?; + + let match_line_start = start.line_number; + let match_line_end = end.line_number.saturating_add(1); + + for line_index in IntoIter::new(match_line_start..match_line_end) { + let current_range = source.line_range(line_index.to_zero_indexed()); + let current_range = match current_range { + Ok(v) => v, + Err(_) => continue, + }; + + let current_text = source_code.text[current_range].trim_end_matches(['\r', '\n']); + + let is_first_line = line_index == start.line_number; + let is_last_line = line_index == end.line_number; + + let start_index_relative_to_line = + span.start().max(current_range.start()) - current_range.start(); + let end_index_relative_to_line = + span.end().min(current_range.end()) - current_range.start(); + + let marker = if is_first_line && is_last_line { + TextRange::new(start_index_relative_to_line, end_index_relative_to_line) + } else if is_last_line { + let start_index: u32 = current_text.text_len().into(); + + let safe_start_index = + start_index.saturating_sub(current_text.trim_start().text_len().into()); + + TextRange::new(TextSize::from(safe_start_index), end_index_relative_to_line) + } else { + TextRange::new(start_index_relative_to_line, current_text.text_len()) + }; + + fmt.write_markup(markup! { + {format_args!("{line_index} \u{2502} ")} + })?; + + let start_range = ¤t_text[0..marker.start().into()]; + let highlighted_range = ¤t_text[marker.start().into()..marker.end().into()]; + let end_range = ¤t_text[marker.end().into()..current_text.text_len().into()]; + + write!(fmt, "{start_range}")?; + fmt.write_markup(markup! { {highlighted_range} })?; + write!(fmt, "{end_range}")?; + + writeln!(fmt)?; + } + + Ok(()) +} + +/// Calculate the length of the string representation of `value` +pub(super) fn calculate_print_width(mut value: OneIndexed) -> NonZeroUsize { + // SAFETY: Constant is being initialized with a non-zero value + const TEN: OneIndexed = unwrap(OneIndexed::new(10)); + + let mut width = ONE; + + while value >= TEN { + value = OneIndexed::new(value.get() / 10).unwrap_or(OneIndexed::MIN); + width = width.checked_add(1).unwrap(); + } + + width +} + +/// Compute the unicode display width of a string, with the width of tab +/// characters set to [TAB_WIDTH] and the width of control characters set to 0 +pub(super) fn text_width(text: &str) -> usize { + text.chars().map(char_width).sum() +} + +/// We need to set a value here since we have no way of knowing what the user's +/// preferred tab display width is, so this is set to `2` to match how tab +/// characters are printed by [print_invisibles] +const TAB_WIDTH: usize = 2; + +/// Some esoteric space characters don't return a width using `char.width()`, so +/// we need to assume a fixed length for them +const ESOTERIC_SPACE_WIDTH: usize = 1; + +/// Return the width of characters, treating whitespace characters in the way +/// we need to properly display it +pub(super) fn char_width(char: char) -> usize { + match char { + '\t' => TAB_WIDTH, + '\u{c}' => ESOTERIC_SPACE_WIDTH, + '\u{b}' => ESOTERIC_SPACE_WIDTH, + '\u{85}' => ESOTERIC_SPACE_WIDTH, + '\u{feff}' => ESOTERIC_SPACE_WIDTH, + '\u{180e}' => ESOTERIC_SPACE_WIDTH, + '\u{200b}' => ESOTERIC_SPACE_WIDTH, + '\u{3000}' => ESOTERIC_SPACE_WIDTH, + _ => char.width().unwrap_or(0), + } +} + +pub(super) struct PrintInvisiblesOptions { + /// Do not print tab characters at the start of the string + pub(super) ignore_leading_tabs: bool, + /// If this is set to true, space characters will only be substituted when + /// at least two of them are found in a row + pub(super) ignore_lone_spaces: bool, + /// Do not print `'\r'` characters if they're followed by `'\n'` + pub(super) ignore_trailing_carriage_return: bool, + // Set to `true` to show invisible characters at the start of the string + pub(super) at_line_start: bool, + // Set to `true` to show invisible characters at the end of the string + pub(super) at_line_end: bool, +} + +/// Print `input` to `fmt` with invisible characters replaced with an +/// appropriate visual representation. Return `true` if any non-whitespace +/// character was printed +pub(super) fn print_invisibles( + fmt: &mut fmt::Formatter<'_>, + input: &str, + options: PrintInvisiblesOptions, +) -> io::Result { + let mut had_non_whitespace = false; + + // Get the first trailing whitespace character in the string + let trailing_whitespace_index = input + .bytes() + .enumerate() + .rev() + .find(|(_, byte)| !byte.is_ascii_whitespace()) + .map_or(input.len(), |(index, _)| index); + + let mut iter = input.char_indices().peekable(); + let mut prev_char_was_whitespace = false; + + while let Some((i, char)) = iter.next() { + let mut show_invisible = true; + + // Only highlight spaces when surrounded by other spaces + if char == ' ' && options.ignore_lone_spaces { + show_invisible = false; + + let next_char_is_whitespace = iter + .peek() + .map_or(false, |(_, char)| char.is_ascii_whitespace()); + + if prev_char_was_whitespace || next_char_is_whitespace { + show_invisible = false; + } + } + + prev_char_was_whitespace = char.is_ascii_whitespace(); + + // Don't show leading tabs + if options.at_line_start + && !had_non_whitespace + && char == '\t' + && options.ignore_leading_tabs + { + show_invisible = false; + } + + // Always show if at the end of line + if options.at_line_end && i >= trailing_whitespace_index { + show_invisible = true; + } + + // If we are a carriage return next to a \n then don't show the character as visible + if options.ignore_trailing_carriage_return && char == '\r' { + let next_char_is_line_feed = iter.peek().map_or(false, |(_, char)| *char == '\n'); + if next_char_is_line_feed { + continue; + } + } + + if !show_invisible { + if !char.is_ascii_whitespace() { + had_non_whitespace = true; + } + + write!(fmt, "{char}")?; + continue; + } + + if let Some(visible) = show_invisible_char(char) { + fmt.write_markup(markup! { {visible} })?; + continue; + } + + if (char.is_whitespace() && !char.is_ascii_whitespace()) || char.is_control() { + let code = u32::from(char); + fmt.write_markup(markup! { "U+"{format_args!("{code:x}")} })?; + continue; + } + + write!(fmt, "{char}")?; + } + + Ok(had_non_whitespace) +} + +fn show_invisible_char(char: char) -> Option<&'static str> { + match char { + ' ' => Some("\u{b7}"), // Middle Dot + '\r' => Some("\u{240d}"), // Carriage Return Symbol + '\n' => Some("\u{23ce}"), // Return Symbol + '\t' => Some("\u{2192} "), // Rightwards Arrow + '\0' => Some("\u{2400}"), // Null Symbol + '\x0b' => Some("\u{240b}"), // Vertical Tabulation Symbol + '\x08' => Some("\u{232b}"), // Backspace Symbol + '\x0c' => Some("\u{21a1}"), // Downwards Two Headed Arrow + '\u{85}' => Some("\u{2420}"), // Space Symbol + '\u{a0}' => Some("\u{2420}"), // Space Symbol + '\u{1680}' => Some("\u{2420}"), // Space Symbol + '\u{2000}' => Some("\u{2420}"), // Space Symbol + '\u{2001}' => Some("\u{2420}"), // Space Symbol + '\u{2002}' => Some("\u{2420}"), // Space Symbol + '\u{2003}' => Some("\u{2420}"), // Space Symbol + '\u{2004}' => Some("\u{2420}"), // Space Symbol + '\u{2005}' => Some("\u{2420}"), // Space Symbol + '\u{2006}' => Some("\u{2420}"), // Space Symbol + '\u{2007}' => Some("\u{2420}"), // Space Symbol + '\u{2008}' => Some("\u{2420}"), // Space Symbol + '\u{2009}' => Some("\u{2420}"), // Space Symbol + '\u{200a}' => Some("\u{2420}"), // Space Symbol + '\u{202f}' => Some("\u{2420}"), // Space Symbol + '\u{205f}' => Some("\u{2420}"), // Space Symbol + '\u{3000}' => Some("\u{2420}"), // Space Symbol + _ => None, + } +} + +/// A user-facing location in a source file. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct SourceLocation { + /// The user-facing line number. + pub line_number: OneIndexed, + /// The user-facing column number. + pub column_number: OneIndexed, +} + +/// Representation of a single source file holding additional information for +/// efficiently rendering code frames +#[derive(Clone)] +pub struct SourceFile<'diagnostic> { + /// The source code of the file. + source: &'diagnostic str, + /// The starting byte indices in the source code. + line_starts: Cow<'diagnostic, LineIndex>, +} + +impl<'diagnostic> SourceFile<'diagnostic> { + /// Create a new [SourceFile] from a slice of text + pub fn new(source_code: BorrowedSourceCode<'diagnostic>) -> Self { + // Either re-use the existing line index provided by the diagnostic or create one + Self { + source: source_code.text, + line_starts: source_code.line_starts.map_or_else( + || Cow::Owned(LineIndexBuf::from_source_text(source_code.text)), + Cow::Borrowed, + ), + } + } + + /// Return the starting byte index of the line with the specified line index. + /// Convenience method that already generates errors if necessary. + fn line_start(&self, line_index: usize) -> io::Result { + use std::cmp::Ordering; + + match line_index.cmp(&self.line_starts.len()) { + Ordering::Less => Ok(self + .line_starts + .get(line_index) + .copied() + .expect("failed despite previous check")), + Ordering::Equal => Ok(self.source.text_len()), + Ordering::Greater => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "overflow error", + )), + } + } + + fn line_index(&self, byte_index: TextSize) -> usize { + self.line_starts + .binary_search(&byte_index) + .unwrap_or_else(|next_line| next_line - 1) + } + + fn line_range(&self, line_index: usize) -> io::Result { + let line_start = self.line_start(line_index)?; + let next_line_start = self.line_start(line_index + 1)?; + + Ok(TextRange::new(line_start, next_line_start)) + } + + fn line_number(&self, line_index: usize) -> OneIndexed { + // SAFETY: Adding `1` to the value of `line_index` ensures it's non-zero + OneIndexed::from_zero_indexed(line_index) + } + + fn column_number(&self, line_index: usize, byte_index: TextSize) -> io::Result { + let source = self.source; + let line_range = self.line_range(line_index)?; + let column_index = column_index(source, line_range, byte_index); + + // SAFETY: Adding `1` to the value of `column_index` ensures it's non-zero + Ok(OneIndexed::from_zero_indexed(column_index)) + } + + /// Get a source location from a byte index into the text of this file + pub fn location(&self, byte_index: TextSize) -> io::Result { + let line_index = self.line_index(byte_index); + + Ok(SourceLocation { + line_number: self.line_number(line_index), + column_number: self.column_number(line_index, byte_index)?, + }) + } +} + +/// The column index at the given byte index in the source file. +/// This is the number of characters to the given byte index. +/// +/// If the byte index is smaller than the start of the line, then `0` is returned. +/// If the byte index is past the end of the line, the column index of the last +/// character `+ 1` is returned. +fn column_index(source: &str, line_range: TextRange, byte_index: TextSize) -> usize { + let end_index = std::cmp::min( + byte_index, + std::cmp::min(line_range.end(), source.text_len()), + ); + + (usize::from(line_range.start())..usize::from(end_index)) + .filter(|byte_index| source.is_char_boundary(byte_index + 1)) + .count() +} + +/// Type-safe wrapper for a value whose logical range starts at `1`, for +/// instance the line or column numbers in a file +/// +/// Internally this is represented as a [NonZeroUsize], this enables some +/// memory optimizations +#[repr(transparent)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OneIndexed(NonZeroUsize); + +impl OneIndexed { + // SAFETY: These constants are being initialized with non-zero values + /// The smallest value that can be represented by this integer type. + pub const MIN: Self = unwrap(Self::new(1)); + /// The largest value that can be represented by this integer type + pub const MAX: Self = unwrap(Self::new(usize::MAX)); + + /// Creates a non-zero if the given value is not zero. + pub const fn new(value: usize) -> Option { + match NonZeroUsize::new(value) { + Some(value) => Some(Self(value)), + None => None, + } + } + + /// Construct a new [OneIndexed] from a zero-indexed value + pub const fn from_zero_indexed(value: usize) -> Self { + Self(ONE.saturating_add(value)) + } + + /// Returns the value as a primitive type. + pub const fn get(self) -> usize { + self.0.get() + } + + /// Return the zero-indexed primitive value for this [OneIndexed] + pub const fn to_zero_indexed(self) -> usize { + self.0.get() - 1 + } + + /// Saturating integer addition. Computes `self + rhs`, saturating at + /// the numeric bounds instead of overflowing. + pub const fn saturating_add(self, rhs: usize) -> Self { + match NonZeroUsize::new(self.0.get().saturating_add(rhs)) { + Some(value) => Self(value), + None => Self::MAX, + } + } + + /// Saturating integer subtraction. Computes `self - rhs`, saturating + /// at the numeric bounds instead of overflowing. + pub const fn saturating_sub(self, rhs: usize) -> Self { + match NonZeroUsize::new(self.0.get().saturating_sub(rhs)) { + Some(value) => Self(value), + None => Self::MIN, + } + } +} + +impl fmt::Display for OneIndexed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.0.get().fmt(f) + } +} + +impl std::fmt::Display for OneIndexed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.get().fmt(f) + } +} + +/// Adapter type implementing [Iterator] for ranges of [OneIndexed], +/// since [std::iter::Step] is unstable +pub struct IntoIter(std::ops::Range); + +impl IntoIter { + /// Construct a new iterator over a range of [OneIndexed] of any kind + /// (`..`, `a..`, `..b`, `..=c`, `d..e`, or `f..=g`) + pub fn new>(range: R) -> Self { + let start = match range.start_bound() { + Bound::Included(value) => value.get(), + Bound::Excluded(value) => value.get() + 1, + Bound::Unbounded => 1, + }; + + let end = match range.end_bound() { + Bound::Included(value) => value.get() + 1, + Bound::Excluded(value) => value.get(), + Bound::Unbounded => usize::MAX, + }; + + Self(start..end) + } +} + +impl Iterator for IntoIter { + type Item = OneIndexed; + + fn next(&mut self) -> Option { + self.0.next().map(|index| OneIndexed::new(index).unwrap()) + } + + fn size_hint(&self) -> (usize, Option) { + self.0.size_hint() + } +} + +impl DoubleEndedIterator for IntoIter { + fn next_back(&mut self) -> Option { + self.0 + .next_back() + .map(|index| OneIndexed::new(index).unwrap()) + } +} + +impl FusedIterator for IntoIter {} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use super::{calculate_print_width, OneIndexed}; + + #[test] + fn print_width() { + let one = NonZeroUsize::new(1).unwrap(); + let two = NonZeroUsize::new(2).unwrap(); + let three = NonZeroUsize::new(3).unwrap(); + let four = NonZeroUsize::new(4).unwrap(); + + assert_eq!(calculate_print_width(OneIndexed::new(1).unwrap()), one); + assert_eq!(calculate_print_width(OneIndexed::new(9).unwrap()), one); + + assert_eq!(calculate_print_width(OneIndexed::new(10).unwrap()), two); + assert_eq!(calculate_print_width(OneIndexed::new(11).unwrap()), two); + assert_eq!(calculate_print_width(OneIndexed::new(19).unwrap()), two); + assert_eq!(calculate_print_width(OneIndexed::new(20).unwrap()), two); + assert_eq!(calculate_print_width(OneIndexed::new(21).unwrap()), two); + assert_eq!(calculate_print_width(OneIndexed::new(99).unwrap()), two); + + assert_eq!(calculate_print_width(OneIndexed::new(100).unwrap()), three); + assert_eq!(calculate_print_width(OneIndexed::new(101).unwrap()), three); + assert_eq!(calculate_print_width(OneIndexed::new(110).unwrap()), three); + assert_eq!(calculate_print_width(OneIndexed::new(199).unwrap()), three); + assert_eq!(calculate_print_width(OneIndexed::new(999).unwrap()), three); + + assert_eq!(calculate_print_width(OneIndexed::new(1000).unwrap()), four); + } +} diff --git a/crates/pg_diagnostics/src/display/message.rs b/crates/pg_diagnostics/src/display/message.rs new file mode 100644 index 000000000..460f70387 --- /dev/null +++ b/crates/pg_diagnostics/src/display/message.rs @@ -0,0 +1,97 @@ +use pg_console::fmt::{Formatter, Termcolor}; +use pg_console::{markup, MarkupBuf}; +use serde::{Deserialize, Serialize}; +use termcolor::NoColor; + +/// Convenient type that can be used when message and descriptions match, and they need to be +/// displayed using different formatters +/// +/// ## Examples +/// +/// ``` +/// use pg_diagnostics::{Diagnostic, MessageAndDescription}; +/// +/// #[derive(Debug, Diagnostic)] +/// struct TestDiagnostic { +/// #[message] +/// #[description] +/// message: MessageAndDescription +/// } +/// ``` +#[derive(Clone, Deserialize, Serialize)] +pub struct MessageAndDescription { + /// Shown when medium supports custom markup + message: MarkupBuf, + /// Shown when the medium doesn't support markup + description: String, +} + +impl MessageAndDescription { + /// It sets a custom message. It updates only the message. + pub fn set_message(&mut self, new_message: MarkupBuf) { + self.message = new_message; + } + + /// It sets a custom description. It updates only the description + pub fn set_description(&mut self, new_description: String) { + self.description = new_description; + } +} + +impl From for MessageAndDescription { + fn from(description: String) -> Self { + Self { + message: markup! { {description} }.to_owned(), + description, + } + } +} + +impl From for MessageAndDescription { + fn from(message: MarkupBuf) -> Self { + let description = markup_to_string(&message); + Self { + message, + description, + } + } +} + +impl std::fmt::Display for MessageAndDescription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.description) + } +} + +impl std::fmt::Debug for MessageAndDescription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, f) + } +} + +impl pg_console::fmt::Display for MessageAndDescription { + fn fmt(&self, fmt: &mut Formatter<'_>) -> std::io::Result<()> { + fmt.write_markup(markup! {{self.message}}) + } +} + +/// Utility function to transform a [MarkupBuf] into a [String] +pub fn markup_to_string(markup: &MarkupBuf) -> String { + let mut buffer = Vec::new(); + let mut write = Termcolor(NoColor::new(&mut buffer)); + let mut fmt = Formatter::new(&mut write); + fmt.write_markup(markup! { {markup} }) + .expect("to have written in the buffer"); + + String::from_utf8(buffer).expect("to have convert a buffer into a String") +} + +#[cfg(test)] +mod test { + use crate::MessageAndDescription; + + #[test] + fn message_size() { + assert_eq!(std::mem::size_of::(), 48); + } +} diff --git a/crates/pg_diagnostics/src/display_github.rs b/crates/pg_diagnostics/src/display_github.rs new file mode 100644 index 000000000..d029e508a --- /dev/null +++ b/crates/pg_diagnostics/src/display_github.rs @@ -0,0 +1,129 @@ +use crate::display::frame::SourceFile; +use crate::{diagnostic::internal::AsDiagnostic, Diagnostic, Resource, Severity}; +use pg_console::{fmt, markup, MarkupBuf}; +use std::io; +use text_size::{TextRange, TextSize}; + +/// Helper struct for printing a diagnostic as markup into any formatter +/// implementing [pg_console::fmt::Write]. +pub struct PrintGitHubDiagnostic<'fmt, D: ?Sized>(pub &'fmt D); + +impl fmt::Display for PrintGitHubDiagnostic<'_, D> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let diagnostic = self.0.as_diagnostic(); + let location = diagnostic.location(); + + // Docs: + // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions + let span = location + .span + // We fall back to 1:1. This usually covers diagnostics that belong to the formatter or organize imports + .unwrap_or(TextRange::new(TextSize::from(1), TextSize::from(1))); + + let Some(source_code) = location.source_code else { + return Ok(()); + }; + + let file_name_unescaped = match &location.resource { + Some(Resource::File(file)) => file, + _ => return Ok(()), + }; + + let source = SourceFile::new(source_code); + let start = source.location(span.start())?; + let end = source.location(span.end())?; + + let command = match diagnostic.severity() { + Severity::Error | Severity::Fatal => "error", + Severity::Warning => "warning", + Severity::Hint | Severity::Information => "notice", + }; + + let message = { + let mut message = MarkupBuf::default(); + let mut fmt = fmt::Formatter::new(&mut message); + fmt.write_markup(markup!({ PrintDiagnosticMessage(diagnostic) }))?; + markup_to_string(&message) + }; + + let title = { + diagnostic + .category() + .map(|category| category.name()) + .unwrap_or_default() + }; + + fmt.write_str( + format! { + "::{} title={},file={},line={},endLine={},col={},endColumn={}::{}", + command, // constant, doesn't need escaping + title, // the diagnostic category + escape_property(file_name_unescaped), + start.line_number, // integer, doesn't need escaping + end.line_number, // integer, doesn't need escaping + start.column_number, // integer, doesn't need escaping + end.column_number, // integer, doesn't need escaping + message.map_or_else(String::new, escape_data), + } + .as_str(), + )?; + + Ok(()) + } +} + +struct PrintDiagnosticMessage<'fmt, D: ?Sized>(&'fmt D); + +impl fmt::Display for PrintDiagnosticMessage<'_, D> { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + let Self(diagnostic) = *self; + diagnostic.message(fmt)?; + Ok(()) + } +} + +fn escape_data>(value: S) -> String { + let value = value.as_ref(); + + // Refs: + // - https://github.com/actions/runner/blob/a4c57f27477077e57545af79851551ff7f5632bd/src/Runner.Common/ActionCommand.cs#L18-L22 + // - https://github.com/actions/toolkit/blob/fe3e7ce9a7f995d29d1fcfd226a32bca407f9dc8/packages/core/src/command.ts#L80-L94 + let mut result = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\r' => result.push_str("%0D"), + '\n' => result.push_str("%0A"), + '%' => result.push_str("%25"), + _ => result.push(c), + } + } + result +} + +fn escape_property>(value: S) -> String { + let value = value.as_ref(); + + // Refs: + // - https://github.com/actions/runner/blob/a4c57f27477077e57545af79851551ff7f5632bd/src/Runner.Common/ActionCommand.cs#L25-L32 + // - https://github.com/actions/toolkit/blob/fe3e7ce9a7f995d29d1fcfd226a32bca407f9dc8/packages/core/src/command.ts#L80-L94 + let mut result = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\r' => result.push_str("%0D"), + '\n' => result.push_str("%0A"), + ':' => result.push_str("%3A"), + ',' => result.push_str("%2C"), + '%' => result.push_str("%25"), + _ => result.push(c), + } + } + result +} + +fn markup_to_string(markup: &MarkupBuf) -> Option { + let mut buffer = Vec::new(); + let mut write = fmt::Termcolor(termcolor::NoColor::new(&mut buffer)); + let mut fmt = fmt::Formatter::new(&mut write); + fmt.write_markup(markup! { {markup} }).ok()?; + String::from_utf8(buffer).ok() +} diff --git a/crates/pg_diagnostics/src/error.rs b/crates/pg_diagnostics/src/error.rs new file mode 100644 index 000000000..eea8fc9cb --- /dev/null +++ b/crates/pg_diagnostics/src/error.rs @@ -0,0 +1,173 @@ +//! The `error` module contains the implementation of [Error], a dynamic +//! container struct for any type implementing [Diagnostic]. +//! +//! We reduce the size of `Error` by using `Box>` (a thin +//! pointer to a fat pointer) rather than `Box` (a fat +//! pointer), in order to make returning a `Result` more efficient. +//! +//! When [`ThinBox`](https://doc.rust-lang.org/std/boxed/struct.ThinBox.html) +//! becomes available in stable Rust, we can switch to that. + +use std::ops::Deref; +use std::{ + fmt::{Debug, Formatter}, + io, +}; + +use pg_console::fmt; + +use crate::{ + diagnostic::internal::AsDiagnostic, Category, Diagnostic, DiagnosticTags, Location, Severity, + Visit, +}; + +/// The `Error` struct wraps any type implementing [Diagnostic] into a single +/// dynamic type. +pub struct Error { + inner: Box>, +} + +/// Implement the [Diagnostic] trait as inherent methods on the [Error] type. +impl Error { + /// Calls [Diagnostic::category] on the [Diagnostic] wrapped by this [Error]. + pub fn category(&self) -> Option<&'static Category> { + self.as_diagnostic().category() + } + + /// Calls [Diagnostic::severity] on the [Diagnostic] wrapped by this [Error]. + pub fn severity(&self) -> Severity { + self.as_diagnostic().severity() + } + + /// Calls [Diagnostic::description] on the [Diagnostic] wrapped by this [Error]. + pub fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_diagnostic().description(fmt) + } + + /// Calls [Diagnostic::message] on the [Diagnostic] wrapped by this [Error]. + pub fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + self.as_diagnostic().message(fmt) + } + + /// Calls [Diagnostic::advices] on the [Diagnostic] wrapped by this [Error]. + pub fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.as_diagnostic().advices(visitor) + } + + /// Calls [Diagnostic::verbose_advices] on the [Diagnostic] wrapped by this [Error]. + pub fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.as_diagnostic().verbose_advices(visitor) + } + + /// Calls [Diagnostic::location] on the [Diagnostic] wrapped by this [Error]. + pub fn location(&self) -> Location<'_> { + self.as_diagnostic().location() + } + + /// Calls [Diagnostic::tags] on the [Diagnostic] wrapped by this [Error]. + pub fn tags(&self) -> DiagnosticTags { + self.as_diagnostic().tags() + } + + /// Calls [Diagnostic::source] on the [Diagnostic] wrapped by this [Error]. + pub fn source(&self) -> Option<&dyn Diagnostic> { + self.as_diagnostic().source() + } +} + +/// Implement [From] for all types implementing [Diagnostic], [Send], [Sync] +/// and outlives the `'static` lifetime. +impl From for Error +where + T: Diagnostic + Send + Sync + 'static, +{ + fn from(diag: T) -> Self { + Self { + inner: Box::new(Box::new(diag)), + } + } +} + +impl AsDiagnostic for Error { + type Diagnostic = dyn Diagnostic; + + fn as_diagnostic(&self) -> &Self::Diagnostic { + &**self.inner + } + + fn as_dyn(&self) -> &dyn Diagnostic { + self.as_diagnostic() + } +} + +impl AsRef for Error { + fn as_ref(&self) -> &(dyn Diagnostic + 'static) { + self.as_diagnostic() + } +} + +impl Deref for Error { + type Target = dyn Diagnostic + 'static; + + fn deref(&self) -> &Self::Target { + self.as_diagnostic() + } +} + +// Defer the implementation of `Debug` and `Drop` to the wrapped type +impl Debug for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self.as_diagnostic(), f) + } +} + +/// Alias of [std::result::Result] with the `Err` type defaulting to [Error]. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use std::{ + mem::size_of, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + }; + + use crate::{Diagnostic, Error, Result}; + + #[derive(Debug)] + struct TestDiagnostic(Arc); + + impl Diagnostic for TestDiagnostic {} + + impl Drop for TestDiagnostic { + fn drop(&mut self) { + let was_dropped = self.0.swap(true, Ordering::Relaxed); + assert!(!was_dropped); + } + } + + #[test] + fn test_drop() { + let drop_flag = AtomicBool::new(false); + let drop_flag = Arc::new(drop_flag); + + let diag = TestDiagnostic(drop_flag.clone()); + + let error = Error::from(diag); + drop(error); + + assert!(drop_flag.load(Ordering::Relaxed)); + } + + #[test] + fn test_error_size() { + assert_eq!(size_of::(), size_of::()); + } + + #[test] + fn test_result_size() { + assert_eq!(size_of::>(), size_of::()); + } +} diff --git a/crates/pg_diagnostics/src/lib.rs b/crates/pg_diagnostics/src/lib.rs index a1391f855..93e4a5def 100644 --- a/crates/pg_diagnostics/src/lib.rs +++ b/crates/pg_diagnostics/src/lib.rs @@ -1,27 +1,91 @@ -use std::fmt::Debug; -use text_size::TextRange; - -#[derive(Debug, PartialEq, Eq)] -pub struct Diagnostic { - pub message: String, - pub description: Option, - pub severity: Severity, - pub source: String, - pub range: TextRange, +#![deny(rust_2018_idioms)] + +use ::serde::{Deserialize, Serialize}; + +pub mod adapters; +pub mod advice; +pub mod context; +pub mod diagnostic; +pub mod display; +pub mod display_github; +pub mod error; +pub mod location; +pub mod panic; +pub mod serde; + +mod suggestion; + +pub use self::suggestion::{Applicability, CodeSuggestion}; +pub use termcolor; + +#[doc(hidden)] +// Convenience re-export for procedural macro +pub use pg_console as console; + +// Re-export macros from utility crates +pub use pg_diagnostics_categories::{category, category_concat, Category}; +pub use pg_diagnostics_macros::Diagnostic; + +pub use crate::advice::{ + Advices, CodeFrameAdvice, CommandAdvice, DiffAdvice, LogAdvice, LogCategory, Visit, +}; +pub use crate::context::{Context, DiagnosticExt}; +pub use crate::diagnostic::{Diagnostic, DiagnosticTags, Severity}; +pub use crate::display::{ + set_bottom_frame, Backtrace, MessageAndDescription, PrintDescription, PrintDiagnostic, +}; +pub use crate::display_github::PrintGitHubDiagnostic; +pub use crate::error::{Error, Result}; +pub use crate::location::{LineIndex, LineIndexBuf, Location, Resource, SourceCode}; +use pg_console::fmt::{Formatter, Termcolor}; +use pg_console::markup; +use std::fmt::Write; + +pub mod prelude { + //! Anonymously re-exports all the traits declared by this module, this is + //! intended to be imported as `use pg_diagnostics::prelude::*;` to + //! automatically bring all these traits into the ambient context + + pub use crate::advice::{Advices as _, Visit as _}; + pub use crate::context::{Context as _, DiagnosticExt as _}; + pub use crate::diagnostic::Diagnostic as _; } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -/// The severity to associate to a diagnostic. -pub enum Severity { - /// Reports a hint. - Hint, - /// Reports an information. - #[default] - Information, - /// Reports a warning. - Warning, - /// Reports an error. - Error, - /// Reports a crash. - Fatal, +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum DiagnosticTag { + Unnecessary, + Deprecated, + Both, +} + +impl DiagnosticTag { + pub fn is_unnecessary(&self) -> bool { + matches!(self, DiagnosticTag::Unnecessary | DiagnosticTag::Both) + } + + pub fn is_deprecated(&self) -> bool { + matches!(self, DiagnosticTag::Deprecated | DiagnosticTag::Both) + } +} + +/// Utility function for testing purpose. The function will print an [Error] +/// to a string, which is then returned by the function. +pub fn print_diagnostic_to_string(diagnostic: &Error) -> String { + let mut buffer = termcolor::Buffer::no_color(); + + Formatter::new(&mut Termcolor(&mut buffer)) + .write_markup(markup! { + {PrintDiagnostic::verbose(diagnostic)} + }) + .expect("failed to emit diagnostic"); + + let mut content = String::new(); + writeln!( + content, + "{}", + std::str::from_utf8(buffer.as_slice()).expect("non utf8 in error buffer") + ) + .unwrap(); + + content } diff --git a/crates/pg_diagnostics/src/location.rs b/crates/pg_diagnostics/src/location.rs new file mode 100644 index 000000000..4b9c8fe82 --- /dev/null +++ b/crates/pg_diagnostics/src/location.rs @@ -0,0 +1,394 @@ +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use std::ops::Range; +use std::{borrow::Borrow, ops::Deref}; +use text_size::{TextRange, TextSize}; + +/// Represents the location of a diagnostic in a resource. +#[derive(Debug, Default, Clone, Copy)] +pub struct Location<'a> { + /// The resource this diagnostic is associated with. + pub resource: Option>, + /// An optional range of text within the resource associated with the + /// diagnostic. + pub span: Option, + /// The optional source code of the resource. + pub source_code: Option>, +} + +impl<'a> Location<'a> { + /// Creates a new instance of [LocationBuilder]. + pub fn builder() -> LocationBuilder<'a> { + LocationBuilder { + resource: None, + span: None, + source_code: None, + } + } +} + +/// The implementation of [PartialEq] for [Location] only compares the `path` +/// and `span` fields +impl PartialEq for Location<'_> { + fn eq(&self, other: &Self) -> bool { + self.resource == other.resource && self.span == other.span + } +} + +impl Eq for Location<'_> {} + +/// Represents the resource a diagnostic is associated with. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Resource

{ + /// The diagnostic is related to the content of the command line arguments. + Argv, + /// The diagnostic is related to the content of a memory buffer. + Memory, + /// The diagnostic is related to a file on the filesystem. + File(P), +} + +impl

Resource

{ + /// Returns a `FilePath<&P::Target>` if `self` points to a `Path`, or + /// `None` otherwise. + pub fn as_file(&self) -> Option<&

::Target> + where + P: Deref, + { + if let Resource::File(file) = self { + Some(file) + } else { + None + } + } + + /// Converts a `Path

` to `Path<&P::Target>`. + pub fn as_deref(&self) -> Resource<&

::Target> + where + P: Deref, + { + match self { + Resource::Argv => Resource::Argv, + Resource::Memory => Resource::Memory, + Resource::File(file) => Resource::File(file), + } + } +} + +impl Resource<&'_ str> { + /// Converts a `Path<&str>` to `Path`. + pub fn to_owned(self) -> Resource { + match self { + Resource::Argv => Resource::Argv, + Resource::Memory => Resource::Memory, + Resource::File(file) => Resource::File(file.to_owned()), + } + } +} + +type OwnedSourceCode = SourceCode; +pub(crate) type BorrowedSourceCode<'a> = SourceCode<&'a str, &'a LineIndex>; + +/// Represents the source code of a file. +#[derive(Debug, Clone, Copy)] +pub struct SourceCode { + /// The text content of the file. + pub text: T, + /// An optional "line index" for the file, a list of byte offsets for the + /// start of each line in the file. + pub line_starts: Option, +} + +impl SourceCode { + /// Converts a `SourceCode` to `SourceCode<&T::Target, &L::Target>`. + pub(crate) fn as_deref(&self) -> SourceCode<&::Target, &::Target> + where + T: Deref, + L: Deref, + { + SourceCode { + text: &self.text, + line_starts: self.line_starts.as_deref(), + } + } +} + +impl BorrowedSourceCode<'_> { + /// Converts a `SourceCode<&str, &LineIndex>` to `SourceCode`. + pub(crate) fn to_owned(self) -> OwnedSourceCode { + SourceCode { + text: self.text.to_owned(), + line_starts: self.line_starts.map(ToOwned::to_owned), + } + } +} + +#[derive(Debug)] +pub struct LineIndex([TextSize]); + +impl LineIndex { + pub fn new(slice: &'_ [TextSize]) -> &'_ Self { + // SAFETY: Transmuting `&[TextSize]` to `&LineIndex` is safe since + // `LineIndex` is a `repr(transparent)` struct containing a `[TextSize]` + // and thus has the same memory layout + unsafe { std::mem::transmute(slice) } + } +} + +impl Deref for LineIndex { + type Target = [TextSize]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ToOwned for LineIndex { + type Owned = LineIndexBuf; + + fn to_owned(&self) -> Self::Owned { + LineIndexBuf(self.0.to_owned()) + } +} + +#[derive(Debug, Clone)] +pub struct LineIndexBuf(Vec); + +impl LineIndexBuf { + pub fn from_source_text(source: &str) -> Self { + Self( + std::iter::once(0) + .chain(source.match_indices(&['\n', '\r']).filter_map(|(i, _)| { + let bytes = source.as_bytes(); + + match bytes[i] { + // Filter out the `\r` in `\r\n` to avoid counting the line break twice + b'\r' if i + 1 < bytes.len() && bytes[i + 1] == b'\n' => None, + _ => Some(i + 1), + } + })) + .map(|i| TextSize::try_from(i).expect("integer overflow")) + .collect(), + ) + } +} + +impl Deref for LineIndexBuf { + type Target = LineIndex; + + fn deref(&self) -> &Self::Target { + LineIndex::new(self.0.as_slice()) + } +} + +impl Borrow for LineIndexBuf { + fn borrow(&self) -> &LineIndex { + self + } +} + +/// Builder type for the [Location] struct +pub struct LocationBuilder<'a> { + resource: Option>, + span: Option, + source_code: Option>, +} + +impl<'a> LocationBuilder<'a> { + pub fn resource(mut self, resource: &'a P) -> Self { + self.resource = resource.as_resource(); + self + } + + pub fn span(mut self, span: &'a S) -> Self { + self.span = span.as_span(); + self + } + + pub fn source_code(mut self, source_code: &'a S) -> Self { + self.source_code = source_code.as_source_code(); + self + } + + pub fn build(self) -> Location<'a> { + Location { + resource: self.resource, + span: self.span, + source_code: self.source_code, + } + } +} + +/// Utility trait for types that can be converted to a [Resource] +pub trait AsResource { + fn as_resource(&self) -> Option>; +} + +impl AsResource for Option { + fn as_resource(&self) -> Option> { + self.as_ref().and_then(T::as_resource) + } +} + +impl AsResource for &'_ T { + fn as_resource(&self) -> Option> { + T::as_resource(*self) + } +} + +impl> AsResource for Resource { + fn as_resource(&self) -> Option> { + Some(self.as_deref()) + } +} + +impl AsResource for String { + fn as_resource(&self) -> Option> { + Some(Resource::File(self)) + } +} + +impl AsResource for str { + fn as_resource(&self) -> Option> { + Some(Resource::File(self)) + } +} + +/// Utility trait for types that can be converted into `Option` +pub trait AsSpan { + fn as_span(&self) -> Option; +} + +impl AsSpan for Option { + fn as_span(&self) -> Option { + self.as_ref().and_then(T::as_span) + } +} + +impl AsSpan for &'_ T { + fn as_span(&self) -> Option { + T::as_span(*self) + } +} + +impl AsSpan for TextRange { + fn as_span(&self) -> Option { + Some(*self) + } +} + +impl AsSpan for Range +where + TextSize: TryFrom, + >::Error: Debug, +{ + fn as_span(&self) -> Option { + Some(TextRange::new( + TextSize::try_from(self.start).expect("integer overflow"), + TextSize::try_from(self.end).expect("integer overflow"), + )) + } +} + +/// Utility trait for types that can be converted into [SourceCode] +pub trait AsSourceCode { + fn as_source_code(&self) -> Option>; +} + +impl AsSourceCode for Option { + fn as_source_code(&self) -> Option> { + self.as_ref().and_then(T::as_source_code) + } +} + +impl AsSourceCode for &'_ T { + fn as_source_code(&self) -> Option> { + T::as_source_code(*self) + } +} + +impl AsSourceCode for BorrowedSourceCode<'_> { + fn as_source_code(&self) -> Option> { + Some(*self) + } +} + +impl AsSourceCode for OwnedSourceCode { + fn as_source_code(&self) -> Option> { + Some(SourceCode { + text: self.text.as_str(), + line_starts: self.line_starts.as_deref(), + }) + } +} + +impl AsSourceCode for str { + fn as_source_code(&self) -> Option> { + Some(SourceCode { + text: self, + line_starts: None, + }) + } +} + +impl AsSourceCode for String { + fn as_source_code(&self) -> Option> { + Some(SourceCode { + text: self, + line_starts: None, + }) + } +} + +#[cfg(test)] +mod tests { + use text_size::TextSize; + + use super::LineIndexBuf; + + #[test] + fn line_starts_with_carriage_return_line_feed() { + let input = "a\r\nb\r\nc"; + let LineIndexBuf(starts) = LineIndexBuf::from_source_text(input); + + assert_eq!( + vec![ + TextSize::from(0u32), + TextSize::from(3u32), + TextSize::from(6u32) + ], + starts + ); + } + + #[test] + fn line_starts_with_carriage_return() { + let input = "a\rb\rc"; + let LineIndexBuf(starts) = LineIndexBuf::from_source_text(input); + + assert_eq!( + vec![ + TextSize::from(0u32), + TextSize::from(2u32), + TextSize::from(4u32) + ], + starts + ); + } + + #[test] + fn line_starts_with_line_feed() { + let input = "a\nb\nc"; + let LineIndexBuf(starts) = LineIndexBuf::from_source_text(input); + + assert_eq!( + vec![ + TextSize::from(0u32), + TextSize::from(2u32), + TextSize::from(4u32) + ], + starts + ); + } +} diff --git a/crates/pg_diagnostics/src/panic.rs b/crates/pg_diagnostics/src/panic.rs new file mode 100644 index 000000000..739fae45a --- /dev/null +++ b/crates/pg_diagnostics/src/panic.rs @@ -0,0 +1,50 @@ +use std::panic::UnwindSafe; + +#[derive(Default, Debug)] +pub struct PanicError { + pub info: String, + pub backtrace: Option, +} + +thread_local! { + static LAST_PANIC: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +impl std::fmt::Display for PanicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let r = f.write_fmt(format_args!("{}\n", self.info)); + if let Some(backtrace) = &self.backtrace { + f.write_fmt(format_args!("Backtrace: {backtrace}")) + } else { + r + } + } +} + +/// Take and set a specific panic hook before calling `f` inside a `catch_unwind`, then +/// return the old set_hook. +/// +/// If `f` panicks am `Error` with the panic message plus backtrace will be returned. +pub fn catch_unwind(f: F) -> Result +where + F: FnOnce() -> R + UnwindSafe, +{ + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(|info| { + let info = info.to_string(); + let backtrace = std::backtrace::Backtrace::capture(); + LAST_PANIC.with(|cell| { + cell.set(Some(PanicError { + info, + backtrace: Some(backtrace), + })) + }) + })); + + let result = std::panic::catch_unwind(f) + .map_err(|_| LAST_PANIC.with(|cell| cell.take()).unwrap_or_default()); + + std::panic::set_hook(prev); + + result +} diff --git a/crates/pg_diagnostics/src/serde.rs b/crates/pg_diagnostics/src/serde.rs new file mode 100644 index 000000000..8d02c2b03 --- /dev/null +++ b/crates/pg_diagnostics/src/serde.rs @@ -0,0 +1,484 @@ +use std::io; + +use pg_console::{fmt, markup, MarkupBuf}; +use pg_text_edit::TextEdit; +use serde::{ + de::{self, SeqAccess}, + Deserialize, Deserializer, Serialize, Serializer, +}; +use text_size::{TextRange, TextSize}; + +use crate::{ + diagnostic::internal::AsDiagnostic, diagnostic::DiagnosticTag, Advices as _, Backtrace, + Category, DiagnosticTags, LogCategory, Resource, Severity, SourceCode, Visit, +}; + +/// Serializable representation for a [Diagnostic](super::Diagnostic). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), serde(rename_all = "snake_case"))] +#[cfg_attr(test, derive(Eq, PartialEq))] +pub struct Diagnostic { + category: Option<&'static Category>, + severity: Severity, + description: String, + message: MarkupBuf, + advices: Advices, + verbose_advices: Advices, + location: Location, + tags: DiagnosticTags, + source: Option>, +} + +impl Diagnostic { + pub fn new(diag: D) -> Self { + Self::new_impl(diag.as_diagnostic()) + } + + fn new_impl(diag: &D) -> Self { + let category = diag.category(); + + let severity = diag.severity(); + + let description = PrintDescription(diag).to_string(); + + let mut message = MarkupBuf::default(); + let mut fmt = fmt::Formatter::new(&mut message); + // SAFETY: Writing to a MarkupBuf should never fail + diag.message(&mut fmt).unwrap(); + + let mut advices = Advices::new(); + // SAFETY: The Advices visitor never returns an error + diag.advices(&mut advices).unwrap(); + + let mut verbose_advices = Advices::new(); + // SAFETY: The Advices visitor never returns an error + diag.verbose_advices(&mut verbose_advices).unwrap(); + + let location = diag.location().into(); + + let tags = diag.tags(); + + let source = diag.source().map(Self::new_impl).map(Box::new); + + Self { + category, + severity, + description, + message, + advices, + verbose_advices, + location, + tags, + source, + } + } + + pub fn with_offset(mut self, offset: TextSize) -> Self { + self.location.span = self + .location + .span + .map(|span| TextRange::new(span.start() + offset, span.end() + offset)); + self + } +} + +impl super::Diagnostic for Diagnostic { + fn category(&self) -> Option<&'static Category> { + self.category + } + + fn severity(&self) -> Severity { + self.severity + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fmt.write_str(&self.description) + } + + fn message(&self, fmt: &mut fmt::Formatter<'_>) -> io::Result<()> { + fmt.write_markup(markup! { {self.message} }) + } + + fn advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.advices.record(visitor) + } + + fn verbose_advices(&self, visitor: &mut dyn Visit) -> io::Result<()> { + self.verbose_advices.record(visitor) + } + + fn location(&self) -> super::Location<'_> { + super::Location::builder() + .resource(&self.location.path) + .span(&self.location.span) + .source_code(&self.location.source_code) + .build() + } + + fn tags(&self) -> DiagnosticTags { + self.tags + } + + fn source(&self) -> Option<&dyn super::Diagnostic> { + self.source + .as_deref() + .map(|source| source as &dyn super::Diagnostic) + } +} + +/// Wrapper type implementing [std::fmt::Display] for types implementing [Diagnostic](super::Diagnostic), +/// prints the description of the diagnostic as a string. +struct PrintDescription<'fmt, D: ?Sized>(pub &'fmt D); + +impl std::fmt::Display for PrintDescription<'_, D> { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.description(fmt).map_err(|_| std::fmt::Error) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), serde(rename_all = "snake_case"))] +#[cfg_attr(test, derive(Eq, PartialEq))] +struct Location { + path: Option>, + span: Option, + source_code: Option, +} + +impl From> for Location { + fn from(loc: super::Location<'_>) -> Self { + Self { + path: loc.resource.map(super::Resource::to_owned), + span: loc.span, + source_code: loc + .source_code + .map(|source_code| source_code.text.to_string()), + } + } +} + +/// Implementation of [Visitor] collecting serializable [Advice] into a vector. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(Eq, PartialEq))] +struct Advices { + advices: Vec, +} + +impl Advices { + fn new() -> Self { + Self { + advices: Vec::new(), + } + } +} + +impl Visit for Advices { + fn record_log(&mut self, category: LogCategory, text: &dyn fmt::Display) -> io::Result<()> { + self.advices + .push(Advice::Log(category, markup!({ text }).to_owned())); + Ok(()) + } + + fn record_list(&mut self, list: &[&dyn fmt::Display]) -> io::Result<()> { + self.advices.push(Advice::List( + list.iter() + .map(|item| markup!({ item }).to_owned()) + .collect(), + )); + Ok(()) + } + + fn record_frame(&mut self, location: super::Location<'_>) -> io::Result<()> { + self.advices.push(Advice::Frame(location.into())); + Ok(()) + } + + fn record_diff(&mut self, diff: &TextEdit) -> io::Result<()> { + self.advices.push(Advice::Diff(diff.clone())); + Ok(()) + } + + fn record_backtrace( + &mut self, + title: &dyn fmt::Display, + backtrace: &Backtrace, + ) -> io::Result<()> { + self.advices.push(Advice::Backtrace( + markup!({ title }).to_owned(), + backtrace.clone(), + )); + Ok(()) + } + + fn record_command(&mut self, command: &str) -> io::Result<()> { + self.advices.push(Advice::Command(command.into())); + Ok(()) + } + + fn record_group( + &mut self, + title: &dyn fmt::Display, + advice: &dyn super::Advices, + ) -> io::Result<()> { + let mut advices = Advices::new(); + advice.record(&mut advices)?; + + self.advices + .push(Advice::Group(markup!({ title }).to_owned(), advices)); + Ok(()) + } +} + +impl super::Advices for Advices { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + for advice in &self.advices { + advice.record(visitor)?; + } + + Ok(()) + } +} + +/// Serializable representation of a [Diagnostic](super::Diagnostic) advice +/// +/// See the [Visitor] trait for additional documentation on all the supported +/// advice types. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(test, derive(Eq, PartialEq))] +enum Advice { + Log(LogCategory, MarkupBuf), + List(Vec), + Frame(Location), + Diff(TextEdit), + Backtrace(MarkupBuf, Backtrace), + Command(String), + Group(MarkupBuf, Advices), +} + +impl super::Advices for Advice { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + match self { + Advice::Log(category, text) => visitor.record_log(*category, text), + Advice::List(list) => { + let as_display: Vec<&dyn fmt::Display> = + list.iter().map(|item| item as &dyn fmt::Display).collect(); + visitor.record_list(&as_display) + } + Advice::Frame(location) => visitor.record_frame(super::Location { + resource: location.path.as_ref().map(super::Resource::as_deref), + span: location.span, + source_code: location.source_code.as_deref().map(|text| SourceCode { + text, + line_starts: None, + }), + }), + Advice::Diff(diff) => visitor.record_diff(diff), + Advice::Backtrace(title, backtrace) => visitor.record_backtrace(title, backtrace), + Advice::Command(command) => visitor.record_command(command), + Advice::Group(title, advice) => visitor.record_group(title, advice), + } + } +} + +impl From for DiagnosticTags { + fn from(tag: DiagnosticTag) -> Self { + match tag { + DiagnosticTag::Fixable => DiagnosticTags::FIXABLE, + DiagnosticTag::Internal => DiagnosticTags::INTERNAL, + DiagnosticTag::UnnecessaryCode => DiagnosticTags::UNNECESSARY_CODE, + DiagnosticTag::DeprecatedCode => DiagnosticTags::DEPRECATED_CODE, + DiagnosticTag::Verbose => DiagnosticTags::VERBOSE, + } + } +} + +// Custom `serde` implementation for `DiagnosticTags` as a list of `DiagnosticTag` enum +impl Serialize for DiagnosticTags { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut flags = Vec::new(); + + if self.contains(Self::FIXABLE) { + flags.push(DiagnosticTag::Fixable); + } + + if self.contains(Self::INTERNAL) { + flags.push(DiagnosticTag::Internal); + } + + if self.contains(Self::UNNECESSARY_CODE) { + flags.push(DiagnosticTag::UnnecessaryCode); + } + + if self.contains(Self::DEPRECATED_CODE) { + flags.push(DiagnosticTag::DeprecatedCode); + } + + serializer.collect_seq(flags) + } +} + +impl<'de> Deserialize<'de> for DiagnosticTags { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> de::Visitor<'de> for Visitor { + type Value = DiagnosticTags; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "DiagnosticTags") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut result = DiagnosticTags::empty(); + + while let Some(item) = seq.next_element::()? { + result |= DiagnosticTags::from(item); + } + + Ok(result) + } + } + + deserializer.deserialize_seq(Visitor) + } +} + +#[cfg(test)] +mod tests { + use std::io; + + use serde_json::{from_value, json, to_value, Value}; + use text_size::{TextRange, TextSize}; + + use crate::{ + self as pg_diagnostics, {Advices, LogCategory, Visit}, + }; + use pg_diagnostics_macros::Diagnostic; + + #[derive(Debug, Diagnostic)] + #[diagnostic( + severity = Warning, + category = "internalError/io", + message( + description = "text description", + message("markup message"), + ), + tags(INTERNAL) + )] + struct TestDiagnostic { + #[location(resource)] + path: String, + #[location(span)] + span: TextRange, + #[location(source_code)] + source_code: String, + #[advice] + advices: TestAdvices, + #[verbose_advice] + verbose_advices: TestAdvices, + } + + impl Default for TestDiagnostic { + fn default() -> Self { + TestDiagnostic { + path: String::from("path"), + span: TextRange::new(TextSize::from(0), TextSize::from(6)), + source_code: String::from("source_code"), + advices: TestAdvices, + verbose_advices: TestAdvices, + } + } + } + + #[derive(Debug)] + struct TestAdvices; + + impl Advices for TestAdvices { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + visitor.record_log(LogCategory::Warn, &"log")?; + Ok(()) + } + } + + fn serialized() -> Value { + let advices = json!([ + { + "log": [ + "warn", + [ + { + "elements": [], + "content": "log" + } + ] + ] + } + ]); + + json!({ + "category": "internalError/io", + "severity": "warning", + "description": "text description", + "message": [ + { + "elements": [ + "Emphasis" + ], + "content": "markup message" + } + ], + "advices": { + "advices": advices + }, + "verbose_advices": { + "advices": advices + }, + "location": { + "path": { + "file": "path" + }, + "sourceCode": "source_code", + "span": [ + 0, + 6 + ] + }, + "tags": [ + "internal" + ], + "source": null + }) + } + + // #[test] + // fn test_serialize() { + // let diag = TestDiagnostic::default(); + // let diag = super::Diagnostic::new(diag); + // let json = to_value(&diag).unwrap(); + // + // let expected = serialized(); + // assert_eq!(json, expected); + // } + // + // #[test] + // fn test_deserialize() { + // let json = serialized(); + // let diag: super::Diagnostic = from_value(json).unwrap(); + // + // let expected = TestDiagnostic::default(); + // let expected = super::Diagnostic::new(expected); + // + // assert_eq!(diag, expected); + // } +} diff --git a/crates/pg_diagnostics/src/suggestion.rs b/crates/pg_diagnostics/src/suggestion.rs new file mode 100644 index 000000000..44ed4548f --- /dev/null +++ b/crates/pg_diagnostics/src/suggestion.rs @@ -0,0 +1,27 @@ +use ::serde::{Deserialize, Serialize}; +use pg_console::MarkupBuf; +use pg_text_edit::TextEdit; +use text_size::TextRange; + +/// Indicates how a tool should manage this suggestion. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Applicability { + /// The suggestion is definitely what the user intended. + /// This suggestion should be automatically applied. + Always, + /// The suggestion may be what the user intended, but it is uncertain. + /// The suggestion should result in valid JavaScript/TypeScript code if it is applied. + MaybeIncorrect, +} + +/// A Suggestion that is provided by the linter, and +/// can be reported to the user, and can be automatically +/// applied if it has the right [`Applicability`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CodeSuggestion { + pub span: TextRange, + pub applicability: Applicability, + pub msg: MarkupBuf, + pub suggestion: TextEdit, + pub labels: Vec, +} diff --git a/crates/pg_diagnostics_categories/Cargo.toml b/crates/pg_diagnostics_categories/Cargo.toml new file mode 100644 index 000000000..01258c096 --- /dev/null +++ b/crates/pg_diagnostics_categories/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pg_diagnostics_categories" +version = "0.0.0" +edition = "2021" + +[dependencies] +schemars = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[build-dependencies] +quote = "1.0.14" + diff --git a/crates/pg_diagnostics_categories/build.rs b/crates/pg_diagnostics_categories/build.rs new file mode 100644 index 000000000..d9fe0a9cd --- /dev/null +++ b/crates/pg_diagnostics_categories/build.rs @@ -0,0 +1,135 @@ +use quote::{format_ident, quote}; +use std::{env, fs, io, path::PathBuf}; + +macro_rules! define_categories { + ( $( $name_link:literal : $link:literal, )* ; $( $name:literal , )* ) => { + const CATEGORIES: &[(&str, Option<&str>)] = &[ + $( ($name_link, Some($link)), )* + $( ($name, None), )* + ]; + }; +} + +include!("src/categories.rs"); + +pub fn main() -> io::Result<()> { + let mut metadata = Vec::with_capacity(CATEGORIES.len()); + let mut macro_arms = Vec::with_capacity(CATEGORIES.len()); + let mut parse_arms = Vec::with_capacity(CATEGORIES.len()); + let mut enum_variants = Vec::with_capacity(CATEGORIES.len()); + let mut concat_macro_arms = Vec::with_capacity(CATEGORIES.len()); + + for (name, link) in CATEGORIES { + let meta_name = name.replace('/', "_").to_uppercase(); + let meta_ident = format_ident!("{meta_name}"); + + let link = if let Some(link) = link { + quote! { Some(#link) } + } else { + quote! { None } + }; + + metadata.push(quote! { + pub static #meta_ident: crate::Category = crate::Category { + name: #name, + link: #link, + }; + }); + + macro_arms.push(quote! { + (#name) => { &$crate::registry::#meta_ident }; + }); + + parse_arms.push(quote! { + #name => Ok(&crate::registry::#meta_ident), + }); + + enum_variants.push(*name); + + let parts = name.split('/'); + concat_macro_arms.push(quote! { + ( #( #parts ),* ) => { &$crate::registry::#meta_ident }; + }); + } + + let tokens = quote! { + impl FromStr for &'static Category { + type Err = (); + + fn from_str(name: &str) -> Result { + match name { + #( #parse_arms )* + _ => Err(()), + } + } + } + + #[cfg(feature = "schemars")] + impl schemars::JsonSchema for &'static Category { + fn schema_name() -> String { + String::from("Category") + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + schemars::schema::Schema::Object(schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + enum_values: Some(vec![#( #enum_variants.into() ),*]), + ..Default::default() + }) + } + } + + /// The `category!` macro can be used to statically lookup a category + /// by name from the registry + /// + /// # Example + /// + /// ``` + /// # use pg_diagnostics_categories::{Category, category}; + /// let category: &'static Category = category!("internalError/io"); + /// assert_eq!(category.name(), "internalError/io"); + /// assert_eq!(category.link(), None); + /// ``` + #[macro_export] + macro_rules! category { + #( #macro_arms )* + + ( $name:literal ) => { + compile_error!(concat!("Unregistered diagnostic category \"", $name, "\", please add it to \"crates/pg_diagnostics_categories/src/categories.rs\"")) + }; + ( $( $parts:tt )* ) => { + compile_error!(concat!("Invalid diagnostic category `", stringify!($( $parts )*), "`, expected a single string literal")) + }; + } + + /// The `category_concat!` macro is a variant of `category!` using a + /// slightly different syntax, for use in the `declare_group` and + /// `declare_rule` macros in the analyzer + #[macro_export] + macro_rules! category_concat { + #( #concat_macro_arms )* + + ( @compile_error $( $parts:tt )* ) => { + compile_error!(concat!("Unregistered diagnostic category \"", $( $parts, )* "\", please add it to \"crates/pg_diagnostics_categories/src/categories.rs\"")) + }; + ( $( $parts:tt ),* ) => { + $crate::category_concat!( @compile_error $( $parts )"/"* ) + }; + ( $( $parts:tt )* ) => { + compile_error!(concat!("Invalid diagnostic category `", stringify!($( $parts )*), "`, expected a comma-separated list of string literals")) + }; + } + + pub mod registry { + #( #metadata )* + } + }; + + let out_dir = env::var("OUT_DIR").unwrap(); + fs::write( + PathBuf::from(out_dir).join("categories.rs"), + tokens.to_string(), + )?; + + Ok(()) +} diff --git a/crates/pg_diagnostics_categories/src/categories.rs b/crates/pg_diagnostics_categories/src/categories.rs new file mode 100644 index 000000000..975291591 --- /dev/null +++ b/crates/pg_diagnostics_categories/src/categories.rs @@ -0,0 +1,29 @@ +// This file contains the list of all diagnostic categories for the pg +// toolchain +// +// The `define_categories` macro is preprocessed in the build script for the +// crate in order to generate the static registry. The body of the macro +// consists of a list of key-value pairs defining the categories that have an +// associated hyperlink, then a list of string literals defining the remaining +// categories without a link. + +// PLEASE, DON'T EDIT THIS FILE BY HAND. +// Use `just new-lintrule` to create a new rule. +// lint rules are lexicographically sorted and +// must be between `define_categories! {\n` and `\n ;\n`. + +define_categories! { + "somerule": "https://example.com/some-rule", + ; + "stdin", + "lint", + "configuration", + "database/connection", + "internalError/io", + "internalError/runtime", + "internalError/fs", + "flags/invalid", + "project", + "internalError/panic", + "dummy", +} diff --git a/crates/pg_diagnostics_categories/src/lib.rs b/crates/pg_diagnostics_categories/src/lib.rs new file mode 100644 index 000000000..fda85b8e2 --- /dev/null +++ b/crates/pg_diagnostics_categories/src/lib.rs @@ -0,0 +1,107 @@ +use std::{ + hash::{Hash, Hasher}, + str::FromStr, +}; + +/// Metadata for a diagnostic category +/// +/// This type cannot be instantiated outside of the `pg_diagnostics_categories` +/// crate, which serves as a registry for all known diagnostic categories +/// (currently this registry is fully static and generated at compile time) +#[derive(Debug)] +pub struct Category { + name: &'static str, + link: Option<&'static str>, +} + +impl Category { + /// Return the name of this category + pub fn name(&self) -> &'static str { + self.name + } + + /// Return the hyperlink associated with this category if it has one + /// + /// This will generally be a link to the documentation page for diagnostics + /// with this category + pub fn link(&self) -> Option<&'static str> { + self.link + } +} + +impl Eq for Category {} + +impl PartialEq for Category { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl Hash for Category { + fn hash(&self, state: &mut H) { + self.name.hash(state); + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for &'static Category { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.name().serialize(serializer) + } +} + +#[cfg(feature = "serde")] +struct CategoryVisitor; + +#[cfg(feature = "serde")] +fn deserialize_parse(code: &str) -> Result<&'static Category, E> { + code.parse().map_err(|()| { + serde::de::Error::custom(format_args!("failed to deserialize category from {code}")) + }) +} + +#[cfg(feature = "serde")] +impl<'de> serde::de::Visitor<'de> for CategoryVisitor { + type Value = &'static Category; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a borrowed string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + deserialize_parse(v) + } + + fn visit_borrowed_str(self, v: &'de str) -> Result + where + E: serde::de::Error, + { + deserialize_parse(v) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + deserialize_parse(&v) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for &'static Category { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(CategoryVisitor) + } +} + +// Import the code generated by the build script from the content of `src/categories.rs` +include!(concat!(env!("OUT_DIR"), "/categories.rs")); diff --git a/crates/pg_diagnostics_macros/Cargo.toml b/crates/pg_diagnostics_macros/Cargo.toml new file mode 100644 index 000000000..764e5dc65 --- /dev/null +++ b/crates/pg_diagnostics_macros/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pg_diagnostics_macros" +version = "0.0.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +proc-macro-error = { version = "1.0.4", default-features = false } +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + +[dev-dependencies] + +[features] diff --git a/crates/pg_diagnostics_macros/src/generate.rs b/crates/pg_diagnostics_macros/src/generate.rs new file mode 100644 index 000000000..66c231f3e --- /dev/null +++ b/crates/pg_diagnostics_macros/src/generate.rs @@ -0,0 +1,339 @@ +use proc_macro2::{Ident, Span, TokenStream}; +use proc_macro_error::*; +use quote::quote; + +use crate::parse::{ + DeriveEnumInput, DeriveInput, DeriveStructInput, StaticOrDynamic, StringOrMarkup, +}; + +pub(crate) fn generate_diagnostic(input: DeriveInput) -> TokenStream { + match input { + DeriveInput::DeriveStructInput(input) => generate_struct_diagnostic(input), + DeriveInput::DeriveEnumInput(input) => generate_enum_diagnostic(input), + } +} + +fn generate_struct_diagnostic(input: DeriveStructInput) -> TokenStream { + let category = generate_category(&input); + let severity = generate_severity(&input); + let description = generate_description(&input); + let message = generate_message(&input); + let advices = generate_advices(&input); + let verbose_advices = generate_verbose_advices(&input); + let location = generate_location(&input); + let tags = generate_tags(&input); + let source = generate_source(&input); + + let generic_params = if !input.generics.params.is_empty() { + let lt_token = &input.generics.lt_token; + let params = &input.generics.params; + let gt_token = &input.generics.gt_token; + quote! { #lt_token #params #gt_token } + } else { + quote!() + }; + + let ident = input.ident; + let generics = input.generics; + + quote! { + impl #generic_params pg_diagnostics::Diagnostic for #ident #generics { + #category + #severity + #description + #message + #advices + #verbose_advices + #location + #tags + #source + } + } +} + +fn generate_category(input: &DeriveStructInput) -> TokenStream { + let category = match &input.category { + Some(StaticOrDynamic::Static(value)) => quote! { + pg_diagnostics::category!(#value) + }, + Some(StaticOrDynamic::Dynamic(value)) => quote! { + self.#value + }, + None => return quote!(), + }; + + quote! { + fn category(&self) -> Option<&'static pg_diagnostics::Category> { + Some(#category) + } + } +} + +fn generate_severity(input: &DeriveStructInput) -> TokenStream { + let severity = match &input.severity { + Some(StaticOrDynamic::Static(value)) => quote! { + pg_diagnostics::Severity::#value + }, + Some(StaticOrDynamic::Dynamic(value)) => quote! { + self.#value + }, + None => return quote!(), + }; + + quote! { + fn severity(&self) -> pg_diagnostics::Severity { + #severity + } + } +} + +fn generate_description(input: &DeriveStructInput) -> TokenStream { + let description = match &input.description { + Some(StaticOrDynamic::Static(StringOrMarkup::String(value))) => { + let mut format_string = String::new(); + let mut format_params = Vec::new(); + + let input = value.value(); + let mut input = input.as_str(); + + while let Some(idx) = input.find('{') { + let (before, after) = input.split_at(idx); + format_string.push_str(before); + + let after = &after[1..]; + format_string.push('{'); + + if let Some(after) = after.strip_prefix('{') { + input = after; + continue; + } + + let end = match after.find([':', '}']) { + Some(end) => end, + None => abort!(value.span(), "failed to parse format string"), + }; + + let (ident, after) = after.split_at(end); + let ident = Ident::new(ident, Span::call_site()); + format_params.push(quote! { self.#ident }); + + input = after; + } + + if !input.is_empty() { + format_string.push_str(input); + } + + if format_params.is_empty() { + quote! { + fmt.write_str(#format_string) + } + } else { + quote! { + fmt.write_fmt(::std::format_args!(#format_string, #( #format_params ),*)) + } + } + } + Some(StaticOrDynamic::Static(StringOrMarkup::Markup(markup))) => quote! { + let mut buffer = Vec::new(); + + let write = pg_diagnostics::termcolor::NoColor::new(&mut buffer); + let mut write = pg_diagnostics::console::fmt::Termcolor(write); + let mut write = pg_diagnostics::console::fmt::Formatter::new(&mut write); + + use pg_diagnostics::console as pg_console; + write.write_markup(&pg_diagnostics::console::markup!{ #markup }) + .map_err(|_| ::std::fmt::Error)?; + + fmt.write_str(::std::str::from_utf8(&buffer).map_err(|_| ::std::fmt::Error)?) + }, + Some(StaticOrDynamic::Dynamic(value)) => quote! { + fmt.write_fmt(::std::format_args!("{}", self.#value)) + }, + None => return quote!(), + }; + + quote! { + fn description(&self, fmt: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + #description + } + } +} + +fn generate_message(input: &DeriveStructInput) -> TokenStream { + let message = match &input.message { + Some(StaticOrDynamic::Static(StringOrMarkup::String(value))) => quote! { + fmt.write_str(#value) + }, + Some(StaticOrDynamic::Static(StringOrMarkup::Markup(markup))) => quote! { + use pg_diagnostics::console as pg_console; + fmt.write_markup(pg_diagnostics::console::markup!{ #markup }) + }, + Some(StaticOrDynamic::Dynamic(value)) => quote! { + pg_diagnostics::console::fmt::Display::fmt(&self.#value, fmt) + }, + None => return quote!(), + }; + + quote! { + fn message(&self, fmt: &mut pg_diagnostics::console::fmt::Formatter<'_>) -> ::std::io::Result<()> { + #message + } + } +} + +fn generate_advices(input: &DeriveStructInput) -> TokenStream { + if input.advices.is_empty() { + return quote!(); + } + + let advices = input.advices.iter(); + + quote! { + fn advices(&self, visitor: &mut dyn pg_diagnostics::Visit) -> ::std::io::Result<()> { + #( pg_diagnostics::Advices::record(&self.#advices, visitor)?; )* + Ok(()) + } + } +} + +fn generate_verbose_advices(input: &DeriveStructInput) -> TokenStream { + if input.verbose_advices.is_empty() { + return quote!(); + } + + let verbose_advices = input.verbose_advices.iter(); + + quote! { + fn verbose_advices(&self, visitor: &mut dyn pg_diagnostics::Visit) -> ::std::io::Result<()> { + #( pg_diagnostics::Advices::record(&self.#verbose_advices, visitor)?; )* + Ok(()) + } + } +} + +fn generate_location(input: &DeriveStructInput) -> TokenStream { + if input.location.is_empty() { + return quote!(); + } + + let field = input.location.iter().map(|(field, _)| field); + let method = input.location.iter().map(|(_, method)| method); + + quote! { + fn location(&self) -> pg_diagnostics::Location<'_> { + pg_diagnostics::Location::builder() + #( .#method(&self.#field) )* + .build() + } + } +} + +fn generate_tags(input: &DeriveStructInput) -> TokenStream { + let tags = match &input.tags { + Some(StaticOrDynamic::Static(value)) => { + let values = value.iter(); + quote! { + #( pg_diagnostics::DiagnosticTags::#values )|* + } + } + Some(StaticOrDynamic::Dynamic(value)) => quote! { + self.#value + }, + None => return quote!(), + }; + + quote! { + fn tags(&self) -> pg_diagnostics::DiagnosticTags { + #tags + } + } +} + +fn generate_source(input: &DeriveStructInput) -> TokenStream { + match &input.source { + Some(value) => quote! { + fn source(&self) -> Option<&dyn pg_diagnostics::Diagnostic> { + self.#value.as_deref() + } + }, + None => quote!(), + } +} + +fn generate_enum_diagnostic(input: DeriveEnumInput) -> TokenStream { + let generic_params = if !input.generics.params.is_empty() { + let lt_token = &input.generics.lt_token; + let params = &input.generics.params; + let gt_token = &input.generics.gt_token; + quote! { #lt_token #params #gt_token } + } else { + quote!() + }; + + let ident = input.ident; + let generics = input.generics; + let variants: Vec<_> = input + .variants + .iter() + .map(|variant| &variant.ident) + .collect(); + + quote! { + impl #generic_params pg_diagnostics::Diagnostic for #ident #generics { + fn category(&self) -> Option<&'static pg_diagnostics::Category> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::category(error),)* + } + } + + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::description(error, fmt),)* + } + } + + fn message(&self, fmt: &mut pg_console::fmt::Formatter<'_>) -> std::io::Result<()> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::message(error, fmt),)* + } + } + + fn severity(&self) -> pg_diagnostics::Severity { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::severity(error),)* + } + } + + fn tags(&self) -> pg_diagnostics::DiagnosticTags { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::tags(error),)* + } + } + + fn location(&self) -> pg_diagnostics::Location<'_> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::location(error),)* + } + } + + fn source(&self) -> Option<&dyn pg_diagnostics::Diagnostic> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::source(error),)* + } + } + + fn advices(&self, visitor: &mut dyn pg_diagnostics::Visit) -> std::io::Result<()> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::advices(error, visitor),)* + } + } + + fn verbose_advices(&self, visitor: &mut dyn pg_diagnostics::Visit) -> std::io::Result<()> { + match self { + #(Self::#variants(error) => pg_diagnostics::Diagnostic::verbose_advices(error, visitor),)* + } + } + } + } +} diff --git a/crates/pg_diagnostics_macros/src/lib.rs b/crates/pg_diagnostics_macros/src/lib.rs new file mode 100644 index 000000000..63b822a44 --- /dev/null +++ b/crates/pg_diagnostics_macros/src/lib.rs @@ -0,0 +1,36 @@ +use proc_macro::TokenStream; +use proc_macro_error::*; +use syn::{parse_macro_input, DeriveInput}; + +mod generate; +mod parse; + +#[proc_macro_derive( + Diagnostic, + attributes( + diagnostic, + severity, + category, + description, + message, + advice, + verbose_advice, + location, + tags, + source + ) +)] +#[proc_macro_error] +pub fn derive_diagnostic(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let input = parse::DeriveInput::parse(input); + + let tokens = generate::generate_diagnostic(input); + + if false { + panic!("{tokens}"); + } + + TokenStream::from(tokens) +} diff --git a/crates/pg_diagnostics_macros/src/parse.rs b/crates/pg_diagnostics_macros/src/parse.rs new file mode 100644 index 000000000..e1722f283 --- /dev/null +++ b/crates/pg_diagnostics_macros/src/parse.rs @@ -0,0 +1,471 @@ +use proc_macro2::{Ident, TokenStream}; +use proc_macro_error::*; +use quote::{quote, ToTokens}; +use syn::{ + parse::{discouraged::Speculative, Error, Parse, ParseStream, Parser, Result}, + punctuated::Punctuated, + spanned::Spanned, + token::Paren, + Attribute, DataEnum, DataStruct, Generics, Token, Variant, +}; + +pub(crate) enum DeriveInput { + DeriveStructInput(DeriveStructInput), + DeriveEnumInput(DeriveEnumInput), +} + +pub(crate) struct DeriveStructInput { + pub(crate) ident: Ident, + pub(crate) generics: Generics, + + pub(crate) severity: Option>, + pub(crate) category: Option>, + pub(crate) description: Option>, + pub(crate) message: Option>, + pub(crate) advices: Vec, + pub(crate) verbose_advices: Vec, + pub(crate) location: Vec<(TokenStream, LocationField)>, + pub(crate) tags: Option>>, + pub(crate) source: Option, +} + +pub(crate) struct DeriveEnumInput { + pub(crate) ident: Ident, + pub(crate) generics: Generics, + + pub(crate) variants: Vec, +} + +impl DeriveInput { + pub(crate) fn parse(input: syn::DeriveInput) -> Self { + match input.data { + syn::Data::Struct(data) => Self::DeriveStructInput(DeriveStructInput::parse( + input.ident, + input.generics, + input.attrs, + data, + )), + syn::Data::Enum(data) => Self::DeriveEnumInput(DeriveEnumInput::parse( + input.ident, + input.generics, + input.attrs, + data, + )), + syn::Data::Union(data) => abort!( + data.union_token.span(), + "unions are not supported by the Diagnostic derive macro" + ), + } + } +} + +impl DeriveStructInput { + pub(crate) fn parse( + ident: Ident, + generics: Generics, + attrs: Vec, + data: DataStruct, + ) -> Self { + let mut result = Self { + ident, + generics, + + severity: None, + category: None, + description: None, + message: None, + advices: Vec::new(), + verbose_advices: Vec::new(), + location: Vec::new(), + tags: None, + source: None, + }; + + for attr in attrs { + if attr.path.is_ident("diagnostic") { + let tokens = attr.tokens.into(); + let attrs = match DiagnosticAttrs::parse.parse(tokens) { + Ok(attrs) => attrs, + Err(err) => abort!( + err.span(), + "failed to parse \"diagnostic\" attribute: {}", + err + ), + }; + + for item in attrs.attrs { + match item { + DiagnosticAttr::Severity(attr) => { + result.severity = Some(StaticOrDynamic::Static(attr.value)); + } + DiagnosticAttr::Category(attr) => { + result.category = Some(StaticOrDynamic::Static(attr.value)); + } + DiagnosticAttr::Message(MessageAttr::SingleString { value, .. }) => { + let value = StringOrMarkup::from(value); + result.description = Some(StaticOrDynamic::Static(value.clone())); + result.message = Some(StaticOrDynamic::Static(value)); + } + DiagnosticAttr::Message(MessageAttr::SingleMarkup { markup, .. }) => { + let value = StringOrMarkup::from(markup); + result.description = Some(StaticOrDynamic::Static(value.clone())); + result.message = Some(StaticOrDynamic::Static(value)); + } + DiagnosticAttr::Message(MessageAttr::Split(attr)) => { + for item in attr.attrs { + match item { + SplitMessageAttr::Description { value, .. } => { + result.description = + Some(StaticOrDynamic::Static(value.into())); + } + SplitMessageAttr::Message { markup, .. } => { + result.message = + Some(StaticOrDynamic::Static(markup.into())); + } + } + } + } + DiagnosticAttr::Tags(attr) => { + result.tags = Some(StaticOrDynamic::Static(attr.tags)); + } + } + } + + continue; + } + } + + for (index, field) in data.fields.into_iter().enumerate() { + let ident = match field.ident { + Some(ident) => quote! { #ident }, + None => quote! { #index }, + }; + + for attr in field.attrs { + if attr.path.is_ident("category") { + result.category = Some(StaticOrDynamic::Dynamic(ident.clone())); + continue; + } + + if attr.path.is_ident("severity") { + result.severity = Some(StaticOrDynamic::Dynamic(ident.clone())); + continue; + } + + if attr.path.is_ident("description") { + result.description = Some(StaticOrDynamic::Dynamic(ident.clone())); + continue; + } + + if attr.path.is_ident("message") { + result.message = Some(StaticOrDynamic::Dynamic(ident.clone())); + continue; + } + + if attr.path.is_ident("advice") { + result.advices.push(ident.clone()); + continue; + } + + if attr.path.is_ident("verbose_advice") { + result.verbose_advices.push(ident.clone()); + continue; + } + + if attr.path.is_ident("location") { + let tokens = attr.tokens.into(); + let attr = match LocationAttr::parse.parse(tokens) { + Ok(attr) => attr, + Err(err) => abort!( + err.span(), + "failed to parse \"location\" attribute: {}", + err + ), + }; + + result.location.push((ident.clone(), attr.field)); + continue; + } + + if attr.path.is_ident("tags") { + result.tags = Some(StaticOrDynamic::Dynamic(ident.clone())); + continue; + } + + if attr.path.is_ident("source") { + result.source = Some(ident.clone()); + continue; + } + } + } + + result + } +} + +impl DeriveEnumInput { + pub(crate) fn parse( + ident: Ident, + generics: Generics, + attrs: Vec, + data: DataEnum, + ) -> Self { + for attr in attrs { + if attr.path.is_ident("diagnostic") { + abort!( + attr.span(), + "\"diagnostic\" attributes are not supported on enums" + ); + } + } + + Self { + ident, + generics, + + variants: data.variants.into_iter().collect(), + } + } +} + +pub(crate) enum StaticOrDynamic { + Static(S), + Dynamic(TokenStream), +} + +#[derive(Clone)] +pub(crate) enum StringOrMarkup { + String(syn::LitStr), + Markup(TokenStream), +} + +impl From for StringOrMarkup { + fn from(value: syn::LitStr) -> Self { + Self::String(value) + } +} + +impl From for StringOrMarkup { + fn from(value: TokenStream) -> Self { + Self::Markup(value) + } +} + +struct DiagnosticAttrs { + _paren_token: Paren, + attrs: Punctuated, +} + +impl Parse for DiagnosticAttrs { + fn parse(input: ParseStream) -> Result { + let content; + Ok(Self { + _paren_token: syn::parenthesized!(content in input), + attrs: content.parse_terminated(DiagnosticAttr::parse)?, + }) + } +} + +enum DiagnosticAttr { + Severity(SeverityAttr), + Category(CategoryAttr), + Message(MessageAttr), + Tags(TagsAttr), +} + +impl Parse for DiagnosticAttr { + fn parse(input: ParseStream) -> Result { + let name: Ident = input.parse()?; + + if name == "severity" { + return Ok(Self::Severity(input.parse()?)); + } + + if name == "category" { + return Ok(Self::Category(input.parse()?)); + } + + if name == "message" { + return Ok(Self::Message(input.parse()?)); + } + + if name == "tags" { + return Ok(Self::Tags(input.parse()?)); + } + + Err(Error::new_spanned(name, "unknown attribute")) + } +} + +struct SeverityAttr { + _eq_token: Token![=], + value: Ident, +} + +impl Parse for SeverityAttr { + fn parse(input: ParseStream) -> Result { + Ok(Self { + _eq_token: input.parse()?, + value: input.parse()?, + }) + } +} + +struct CategoryAttr { + _eq_token: Token![=], + value: syn::LitStr, +} + +impl Parse for CategoryAttr { + fn parse(input: ParseStream) -> Result { + Ok(Self { + _eq_token: input.parse()?, + value: input.parse()?, + }) + } +} + +enum MessageAttr { + SingleString { + _eq_token: Token![=], + value: syn::LitStr, + }, + SingleMarkup { + _paren_token: Paren, + markup: TokenStream, + }, + Split(SplitMessageAttrs), +} + +impl Parse for MessageAttr { + fn parse(input: ParseStream) -> Result { + let lookahead = input.lookahead1(); + + if lookahead.peek(Token![=]) { + return Ok(Self::SingleString { + _eq_token: input.parse()?, + value: input.parse()?, + }); + } + + let fork = input.fork(); + if let Ok(attr) = fork.parse() { + input.advance_to(&fork); + return Ok(Self::Split(attr)); + } + + let content; + Ok(Self::SingleMarkup { + _paren_token: syn::parenthesized!(content in input), + markup: content.parse()?, + }) + } +} + +struct SplitMessageAttrs { + _paren_token: Paren, + attrs: Punctuated, +} + +impl Parse for SplitMessageAttrs { + fn parse(input: ParseStream) -> Result { + let content; + Ok(Self { + _paren_token: syn::parenthesized!(content in input), + attrs: content.parse_terminated(SplitMessageAttr::parse)?, + }) + } +} + +enum SplitMessageAttr { + Description { + _eq_token: Token![=], + value: syn::LitStr, + }, + Message { + _paren_token: Paren, + markup: TokenStream, + }, +} + +impl Parse for SplitMessageAttr { + fn parse(input: ParseStream) -> Result { + let name: Ident = input.parse()?; + + if name == "description" { + return Ok(Self::Description { + _eq_token: input.parse()?, + value: input.parse()?, + }); + } + + if name == "message" { + let content; + return Ok(Self::Message { + _paren_token: syn::parenthesized!(content in input), + markup: content.parse()?, + }); + } + + Err(Error::new_spanned(name, "unknown attribute")) + } +} + +struct TagsAttr { + _paren_token: Paren, + tags: Punctuated, +} + +impl Parse for TagsAttr { + fn parse(input: ParseStream) -> Result { + let content; + Ok(Self { + _paren_token: syn::parenthesized!(content in input), + tags: content.parse_terminated(Ident::parse)?, + }) + } +} + +struct LocationAttr { + _paren_token: Paren, + field: LocationField, +} + +pub(crate) enum LocationField { + Resource(Ident), + Span(Ident), + SourceCode(Ident), +} + +impl Parse for LocationAttr { + fn parse(input: ParseStream) -> Result { + let content; + let _paren_token = syn::parenthesized!(content in input); + let ident: Ident = content.parse()?; + + let field = if ident == "resource" { + LocationField::Resource(ident) + } else if ident == "span" { + LocationField::Span(ident) + } else if ident == "source_code" { + LocationField::SourceCode(ident) + } else { + return Err(Error::new_spanned(ident, "unknown location field")); + }; + + Ok(Self { + _paren_token, + field, + }) + } +} + +impl ToTokens for LocationField { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + LocationField::Resource(ident) => ident.to_tokens(tokens), + LocationField::Span(ident) => ident.to_tokens(tokens), + LocationField::SourceCode(ident) => ident.to_tokens(tokens), + } + } +} diff --git a/crates/pg_flags/Cargo.toml b/crates/pg_flags/Cargo.toml new file mode 100644 index 000000000..e5cf63658 --- /dev/null +++ b/crates/pg_flags/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pg_flags" +version = "0.0.0" +edition = "2021" + +[dependencies] +pg_console = { workspace = true } + +[dev-dependencies] + +[features] diff --git a/crates/pg_flags/src/lib.rs b/crates/pg_flags/src/lib.rs new file mode 100644 index 000000000..21b2a15c0 --- /dev/null +++ b/crates/pg_flags/src/lib.rs @@ -0,0 +1,111 @@ +//! A simple implementation of feature flags. + +use pg_console::fmt::{Display, Formatter}; +use pg_console::{markup, DebugDisplay, KeyValuePair}; +use std::env; +use std::ops::Deref; +use std::sync::{LazyLock, OnceLock}; + +/// Returns `true` if this is an unstable build of PgLsp +pub fn is_unstable() -> bool { + PGLSP_VERSION.deref().is_none() +} + +/// The internal version of PgLsp. This is usually supplied during the CI build +pub static PGLSP_VERSION: LazyLock> = LazyLock::new(|| option_env!("PGLSP_VERSION")); + +pub struct PgLspEnv { + pub pglsp_log_path: PgLspEnvVariable, + pub pglsp_log_prefix: PgLspEnvVariable, + pub pglsp_config_path: PgLspEnvVariable, +} + +pub static PGLSP_ENV: OnceLock = OnceLock::new(); + +impl PgLspEnv { + fn new() -> Self { + Self { + pglsp_log_path: PgLspEnvVariable::new( + "BIOME_LOG_PATH", + "The directory where the Daemon logs will be saved.", + ), + pglsp_log_prefix: PgLspEnvVariable::new( + "BIOME_LOG_PREFIX_NAME", + "A prefix that's added to the name of the log. Default: `server.log.`", + ), + pglsp_config_path: PgLspEnvVariable::new( + "BIOME_CONFIG_PATH", + "A path to the configuration file", + ), + } + } +} + +pub struct PgLspEnvVariable { + /// The name of the environment variable + name: &'static str, + /// The description of the variable. + // This field will be used in the website to automate its generation + description: &'static str, +} + +impl PgLspEnvVariable { + fn new(name: &'static str, description: &'static str) -> Self { + Self { name, description } + } + + /// It attempts to read the value of the variable + pub fn value(&self) -> Option { + env::var(self.name).ok() + } + + /// It returns the description of the variable + pub fn description(&self) -> &'static str { + self.description + } + + /// It returns the name of the variable. + pub fn name(&self) -> &'static str { + self.name + } +} + +pub fn pglsp_env() -> &'static PgLspEnv { + PGLSP_ENV.get_or_init(PgLspEnv::new) +} + +impl Display for PgLspEnv { + fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> { + match self.pglsp_log_path.value() { + None => { + KeyValuePair(self.pglsp_log_path.name, markup! { "unset" }).fmt(fmt)?; + } + Some(value) => { + KeyValuePair(self.pglsp_log_path.name, markup! {{DebugDisplay(value)}}).fmt(fmt)?; + } + }; + match self.pglsp_log_prefix.value() { + None => { + KeyValuePair(self.pglsp_log_prefix.name, markup! { "unset" }) + .fmt(fmt)?; + } + Some(value) => { + KeyValuePair(self.pglsp_log_prefix.name, markup! {{DebugDisplay(value)}}) + .fmt(fmt)?; + } + }; + + match self.pglsp_config_path.value() { + None => { + KeyValuePair(self.pglsp_config_path.name, markup! { "unset" }) + .fmt(fmt)?; + } + Some(value) => { + KeyValuePair(self.pglsp_config_path.name, markup! {{DebugDisplay(value)}}) + .fmt(fmt)?; + } + }; + + Ok(()) + } +} diff --git a/crates/pg_fs/Cargo.toml b/crates/pg_fs/Cargo.toml index f5158d147..5022fcaa5 100644 --- a/crates/pg_fs/Cargo.toml +++ b/crates/pg_fs/Cargo.toml @@ -6,10 +6,21 @@ edition = "2021" [dependencies] directories = "5.0.1" tracing = { workspace = true } +serde = { workspace = true } +parking_lot = { version = "0.12.3", features = ["arc_lock"] } +rayon = { workspace = true } +rustc-hash = { workspace = true } +pg_diagnostics = { workspace = true } +crossbeam = { workspace = true } +smallvec = { workspace = true } +enumflags2 = { workspace = true } +schemars = { workspace = true, optional = true } + +[features] +serde = ["schemars", "pg_diagnostics/schema"] [dev-dependencies] [lib] doctest = false -[features] diff --git a/crates/pg_fs/src/dir.rs b/crates/pg_fs/src/dir.rs index dd94c008d..d4a635208 100644 --- a/crates/pg_fs/src/dir.rs +++ b/crates/pg_fs/src/dir.rs @@ -4,9 +4,9 @@ use tracing::warn; pub fn ensure_cache_dir() -> PathBuf { if let Some(proj_dirs) = ProjectDirs::from("dev", "supabase-community", "pglsp") { - // Linux: /home/alice/.cache/biome - // Win: C:\Users\Alice\AppData\Local\biomejs\biome\cache - // Mac: /Users/Alice/Library/Caches/dev.biomejs.biome + // Linux: /home/alice/.cache/pglsp + // Win: C:\Users\Alice\AppData\Local\supabase-community\pglsp\cache + // Mac: /Users/Alice/Library/Caches/dev.supabase-community.pglsp let cache_dir = proj_dirs.cache_dir().to_path_buf(); if let Err(err) = fs::create_dir_all(&cache_dir) { let temp_dir = env::temp_dir(); diff --git a/crates/pg_fs/src/fs.rs b/crates/pg_fs/src/fs.rs new file mode 100644 index 000000000..9771a0c6c --- /dev/null +++ b/crates/pg_fs/src/fs.rs @@ -0,0 +1,439 @@ +use crate::{PathInterner, PgLspPath}; +pub use memory::{ErrorEntry, MemoryFileSystem}; +pub use os::OsFileSystem; +use pg_diagnostics::{console, Advices, Diagnostic, LogCategory, Visit}; +use pg_diagnostics::{Error, Severity}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fmt::{Debug, Display, Formatter}; +use std::panic::RefUnwindSafe; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{fmt, io}; +use tracing::{error, info}; + +mod memory; +mod os; + +pub struct ConfigName; + +impl ConfigName { + const PGLSP_TOML: [&'static str; 1] = ["pglsp.toml"]; + + pub const fn pglsp_toml() -> &'static str { + Self::PGLSP_TOML[0] + } + + pub const fn file_names() -> [&'static str; 1] { + Self::PGLSP_TOML + } +} + +type AutoSearchResultAlias = Result, FileSystemDiagnostic>; + +pub trait FileSystem: Send + Sync + RefUnwindSafe { + /// It opens a file with the given set of options + fn open_with_options(&self, path: &Path, options: OpenOptions) -> io::Result>; + + /// Initiate a traversal of the filesystem + /// + /// This method creates a new "traversal scope" that can be used to + /// efficiently batch many filesystem read operations + fn traversal<'scope>(&'scope self, func: BoxedTraversal<'_, 'scope>); + + /// Return the path to the working directory + fn working_directory(&self) -> Option; + + /// Checks if the given path exists in the file system + fn path_exists(&self, path: &Path) -> bool; + + /// Checks if the given path is a regular file + fn path_is_file(&self, path: &Path) -> bool; + + /// Checks if the given path is a directory + fn path_is_dir(&self, path: &Path) -> bool; + + /// Checks if the given path is a symlink + fn path_is_symlink(&self, path: &Path) -> bool; + + /// This method accepts a directory path (`search_dir`) and a list of filenames (`file_names`), + /// It looks for the files in the specified directory in the order they appear in the list. + /// If a file is not found in the initial directory, the search may continue into the parent + /// directories based on the `should_error_if_file_not_found` flag. + /// + /// Behavior if files are not found in `search_dir`: + /// + /// - If `should_error_if_file_not_found` is set to `true`, the method will return an error. + /// - If `should_error_if_file_not_found` is set to `false`, the method will search for the files in the parent + /// directories of `search_dir` recursively until: + /// - It finds a file, reads it, and returns its contents along with its path. + /// - It confirms that the file doesn't exist in any of the checked directories. + /// + /// ## Errors + /// + /// The method returns an error if `should_error_if_file_not_found` is `true`, + /// and the file is not found or cannot be opened or read. + /// + fn auto_search( + &self, + search_dir: &Path, + file_names: &[&str], + should_error_if_file_not_found: bool, + ) -> AutoSearchResultAlias { + let mut curret_search_dir = search_dir.to_path_buf(); + let mut is_searching_in_parent_dir = false; + loop { + let mut errors: Vec = vec![]; + + // Iterate all possible file names + for file_name in file_names { + let file_path = curret_search_dir.join(file_name); + match self.read_file_from_path(&file_path) { + Ok(content) => { + if is_searching_in_parent_dir { + info!( + "Auto discovered the file at the following path that isn't in the working directory:\n{:?}", + curret_search_dir.display() + ); + } + return Ok(Some(AutoSearchResult { content, file_path })); + } + Err(error) => { + // We don't return the error immediately because + // there're multiple valid file names to search for + if !is_searching_in_parent_dir && should_error_if_file_not_found { + errors.push(error); + } + } + } + } + + if !is_searching_in_parent_dir && should_error_if_file_not_found { + if let Some(diagnostic) = errors.into_iter().next() { + // We can only return one Err, so we return the first diagnostic. + return Err(diagnostic); + } + } + + if let Some(parent_search_dir) = curret_search_dir.parent() { + curret_search_dir = PathBuf::from(parent_search_dir); + is_searching_in_parent_dir = true; + } else { + break; + } + } + + Ok(None) + } + + /// Reads the content of a file specified by `file_path`. + /// + /// This method attempts to open and read the entire content of a file at the given path. + /// + /// ## Errors + /// This method logs an error message and returns a `FileSystemDiagnostic` error in two scenarios: + /// - If the file cannot be opened, possibly due to incorrect path or permission issues. + /// - If the file is opened but its content cannot be read, potentially due to the file being damaged. + fn read_file_from_path(&self, file_path: &PathBuf) -> Result { + match self.open_with_options(file_path, OpenOptions::default().read(true)) { + Ok(mut file) => { + let mut content = String::new(); + match file.read_to_string(&mut content) { + Ok(_) => Ok(content), + Err(err) => { + error!("Couldn't read the file {:?}, reason:\n{:?}", file_path, err); + Err(FileSystemDiagnostic { + path: file_path.display().to_string(), + severity: Severity::Error, + error_kind: ErrorKind::CantReadFile(file_path.display().to_string()), + }) + } + } + } + Err(err) => { + error!("Couldn't open the file {:?}, reason:\n{:?}", file_path, err); + Err(FileSystemDiagnostic { + path: file_path.display().to_string(), + severity: Severity::Error, + error_kind: ErrorKind::CantReadFile(file_path.display().to_string()), + }) + } + } + } + + fn get_changed_files(&self, base: &str) -> io::Result>; + + fn get_staged_files(&self) -> io::Result>; +} + +/// Result of the auto search +#[derive(Debug)] +pub struct AutoSearchResult { + /// The content of the file + pub content: String, + /// The path of the file found + pub file_path: PathBuf, +} + +pub trait File { + /// Read the content of the file into `buffer` + fn read_to_string(&mut self, buffer: &mut String) -> io::Result<()>; + + /// Overwrite the content of the file with the provided bytes + /// + /// This will write to the associated memory buffer, as well as flush the + /// new content to the disk if this is a physical file + fn set_content(&mut self, content: &[u8]) -> io::Result<()>; + + /// Returns the version of the current file + fn file_version(&self) -> i32; +} + +/// This struct is a "mirror" of [std::fs::FileOptions]. +/// Refer to their documentation for more details +#[derive(Default, Debug)] +pub struct OpenOptions { + read: bool, + write: bool, + truncate: bool, + create: bool, + create_new: bool, +} + +impl OpenOptions { + pub fn read(mut self, read: bool) -> Self { + self.read = read; + self + } + pub fn write(mut self, write: bool) -> Self { + self.write = write; + self + } + pub fn truncate(mut self, truncate: bool) -> Self { + self.truncate = truncate; + self + } + pub fn create(mut self, create: bool) -> Self { + self.create = create; + self + } + pub fn create_new(mut self, create_new: bool) -> Self { + self.create_new = create_new; + self + } + + pub fn into_fs_options(self, options: &mut std::fs::OpenOptions) -> &mut std::fs::OpenOptions { + options + .read(self.read) + .write(self.write) + .truncate(self.truncate) + .create(self.create) + .create_new(self.create_new) + } +} + +/// Trait that contains additional methods to work with [FileSystem] +pub trait FileSystemExt: FileSystem { + /// Open a file with the `read` option + /// + /// Equivalent to [std::fs::File::open] + fn open(&self, path: &Path) -> io::Result> { + self.open_with_options(path, OpenOptions::default().read(true)) + } + + /// Open a file with the `write` and `create` options + /// + /// Equivalent to [std::fs::File::create] + fn create(&self, path: &Path) -> io::Result> { + self.open_with_options( + path, + OpenOptions::default() + .write(true) + .create(true) + .truncate(true), + ) + } + + /// Opens a file with the `read`, `write` and `create_new` options + /// + /// Equivalent to [std::fs::File::create_new] + fn create_new(&self, path: &Path) -> io::Result> { + self.open_with_options( + path, + OpenOptions::default() + .read(true) + .write(true) + .create_new(true), + ) + } +} + +impl FileSystemExt for T {} + +type BoxedTraversal<'fs, 'scope> = Box) + Send + 'fs>; + +pub trait TraversalScope<'scope> { + /// Spawn a new filesystem read task. + /// + /// If the provided path exists and is a file, then the [`handle_file`](TraversalContext::handle_path) + /// method of the provided [TraversalContext] will be called. If it's a + /// directory, it will be recursively traversed and all the files the + /// [TraversalContext::can_handle] method of the context + /// returns true for will be handled as well + fn evaluate(&self, context: &'scope dyn TraversalContext, path: PathBuf); + + /// Spawn a new filesystem read task. + /// + /// It's assumed that the provided already exist and was already evaluated via [TraversalContext::can_handle]. + /// + /// This method will call [TraversalContext::handle_path]. + fn handle(&self, context: &'scope dyn TraversalContext, path: PathBuf); +} + +pub trait TraversalContext: Sync { + /// Provides the traversal scope with an instance of [PathInterner], used + /// to emit diagnostics for IO errors that may happen in the traversal process + fn interner(&self) -> &PathInterner; + + /// Called by the traversal process to emit an error diagnostic associated + /// with a particular file ID when an IO error happens + fn push_diagnostic(&self, error: Error); + + /// Checks if the traversal context can handle a particular path, used as + /// an optimization to bail out of scheduling a file handler if it wouldn't + /// be able to process the file anyway + fn can_handle(&self, path: &PgLspPath) -> bool; + + /// This method will be called by the traversal for each file it finds + /// where [TraversalContext::can_handle] returned true + fn handle_path(&self, path: PgLspPath); + + /// This method will be called by the traversal for each file it finds + /// where [TraversalContext::store_path] returned true + fn store_path(&self, path: PgLspPath); + + /// Returns the paths that should be handled + fn evaluated_paths(&self) -> BTreeSet; +} + +impl FileSystem for Arc +where + T: FileSystem + Send, +{ + fn open_with_options(&self, path: &Path, options: OpenOptions) -> io::Result> { + T::open_with_options(self, path, options) + } + + fn traversal<'scope>(&'scope self, func: BoxedTraversal<'_, 'scope>) { + T::traversal(self, func) + } + + fn working_directory(&self) -> Option { + T::working_directory(self) + } + + fn path_exists(&self, path: &Path) -> bool { + T::path_exists(self, path) + } + + fn path_is_file(&self, path: &Path) -> bool { + T::path_is_file(self, path) + } + + fn path_is_dir(&self, path: &Path) -> bool { + T::path_is_dir(self, path) + } + + fn path_is_symlink(&self, path: &Path) -> bool { + T::path_is_symlink(self, path) + } + + fn get_changed_files(&self, base: &str) -> io::Result> { + T::get_changed_files(self, base) + } + + fn get_staged_files(&self) -> io::Result> { + T::get_staged_files(self) + } +} + +#[derive(Debug, Diagnostic, Deserialize, Serialize)] +#[diagnostic(category = "internalError/fs")] +pub struct FileSystemDiagnostic { + #[severity] + pub severity: Severity, + #[location(resource)] + pub path: String, + #[message] + #[description] + #[advice] + pub error_kind: ErrorKind, +} + +impl Display for FileSystemDiagnostic { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Diagnostic::description(self, f) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum ErrorKind { + /// File not found + CantReadFile(String), + /// Unknown file type + UnknownFileType, + /// Dereferenced (broken) symbolic link + DereferencedSymlink(String), + /// Too deeply nested symbolic link expansion + DeeplyNestedSymlinkExpansion(String), +} + +impl console::fmt::Display for ErrorKind { + fn fmt(&self, fmt: &mut console::fmt::Formatter) -> io::Result<()> { + match self { + ErrorKind::CantReadFile(_) => fmt.write_str("Cannot read file"), + ErrorKind::UnknownFileType => fmt.write_str("Unknown file type"), + ErrorKind::DereferencedSymlink(_) => fmt.write_str("Dereferenced symlink"), + ErrorKind::DeeplyNestedSymlinkExpansion(_) => { + fmt.write_str("Deeply nested symlink expansion") + } + } + } +} + +impl std::fmt::Display for ErrorKind { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ErrorKind::CantReadFile(_) => fmt.write_str("Cannot read file"), + ErrorKind::UnknownFileType => write!(fmt, "Unknown file type"), + ErrorKind::DereferencedSymlink(_) => write!(fmt, "Dereferenced symlink"), + ErrorKind::DeeplyNestedSymlinkExpansion(_) => { + write!(fmt, "Deeply nested symlink expansion") + } + } + } +} + +impl Advices for ErrorKind { + fn record(&self, visitor: &mut dyn Visit) -> io::Result<()> { + match self { + ErrorKind::CantReadFile(path) => visitor.record_log( + LogCategory::Error, + &format!("Can't read the following file, maybe for permissions reasons or it doesn't exist: {path}") + ), + + ErrorKind::UnknownFileType => visitor.record_log( + LogCategory::Info, + &"Encountered a file system entry that's neither a file, directory or symbolic link", + ), + ErrorKind::DereferencedSymlink(path) => visitor.record_log( + LogCategory::Info, + &format!("Encountered a file system entry that is a broken symbolic link: {path}"), + ), + ErrorKind::DeeplyNestedSymlinkExpansion(path) => visitor.record_log( + LogCategory::Error, + &format!("Encountered a file system entry with too many nested symbolic links, possibly forming an infinite cycle: {path}"), + ), + } + } +} diff --git a/crates/pg_fs/src/fs/memory.rs b/crates/pg_fs/src/fs/memory.rs new file mode 100644 index 000000000..c467863f8 --- /dev/null +++ b/crates/pg_fs/src/fs/memory.rs @@ -0,0 +1,571 @@ +use rustc_hash::FxHashMap; +use std::collections::hash_map::{Entry, IntoIter}; +use std::io; +use std::panic::{AssertUnwindSafe, RefUnwindSafe}; +use std::path::{Path, PathBuf}; +use std::str; +use std::sync::Arc; + +use parking_lot::{lock_api::ArcMutexGuard, Mutex, RawMutex, RwLock}; +use pg_diagnostics::{Error, Severity}; + +use crate::fs::OpenOptions; +use crate::{FileSystem, PgLspPath, TraversalContext, TraversalScope}; + +use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic}; + +type OnGetChangedFiles = Option< + Arc< + AssertUnwindSafe< + Mutex Vec + Send + 'static + RefUnwindSafe>>>, + >, + >, +>; + +/// Fully in-memory file system, stores the content of all known files in a hashmap +pub struct MemoryFileSystem { + files: AssertUnwindSafe>>, + errors: FxHashMap, + allow_write: bool, + on_get_staged_files: OnGetChangedFiles, + on_get_changed_files: OnGetChangedFiles, +} + +impl Default for MemoryFileSystem { + fn default() -> Self { + Self { + files: Default::default(), + errors: Default::default(), + allow_write: true, + on_get_staged_files: Some(Arc::new(AssertUnwindSafe(Mutex::new(Some(Box::new( + Vec::new, + )))))), + on_get_changed_files: Some(Arc::new(AssertUnwindSafe(Mutex::new(Some(Box::new( + Vec::new, + )))))), + } + } +} + +/// This is what's actually being stored for each file in the filesystem +/// +/// To break it down: +/// - `Vec` is the byte buffer holding the content of the file +/// - `Mutex` lets it safely be read an written concurrently from multiple +/// threads ([FileSystem] is required to be [Sync]) +/// - `Arc` allows [MemoryFile] handles to outlive references to the filesystem +/// itself (since [FileSystem::open] returns an owned value) +/// - `AssertUnwindSafe` tells the type system this value can safely be +/// accessed again after being recovered from a panic (using `catch_unwind`), +/// which means the filesystem guarantees a file will never get into an +/// inconsistent state if a thread panics while having a handle open (a read +/// or write either happens or not, but will never panic halfway through) +type FileEntry = Arc>>; + +/// Error entries are special file system entries that cause an error to be +/// emitted when they are reached through a filesystem traversal. This is +/// mainly useful as a mechanism to test the handling of filesystem error in +/// client code. +#[derive(Clone, Debug)] +pub enum ErrorEntry { + UnknownFileType, + DereferencedSymlink(PathBuf), + DeeplyNestedSymlinkExpansion(PathBuf), +} + +impl MemoryFileSystem { + /// Create a read-only instance of [MemoryFileSystem] + /// + /// This instance will disallow any modification through the [FileSystem] + /// trait, but the content of the filesystem may still be modified using + /// the methods on [MemoryFileSystem] itself. + pub fn new_read_only() -> Self { + Self { + allow_write: false, + ..Self::default() + } + } + + /// Create or update a file in the filesystem + pub fn insert(&mut self, path: PathBuf, content: impl Into>) { + let files = self.files.0.get_mut(); + files.insert(path, Arc::new(Mutex::new(content.into()))); + } + + /// Create or update an error in the filesystem + pub fn insert_error(&mut self, path: PathBuf, kind: ErrorEntry) { + self.errors.insert(path, kind); + } + + /// Remove a file from the filesystem + pub fn remove(&mut self, path: &Path) { + self.files.0.write().remove(path); + } + + pub fn files(self) -> IntoIter { + let files = self.files.0.into_inner(); + files.into_iter() + } + + pub fn set_on_get_changed_files( + &mut self, + cfn: Box Vec + Send + RefUnwindSafe + 'static>, + ) { + self.on_get_changed_files = Some(Arc::new(AssertUnwindSafe(Mutex::new(Some(cfn))))); + } + + pub fn set_on_get_staged_files( + &mut self, + cfn: Box Vec + Send + RefUnwindSafe + 'static>, + ) { + self.on_get_staged_files = Some(Arc::new(AssertUnwindSafe(Mutex::new(Some(cfn))))); + } +} + +impl FileSystem for MemoryFileSystem { + fn open_with_options(&self, path: &Path, options: OpenOptions) -> io::Result> { + if !self.allow_write + && (options.create || options.create_new || options.truncate || options.write) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "cannot acquire write access to file in read-only filesystem", + )); + } + + let mut inner = if options.create || options.create_new { + // Acquire write access to the files map if the file may need to be created + let mut files = self.files.0.write(); + match files.entry(PathBuf::from(path)) { + Entry::Vacant(entry) => { + // we create an empty file + let file: FileEntry = Arc::new(Mutex::new(vec![])); + let entry = entry.insert(file); + entry.lock_arc() + } + Entry::Occupied(entry) => { + if options.create { + // If `create` is true, truncate the file + let entry = entry.into_mut(); + *entry = Arc::new(Mutex::new(vec![])); + entry.lock_arc() + } else { + // This branch can only be reached if `create_new` was true, + // we should return an error if the file already exists + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("path {path:?} already exists in memory filesystem"), + )); + } + } + } + } else { + let files = self.files.0.read(); + let entry = files.get(path).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("path {path:?} does not exists in memory filesystem"), + ) + })?; + + entry.lock_arc() + }; + + if options.truncate { + // Clear the buffer if the file was open with `truncate` + inner.clear(); + } + + Ok(Box::new(MemoryFile { + inner, + can_read: options.read, + can_write: options.write, + version: 0, + })) + } + fn traversal<'scope>(&'scope self, func: BoxedTraversal<'_, 'scope>) { + func(&MemoryTraversalScope { fs: self }) + } + + fn working_directory(&self) -> Option { + None + } + + fn path_exists(&self, path: &Path) -> bool { + self.path_is_file(path) + } + + fn path_is_file(&self, path: &Path) -> bool { + let files = self.files.0.read(); + files.get(path).is_some() + } + + fn path_is_dir(&self, path: &Path) -> bool { + !self.path_is_file(path) + } + + fn path_is_symlink(&self, _path: &Path) -> bool { + false + } + + fn get_changed_files(&self, _base: &str) -> io::Result> { + let cb_arc = self.on_get_changed_files.as_ref().unwrap().clone(); + + let mut cb_guard = cb_arc.lock(); + + let cb = cb_guard.take().unwrap(); + + Ok(cb()) + } + + fn get_staged_files(&self) -> io::Result> { + let cb_arc = self.on_get_staged_files.as_ref().unwrap().clone(); + + let mut cb_guard = cb_arc.lock(); + + let cb = cb_guard.take().unwrap(); + + Ok(cb()) + } +} + +struct MemoryFile { + inner: ArcMutexGuard>, + can_read: bool, + can_write: bool, + version: i32, +} + +impl File for MemoryFile { + fn read_to_string(&mut self, buffer: &mut String) -> io::Result<()> { + if !self.can_read { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "this file wasn't open with read access", + )); + } + + // Verify the stored byte content is valid UTF-8 + let content = str::from_utf8(&self.inner) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + // Append the content of the file to the buffer + buffer.push_str(content); + Ok(()) + } + + fn set_content(&mut self, content: &[u8]) -> io::Result<()> { + if !self.can_write { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "this file wasn't open with write access", + )); + } + + // Resize the memory buffer to fit the new content + self.inner.resize(content.len(), 0); + // Copy the new content into the memory buffer + self.inner.copy_from_slice(content); + // we increase its version + self.version += 1; + Ok(()) + } + + fn file_version(&self) -> i32 { + self.version + } +} + +pub struct MemoryTraversalScope<'scope> { + fs: &'scope MemoryFileSystem, +} + +impl<'scope> TraversalScope<'scope> for MemoryTraversalScope<'scope> { + fn evaluate(&self, ctx: &'scope dyn TraversalContext, base: PathBuf) { + // Traversal is implemented by iterating on all keys, and matching on + // those that are prefixed with the provided `base` path + { + let files = &self.fs.files.0.read(); + for path in files.keys() { + let should_process_file = if base.starts_with(".") || base.starts_with("./") { + // we simulate absolute paths, so we can correctly strips out the base path from the path + let absolute_base = PathBuf::from("/").join(&base); + let absolute_path = Path::new("/").join(path); + absolute_path.strip_prefix(&absolute_base).is_ok() + } else { + path.strip_prefix(&base).is_ok() + }; + + if should_process_file { + let _ = ctx.interner().intern_path(path.into()); + let biome_path = PgLspPath::new(path); + if !ctx.can_handle(&biome_path) { + continue; + } + ctx.store_path(biome_path); + } + } + } + + for (path, entry) in &self.fs.errors { + if path.strip_prefix(&base).is_ok() { + ctx.push_diagnostic(Error::from(FileSystemDiagnostic { + path: path.to_string_lossy().to_string(), + error_kind: match entry { + ErrorEntry::UnknownFileType => ErrorKind::UnknownFileType, + ErrorEntry::DereferencedSymlink(path) => { + ErrorKind::DereferencedSymlink(path.to_string_lossy().to_string()) + } + ErrorEntry::DeeplyNestedSymlinkExpansion(path) => { + ErrorKind::DeeplyNestedSymlinkExpansion( + path.to_string_lossy().to_string(), + ) + } + }, + severity: Severity::Warning, + })); + } + } + } + + fn handle(&self, context: &'scope dyn TraversalContext, path: PathBuf) { + context.handle_path(PgLspPath::new(path)); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::{ + io, + mem::swap, + path::{Path, PathBuf}, + }; + + use parking_lot::Mutex; + use pg_diagnostics::Error; + + use crate::{fs::FileSystemExt, OpenOptions}; + use crate::{FileSystem, MemoryFileSystem, PathInterner, PgLspPath, TraversalContext}; + + #[test] + fn fs_read_only() { + let mut fs = MemoryFileSystem::new_read_only(); + + let path = Path::new("file.js"); + fs.insert(path.into(), *b"content"); + + assert!(fs.open(path).is_ok()); + + match fs.create(path) { + Ok(_) => panic!("fs.create() for a read-only filesystem should return an error"), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + } + + match fs.create_new(path) { + Ok(_) => panic!("fs.create() for a read-only filesystem should return an error"), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + } + + match fs.open_with_options(path, OpenOptions::default().read(true).write(true)) { + Ok(_) => panic!("fs.open_with_options(read + write) for a read-only filesystem should return an error"), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + } + } + + #[test] + fn file_read_write() { + let mut fs = MemoryFileSystem::default(); + + let path = Path::new("file.js"); + let content_1 = "content 1"; + let content_2 = "content 2"; + + fs.insert(path.into(), content_1.as_bytes()); + + let mut file = fs + .open_with_options(path, OpenOptions::default().read(true).write(true)) + .expect("the file should exist in the memory file system"); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .expect("the file should be read without error"); + + assert_eq!(buffer, content_1); + + file.set_content(content_2.as_bytes()) + .expect("the file should be written without error"); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .expect("the file should be read without error"); + + assert_eq!(buffer, content_2); + } + + #[test] + fn file_create() { + let fs = MemoryFileSystem::default(); + + let path = Path::new("file.js"); + let mut file = fs.create(path).expect("the file should not fail to open"); + + file.set_content(b"content".as_slice()) + .expect("the file should be written without error"); + } + + #[test] + fn file_create_truncate() { + let mut fs = MemoryFileSystem::default(); + + let path = Path::new("file.js"); + fs.insert(path.into(), b"content".as_slice()); + + let file = fs.create(path).expect("the file should not fail to create"); + + drop(file); + + let mut file = fs.open(path).expect("the file should not fail to open"); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .expect("the file should be read without error"); + + assert!( + buffer.is_empty(), + "fs.create() should truncate the file content" + ); + } + + #[test] + fn file_create_new() { + let fs = MemoryFileSystem::default(); + + let path = Path::new("file.js"); + let content = "content"; + + let mut file = fs + .create_new(path) + .expect("the file should not fail to create"); + + file.set_content(content.as_bytes()) + .expect("the file should be written without error"); + + drop(file); + + let mut file = fs.open(path).expect("the file should not fail to open"); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer) + .expect("the file should be read without error"); + + assert_eq!(buffer, content); + } + + #[test] + fn file_create_new_exists() { + let mut fs = MemoryFileSystem::default(); + + let path = Path::new("file.js"); + fs.insert(path.into(), b"content".as_slice()); + + let result = fs.create_new(path); + + match result { + Ok(_) => panic!("fs.create_new() for an existing file should return an error"), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + } + } + } + + #[test] + fn missing_file() { + let fs = MemoryFileSystem::default(); + + let result = fs.open(Path::new("non_existing")); + + match result { + Ok(_) => panic!("opening a non-existing file should return an error"), + Err(error) => { + assert_eq!(error.kind(), io::ErrorKind::NotFound); + } + } + } + + #[test] + fn traversal() { + let mut fs = MemoryFileSystem::default(); + + fs.insert(PathBuf::from("dir1/file1"), "dir1/file1".as_bytes()); + fs.insert(PathBuf::from("dir1/file2"), "dir1/file1".as_bytes()); + fs.insert(PathBuf::from("dir2/file1"), "dir2/file1".as_bytes()); + fs.insert(PathBuf::from("dir2/file2"), "dir2/file1".as_bytes()); + + struct TestContext { + interner: PathInterner, + visited: Mutex>, + } + + impl TraversalContext for TestContext { + fn interner(&self) -> &PathInterner { + &self.interner + } + + fn push_diagnostic(&self, err: Error) { + panic!("unexpected error {err:?}") + } + + fn can_handle(&self, _: &PgLspPath) -> bool { + true + } + + fn handle_path(&self, path: PgLspPath) { + self.visited.lock().insert(path.to_written()); + } + + fn store_path(&self, path: PgLspPath) { + self.visited.lock().insert(path); + } + + fn evaluated_paths(&self) -> BTreeSet { + let lock = self.visited.lock(); + lock.clone() + } + } + + let (interner, _) = PathInterner::new(); + let mut ctx = TestContext { + interner, + visited: Mutex::default(), + }; + + // Traverse a directory + fs.traversal(Box::new(|scope| { + scope.evaluate(&ctx, PathBuf::from("dir1")); + })); + + let mut visited = BTreeSet::default(); + swap(&mut visited, ctx.visited.get_mut()); + + assert_eq!(visited.len(), 2); + assert!(visited.contains(&PgLspPath::new("dir1/file1"))); + assert!(visited.contains(&PgLspPath::new("dir1/file2"))); + + // Traverse a single file + fs.traversal(Box::new(|scope| { + scope.evaluate(&ctx, PathBuf::from("dir2/file2")); + })); + + let mut visited = BTreeSet::default(); + swap(&mut visited, ctx.visited.get_mut()); + + assert_eq!(visited.len(), 1); + assert!(visited.contains(&PgLspPath::new("dir2/file2"))); + } +} diff --git a/crates/pg_fs/src/fs/os.rs b/crates/pg_fs/src/fs/os.rs new file mode 100644 index 000000000..58a3263ee --- /dev/null +++ b/crates/pg_fs/src/fs/os.rs @@ -0,0 +1,429 @@ +//! Implementation of the [FileSystem] and related traits for the underlying OS filesystem +use super::{BoxedTraversal, ErrorKind, File, FileSystemDiagnostic}; +use crate::fs::OpenOptions; +use crate::{ + fs::{TraversalContext, TraversalScope}, + FileSystem, PgLspPath, +}; +use pg_diagnostics::{adapters::IoError, DiagnosticExt, Error, Severity}; +use rayon::{scope, Scope}; +use std::fs::{DirEntry, FileType}; +use std::process::Command; +use std::{ + env, fs, + io::{self, ErrorKind as IoErrorKind, Read, Seek, Write}, + mem, + path::{Path, PathBuf}, +}; + +const MAX_SYMLINK_DEPTH: u8 = 3; + +/// Implementation of [FileSystem] that directly calls through to the underlying OS +pub struct OsFileSystem { + pub working_directory: Option, +} + +impl OsFileSystem { + pub fn new(working_directory: PathBuf) -> Self { + Self { + working_directory: Some(working_directory), + } + } +} + +impl Default for OsFileSystem { + fn default() -> Self { + Self { + working_directory: env::current_dir().ok(), + } + } +} + +impl FileSystem for OsFileSystem { + fn open_with_options(&self, path: &Path, options: OpenOptions) -> io::Result> { + tracing::debug_span!("OsFileSystem::open_with_options", path = ?path, options = ?options) + .in_scope(move || -> io::Result> { + let mut fs_options = fs::File::options(); + Ok(Box::new(OsFile { + inner: options.into_fs_options(&mut fs_options).open(path)?, + version: 0, + })) + }) + } + + fn traversal(&self, func: BoxedTraversal) { + OsTraversalScope::with(move |scope| { + func(scope); + }) + } + + fn working_directory(&self) -> Option { + self.working_directory.clone() + } + + fn path_exists(&self, path: &Path) -> bool { + path.exists() + } + + fn path_is_file(&self, path: &Path) -> bool { + path.is_file() + } + + fn path_is_dir(&self, path: &Path) -> bool { + path.is_dir() + } + + fn path_is_symlink(&self, path: &Path) -> bool { + path.is_symlink() + } + + fn get_changed_files(&self, base: &str) -> io::Result> { + let output = Command::new("git") + .arg("diff") + .arg("--name-only") + .arg("--relative") + // A: added + // C: copied + // M: modified + // R: renamed + // Source: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203 + .arg("--diff-filter=ACMR") + .arg(format!("{base}...HEAD")) + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.to_string()) + .collect()) + } + + fn get_staged_files(&self) -> io::Result> { + let output = Command::new("git") + .arg("diff") + .arg("--name-only") + .arg("--relative") + .arg("--staged") + // A: added + // C: copied + // M: modified + // R: renamed + // Source: https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203 + .arg("--diff-filter=ACMR") + .output()?; + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.to_string()) + .collect()) + } +} + +struct OsFile { + inner: fs::File, + version: i32, +} + +impl File for OsFile { + fn read_to_string(&mut self, buffer: &mut String) -> io::Result<()> { + tracing::debug_span!("OsFile::read_to_string").in_scope(move || { + // Reset the cursor to the starting position + self.inner.rewind()?; + // Read the file content + self.inner.read_to_string(buffer)?; + Ok(()) + }) + } + + fn set_content(&mut self, content: &[u8]) -> io::Result<()> { + tracing::trace_span!("OsFile::set_content").in_scope(move || { + // Truncate the file + self.inner.set_len(0)?; + // Reset the cursor to the starting position + self.inner.rewind()?; + // Write the byte slice + self.inner.write_all(content)?; + // new version stored + self.version += 1; + Ok(()) + }) + } + + fn file_version(&self) -> i32 { + self.version + } +} + +#[repr(transparent)] +pub struct OsTraversalScope<'scope> { + scope: Scope<'scope>, +} + +impl<'scope> OsTraversalScope<'scope> { + pub(crate) fn with(func: F) + where + F: FnOnce(&Self) + Send, + { + scope(move |scope| func(Self::from_rayon(scope))) + } + + fn from_rayon<'a>(scope: &'a Scope<'scope>) -> &'a Self { + // SAFETY: transmuting from Scope to OsTraversalScope is safe since + // OsTraversalScope has the `repr(transparent)` attribute that + // guarantees its layout is the same as Scope + unsafe { mem::transmute(scope) } + } +} + +impl<'scope> TraversalScope<'scope> for OsTraversalScope<'scope> { + fn evaluate(&self, ctx: &'scope dyn TraversalContext, path: PathBuf) { + let file_type = match path.metadata() { + Ok(meta) => meta.file_type(), + Err(err) => { + ctx.push_diagnostic( + IoError::from(err).with_file_path(path.to_string_lossy().to_string()), + ); + return; + } + }; + handle_any_file(&self.scope, ctx, path, file_type, None); + } + + fn handle(&self, context: &'scope dyn TraversalContext, path: PathBuf) { + self.scope.spawn(move |_| { + context.handle_path(PgLspPath::new(path)); + }); + } +} + +// TODO: remove in Biome 2.0, and directly use `.gitignore` +/// Default list of ignored directories, in the future will be supplanted by +/// detecting and parsing .ignore files +const DEFAULT_IGNORE: &[&[u8]] = &[b".git", b".svn", b".hg", b".yarn", b"node_modules"]; + +/// Traverse a single directory +fn handle_dir<'scope>( + scope: &Scope<'scope>, + ctx: &'scope dyn TraversalContext, + path: &Path, + // The unresolved origin path in case the directory is behind a symbolic link + origin_path: Option, +) { + if let Some(file_name) = path.file_name() { + if DEFAULT_IGNORE.contains(&file_name.as_encoded_bytes()) { + return; + } + } + let iter = match fs::read_dir(path) { + Ok(iter) => iter, + Err(err) => { + ctx.push_diagnostic(IoError::from(err).with_file_path(path.display().to_string())); + return; + } + }; + + for entry in iter { + match entry { + Ok(entry) => handle_dir_entry(scope, ctx, entry, origin_path.clone()), + Err(err) => { + ctx.push_diagnostic(IoError::from(err).with_file_path(path.display().to_string())); + } + } + } +} + +/// Traverse a single directory entry, scheduling any file to execute the context +/// handler and sub-directories for subsequent traversal +fn handle_dir_entry<'scope>( + scope: &Scope<'scope>, + ctx: &'scope dyn TraversalContext, + entry: DirEntry, + // The unresolved origin path in case the directory is behind a symbolic link + origin_path: Option, +) { + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + ctx.push_diagnostic( + IoError::from(err).with_file_path(path.to_string_lossy().to_string()), + ); + return; + } + }; + handle_any_file(scope, ctx, path, file_type, origin_path); +} + +fn handle_any_file<'scope>( + scope: &Scope<'scope>, + ctx: &'scope dyn TraversalContext, + mut path: PathBuf, + mut file_type: FileType, + // The unresolved origin path in case the directory is behind a symbolic link + mut origin_path: Option, +) { + if !ctx.interner().intern_path(path.clone()) { + // If the path was already inserted, it could have been pointed at by + // multiple symlinks. No need to traverse again. + return; + } + + if file_type.is_symlink() { + if !ctx.can_handle(&PgLspPath::new(path.clone())) { + return; + } + let Ok((target_path, target_file_type)) = expand_symbolic_link(path.clone(), ctx) else { + return; + }; + + if !ctx.interner().intern_path(target_path.clone()) { + // If the path was already inserted, it could have been pointed at by + // multiple symlinks. No need to traverse again. + return; + } + + if target_file_type.is_dir() { + scope.spawn(move |scope| { + handle_dir(scope, ctx, &target_path, Some(path)); + }); + return; + } + + path = target_path; + file_type = target_file_type; + } + + // In case the file is inside a directory that is behind a symbolic link, + // the unresolved origin path is used to construct a new path. + // This is required to support ignore patterns to symbolic links. + let biome_path = if let Some(old_origin_path) = &origin_path { + if let Some(file_name) = path.file_name() { + let new_origin_path = old_origin_path.join(file_name); + origin_path = Some(new_origin_path.clone()); + PgLspPath::new(new_origin_path) + } else { + ctx.push_diagnostic(Error::from(FileSystemDiagnostic { + path: path.to_string_lossy().to_string(), + error_kind: ErrorKind::UnknownFileType, + severity: Severity::Warning, + })); + return; + } + } else { + PgLspPath::new(&path) + }; + + // Performing this check here let's us skip unsupported + // files entirely, as well as silently ignore unsupported files when + // doing a directory traversal, but printing an error message if the + // user explicitly requests an unsupported file to be handled. + // This check also works for symbolic links. + if !ctx.can_handle(&biome_path) { + return; + } + + if file_type.is_dir() { + scope.spawn(move |scope| { + handle_dir(scope, ctx, &path, origin_path); + }); + return; + } + + if file_type.is_file() { + scope.spawn(move |_| { + ctx.store_path(PgLspPath::new(path)); + }); + return; + } + + ctx.push_diagnostic(Error::from(FileSystemDiagnostic { + path: path.to_string_lossy().to_string(), + error_kind: ErrorKind::from(file_type), + severity: Severity::Warning, + })); +} + +/// Indicates a symbolic link could not be expanded. +/// +/// Has no fields, since the diagnostics are already generated inside +/// [follow_symbolic_link()] and the caller doesn't need to do anything except +/// an early return. +struct SymlinkExpansionError; + +/// Expands symlinks by recursively following them up to [MAX_SYMLINK_DEPTH]. +/// +/// ## Returns +/// +/// Returns a tuple where the first argument is the target path being pointed to +/// and the second argument is the target file type. +fn expand_symbolic_link( + mut path: PathBuf, + ctx: &dyn TraversalContext, +) -> Result<(PathBuf, FileType), SymlinkExpansionError> { + let mut symlink_depth = 0; + loop { + symlink_depth += 1; + if symlink_depth > MAX_SYMLINK_DEPTH { + let path = path.to_string_lossy().to_string(); + ctx.push_diagnostic(Error::from(FileSystemDiagnostic { + path: path.clone(), + error_kind: ErrorKind::DeeplyNestedSymlinkExpansion(path), + severity: Severity::Warning, + })); + return Err(SymlinkExpansionError); + } + + let (target_path, target_file_type) = follow_symlink(&path, ctx)?; + + if target_file_type.is_symlink() { + path = target_path; + continue; + } + + return Ok((target_path, target_file_type)); + } +} + +fn follow_symlink( + path: &Path, + ctx: &dyn TraversalContext, +) -> Result<(PathBuf, FileType), SymlinkExpansionError> { + tracing::info!("Translating symlink: {path:?}"); + + let target_path = fs::read_link(path).map_err(|err| { + ctx.push_diagnostic(IoError::from(err).with_file_path(path.to_string_lossy().to_string())); + SymlinkExpansionError + })?; + + // Make sure relative symlinks are resolved: + let target_path = path + .parent() + .map(|parent_dir| parent_dir.join(&target_path)) + .unwrap_or(target_path); + + let target_file_type = match fs::symlink_metadata(&target_path) { + Ok(meta) => meta.file_type(), + Err(err) => { + if err.kind() == IoErrorKind::NotFound { + let path = path.to_string_lossy().to_string(); + ctx.push_diagnostic(Error::from(FileSystemDiagnostic { + path: path.clone(), + error_kind: ErrorKind::DereferencedSymlink(path), + severity: Severity::Warning, + })); + } else { + ctx.push_diagnostic( + IoError::from(err).with_file_path(path.to_string_lossy().to_string()), + ); + } + return Err(SymlinkExpansionError); + } + }; + + Ok((target_path, target_file_type)) +} + +impl From for ErrorKind { + fn from(_: FileType) -> Self { + Self::UnknownFileType + } +} diff --git a/crates/pg_fs/src/interner.rs b/crates/pg_fs/src/interner.rs new file mode 100644 index 000000000..4ffeb52f4 --- /dev/null +++ b/crates/pg_fs/src/interner.rs @@ -0,0 +1,34 @@ +use crossbeam::channel::{unbounded, Receiver, Sender}; +use rustc_hash::FxHashSet; +use std::path::PathBuf; +use std::sync::RwLock; + +/// File paths interner cache +/// +/// The path interner stores an instance of [PathBuf] +pub struct PathInterner { + storage: RwLock>, + handler: Sender, +} + +impl PathInterner { + pub fn new() -> (Self, Receiver) { + let (send, recv) = unbounded(); + let interner = Self { + storage: RwLock::new(FxHashSet::default()), + handler: send, + }; + + (interner, recv) + } + + /// Insert the path. + /// Returns `true` if the path was not previously inserted. + pub fn intern_path(&self, path: PathBuf) -> bool { + let result = self.storage.write().unwrap().insert(path.clone()); + if result { + self.handler.send(path).ok(); + } + result + } +} diff --git a/crates/pg_fs/src/lib.rs b/crates/pg_fs/src/lib.rs index 3deea6c7e..660d9a6d2 100644 --- a/crates/pg_fs/src/lib.rs +++ b/crates/pg_fs/src/lib.rs @@ -1,8 +1,15 @@ //! # pg_fs mod dir; +mod fs; +mod interner; mod path; pub use dir::ensure_cache_dir; +pub use interner::PathInterner; pub use path::PgLspPath; +pub use fs::{ + AutoSearchResult, ConfigName, ErrorEntry, File, FileSystem, FileSystemDiagnostic, + FileSystemExt, MemoryFileSystem, OpenOptions, OsFileSystem, TraversalContext, TraversalScope, +}; diff --git a/crates/pg_fs/src/path.rs b/crates/pg_fs/src/path.rs index 8743766c7..e25c158ec 100644 --- a/crates/pg_fs/src/path.rs +++ b/crates/pg_fs/src/path.rs @@ -1,8 +1,100 @@ -use std::{ops::Deref, path::PathBuf}; +use enumflags2::{bitflags, BitFlags}; +use smallvec::SmallVec; +use std::{ + cmp::Ordering, + ffi::OsStr, + fs::read_to_string, + fs::File, + io, + io::Write, + ops::{Deref, DerefMut}, + path::PathBuf, +}; -#[derive(Debug, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)] +use crate::ConfigName; + +/// The priority of the file +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Ord, PartialOrd, Hash)] +#[repr(u8)] +#[bitflags] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] +// NOTE: The order of the variants is important, the one on the top has the highest priority +pub enum FileKind { + /// A configuration file has the highest priority. It's usually `pglsp.toml` + /// + /// Other third-party configuration files might be added in the future + Config, + /// An ignore file, like `.gitignore` + Ignore, + /// Files that are required to be inspected before handling other files. + Inspectable, + /// A file to handle has the lowest priority. It's usually a traversed file, or a file opened by the LSP + #[default] + Handleable, +} + +#[derive(Debug, Clone, Hash, Ord, PartialOrd, Eq, PartialEq, Default)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde( + from = "smallvec::SmallVec<[FileKind; 5]>", + into = "smallvec::SmallVec<[FileKind; 5]>" + ) +)] +pub struct FileKinds(BitFlags); + +impl From> for FileKinds { + fn from(value: SmallVec<[FileKind; 5]>) -> Self { + value + .into_iter() + .fold(FileKinds::default(), |mut acc, kind| { + acc.insert(kind); + acc + }) + } +} + +impl From for SmallVec<[FileKind; 5]> { + fn from(value: FileKinds) -> Self { + value.iter().collect() + } +} + +impl Deref for FileKinds { + type Target = BitFlags; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for FileKinds { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for FileKinds { + fn from(flag: FileKind) -> Self { + Self(BitFlags::from(flag)) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema) +)] pub struct PgLspPath { path: PathBuf, + /// Determines the kind of the file inside Biome. Some files are considered as configuration files, others as manifest files, and others as files to handle + kind: FileKinds, + /// Whether this path (usually a file) was fixed as a result of a format/lint/check command with the `--write` filag. + was_written: bool, } impl Deref for PgLspPath { @@ -13,11 +105,109 @@ impl Deref for PgLspPath { } } +impl PartialOrd for PgLspPath { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PgLspPath { + fn cmp(&self, other: &Self) -> Ordering { + match self.kind.cmp(&other.kind) { + Ordering::Equal => self.path.cmp(&other.path), + ordering => ordering, + } + } +} + impl PgLspPath { pub fn new(path_to_file: impl Into) -> Self { + let path = path_to_file.into(); + let kind = path.file_name().map(Self::priority).unwrap_or_default(); Self { - path: path_to_file.into(), + path, + kind, + was_written: false, } } + + pub fn new_written(path_to_file: impl Into) -> Self { + let path = path_to_file.into(); + let kind = path.file_name().map(Self::priority).unwrap_or_default(); + Self { + path, + kind, + was_written: true, + } + } + + /// Creates a new [PgLspPath], marked as fixed + pub fn to_written(&self) -> Self { + Self { + path: self.path.clone(), + kind: self.kind.clone(), + was_written: true, + } + } + + pub fn was_written(&self) -> bool { + self.was_written + } + + /// Accepts a file opened in read mode and saves into it + pub fn save(&mut self, content: &str) -> Result<(), std::io::Error> { + let mut file_to_write = File::create(&self.path).unwrap(); + // TODO: handle error with diagnostic + file_to_write.write_all(content.as_bytes()) + } + + /// Returns the contents of a file, if it exists + /// + /// ## Error + /// If Biome doesn't have permissions to read the file + pub fn get_buffer_from_file(&mut self) -> String { + // we assume we have permissions + read_to_string(&self.path).expect("cannot read the file to format") + } + + /// Small wrapper for [read_to_string] + pub fn read_to_string(&self) -> io::Result { + let path = self.path.as_path(); + read_to_string(path) + } + + /// The priority of the file. + /// - `biome.json` and `biome.jsonc` have the highest priority + /// - `package.json` and `tsconfig.json`/`jsconfig.json` have the second-highest priority, and they are considered as manifest files + /// - Other files are considered as files to handle + fn priority(file_name: &OsStr) -> FileKinds { + if file_name == ConfigName::pglsp_toml() { + FileKind::Config.into() + } else { + FileKind::Handleable.into() + } + } + + pub fn is_config(&self) -> bool { + self.kind.contains(FileKind::Config) + } + + pub fn is_ignore(&self) -> bool { + self.kind.contains(FileKind::Ignore) + } + + pub fn is_to_inspect(&self) -> bool { + self.kind.contains(FileKind::Inspectable) + } } +#[cfg(feature = "serde")] +impl schemars::JsonSchema for FileKinds { + fn schema_name() -> String { + String::from("FileKind") + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + >::json_schema(gen) + } +} diff --git a/crates/pg_hover/Cargo.toml b/crates/pg_hover/Cargo.toml index bdbe4d061..6b9a46f48 100644 --- a/crates/pg_hover/Cargo.toml +++ b/crates/pg_hover/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true pg_query_ext.workspace = true pg_schema_cache.workspace = true pg_syntax.workspace = true diff --git a/crates/pg_hover/src/lib.rs b/crates/pg_hover/src/lib.rs index dd283ad23..22e9ed925 100644 --- a/crates/pg_hover/src/lib.rs +++ b/crates/pg_hover/src/lib.rs @@ -28,14 +28,12 @@ pub fn hover(params: HoverParams) -> Option { let elem = if params.enriched_ast.is_some() { resolve::resolve_from_enriched_ast(params.position, params.enriched_ast.unwrap()) } else if params.tree.is_some() { - resolve::resolve_from_tree_sitter(params.position, params.tree.unwrap(), ¶ms.source) + resolve::resolve_from_tree_sitter(params.position, params.tree.unwrap(), params.source) } else { None }; - if elem.is_none() { - return None; - } + elem.as_ref()?; match elem.unwrap() { Hoverable::Relation(r) => { @@ -45,14 +43,14 @@ pub fn hover(params: HoverParams) -> Option { let mut content = t.name.to_owned(); if t.comment.is_some() { - content.push_str("\n"); + content.push('\n'); content.push_str(t.comment.as_ref().unwrap()); } - return HoverResult { + HoverResult { range: Some(r.range), content, - }; + } }) } } diff --git a/crates/pg_inlay_hints/Cargo.toml b/crates/pg_inlay_hints/Cargo.toml index 4f3773c66..68a660dc0 100644 --- a/crates/pg_inlay_hints/Cargo.toml +++ b/crates/pg_inlay_hints/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true pg_query_ext.workspace = true pg_schema_cache.workspace = true pg_type_resolver.workspace = true diff --git a/crates/pg_inlay_hints/src/functions_args.rs b/crates/pg_inlay_hints/src/functions_args.rs index e1a45b3aa..ffd896b9a 100644 --- a/crates/pg_inlay_hints/src/functions_args.rs +++ b/crates/pg_inlay_hints/src/functions_args.rs @@ -25,18 +25,14 @@ impl InlayHintsResolver for FunctionArgHint { ChildrenIterator::new(root.to_owned()) .filter_map(|n| match n { pg_query_ext::NodeEnum::FuncCall(source_fn) => { - if let Some(schema_fn) = pg_type_resolver::resolve_func_call( + pg_type_resolver::resolve_func_call( source_fn.as_ref(), - ¶ms.schema_cache, - ) { - Some(resolve_func_arg_hint( + params.schema_cache, + ).map(|schema_fn| resolve_func_arg_hint( source_fn.as_ref(), schema_fn, - ¶ms.schema_cache, + params.schema_cache, )) - } else { - None - } } _ => None, }) @@ -61,7 +57,7 @@ fn resolve_func_arg_hint( ) .unwrap(), content: InlayHintContent::FunctionArg(FunctionArgHint { - name: if schema_arg.name == "" { + name: if schema_arg.name.is_empty() { None } else { Some(schema_arg.name.clone()) diff --git a/crates/pg_lexer/Cargo.toml b/crates/pg_lexer/Cargo.toml index 4d06d3d3f..33f5dd88f 100644 --- a/crates/pg_lexer/Cargo.toml +++ b/crates/pg_lexer/Cargo.toml @@ -9,7 +9,7 @@ regex = "1.9.1" pg_query = "0.8" pg_lexer_codegen.workspace = true -text-size = "1.1.1" +text-size.workspace = true cstree = { version = "0.12.0", features = ["derive"] } [dev-dependencies] diff --git a/crates/pg_lexer_codegen/Cargo.toml b/crates/pg_lexer_codegen/Cargo.toml index 6376af934..809a66f04 100644 --- a/crates/pg_lexer_codegen/Cargo.toml +++ b/crates/pg_lexer_codegen/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -proc-macro2 = "1.0.66" +proc-macro2.workspace = true quote = "1.0.33" pg_query_proto_parser.workspace = true diff --git a/crates/pg_lint/Cargo.toml b/crates/pg_lint/Cargo.toml index 37ce0de0f..9cb281429 100644 --- a/crates/pg_lint/Cargo.toml +++ b/crates/pg_lint/Cargo.toml @@ -4,14 +4,14 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true pg_base_db.workspace = true pg_query_ext.workspace = true pg_syntax.workspace = true serde_plain = "1.0" -serde = "1.0.195" +serde.workspace = true lazy_static = "1.4.0" -serde_json = "1.0" +serde_json.workspace = true [dev-dependencies] diff --git a/crates/pg_lint/src/rules/ban_drop_column.rs b/crates/pg_lint/src/rules/ban_drop_column.rs index 42301ba26..3e68e53ae 100644 --- a/crates/pg_lint/src/rules/ban_drop_column.rs +++ b/crates/pg_lint/src/rules/ban_drop_column.rs @@ -20,22 +20,17 @@ pub fn ban_drop_column(params: &LinterParams) -> Vec { } } } - } else { - match ¶ms.ast { - pg_query_ext::NodeEnum::AlterTableStmt(stmt) => { - for cmd in &stmt.cmds { - if let Some(pg_query_ext::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { - if cmd.subtype() == pg_query_ext::protobuf::AlterTableType::AtDropColumn { - errs.push(RuleViolation::new( - RuleViolationKind::BanDropColumn, - None, - None, - )); - } - } + } else if let pg_query_ext::NodeEnum::AlterTableStmt(stmt) = ¶ms.ast { + for cmd in &stmt.cmds { + if let Some(pg_query_ext::NodeEnum::AlterTableCmd(cmd)) = &cmd.node { + if cmd.subtype() == pg_query_ext::protobuf::AlterTableType::AtDropColumn { + errs.push(RuleViolation::new( + RuleViolationKind::BanDropColumn, + None, + None, + )); } } - _ => {} } } diff --git a/crates/pg_lsp/Cargo.toml b/crates/pg_lsp/Cargo.toml index cfe2877cf..947eaf287 100644 --- a/crates/pg_lsp/Cargo.toml +++ b/crates/pg_lsp/Cargo.toml @@ -13,13 +13,13 @@ lsp-server = "0.7.6" crossbeam-channel = "0.5.12" async-channel = "2.3.1" lsp-types = "0.95.0" -serde = "1.0.195" -serde_json = "1.0.114" +serde.workspace = true +serde_json.workspace = true anyhow = "1.0.81" async-std = "1.12.0" threadpool = "1.8.1" dashmap = "5.5.3" -text-size = "1.1.1" +text-size.workspace = true line_index.workspace = true sqlx.workspace = true diff --git a/crates/pg_lsp/src/session.rs b/crates/pg_lsp/src/session.rs index 0a3c5bfe0..d3ac532af 100644 --- a/crates/pg_lsp/src/session.rs +++ b/crates/pg_lsp/src/session.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, sync::Arc}; use pg_base_db::{Change, DocumentChange}; use pg_commands::{Command, ExecuteStatementCommand}; use pg_completions::CompletionParams; -use pg_diagnostics::Diagnostic; +use pg_workspace::diagnostics::Diagnostic; use pg_fs::PgLspPath; use pg_hover::HoverParams; use pg_workspace::Workspace; diff --git a/crates/pg_lsp/src/utils/to_proto.rs b/crates/pg_lsp/src/utils/to_proto.rs index 6f076ad7a..94a9552d6 100644 --- a/crates/pg_lsp/src/utils/to_proto.rs +++ b/crates/pg_lsp/src/utils/to_proto.rs @@ -1,13 +1,13 @@ -use pg_diagnostics::Diagnostic; +use pg_workspace::diagnostics::{Diagnostic, Severity}; use tower_lsp::lsp_types; pub fn diagnostic(diagnostic: Diagnostic, range: lsp_types::Range) -> lsp_types::Diagnostic { let severity = match diagnostic.severity { - pg_diagnostics::Severity::Error => lsp_types::DiagnosticSeverity::ERROR, - pg_diagnostics::Severity::Warning => lsp_types::DiagnosticSeverity::WARNING, - pg_diagnostics::Severity::Information => lsp_types::DiagnosticSeverity::INFORMATION, - pg_diagnostics::Severity::Hint => lsp_types::DiagnosticSeverity::HINT, - pg_diagnostics::Severity::Fatal => lsp_types::DiagnosticSeverity::ERROR, + Severity::Error => lsp_types::DiagnosticSeverity::ERROR, + Severity::Warning => lsp_types::DiagnosticSeverity::WARNING, + Severity::Information => lsp_types::DiagnosticSeverity::INFORMATION, + Severity::Hint => lsp_types::DiagnosticSeverity::HINT, + Severity::Fatal => lsp_types::DiagnosticSeverity::ERROR, }; lsp_types::Diagnostic { diff --git a/crates/pg_lsp_converters/Cargo.toml b/crates/pg_lsp_converters/Cargo.toml new file mode 100644 index 000000000..beec78c43 --- /dev/null +++ b/crates/pg_lsp_converters/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pg_lsp_converters" +version = "0.0.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +rustc-hash = { workspace = true } +tower-lsp = { version = "0.20.0" } +text-size.workspace = true + +[dev-dependencies] + +[lib] +doctest = false + +[features] diff --git a/crates/pg_lsp_converters/src/from_proto.rs b/crates/pg_lsp_converters/src/from_proto.rs new file mode 100644 index 000000000..1be893379 --- /dev/null +++ b/crates/pg_lsp_converters/src/from_proto.rs @@ -0,0 +1,41 @@ +use crate::line_index::LineIndex; +use crate::{LineCol, PositionEncoding, WideLineCol}; +use anyhow::{Context, Result}; +use text_size::{TextRange, TextSize}; +use tower_lsp::lsp_types; + +/// The function is used to convert a LSP position to TextSize. +pub fn offset( + line_index: &LineIndex, + position: lsp_types::Position, + position_encoding: PositionEncoding, +) -> Result { + let line_col = match position_encoding { + PositionEncoding::Utf8 => LineCol { + line: position.line, + col: position.character, + }, + PositionEncoding::Wide(enc) => { + let line_col = WideLineCol { + line: position.line, + col: position.character, + }; + line_index.to_utf8(enc, line_col) + } + }; + + line_index + .offset(line_col) + .with_context(|| format!("position {position:?} is out of range")) +} + +/// The function is used to convert a LSP range to TextRange. +pub fn text_range( + line_index: &LineIndex, + range: lsp_types::Range, + position_encoding: PositionEncoding, +) -> Result { + let start = offset(line_index, range.start, position_encoding)?; + let end = offset(line_index, range.end, position_encoding)?; + Ok(TextRange::new(start, end)) +} diff --git a/crates/pg_lsp_converters/src/lib.rs b/crates/pg_lsp_converters/src/lib.rs new file mode 100644 index 000000000..8b5659031 --- /dev/null +++ b/crates/pg_lsp_converters/src/lib.rs @@ -0,0 +1,195 @@ +//! The crate contains a set of converters to translate between `lsp-types` and `text_size` (and vice versa) types. + +use text_size::TextSize; +use tower_lsp::lsp_types::{ClientCapabilities, PositionEncodingKind}; + +pub mod from_proto; +pub mod line_index; +pub mod to_proto; + +pub fn negotiated_encoding(capabilities: &ClientCapabilities) -> PositionEncoding { + let client_encodings = match &capabilities.general { + Some(general) => general.position_encodings.as_deref().unwrap_or_default(), + None => &[], + }; + + for enc in client_encodings { + if enc == &PositionEncodingKind::UTF8 { + return PositionEncoding::Utf8; + } else if enc == &PositionEncodingKind::UTF32 { + return PositionEncoding::Wide(WideEncoding::Utf32); + } + // NB: intentionally prefer just about anything else to utf-16. + } + + PositionEncoding::Wide(WideEncoding::Utf16) +} + +#[derive(Clone, Copy, Debug)] +pub enum PositionEncoding { + Utf8, + Wide(WideEncoding), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WideEncoding { + Utf16, + Utf32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LineCol { + /// Zero-based + pub line: u32, + /// Zero-based utf8 offset + pub col: u32, +} + +/// Deliberately not a generic type and different from `LineCol`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WideLineCol { + /// Zero-based + pub line: u32, + /// Zero-based + pub col: u32, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct WideChar { + /// Start offset of a character inside a line, zero-based + pub start: TextSize, + /// End offset of a character inside a line, zero-based + pub end: TextSize, +} + +impl WideChar { + /// Returns the length in 8-bit UTF-8 code units. + fn len(&self) -> TextSize { + self.end - self.start + } + + /// Returns the length in UTF-16 or UTF-32 code units. + fn wide_len(&self, enc: WideEncoding) -> usize { + match enc { + WideEncoding::Utf16 => { + if self.len() == TextSize::from(4) { + 2 + } else { + 1 + } + } + + WideEncoding::Utf32 => 1, + } + } +} + +#[cfg(test)] +mod tests { + use crate::from_proto::offset; + use crate::line_index::LineIndex; + use crate::to_proto::position; + use crate::WideEncoding::{Utf16, Utf32}; + use crate::{LineCol, PositionEncoding, WideEncoding}; + use text_size::TextSize; + use tower_lsp::lsp_types::Position; + + macro_rules! check_conversion { + ($line_index:ident : $position:expr => $text_size:expr ) => { + let position_encoding = PositionEncoding::Wide(WideEncoding::Utf16); + + let offset = offset(&$line_index, $position, position_encoding).ok(); + assert_eq!(offset, Some($text_size)); + + let position = position(&$line_index, offset.unwrap(), position_encoding).ok(); + + assert_eq!(position, Some($position)); + }; + } + + #[test] + fn empty_string() { + let line_index = LineIndex::new(""); + check_conversion!(line_index: Position { line: 0, character: 0 } => TextSize::from(0)); + } + + #[test] + fn empty_line() { + let line_index = LineIndex::new("\n\n"); + check_conversion!(line_index: Position { line: 1, character: 0 } => TextSize::from(1)); + } + + #[test] + fn line_end() { + let line_index = LineIndex::new("abc\ndef\nghi"); + check_conversion!(line_index: Position { line: 1, character: 3 } => TextSize::from(7)); + } + + #[test] + fn out_of_bounds_line() { + let line_index = LineIndex::new("abcde\nfghij\n"); + + let offset = line_index.offset(LineCol { line: 5, col: 0 }); + assert!(offset.is_none()); + } + + #[test] + fn unicode() { + let line_index = LineIndex::new("'Jan 1, 2018 – Jan 1, 2019'"); + + check_conversion!(line_index: Position { line: 0, character: 0 } => TextSize::from(0)); + check_conversion!(line_index: Position { line: 0, character: 1 } => TextSize::from(1)); + check_conversion!(line_index: Position { line: 0, character: 12 } => TextSize::from(12)); + check_conversion!(line_index: Position { line: 0, character: 13 } => TextSize::from(15)); + check_conversion!(line_index: Position { line: 0, character: 14 } => TextSize::from(18)); + check_conversion!(line_index: Position { line: 0, character: 15 } => TextSize::from(21)); + check_conversion!(line_index: Position { line: 0, character: 26 } => TextSize::from(32)); + check_conversion!(line_index: Position { line: 0, character: 27 } => TextSize::from(33)); + } + + #[ignore] + #[test] + fn test_every_chars() { + let text: String = { + let mut chars: Vec = ((0 as char)..char::MAX).collect(); + chars.extend("\n".repeat(chars.len() / 16).chars()); + chars.into_iter().collect() + }; + + let line_index = LineIndex::new(&text); + + let mut lin_col = LineCol { line: 0, col: 0 }; + let mut col_utf16 = 0; + let mut col_utf32 = 0; + for (offset, char) in text.char_indices() { + let got_offset = line_index.offset(lin_col).unwrap(); + assert_eq!(usize::from(got_offset), offset); + + let got_lin_col = line_index.line_col(got_offset).unwrap(); + assert_eq!(got_lin_col, lin_col); + + for enc in [Utf16, Utf32] { + let wide_lin_col = line_index.to_wide(enc, lin_col).unwrap(); + let got_lin_col = line_index.to_utf8(enc, wide_lin_col); + assert_eq!(got_lin_col, lin_col); + + let want_col = match enc { + Utf16 => col_utf16, + Utf32 => col_utf32, + }; + assert_eq!(wide_lin_col.col, want_col) + } + + if char == '\n' { + lin_col.line += 1; + lin_col.col = 0; + col_utf16 = 0; + col_utf32 = 0; + } else { + lin_col.col += char.len_utf8() as u32; + col_utf16 += char.len_utf16() as u32; + col_utf32 += 1; + } + } + } +} diff --git a/crates/pg_lsp_converters/src/line_index.rs b/crates/pg_lsp_converters/src/line_index.rs new file mode 100644 index 000000000..50376566e --- /dev/null +++ b/crates/pg_lsp_converters/src/line_index.rs @@ -0,0 +1,144 @@ +//! `LineIndex` maps flat `TextSize` offsets into `(Line, Column)` +//! representation. + +use std::mem; + +use rustc_hash::FxHashMap; +use text_size::TextSize; + +use crate::{LineCol, WideChar, WideEncoding, WideLineCol}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LineIndex { + /// Offset the beginning of each line, zero-based. + pub newlines: Vec, + /// List of non-ASCII characters on each line. + pub line_wide_chars: FxHashMap>, +} + +impl LineIndex { + pub fn new(text: &str) -> LineIndex { + let mut line_wide_chars = FxHashMap::default(); + let mut wide_chars = Vec::new(); + + let mut newlines = vec![TextSize::from(0)]; + + let mut current_col = TextSize::from(0); + + let mut line = 0; + for (offset, char) in text.char_indices() { + let char_size = TextSize::of(char); + + if char == '\n' { + // SAFETY: the conversion from `usize` to `TextSize` can fail if `offset` + // is larger than 2^32. We don't support such large files. + let char_offset = TextSize::try_from(offset).expect("TextSize overflow"); + newlines.push(char_offset + char_size); + + // Save any utf-16 characters seen in the previous line + if !wide_chars.is_empty() { + line_wide_chars.insert(line, mem::take(&mut wide_chars)); + } + + // Prepare for processing the next line + current_col = TextSize::from(0); + line += 1; + continue; + } + + if !char.is_ascii() { + wide_chars.push(WideChar { + start: current_col, + end: current_col + char_size, + }); + } + + current_col += char_size; + } + + // Save any utf-16 characters seen in the last line + if !wide_chars.is_empty() { + line_wide_chars.insert(line, wide_chars); + } + + LineIndex { + newlines, + line_wide_chars, + } + } + + /// Return the number of lines in the index, clamped to [u32::MAX] + pub fn len(&self) -> u32 { + self.newlines.len().try_into().unwrap_or(u32::MAX) + } + + /// Return `true` if the index contains no lines. + pub fn is_empty(&self) -> bool { + self.newlines.is_empty() + } + + pub fn line_col(&self, offset: TextSize) -> Option { + let line = self.newlines.partition_point(|&it| it <= offset) - 1; + let line_start_offset = self.newlines.get(line)?; + let col = offset - line_start_offset; + + Some(LineCol { + line: u32::try_from(line).ok()?, + col: col.into(), + }) + } + + pub fn offset(&self, line_col: LineCol) -> Option { + self.newlines + .get(line_col.line as usize) + .map(|offset| offset + TextSize::from(line_col.col)) + } + + pub fn to_wide(&self, enc: WideEncoding, line_col: LineCol) -> Option { + let col = self.utf8_to_wide_col(enc, line_col.line, line_col.col.into()); + Some(WideLineCol { + line: line_col.line, + col: u32::try_from(col).ok()?, + }) + } + + pub fn to_utf8(&self, enc: WideEncoding, line_col: WideLineCol) -> LineCol { + let col = self.wide_to_utf8_col(enc, line_col.line, line_col.col); + LineCol { + line: line_col.line, + col: col.into(), + } + } + + fn utf8_to_wide_col(&self, enc: WideEncoding, line: u32, col: TextSize) -> usize { + let mut res: usize = col.into(); + if let Some(wide_chars) = self.line_wide_chars.get(&line) { + for c in wide_chars { + if c.end <= col { + res -= usize::from(c.len()) - c.wide_len(enc); + } else { + // From here on, all utf16 characters come *after* the character we are mapping, + // so we don't need to take them into account + break; + } + } + } + res + } + + fn wide_to_utf8_col(&self, enc: WideEncoding, line: u32, mut col: u32) -> TextSize { + if let Some(wide_chars) = self.line_wide_chars.get(&line) { + for c in wide_chars { + if col > u32::from(c.start) { + col += u32::from(c.len()) - c.wide_len(enc) as u32; + } else { + // From here on, all utf16 characters come *after* the character we are mapping, + // so we don't need to take them into account + break; + } + } + } + + col.into() + } +} diff --git a/crates/pg_lsp_converters/src/to_proto.rs b/crates/pg_lsp_converters/src/to_proto.rs new file mode 100644 index 000000000..7c63d9f29 --- /dev/null +++ b/crates/pg_lsp_converters/src/to_proto.rs @@ -0,0 +1,39 @@ +use crate::line_index::LineIndex; +use crate::PositionEncoding; +use anyhow::{Context, Result}; +use text_size::{TextRange, TextSize}; +use tower_lsp::lsp_types; + +/// The function is used to convert TextSize to a LSP position. +pub fn position( + line_index: &LineIndex, + offset: TextSize, + position_encoding: PositionEncoding, +) -> Result { + let line_col = line_index + .line_col(offset) + .with_context(|| format!("could not convert offset {offset:?} into a line-column index"))?; + + let position = match position_encoding { + PositionEncoding::Utf8 => lsp_types::Position::new(line_col.line, line_col.col), + PositionEncoding::Wide(enc) => { + let line_col = line_index + .to_wide(enc, line_col) + .with_context(|| format!("could not convert {line_col:?} into wide line column"))?; + lsp_types::Position::new(line_col.line, line_col.col) + } + }; + + Ok(position) +} + +/// The function is used to convert TextRange to a LSP range. +pub fn range( + line_index: &LineIndex, + range: TextRange, + position_encoding: PositionEncoding, +) -> Result { + let start = position(line_index, range.start(), position_encoding)?; + let end = position(line_index, range.end(), position_encoding)?; + Ok(lsp_types::Range::new(start, end)) +} diff --git a/crates/pg_lsp_new/Cargo.toml b/crates/pg_lsp_new/Cargo.toml new file mode 100644 index 000000000..db1bbeb4d --- /dev/null +++ b/crates/pg_lsp_new/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "pg_lsp_new" +version = "0.0.0" +edition = "2021" + +[dependencies] +futures = "0.3.31" +anyhow = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "io-std"] } +tower-lsp = { version = "0.20.0" } +tracing = { workspace = true, features = ["attributes"] } +pg_lsp_converters = { workspace = true } +pg_console = { workspace = true } +pg_diagnostics = { workspace = true } +pg_configuration = { workspace = true } +pg_fs = { workspace = true } +pg_workspace_new = { workspace = true } +pg_text_edit = { workspace = true } +biome_deserialize = { workspace = true } +text-size.workspace = true + +[dev-dependencies] + +[lib] +doctest = false + +[features] diff --git a/crates/pg_lsp_new/src/capabilities.rs b/crates/pg_lsp_new/src/capabilities.rs new file mode 100644 index 000000000..35bcefa43 --- /dev/null +++ b/crates/pg_lsp_new/src/capabilities.rs @@ -0,0 +1,38 @@ +use pg_lsp_converters::{negotiated_encoding, PositionEncoding, WideEncoding}; +use tower_lsp::lsp_types::{ + ClientCapabilities, PositionEncodingKind, SaveOptions, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, + TextDocumentSyncSaveOptions, +}; + +/// The capabilities to send from server as part of [`InitializeResult`] +/// +/// [`InitializeResult`]: lspower::lsp::InitializeResult +pub(crate) fn server_capabilities(capabilities: &ClientCapabilities) -> ServerCapabilities { + ServerCapabilities { + position_encoding: Some(match negotiated_encoding(capabilities) { + PositionEncoding::Utf8 => PositionEncodingKind::UTF8, + PositionEncoding::Wide(wide) => match wide { + WideEncoding::Utf16 => PositionEncodingKind::UTF16, + WideEncoding::Utf32 => PositionEncodingKind::UTF32, + }, + }), + text_document_sync: Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + will_save: None, + will_save_wait_until: None, + save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { + include_text: Some(false), + })), + }, + )), + document_formatting_provider: None, + document_range_formatting_provider: None, + document_on_type_formatting_provider: None, + code_action_provider: None, + rename_provider: None, + ..Default::default() + } +} diff --git a/crates/pg_lsp_new/src/diagnostics.rs b/crates/pg_lsp_new/src/diagnostics.rs new file mode 100644 index 000000000..402043ed5 --- /dev/null +++ b/crates/pg_lsp_new/src/diagnostics.rs @@ -0,0 +1,76 @@ +use crate::utils::into_lsp_error; +use anyhow::Error; +use pg_diagnostics::print_diagnostic_to_string; +use pg_workspace_new::WorkspaceError; +use std::fmt::{Display, Formatter}; +use tower_lsp::lsp_types::MessageType; + +#[derive(Debug)] +pub enum LspError { + WorkspaceError(WorkspaceError), + Anyhow(anyhow::Error), + Error(pg_diagnostics::Error), +} + +impl From for LspError { + fn from(value: WorkspaceError) -> Self { + Self::WorkspaceError(value) + } +} + +impl From for LspError { + fn from(value: pg_diagnostics::Error) -> Self { + Self::Error(value) + } +} + +impl From for LspError { + fn from(value: Error) -> Self { + Self::Anyhow(value) + } +} + +impl Display for LspError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + LspError::WorkspaceError(err) => { + write!(f, "{err}") + } + LspError::Anyhow(err) => { + write!(f, "{err}") + } + LspError::Error(err) => err.description(f), + } + } +} + +/// Receives an error coming from a LSP query, and converts it into a JSON-RPC error. +/// +/// It accepts a `Client`, so contextual messages are sent to the user. +pub(crate) async fn handle_lsp_error( + err: LspError, + client: &tower_lsp::Client, +) -> Result, tower_lsp::jsonrpc::Error> { + match err { + LspError::WorkspaceError(err) => match err { + // diagnostics that shouldn't raise an hard error, but send a message to the user + WorkspaceError::FileIgnored(_) | WorkspaceError::FileTooLarge(_) => { + let message = format!("{err}"); + client.log_message(MessageType::WARNING, message).await; + Ok(None) + } + + _ => { + let message = format!("{err}"); + client.log_message(MessageType::ERROR, message).await; + Ok(None) + } + }, + LspError::Anyhow(err) => Err(into_lsp_error(err)), + LspError::Error(err) => { + let message = print_diagnostic_to_string(&err); + client.log_message(MessageType::ERROR, message).await; + Ok(None) + } + } +} diff --git a/crates/pg_lsp_new/src/documents.rs b/crates/pg_lsp_new/src/documents.rs new file mode 100644 index 000000000..c32da8089 --- /dev/null +++ b/crates/pg_lsp_new/src/documents.rs @@ -0,0 +1,19 @@ +use pg_lsp_converters::line_index::LineIndex; + +/// Represents an open [`textDocument`]. Can be cheaply cloned. +/// +/// [`textDocument`]: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem +#[derive(Clone)] +pub(crate) struct Document { + pub(crate) version: i32, + pub(crate) line_index: LineIndex, +} + +impl Document { + pub(crate) fn new(version: i32, text: &str) -> Self { + Self { + version, + line_index: LineIndex::new(text), + } + } +} diff --git a/crates/pg_lsp_new/src/handlers.rs b/crates/pg_lsp_new/src/handlers.rs new file mode 100644 index 000000000..f55cad671 --- /dev/null +++ b/crates/pg_lsp_new/src/handlers.rs @@ -0,0 +1 @@ +pub(crate) mod text_document; diff --git a/crates/pg_lsp_new/src/handlers/text_document.rs b/crates/pg_lsp_new/src/handlers/text_document.rs new file mode 100644 index 000000000..09ed4cf49 --- /dev/null +++ b/crates/pg_lsp_new/src/handlers/text_document.rs @@ -0,0 +1,122 @@ +use crate::{documents::Document, session::Session, utils::apply_document_changes}; +use anyhow::Result; +use pg_lsp_converters::from_proto::text_range; +use pg_workspace_new::workspace::{ + ChangeFileParams, ChangeParams, CloseFileParams, GetFileContentParams, OpenFileParams, +}; +use tower_lsp::lsp_types; +use tracing::field; + +/// Handler for `textDocument/didOpen` LSP notification +#[tracing::instrument( + level = "debug", + skip_all, + fields( + text_document_uri = display(params.text_document.uri.as_ref()), + text_document_language_id = display(¶ms.text_document.language_id), + ) +)] +pub(crate) async fn did_open( + session: &Session, + params: lsp_types::DidOpenTextDocumentParams, +) -> Result<()> { + let url = params.text_document.uri; + let version = params.text_document.version; + let content = params.text_document.text; + + let biome_path = session.file_path(&url)?; + let doc = Document::new(version, &content); + + session.workspace.open_file(OpenFileParams { + path: biome_path, + version, + content, + })?; + + session.insert_document(url.clone(), doc); + + // if let Err(err) = session.update_diagnostics(url).await { + // error!("Failed to update diagnostics: {}", err); + // } + + Ok(()) +} + +// Handler for `textDocument/didChange` LSP notification +#[tracing::instrument(level = "debug", skip_all, fields(url = field::display(¶ms.text_document.uri), version = params.text_document.version), err)] +pub(crate) async fn did_change( + session: &Session, + params: lsp_types::DidChangeTextDocumentParams, +) -> Result<()> { + let url = params.text_document.uri; + let version = params.text_document.version; + + let pglsp_path = session.file_path(&url)?; + + // we need to keep the documents here too for the line index + let old_text = session.workspace.get_file_content(GetFileContentParams { + path: pglsp_path.clone(), + })?; + + let start = params + .content_changes + .iter() + .rev() + .position(|change| change.range.is_none()) + .map_or(0, |idx| params.content_changes.len() - idx - 1); + + let text = apply_document_changes( + session.position_encoding(), + old_text, + ¶ms.content_changes[start..], + ); + + let new_doc = Document::new(version, &text); + + session.workspace.change_file(ChangeFileParams { + path: pglsp_path, + version, + changes: params.content_changes[start..] + .iter() + .map(|c| ChangeParams { + range: c.range.and_then(|r| { + text_range(&new_doc.line_index, r, session.position_encoding()).ok() + }), + text: c.text.clone(), + }) + .collect(), + })?; + + session.insert_document(url.clone(), new_doc); + + // if let Err(err) = session.update_diagnostics(url).await { + // error!("Failed to update diagnostics: {}", err); + // } + + Ok(()) +} + +/// Handler for `textDocument/didClose` LSP notification +#[tracing::instrument(level = "debug", skip(session), err)] +pub(crate) async fn did_close( + session: &Session, + params: lsp_types::DidCloseTextDocumentParams, +) -> Result<()> { + let url = params.text_document.uri; + let biome_path = session.file_path(&url)?; + + session + .workspace + .close_file(CloseFileParams { path: biome_path })?; + + session.remove_document(&url); + + let diagnostics = vec![]; + let version = None; + session + .client + .publish_diagnostics(url, diagnostics, version) + .await; + + Ok(()) +} diff --git a/crates/pg_lsp_new/src/lib.rs b/crates/pg_lsp_new/src/lib.rs new file mode 100644 index 000000000..99db526f8 --- /dev/null +++ b/crates/pg_lsp_new/src/lib.rs @@ -0,0 +1,9 @@ +mod capabilities; +mod diagnostics; +mod documents; +mod handlers; +mod server; +mod session; +mod utils; + +pub use crate::server::{LSPServer, ServerConnection, ServerFactory}; diff --git a/crates/pg_lsp_new/src/server.rs b/crates/pg_lsp_new/src/server.rs new file mode 100644 index 000000000..fd1e8e16e --- /dev/null +++ b/crates/pg_lsp_new/src/server.rs @@ -0,0 +1,415 @@ +use crate::capabilities::server_capabilities; +use crate::diagnostics::{handle_lsp_error, LspError}; +use crate::handlers; +use crate::session::{ + CapabilitySet, CapabilityStatus, ClientInformation, Session, SessionHandle, SessionKey, +}; +use crate::utils::{into_lsp_error, panic_to_lsp_error}; +use futures::future::ready; +use futures::FutureExt; +use pg_diagnostics::panic::PanicError; +use pg_fs::{ConfigName, FileSystem, OsFileSystem}; +use pg_workspace_new::{workspace, DynRef, Workspace}; +use rustc_hash::FxHashMap; +use serde_json::json; +use std::panic::RefUnwindSafe; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::Notify; +use tokio::task::spawn_blocking; +use tower_lsp::jsonrpc::Result as LspResult; +use tower_lsp::{lsp_types::*, ClientSocket}; +use tower_lsp::{LanguageServer, LspService, Server}; +use tracing::{error, info}; + +pub struct LSPServer { + session: SessionHandle, + /// Map of all sessions connected to the same [ServerFactory] as this [LSPServer]. + sessions: Sessions, + /// If this is true the server will broadcast a shutdown signal once the + /// last client disconnected + stop_on_disconnect: bool, + /// This shared flag is set to true once at least one session has been + /// initialized on this server instance + is_initialized: Arc, +} + +impl RefUnwindSafe for LSPServer {} + +impl LSPServer { + fn new( + session: SessionHandle, + sessions: Sessions, + stop_on_disconnect: bool, + is_initialized: Arc, + ) -> Self { + Self { + session, + sessions, + stop_on_disconnect, + is_initialized, + } + } + + async fn setup_capabilities(&self) { + let mut capabilities = CapabilitySet::default(); + + capabilities.add_capability( + "pglsp_did_change_extension_settings", + "workspace/didChangeConfiguration", + if self.session.can_register_did_change_configuration() { + CapabilityStatus::Enable(None) + } else { + CapabilityStatus::Disable + }, + ); + + capabilities.add_capability( + "pglsp_did_change_workspace_settings", + "workspace/didChangeWatchedFiles", + if let Some(base_path) = self.session.base_path() { + CapabilityStatus::Enable(Some(json!(DidChangeWatchedFilesRegistrationOptions { + watchers: vec![FileSystemWatcher { + glob_pattern: GlobPattern::String(format!( + "{}/pglsp.toml", + base_path.display() + )), + kind: Some(WatchKind::all()), + },], + }))) + } else { + CapabilityStatus::Disable + }, + ); + + self.session.register_capabilities(capabilities).await; + } + + async fn map_op_error( + &self, + result: Result, LspError>, PanicError>, + ) -> LspResult> { + match result { + Ok(result) => match result { + Ok(result) => Ok(result), + Err(err) => handle_lsp_error(err, &self.session.client).await, + }, + + Err(err) => Err(into_lsp_error(err)), + } + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for LSPServer { + #[allow(deprecated)] + #[tracing::instrument( + level = "trace", + skip_all, + fields( + root_uri = params.root_uri.as_ref().map(display), + capabilities = debug(¶ms.capabilities), + client_info = params.client_info.as_ref().map(debug), + workspace_folders = params.workspace_folders.as_ref().map(debug), + ) + )] + async fn initialize(&self, params: InitializeParams) -> LspResult { + info!("Starting Language Server..."); + self.is_initialized.store(true, Ordering::Relaxed); + + let server_capabilities = server_capabilities(¶ms.capabilities); + + self.session.initialize( + params.capabilities, + params.client_info.map(|client_info| ClientInformation { + name: client_info.name, + version: client_info.version, + }), + params.root_uri, + params.workspace_folders, + ); + + // + let init = InitializeResult { + capabilities: server_capabilities, + server_info: Some(ServerInfo { + name: String::from(env!("CARGO_PKG_NAME")), + version: Some(pg_configuration::VERSION.to_string()), + }), + }; + + Ok(init) + } + + #[tracing::instrument(level = "trace", skip(self))] + async fn initialized(&self, params: InitializedParams) { + let _ = params; + + info!("Attempting to load the configuration from 'pglsp.toml' file"); + + futures::join!(self.session.load_workspace_settings()); + + let msg = format!("Server initialized with PID: {}", std::process::id()); + self.session + .client + .log_message(MessageType::INFO, msg) + .await; + + self.setup_capabilities().await; + + // Diagnostics are disabled by default, so update them after fetching workspace config + // self.session.update_all_diagnostics().await; + } + + async fn shutdown(&self) -> LspResult<()> { + Ok(()) + } + + #[tracing::instrument(level = "trace", skip(self))] + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + let _ = params; + self.session.load_workspace_settings().await; + self.setup_capabilities().await; + // self.session.update_all_diagnostics().await; + } + + #[tracing::instrument(level = "trace", skip(self))] + async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { + let file_paths = params + .changes + .iter() + .map(|change| change.uri.to_file_path()); + for file_path in file_paths { + match file_path { + Ok(file_path) => { + let base_path = self.session.base_path(); + if let Some(base_path) = base_path { + let possible_config_toml = file_path.strip_prefix(&base_path); + if let Ok(watched_file) = possible_config_toml { + if ConfigName::file_names() + .contains(&&*watched_file.display().to_string()) + { + self.session.load_workspace_settings().await; + self.setup_capabilities().await; + // self.session.update_all_diagnostics().await; + // for now we are only interested to the configuration file, + // so it's OK to exist the loop + break; + } + } + } + } + Err(_) => { + error!("The Workspace root URI {file_path:?} could not be parsed as a filesystem path"); + continue; + } + } + } + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + handlers::text_document::did_open(&self.session, params) + .await + .ok(); + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + handlers::text_document::did_change(&self.session, params) + .await + .ok(); + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + // handlers::text_document::did_save(&self.session, params) + // .await + // .ok(); + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + handlers::text_document::did_close(&self.session, params) + .await + .ok(); + } +} + +impl Drop for LSPServer { + fn drop(&mut self) { + if let Ok(mut sessions) = self.sessions.lock() { + let _removed = sessions.remove(&self.session.key); + debug_assert!(_removed.is_some(), "Session did not exist."); + + if self.stop_on_disconnect + && sessions.is_empty() + && self.is_initialized.load(Ordering::Relaxed) + { + self.session.cancellation.notify_one(); + } + } + } +} + +/// Map of active sessions connected to a [ServerFactory]. +type Sessions = Arc>>; + +/// Helper method for wrapping a [Workspace] method in a `custom_method` for +/// the [LSPServer] +macro_rules! workspace_method { + ( $builder:ident, $method:ident ) => { + $builder = $builder.custom_method( + concat!("pglsp/", stringify!($method)), + |server: &LSPServer, params| { + let span = tracing::trace_span!(concat!("pglsp/", stringify!($method)), params = ?params).or_current(); + tracing::info!("Received request: {}", stringify!($method)); + + let workspace = server.session.workspace.clone(); + let result = spawn_blocking(move || { + let _guard = span.entered(); + workspace.$method(params) + }); + + result.map(move |result| { + // The type of `result` is `Result, JoinError>`, + // where the inner result is the return value of `$method` while the + // outer one is added by `spawn_blocking` to catch panics or + // cancellations of the task + match result { + Ok(Ok(result)) => Ok(result), + Ok(Err(err)) => Err(into_lsp_error(err)), + Err(err) => match err.try_into_panic() { + Ok(err) => Err(panic_to_lsp_error(err)), + Err(err) => Err(into_lsp_error(err)), + }, + } + }) + }, + ); + }; +} + +/// Factory data structure responsible for creating [ServerConnection] handles +/// for each incoming connection accepted by the server +#[derive(Default)] +pub struct ServerFactory { + /// Synchronization primitive used to broadcast a shutdown signal to all + /// active connections + cancellation: Arc, + /// Optional [Workspace] instance shared between all clients. Currently + /// this field is always [None] (meaning each connection will get its own + /// workspace) until we figure out how to handle concurrent access to the + /// same workspace from multiple client + workspace: Option>, + + /// The sessions of the connected clients indexed by session key. + sessions: Sessions, + + /// Session key generator. Stores the key of the next session. + next_session_key: AtomicU64, + + /// If this is true the server will broadcast a shutdown signal once the + /// last client disconnected + stop_on_disconnect: bool, + /// This shared flag is set to true once at least one sessions has been + /// initialized on this server instance + is_initialized: Arc, +} + +impl ServerFactory { + pub fn new(stop_on_disconnect: bool) -> Self { + Self { + cancellation: Arc::default(), + workspace: None, + sessions: Sessions::default(), + next_session_key: AtomicU64::new(0), + stop_on_disconnect, + is_initialized: Arc::default(), + } + } + + pub fn create(&self, config_path: Option) -> ServerConnection { + self.create_with_fs(config_path, DynRef::Owned(Box::::default())) + } + + /// Create a new [ServerConnection] from this factory + pub fn create_with_fs( + &self, + config_path: Option, + fs: DynRef<'static, dyn FileSystem>, + ) -> ServerConnection { + let workspace = self + .workspace + .clone() + .unwrap_or_else(workspace::server_sync); + + let session_key = SessionKey(self.next_session_key.fetch_add(1, Ordering::Relaxed)); + + let mut builder = LspService::build(move |client| { + let mut session = Session::new( + session_key, + client, + workspace, + self.cancellation.clone(), + fs, + ); + if let Some(path) = config_path { + session.set_config_path(path); + } + let handle = Arc::new(session); + + let mut sessions = self.sessions.lock().unwrap(); + sessions.insert(session_key, handle.clone()); + + LSPServer::new( + handle, + self.sessions.clone(), + self.stop_on_disconnect, + self.is_initialized.clone(), + ) + }); + + // "shutdown" is not part of the Workspace API + builder = builder.custom_method("pglsp/shutdown", |server: &LSPServer, (): ()| { + info!("Sending shutdown signal"); + server.session.broadcast_shutdown(); + ready(Ok(Some(()))) + }); + + workspace_method!(builder, open_file); + workspace_method!(builder, change_file); + workspace_method!(builder, close_file); + + let (service, socket) = builder.finish(); + ServerConnection { socket, service } + } + + /// Return a handle to the cancellation token for this server process + pub fn cancellation(&self) -> Arc { + self.cancellation.clone() + } +} + +/// Handle type created by the server for each incoming connection +pub struct ServerConnection { + socket: ClientSocket, + service: LspService, +} + +impl ServerConnection { + /// Destructure a connection into its inner service instance and socket + pub fn into_inner(self) -> (LspService, ClientSocket) { + (self.service, self.socket) + } + + /// Accept an incoming connection and run the server async I/O loop to + /// completion + pub async fn accept(self, stdin: I, stdout: O) + where + I: AsyncRead + Unpin, + O: AsyncWrite, + { + Server::new(stdin, stdout, self.socket) + .serve(self.service) + .await; + } +} diff --git a/crates/pg_lsp_new/src/session.rs b/crates/pg_lsp_new/src/session.rs new file mode 100644 index 000000000..8981b91d8 --- /dev/null +++ b/crates/pg_lsp_new/src/session.rs @@ -0,0 +1,442 @@ +use crate::documents::Document; +use anyhow::Result; +use pg_configuration::ConfigurationPathHint; +use pg_diagnostics::{DiagnosticExt, Error}; +use pg_fs::{FileSystem, PgLspPath}; +use pg_lsp_converters::{negotiated_encoding, PositionEncoding, WideEncoding}; +use pg_workspace_new::configuration::{load_configuration, LoadedConfiguration}; +use pg_workspace_new::settings::PartialConfigurationExt; +use pg_workspace_new::workspace::UpdateSettingsParams; +use pg_workspace_new::Workspace; +use pg_workspace_new::{DynRef, WorkspaceError}; +use rustc_hash::FxHashMap; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, AtomicU8}; +use std::sync::Arc; +use std::sync::RwLock; +use tokio::sync::Notify; +use tokio::sync::OnceCell; +use tower_lsp::lsp_types; +use tower_lsp::lsp_types::Url; +use tower_lsp::lsp_types::{MessageType, Registration}; +use tower_lsp::lsp_types::{Unregistration, WorkspaceFolder}; +use tracing::{error, info}; + +pub(crate) struct ClientInformation { + /// The name of the client + pub(crate) name: String, + + /// The version of the client + pub(crate) version: Option, +} + +/// Key, uniquely identifying a LSP session. +#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] +pub(crate) struct SessionKey(pub u64); + +/// Represents the state of an LSP server session. +pub(crate) struct Session { + /// The unique key identifying this session. + pub(crate) key: SessionKey, + + /// The LSP client for this session. + pub(crate) client: tower_lsp::Client, + + /// The parameters provided by the client in the "initialize" request + initialize_params: OnceCell, + + pub(crate) workspace: Arc, + + configuration_status: AtomicU8, + + /// A flag to notify a message to the user when the configuration is broken, and the LSP attempts + /// to update the diagnostics + notified_broken_configuration: AtomicBool, + + /// File system to read files inside the workspace + pub(crate) fs: DynRef<'static, dyn FileSystem>, + + documents: RwLock>, + + pub(crate) cancellation: Arc, + + pub(crate) config_path: Option, +} + +/// The parameters provided by the client in the "initialize" request +struct InitializeParams { + /// The capabilities provided by the client as part of [`lsp_types::InitializeParams`] + client_capabilities: lsp_types::ClientCapabilities, + client_information: Option, + root_uri: Option, + #[allow(unused)] + workspace_folders: Option>, +} + +#[repr(u8)] +pub(crate) enum ConfigurationStatus { + /// The configuration file was properly loaded + Loaded = 0, + /// The configuration file does not exist + Missing = 1, + /// The configuration file exists but could not be loaded + Error = 2, + /// Currently loading the configuration + Loading = 3, +} + +impl ConfigurationStatus { + pub(crate) const fn is_error(&self) -> bool { + matches!(self, ConfigurationStatus::Error) + } + + pub(crate) const fn is_loaded(&self) -> bool { + matches!(self, ConfigurationStatus::Loaded) + } +} + +impl TryFrom for ConfigurationStatus { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Loaded), + 1 => Ok(Self::Missing), + 2 => Ok(Self::Error), + 3 => Ok(Self::Loading), + _ => Err(()), + } + } +} + +pub(crate) type SessionHandle = Arc; + +/// Holds the set of capabilities supported by the Language Server +/// instance and whether they are enabled or not +#[derive(Default)] +pub(crate) struct CapabilitySet { + registry: FxHashMap<&'static str, (&'static str, CapabilityStatus)>, +} + +/// Represents whether a capability is enabled or not, optionally holding the +/// configuration associated with the capability +pub(crate) enum CapabilityStatus { + Enable(Option), + Disable, +} + +impl CapabilitySet { + /// Insert a capability in the set + pub(crate) fn add_capability( + &mut self, + id: &'static str, + method: &'static str, + status: CapabilityStatus, + ) { + self.registry.insert(id, (method, status)); + } +} + +impl Session { + pub(crate) fn new( + key: SessionKey, + client: tower_lsp::Client, + workspace: Arc, + cancellation: Arc, + fs: DynRef<'static, dyn FileSystem>, + ) -> Self { + let documents = Default::default(); + Self { + key, + client, + initialize_params: OnceCell::default(), + workspace, + configuration_status: AtomicU8::new(ConfigurationStatus::Missing as u8), + documents, + fs, + cancellation, + config_path: None, + notified_broken_configuration: AtomicBool::new(false), + } + } + + pub(crate) fn set_config_path(&mut self, path: PathBuf) { + self.config_path = Some(path); + } + + /// Initialize this session instance with the incoming initialization parameters from the client + pub(crate) fn initialize( + &self, + client_capabilities: lsp_types::ClientCapabilities, + client_information: Option, + root_uri: Option, + workspace_folders: Option>, + ) { + let result = self.initialize_params.set(InitializeParams { + client_capabilities, + client_information, + root_uri, + workspace_folders, + }); + + if let Err(err) = result { + error!("Failed to initialize session: {err}"); + } + } + + /// Register a set of capabilities with the client + pub(crate) async fn register_capabilities(&self, capabilities: CapabilitySet) { + let mut registrations = Vec::new(); + let mut unregistrations = Vec::new(); + + let mut register_methods = String::new(); + let mut unregister_methods = String::new(); + + for (id, (method, status)) in capabilities.registry { + unregistrations.push(Unregistration { + id: id.to_string(), + method: method.to_string(), + }); + + if !unregister_methods.is_empty() { + unregister_methods.push_str(", "); + } + + unregister_methods.push_str(method); + + if let CapabilityStatus::Enable(register_options) = status { + registrations.push(Registration { + id: id.to_string(), + method: method.to_string(), + register_options, + }); + + if !register_methods.is_empty() { + register_methods.push_str(", "); + } + + register_methods.push_str(method); + } + } + + if let Err(e) = self.client.unregister_capability(unregistrations).await { + error!( + "Error unregistering {unregister_methods:?} capabilities: {}", + e + ); + } else { + info!("Unregister capabilities {unregister_methods:?}"); + } + + if let Err(e) = self.client.register_capability(registrations).await { + error!("Error registering {register_methods:?} capabilities: {}", e); + } else { + info!("Register capabilities {register_methods:?}"); + } + } + + /// Get a [`Document`] matching the provided [`lsp_types::Url`] + /// + /// If document does not exist, result is [WorkspaceError::NotFound] + pub(crate) fn document(&self, url: &lsp_types::Url) -> Result { + self.documents + .read() + .unwrap() + .get(url) + .cloned() + .ok_or_else(|| WorkspaceError::not_found().with_file_path(url.to_string())) + } + + /// Set the [`Document`] for the provided [`lsp_types::Url`] + /// + /// Used by [`handlers::text_document] to synchronize documents with the client. + pub(crate) fn insert_document(&self, url: lsp_types::Url, document: Document) { + self.documents.write().unwrap().insert(url, document); + } + + /// Remove the [`Document`] matching the provided [`lsp_types::Url`] + pub(crate) fn remove_document(&self, url: &lsp_types::Url) { + self.documents.write().unwrap().remove(url); + } + + pub(crate) fn file_path(&self, url: &lsp_types::Url) -> Result { + let path_to_file = match url.to_file_path() { + Err(_) => { + // If we can't create a path, it's probably because the file doesn't exist. + // It can be a newly created file that it's not on disk + PathBuf::from(url.path()) + } + Ok(path) => path, + }; + + Ok(PgLspPath::new(path_to_file)) + } + + /// True if the client supports dynamic registration of "workspace/didChangeConfiguration" requests + pub(crate) fn can_register_did_change_configuration(&self) -> bool { + self.initialize_params + .get() + .and_then(|c| c.client_capabilities.workspace.as_ref()) + .and_then(|c| c.did_change_configuration) + .and_then(|c| c.dynamic_registration) + == Some(true) + } + + /// Get the current workspace folders + pub(crate) fn get_workspace_folders(&self) -> Option<&Vec> { + self.initialize_params + .get() + .and_then(|c| c.workspace_folders.as_ref()) + } + + /// Returns the base path of the workspace on the filesystem if it has one + pub(crate) fn base_path(&self) -> Option { + let initialize_params = self.initialize_params.get()?; + + let root_uri = initialize_params.root_uri.as_ref()?; + match root_uri.to_file_path() { + Ok(base_path) => Some(base_path), + Err(()) => { + error!( + "The Workspace root URI {root_uri:?} could not be parsed as a filesystem path" + ); + None + } + } + } + + /// Returns a reference to the client information for this session + pub(crate) fn client_information(&self) -> Option<&ClientInformation> { + self.initialize_params.get()?.client_information.as_ref() + } + + /// This function attempts to read the `pglsp.toml` configuration file from + /// the root URI and update the workspace settings accordingly + #[tracing::instrument(level = "trace", skip(self))] + pub(crate) async fn load_workspace_settings(&self) { + // Providing a custom configuration path will not allow to support workspaces + if let Some(config_path) = &self.config_path { + let base_path = ConfigurationPathHint::FromUser(config_path.clone()); + let status = self.load_pglsp_configuration_file(base_path).await; + self.set_configuration_status(status); + } else if let Some(folders) = self.get_workspace_folders() { + info!("Detected workspace folder."); + self.set_configuration_status(ConfigurationStatus::Loading); + for folder in folders { + info!("Attempt to load the configuration file in {:?}", folder.uri); + let base_path = folder.uri.to_file_path(); + match base_path { + Ok(base_path) => { + let status = self + .load_pglsp_configuration_file(ConfigurationPathHint::FromWorkspace( + base_path, + )) + .await; + self.set_configuration_status(status); + } + Err(_) => { + error!( + "The Workspace root URI {:?} could not be parsed as a filesystem path", + folder.uri + ); + } + } + } + } else { + let base_path = match self.base_path() { + None => ConfigurationPathHint::default(), + Some(path) => ConfigurationPathHint::FromLsp(path), + }; + let status = self.load_pglsp_configuration_file(base_path).await; + self.set_configuration_status(status); + } + } + + async fn load_pglsp_configuration_file( + &self, + base_path: ConfigurationPathHint, + ) -> ConfigurationStatus { + match load_configuration(&self.fs, base_path.clone()) { + Ok(loaded_configuration) => { + let LoadedConfiguration { + configuration: fs_configuration, + directory_path: configuration_path, + .. + } = loaded_configuration; + info!("Configuration loaded successfully from disk."); + info!("Update workspace settings."); + + let result = fs_configuration + .retrieve_gitignore_matches(&self.fs, configuration_path.as_deref()); + + match result { + Ok((vcs_base_path, gitignore_matches)) => { + let result = self.workspace.update_settings(UpdateSettingsParams { + workspace_directory: self.fs.working_directory(), + configuration: fs_configuration, + vcs_base_path, + gitignore_matches, + }); + + if let Err(error) = result { + error!("Failed to set workspace settings: {}", error); + self.client.log_message(MessageType::ERROR, &error).await; + ConfigurationStatus::Error + } else { + ConfigurationStatus::Loaded + } + } + Err(err) => { + error!("Couldn't load the configuration file, reason:\n {}", err); + self.client.log_message(MessageType::ERROR, &err).await; + ConfigurationStatus::Error + } + } + } + Err(err) => { + error!("Couldn't load the configuration file, reason:\n {}", err); + self.client.log_message(MessageType::ERROR, &err).await; + ConfigurationStatus::Error + } + } + } + + /// Broadcast a shutdown signal to all active connections + pub(crate) fn broadcast_shutdown(&self) { + self.cancellation.notify_one(); + } + + /// Retrieves information regarding the configuration status + pub(crate) fn configuration_status(&self) -> ConfigurationStatus { + self.configuration_status + .load(Ordering::Relaxed) + .try_into() + .unwrap() + } + + /// Updates the status of the configuration + fn set_configuration_status(&self, status: ConfigurationStatus) { + self.notified_broken_configuration + .store(false, Ordering::Relaxed); + self.configuration_status + .store(status as u8, Ordering::Relaxed); + } + + fn notified_broken_configuration(&self) -> bool { + self.notified_broken_configuration.load(Ordering::Relaxed) + } + fn set_notified_broken_configuration(&self) { + self.notified_broken_configuration + .store(true, Ordering::Relaxed); + } + + pub fn position_encoding(&self) -> PositionEncoding { + self.initialize_params + .get() + .map_or(PositionEncoding::Wide(WideEncoding::Utf16), |params| { + negotiated_encoding(¶ms.client_capabilities) + }) + } +} diff --git a/crates/pg_lsp_new/src/utils.rs b/crates/pg_lsp_new/src/utils.rs new file mode 100644 index 000000000..10010ac3b --- /dev/null +++ b/crates/pg_lsp_new/src/utils.rs @@ -0,0 +1,441 @@ +use anyhow::{ensure, Context, Result}; +use pg_console::fmt::Termcolor; +use pg_console::fmt::{self, Formatter}; +use pg_console::MarkupBuf; +use pg_diagnostics::termcolor::NoColor; +use pg_diagnostics::{Diagnostic, DiagnosticTags, Location, PrintDescription, Severity, Visit}; +use pg_lsp_converters::line_index::LineIndex; +use pg_lsp_converters::{from_proto, to_proto, PositionEncoding}; +use pg_text_edit::{CompressedOp, DiffOp, TextEdit}; +use std::any::Any; +use std::borrow::Cow; +use std::fmt::{Debug, Display}; +use std::ops::{Add, Range}; +use std::io; +use text_size::{TextRange, TextSize}; +use tower_lsp::jsonrpc::Error as LspError; +use tower_lsp::lsp_types; +use tower_lsp::lsp_types::{self as lsp, CodeDescription, Url}; +use tracing::error; + +pub(crate) fn text_edit( + line_index: &LineIndex, + diff: TextEdit, + position_encoding: PositionEncoding, + offset: Option, +) -> Result> { + let mut result: Vec = Vec::new(); + let mut offset = if let Some(offset) = offset { + TextSize::from(offset) + } else { + TextSize::from(0) + }; + + for op in diff.iter() { + match op { + CompressedOp::DiffOp(DiffOp::Equal { range }) => { + offset += range.len(); + } + CompressedOp::DiffOp(DiffOp::Insert { range }) => { + let start = to_proto::position(line_index, offset, position_encoding)?; + + // Merge with a previous delete operation if possible + let last_edit = result.last_mut().filter(|text_edit| { + text_edit.range.end == start && text_edit.new_text.is_empty() + }); + + if let Some(last_edit) = last_edit { + last_edit.new_text = diff.get_text(*range).to_string(); + } else { + result.push(lsp::TextEdit { + range: lsp::Range::new(start, start), + new_text: diff.get_text(*range).to_string(), + }); + } + } + CompressedOp::DiffOp(DiffOp::Delete { range }) => { + let start = to_proto::position(line_index, offset, position_encoding)?; + offset += range.len(); + let end = to_proto::position(line_index, offset, position_encoding)?; + + result.push(lsp::TextEdit { + range: lsp::Range::new(start, end), + new_text: String::new(), + }); + } + + CompressedOp::EqualLines { line_count } => { + let mut line_col = line_index + .line_col(offset) + .expect("diff length is overflowing the line count in the original file"); + + line_col.line += line_count.get() + 1; + line_col.col = 0; + + // SAFETY: This should only happen if `line_index` wasn't built + // from the same string as the old revision of `diff` + let new_offset = line_index + .offset(line_col) + .expect("diff length is overflowing the line count in the original file"); + + offset = new_offset; + } + } + } + + Ok(result) +} + +/// Convert an [pg_diagnostics::Diagnostic] to a [lsp::Diagnostic], using the span +/// of the diagnostic's primary label as the diagnostic range. +/// Requires a [LineIndex] to convert a byte offset range to the line/col range +/// expected by LSP. +pub(crate) fn diagnostic_to_lsp( + diagnostic: D, + url: &lsp::Url, + line_index: &LineIndex, + position_encoding: PositionEncoding, + offset: Option, +) -> Result { + let location = diagnostic.location(); + + let span = location.span.context("diagnostic location has no span")?; + let span = if let Some(offset) = offset { + TextRange::new( + span.start().add(TextSize::from(offset)), + span.end().add(TextSize::from(offset)), + ) + } else { + span + }; + let span = to_proto::range(line_index, span, position_encoding) + .context("failed to convert diagnostic span to LSP range")?; + + let severity = match diagnostic.severity() { + Severity::Fatal | Severity::Error => lsp::DiagnosticSeverity::ERROR, + Severity::Warning => lsp::DiagnosticSeverity::WARNING, + Severity::Information => lsp::DiagnosticSeverity::INFORMATION, + Severity::Hint => lsp::DiagnosticSeverity::HINT, + }; + + let code = diagnostic + .category() + .map(|category| lsp::NumberOrString::String(category.name().to_string())); + + let code_description = diagnostic + .category() + .and_then(|category| category.link()) + .and_then(|link| { + let href = Url::parse(link).ok()?; + Some(CodeDescription { href }) + }); + + let message = PrintDescription(&diagnostic).to_string(); + ensure!(!message.is_empty(), "diagnostic description is empty"); + + let mut related_information = None; + let mut visitor = RelatedInformationVisitor { + url, + line_index, + position_encoding, + related_information: &mut related_information, + }; + + diagnostic.advices(&mut visitor).unwrap(); + + let tags = diagnostic.tags(); + let tags = { + let mut result = Vec::new(); + + if tags.contains(DiagnosticTags::UNNECESSARY_CODE) { + result.push(lsp::DiagnosticTag::UNNECESSARY); + } + + if tags.contains(DiagnosticTags::DEPRECATED_CODE) { + result.push(lsp::DiagnosticTag::DEPRECATED); + } + + if !result.is_empty() { + Some(result) + } else { + None + } + }; + + let mut diagnostic = lsp::Diagnostic::new( + span, + Some(severity), + code, + Some("pg".into()), + message, + related_information, + tags, + ); + diagnostic.code_description = code_description; + Ok(diagnostic) +} + +struct RelatedInformationVisitor<'a> { + url: &'a lsp::Url, + line_index: &'a LineIndex, + position_encoding: PositionEncoding, + related_information: &'a mut Option>, +} + +impl Visit for RelatedInformationVisitor<'_> { + fn record_frame(&mut self, location: Location<'_>) -> io::Result<()> { + let span = match location.span { + Some(span) => span, + None => return Ok(()), + }; + + let range = match to_proto::range(self.line_index, span, self.position_encoding) { + Ok(range) => range, + Err(_) => return Ok(()), + }; + + let related_information = self.related_information.get_or_insert_with(Vec::new); + + related_information.push(lsp::DiagnosticRelatedInformation { + location: lsp::Location { + uri: self.url.clone(), + range, + }, + message: String::new(), + }); + + Ok(()) + } +} + +/// Convert a piece of markup into a String +fn print_markup(markup: &MarkupBuf) -> String { + let mut message = Termcolor(NoColor::new(Vec::new())); + fmt::Display::fmt(markup, &mut Formatter::new(&mut message)) + // SAFETY: Writing to a memory buffer should never fail + .unwrap(); + + // SAFETY: Printing uncolored markup never generates non UTF-8 byte sequences + String::from_utf8(message.0.into_inner()).unwrap() +} + +/// Helper to create a [tower_lsp::jsonrpc::Error] from a message +pub(crate) fn into_lsp_error(msg: impl Display + Debug) -> LspError { + let mut error = LspError::internal_error(); + error!("Error: {}", msg); + error.message = Cow::Owned(msg.to_string()); + error.data = Some(format!("{msg:?}").into()); + error +} + +pub(crate) fn panic_to_lsp_error(err: Box) -> LspError { + let mut error = LspError::internal_error(); + + match err.downcast::() { + Ok(msg) => { + error.message = Cow::Owned(msg.to_string()); + } + Err(err) => match err.downcast::<&str>() { + Ok(msg) => { + error.message = Cow::Owned(msg.to_string()); + } + Err(_) => { + error.message = Cow::Owned(String::from("Encountered an unknown error")); + } + }, + } + + error +} + +pub(crate) fn apply_document_changes( + position_encoding: PositionEncoding, + current_content: String, + content_changes: &[lsp_types::TextDocumentContentChangeEvent], +) -> String { + // Skip to the last full document change, as it invalidates all previous changes anyways. + let mut start = content_changes + .iter() + .rev() + .position(|change| change.range.is_none()) + .map_or(0, |idx| content_changes.len() - idx - 1); + + let mut text: String = match content_changes.get(start) { + // peek at the first content change as an optimization + Some(lsp_types::TextDocumentContentChangeEvent { + range: None, text, .. + }) => { + let text = text.clone(); + start += 1; + + // The only change is a full document update + if start == content_changes.len() { + return text; + } + text + } + Some(_) => current_content, + // we received no content changes + None => return current_content, + }; + + let mut line_index = LineIndex::new(&text); + + // The changes we got must be applied sequentially, but can cross lines so we + // have to keep our line index updated. + // Some clients (e.g. Code) sort the ranges in reverse. As an optimization, we + // remember the last valid line in the index and only rebuild it if needed. + let mut index_valid = u32::MAX; + for change in content_changes { + // The None case can't happen as we have handled it above already + if let Some(range) = change.range { + if index_valid <= range.end.line { + line_index = LineIndex::new(&text); + } + index_valid = range.start.line; + if let Ok(range) = from_proto::text_range(&line_index, range, position_encoding) { + text.replace_range(Range::::from(range), &change.text); + } + } + } + + text +} + +#[cfg(test)] +mod tests { + use super::apply_document_changes; + use pg_lsp_converters::line_index::LineIndex; + use pg_lsp_converters::{PositionEncoding, WideEncoding}; + use pg_text_edit::TextEdit; + use tower_lsp::lsp_types as lsp; + use tower_lsp::lsp_types::{Position, Range, TextDocumentContentChangeEvent}; + + #[test] + fn test_diff_1() { + const OLD: &str = "line 1 old +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 old"; + + const NEW: &str = "line 1 new +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 new"; + + let line_index = LineIndex::new(OLD); + let diff = TextEdit::from_unicode_words(OLD, NEW); + + let text_edit = super::text_edit(&line_index, diff, PositionEncoding::Utf8, None).unwrap(); + + assert_eq!( + text_edit.as_slice(), + &[ + lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 7, + }, + end: lsp::Position { + line: 0, + character: 10, + }, + }, + new_text: String::from("new"), + }, + lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 6, + character: 7 + }, + end: lsp::Position { + line: 6, + character: 10 + } + }, + new_text: String::from("new"), + }, + ] + ); + } + + #[test] + fn test_diff_2() { + const OLD: &str = "console.log(\"Variable: \" + variable);"; + const NEW: &str = "console.log(`Variable: ${variable}`);"; + + let line_index = LineIndex::new(OLD); + let diff = TextEdit::from_unicode_words(OLD, NEW); + + let text_edit = super::text_edit(&line_index, diff, PositionEncoding::Utf8, None).unwrap(); + + assert_eq!( + text_edit.as_slice(), + &[ + lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 12, + }, + end: lsp::Position { + line: 0, + character: 13, + }, + }, + new_text: String::from("`"), + }, + lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 23 + }, + end: lsp::Position { + line: 0, + character: 27 + } + }, + new_text: String::from("${"), + }, + lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 35 + }, + end: lsp::Position { + line: 0, + character: 35 + } + }, + new_text: String::from("}`"), + }, + ] + ); + } + + // #[test] + // fn test_range_formatting() { + // let encoding = PositionEncoding::Wide(WideEncoding::Utf16); + // let input = "(\"Jan 1, 2018\u{2009}–\u{2009}Jan 1, 2019\");\n(\"Jan 1, 2018\u{2009}–\u{2009}Jan 1, 2019\");\nisSpreadAssignment;\n".to_string(); + // let change = TextDocumentContentChangeEvent { + // range: Some(Range::new(Position::new(0, 30), Position::new(1, 0))), + // range_length: Some(1), + // text: String::new(), + // }; + // + // let output = apply_document_changes(encoding, input, vec![change]); + // let expected = "(\"Jan 1, 2018\u{2009}–\u{2009}Jan 1, 2019\");(\"Jan 1, 2018\u{2009}–\u{2009}Jan 1, 2019\");\nisSpreadAssignment;\n"; + // + // assert_eq!(output, expected); + // } +} diff --git a/crates/pg_markup/Cargo.toml b/crates/pg_markup/Cargo.toml new file mode 100644 index 000000000..f0a615524 --- /dev/null +++ b/crates/pg_markup/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pg_markup" +version = "0.0.0" +edition = "2021" + +[dependencies] +proc-macro-error = { version = "1.0.4", default-features = false } +proc-macro2 = { workspace = true } +quote = "1.0.14" + +[dev-dependencies] + +[lib] +proc-macro = true + +[features] diff --git a/crates/pg_markup/README.md b/crates/pg_markup/README.md new file mode 100644 index 000000000..cc6462e16 --- /dev/null +++ b/crates/pg_markup/README.md @@ -0,0 +1,10 @@ +# `pg_markup` + +The crate contains procedural macros to build `pg_console` markup object with a JSX-like syntax + +The macro cannot be used alone as it generates code that requires supporting types declared in the +`pg_console` crate, so it's re-exported from there and should be used as `pg_console::markup` + +## Acknowledgement + +This crate was initially forked from [biome](https://github.com/biomejs/biome). diff --git a/crates/pg_markup/src/lib.rs b/crates/pg_markup/src/lib.rs new file mode 100644 index 000000000..bcd3d86cf --- /dev/null +++ b/crates/pg_markup/src/lib.rs @@ -0,0 +1,169 @@ +use proc_macro2::{Delimiter, Group, Ident, TokenStream, TokenTree}; +use proc_macro_error::*; +use quote::{quote, ToTokens}; + +struct StackEntry { + name: Ident, + attributes: Vec<(Ident, TokenTree)>, +} + +impl ToTokens for StackEntry { + fn to_tokens(&self, tokens: &mut TokenStream) { + let name = &self.name; + tokens.extend(quote! { + pg_console::MarkupElement::#name + }); + + if !self.attributes.is_empty() { + let attributes: Vec<_> = self + .attributes + .iter() + .map(|(key, value)| quote! { #key: (#value).into() }) + .collect(); + + tokens.extend(quote! { { #( #attributes ),* } }) + } + } +} + +#[proc_macro] +#[proc_macro_error] +pub fn markup(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let mut input = TokenStream::from(input).into_iter().peekable(); + let mut stack = Vec::new(); + let mut output = Vec::new(); + + while let Some(token) = input.next() { + match token { + TokenTree::Punct(punct) => match punct.as_char() { + '<' => { + let is_closing_element = match input.peek() { + Some(TokenTree::Punct(punct)) if punct.as_char() == '/' => { + // SAFETY: Guarded by above call to peek + input.next().unwrap(); + true + } + _ => false, + }; + + let name = match input.next() { + Some(TokenTree::Ident(ident)) => ident, + Some(token) => abort!(token.span(), "unexpected token"), + None => abort_call_site!("unexpected end of input"), + }; + + let mut attributes = Vec::new(); + while let Some(TokenTree::Ident(_)) = input.peek() { + // SAFETY: these panics are checked by the above call to peek + let attr = match input.next().unwrap() { + TokenTree::Ident(attr) => attr, + _ => unreachable!(), + }; + + match input.next() { + Some(TokenTree::Punct(punct)) => { + if punct.as_char() != '=' { + abort!(punct.span(), "unexpected token"); + } + } + Some(token) => abort!(token.span(), "unexpected token"), + None => abort_call_site!("unexpected end of input"), + } + + let value = match input.next() { + Some(TokenTree::Literal(value)) => TokenTree::Literal(value), + Some(TokenTree::Group(group)) => { + TokenTree::Group(Group::new(Delimiter::None, group.stream())) + } + Some(token) => abort!(token.span(), "unexpected token"), + None => abort_call_site!("unexpected end of input"), + }; + + attributes.push((attr, value)); + } + + let is_self_closing = match input.next() { + Some(TokenTree::Punct(punct)) => match punct.as_char() { + '>' => false, + '/' if !is_closing_element => { + match input.next() { + Some(TokenTree::Punct(punct)) if punct.as_char() == '>' => {} + Some(token) => abort!(token.span(), "unexpected token"), + None => abort_call_site!("unexpected end of input"), + } + true + } + _ => abort!(punct.span(), "unexpected token"), + }, + Some(token) => abort!(token.span(), "unexpected token"), + None => abort_call_site!("unexpected end of input"), + }; + + if !is_closing_element { + stack.push(StackEntry { + name: name.clone(), + attributes: attributes.clone(), + }); + } else if let Some(top) = stack.last() { + // Only verify the coherence of the top element on the + // stack with a closing element, skip over the check if + // the stack is empty as that error will be handled + // when the top element gets popped off the stack later + let name_str = name.to_string(); + let top_str = top.name.to_string(); + if name_str != top_str { + abort!( + name.span(), "closing element mismatch"; + close = "found closing element {}", name_str; + open = top.name.span() => "expected {}", top_str + ); + } + } + + if (is_closing_element || is_self_closing) && stack.pop().is_none() { + abort!(name.span(), "unexpected closing element"); + } + } + _ => { + abort!(punct.span(), "unexpected token"); + } + }, + TokenTree::Literal(literal) => { + let elements: Vec<_> = stack + .iter() + .map(|entry| { + quote! { #entry } + }) + .collect(); + + output.push(quote! { + pg_console::MarkupNode { + elements: &[ #( #elements ),* ], + content: &(#literal), + } + }); + } + TokenTree::Group(group) => match group.delimiter() { + Delimiter::Brace => { + let elements: Vec<_> = stack.iter().map(|entry| quote! { #entry }).collect(); + + let body = group.stream(); + output.push(quote! { + pg_console::MarkupNode { + elements: &[ #( #elements ),* ], + content: &(#body) as &dyn pg_console::fmt::Display, + } + }); + } + _ => abort!(group.span(), "unexpected token"), + }, + TokenTree::Ident(_) => abort!(token.span(), "unexpected token"), + } + } + + if let Some(top) = stack.pop() { + abort!(top.name.span(), "unclosed element"); + } + + quote! { pg_console::Markup(&[ #( #output ),* ]) }.into() +} diff --git a/crates/pg_query_ext_codegen/Cargo.toml b/crates/pg_query_ext_codegen/Cargo.toml index 55a8fa7df..88f575406 100644 --- a/crates/pg_query_ext_codegen/Cargo.toml +++ b/crates/pg_query_ext_codegen/Cargo.toml @@ -4,8 +4,8 @@ version = "0.0.0" edition = "2021" [dependencies] -proc-macro2 = "1.0.66" -quote = "1.0.33" +proc-macro2.workspace = true +quote.workspace = true pg_query_proto_parser.workspace = true diff --git a/crates/pg_query_ext_codegen/src/get_location.rs b/crates/pg_query_ext_codegen/src/get_location.rs index 2d018a99d..507a5dacd 100644 --- a/crates/pg_query_ext_codegen/src/get_location.rs +++ b/crates/pg_query_ext_codegen/src/get_location.rs @@ -103,8 +103,7 @@ fn location_idents(nodes: &[Node], exclude_nodes: &[&str]) -> Vec { if node .fields .iter() - .find(|n| n.name == "location" && n.field_type == FieldType::Int32) - .is_some() + .any(|n| n.name == "location" && n.field_type == FieldType::Int32) { quote! { Some(n.location) } } else { diff --git a/crates/pg_query_ext_codegen/src/get_node_properties.rs b/crates/pg_query_ext_codegen/src/get_node_properties.rs index df7cca6bf..455d4fc9b 100644 --- a/crates/pg_query_ext_codegen/src/get_node_properties.rs +++ b/crates/pg_query_ext_codegen/src/get_node_properties.rs @@ -151,8 +151,8 @@ fn node_handlers(nodes: &[Node]) -> Vec { nodes .iter() .map(|node| { - let string_property_handlers = string_property_handlers(&node); - let custom_handlers = custom_handlers(&node); + let string_property_handlers = string_property_handlers(node); + let custom_handlers = custom_handlers(node); quote! { #custom_handlers #(#string_property_handlers)* diff --git a/crates/pg_query_ext_codegen/src/get_nodes.rs b/crates/pg_query_ext_codegen/src/get_nodes.rs index bf3174401..c097773de 100644 --- a/crates/pg_query_ext_codegen/src/get_nodes.rs +++ b/crates/pg_query_ext_codegen/src/get_nodes.rs @@ -96,7 +96,7 @@ fn node_handlers(nodes: &[Node], exclude_nodes: &[&str]) -> Vec { .iter() .filter(|node| !exclude_nodes.contains(&node.name.as_str())) .map(|node| { - let property_handlers = property_handlers(&node); + let property_handlers = property_handlers(node); quote! { #(#property_handlers)* } @@ -117,7 +117,7 @@ fn property_handlers(node: &Node) -> Vec { handle_child(x.node.as_ref().unwrap().to_owned()); }); }) - } else if field.field_type == FieldType::Node && field.is_one_of == false { + } else if field.field_type == FieldType::Node && !field.is_one_of { if field.node_name == Some("Node".to_owned()) { Some(quote! { if n.#field_name.is_some() { diff --git a/crates/pg_query_ext_codegen/src/node_iterator.rs b/crates/pg_query_ext_codegen/src/node_iterator.rs index ce8721e18..7b0187d5c 100644 --- a/crates/pg_query_ext_codegen/src/node_iterator.rs +++ b/crates/pg_query_ext_codegen/src/node_iterator.rs @@ -78,7 +78,7 @@ fn node_handlers(nodes: &[Node], exclude_nodes: &[&str]) -> Vec { .iter() .filter(|node| !exclude_nodes.contains(&node.name.as_str())) .map(|node| { - let property_handlers = property_handlers(&node); + let property_handlers = property_handlers(node); quote! { #(#property_handlers)* } @@ -99,7 +99,7 @@ fn property_handlers(node: &Node) -> Vec { self.stack.push_back((x.node.as_ref().unwrap().to_owned(), current_depth)); }); }) - } else if field.field_type == FieldType::Node && field.is_one_of == false { + } else if field.field_type == FieldType::Node && !field.is_one_of { if field.node_name == Some("Node".to_owned()) { Some(quote! { if n.#field_name.is_some() { diff --git a/crates/pg_query_proto_parser/src/proto_parser.rs b/crates/pg_query_proto_parser/src/proto_parser.rs index 70a890dc9..12a3e65e2 100644 --- a/crates/pg_query_proto_parser/src/proto_parser.rs +++ b/crates/pg_query_proto_parser/src/proto_parser.rs @@ -60,10 +60,7 @@ impl ProtoParser { .field .iter() .find(|e| e.type_name().split(".").last().unwrap() == type_name); - match variant { - Some(v) => Some(v.name.clone().unwrap().to_case(Case::UpperCamel)), - None => None, - } + variant.map(|v| v.name.clone().unwrap().to_case(Case::UpperCamel)) } fn nodes(&self) -> Vec { diff --git a/crates/pg_schema_cache/Cargo.toml b/crates/pg_schema_cache/Cargo.toml index 2cfb97e40..c1061b5f0 100644 --- a/crates/pg_schema_cache/Cargo.toml +++ b/crates/pg_schema_cache/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" [dependencies] async-std = { version = "1.12.0" } -serde = "1.0.195" -serde_json = "1.0.114" +serde.workspace = true +serde_json.workspace = true sqlx.workspace = true diff --git a/crates/pg_schema_cache/src/functions.rs b/crates/pg_schema_cache/src/functions.rs index 29c97e4f6..42f9ca877 100644 --- a/crates/pg_schema_cache/src/functions.rs +++ b/crates/pg_schema_cache/src/functions.rs @@ -5,17 +5,14 @@ use sqlx::PgPool; use crate::schema_cache::SchemaCacheItem; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Default)] pub enum Behavior { Immutable, Stable, + #[default] Volatile, } -impl Default for Behavior { - fn default() -> Self { - Behavior::Volatile - } -} impl From> for Behavior { fn from(s: Option) -> Self { diff --git a/crates/pg_schema_cache/src/tables.rs b/crates/pg_schema_cache/src/tables.rs index 8ca9ce3c7..3ca5802e1 100644 --- a/crates/pg_schema_cache/src/tables.rs +++ b/crates/pg_schema_cache/src/tables.rs @@ -3,18 +3,15 @@ use sqlx::PgPool; use crate::schema_cache::SchemaCacheItem; #[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Default)] pub enum ReplicaIdentity { + #[default] Default, Index, Full, Nothing, } -impl Default for ReplicaIdentity { - fn default() -> Self { - ReplicaIdentity::Default - } -} impl From for ReplicaIdentity { fn from(s: String) -> Self { diff --git a/crates/pg_statement_splitter/Cargo.toml b/crates/pg_statement_splitter/Cargo.toml index af00de08f..e7023fb7b 100644 --- a/crates/pg_statement_splitter/Cargo.toml +++ b/crates/pg_statement_splitter/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] pg_lexer.workspace = true -text-size = "1.1.1" +text-size.workspace = true [dev-dependencies] pg_query = "0.8" diff --git a/crates/pg_syntax/Cargo.toml b/crates/pg_syntax/Cargo.toml index 38907eaaf..d3ed23937 100644 --- a/crates/pg_syntax/Cargo.toml +++ b/crates/pg_syntax/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" petgraph = "0.6.4" cstree = { version = "0.12.0", features = ["derive"] } -text-size = "1.1.1" +text-size.workspace = true pg_query_ext.workspace = true pg_lexer.workspace = true diff --git a/crates/pg_syntax/src/ast/builder.rs b/crates/pg_syntax/src/ast/builder.rs index 93fdc5448..8da2b984a 100644 --- a/crates/pg_syntax/src/ast/builder.rs +++ b/crates/pg_syntax/src/ast/builder.rs @@ -25,7 +25,7 @@ impl AstBuilder { start: self.current_pos.try_into().unwrap(), end: None, }); - if self.open_nodes.len() > 0 { + if !self.open_nodes.is_empty() { let parent = self.open_nodes.last().unwrap(); self.inner.add_edge(parent.to_owned(), idx, ()); } diff --git a/crates/pg_syntax/src/lib.rs b/crates/pg_syntax/src/lib.rs index c72c75306..81e46e2ae 100644 --- a/crates/pg_syntax/src/lib.rs +++ b/crates/pg_syntax/src/lib.rs @@ -13,7 +13,7 @@ use syntax_builder::{Syntax, SyntaxBuilder}; pub fn parse_syntax(sql: &str, root: &pg_query_ext::NodeEnum) -> Syntax { let mut builder = SyntaxBuilder::new(); - StatementParser::new(&root, sql, &mut builder).parse(); + StatementParser::new(root, sql, &mut builder).parse(); builder.finish() } diff --git a/crates/pg_syntax/src/statement_parser.rs b/crates/pg_syntax/src/statement_parser.rs index ebb92384a..40fcd6419 100644 --- a/crates/pg_syntax/src/statement_parser.rs +++ b/crates/pg_syntax/src/statement_parser.rs @@ -42,7 +42,7 @@ impl<'p> StatementParser<'p> { event_sink: &'p mut SyntaxBuilder, ) -> StatementParser<'p> { Self { - node_graph: get_nodes(&root), + node_graph: get_nodes(root), current_node: NodeIndex::::new(0), open_nodes: Vec::new(), parser: Parser::new(lex(sql), Some(event_sink)), @@ -72,11 +72,11 @@ impl<'p> StatementParser<'p> { if !self.node_is_open(&node_idx) { // open all nodes from `self.current_node` to the target node `node_idx` - let mut ancestors = self.ancestors(Some(node_idx)); + let ancestors = self.ancestors(Some(node_idx)); let mut nodes_to_open = Vec::>::new(); // including the target node itself nodes_to_open.push(node_idx); - while let Some(nx) = ancestors.next() { + for nx in ancestors { if nx == self.current_node { break; } @@ -170,7 +170,7 @@ impl<'p> StatementParser<'p> { // if all direct children of the current node are being skipped, break if current_node_children .iter() - .all(|n| skipped_nodes.contains(&n)) + .all(|n| skipped_nodes.contains(n)) { break; } @@ -252,7 +252,7 @@ impl<'p> StatementParser<'p> { == 0 { // check if the node contains properties that are not at all in the part of the token stream that is not yet consumed and remove them - if self.node_graph[self.current_node].properties.len() > 0 { + if !self.node_graph[self.current_node].properties.is_empty() { // if there is any property left it must be next in the token stream because we are at a // leaf node. We can thereby reduce the search space to the next n non-whitespace token // where n is the number of remaining properties of the current node @@ -265,7 +265,7 @@ impl<'p> StatementParser<'p> { if token.kind == SyntaxKind::Eof { break; } - if cmp_tokens(&p, token) { + if cmp_tokens(p, token) { return true; } // FIXME: we also need to skip non-whitespace tokens such as "(" or ")", but @@ -281,15 +281,15 @@ impl<'p> StatementParser<'p> { }); } - if self.node_graph[self.current_node].properties.len() > 0 { + if !self.node_graph[self.current_node].properties.is_empty() { break; } self.finish_node(); - if self.open_nodes.len() == 0 { + if self.open_nodes.is_empty() { break; } - self.current_node = self.open_nodes.last().unwrap().clone(); + self.current_node = *self.open_nodes.last().unwrap(); } } @@ -426,7 +426,7 @@ fn aliases(text: &str) -> Vec<&str> { return alias.to_vec(); } } - return vec![text]; + vec![text] } /// Custom iterator for walking ancestors of a node until the root of the tree is reached diff --git a/crates/pg_text_edit/Cargo.toml b/crates/pg_text_edit/Cargo.toml new file mode 100644 index 000000000..706a8b8fc --- /dev/null +++ b/crates/pg_text_edit/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "pg_text_edit" +version = "0.0.0" +edition = "2021" + +[dependencies] +text-size = { workspace = true, features = ["serde"] } +similar = { workspace = true, features = ["unicode"] } +serde = { workspace = true, features = ["derive"] } +schemars = { workspace = true, optional = true } + +[features] +schemars = ["dep:schemars"] + +[dev-dependencies] + +[lib] +doctest = false + diff --git a/crates/pg_text_edit/src/lib.rs b/crates/pg_text_edit/src/lib.rs new file mode 100644 index 000000000..1970f0eca --- /dev/null +++ b/crates/pg_text_edit/src/lib.rs @@ -0,0 +1,372 @@ +//! Representation of a `TextEdit`. +//! +//! This is taken from [biome's text_edit crate](https://github.com/biomejs/biome) + +#![warn( + rust_2018_idioms, + unused_lifetimes, + semicolon_in_expressions_from_macros +)] + +use std::{cmp::Ordering, num::NonZeroU32}; + +use serde::{Deserialize, Serialize}; +pub use similar::ChangeTag; +use similar::{utils::TextDiffRemapper, TextDiff}; +use text_size::{TextRange, TextSize}; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct TextEdit { + dictionary: String, + ops: Vec, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompressedOp { + DiffOp(DiffOp), + EqualLines { line_count: NonZeroU32 }, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiffOp { + Equal { range: TextRange }, + Insert { range: TextRange }, + Delete { range: TextRange }, +} + +impl DiffOp { + pub fn tag(self) -> ChangeTag { + match self { + DiffOp::Equal { .. } => ChangeTag::Equal, + DiffOp::Insert { .. } => ChangeTag::Insert, + DiffOp::Delete { .. } => ChangeTag::Delete, + } + } + + pub fn text(self, diff: &TextEdit) -> &str { + let range = match self { + DiffOp::Equal { range } => range, + DiffOp::Insert { range } => range, + DiffOp::Delete { range } => range, + }; + + diff.get_text(range) + } +} + +#[derive(Debug, Default, Clone)] +pub struct TextEditBuilder { + index: Vec, + edit: TextEdit, +} + +impl TextEdit { + /// Convenience method for creating a new [TextEditBuilder] + pub fn builder() -> TextEditBuilder { + TextEditBuilder::default() + } + + /// Create a diff of `old` to `new`, tokenized by Unicode words + pub fn from_unicode_words(old: &str, new: &str) -> Self { + let mut builder = Self::builder(); + builder.with_unicode_words_diff(old, new); + builder.finish() + } + + /// Returns the number of [DiffOp] in this [TextEdit] + pub fn len(&self) -> usize { + self.ops.len() + } + + /// Return `true` is this [TextEdit] doesn't contain any [DiffOp] + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Returns an [Iterator] over the [DiffOp] of this [TextEdit] + pub fn iter(&self) -> std::slice::Iter<'_, CompressedOp> { + self.into_iter() + } + + /// Return the text value of range interned in this [TextEdit] dictionnary + pub fn get_text(&self, range: TextRange) -> &str { + &self.dictionary[range] + } + + /// Return the content of the "new" revision of the text represented in + /// this [TextEdit]. This methods needs to be provided with the "old" + /// revision of the string since [TextEdit] doesn't store the content of + /// text sections that are equal between revisions + pub fn new_string(&self, old_string: &str) -> String { + let mut output = String::new(); + let mut input_position = TextSize::from(0); + + for op in &self.ops { + match op { + CompressedOp::DiffOp(DiffOp::Equal { range }) => { + output.push_str(&self.dictionary[*range]); + input_position += range.len(); + } + CompressedOp::DiffOp(DiffOp::Insert { range }) => { + output.push_str(&self.dictionary[*range]); + } + CompressedOp::DiffOp(DiffOp::Delete { range }) => { + input_position += range.len(); + } + CompressedOp::EqualLines { line_count } => { + let start = u32::from(input_position) as usize; + let input = &old_string[start..]; + + let line_break_count = line_count.get() as usize + 1; + for line in input.split_inclusive('\n').take(line_break_count) { + output.push_str(line); + input_position += TextSize::of(line); + } + } + } + } + + output + } +} + +impl IntoIterator for TextEdit { + type Item = CompressedOp; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.ops.into_iter() + } +} + +impl<'a> IntoIterator for &'a TextEdit { + type Item = &'a CompressedOp; + type IntoIter = std::slice::Iter<'a, CompressedOp>; + + fn into_iter(self) -> Self::IntoIter { + self.ops.iter() + } +} + +impl TextEditBuilder { + pub fn is_empty(&self) -> bool { + self.edit.ops.is_empty() + } + + /// Add a piece of string to the dictionnary, returning the corresponding + /// range in the dictionnary string + fn intern(&mut self, value: &str) -> TextRange { + let value_bytes = value.as_bytes(); + let value_len = TextSize::of(value); + + let index = self.index.binary_search_by(|range| { + let entry = self.edit.dictionary[*range].as_bytes(); + + for (lhs, rhs) in entry.iter().zip(value_bytes) { + match lhs.cmp(rhs) { + Ordering::Equal => continue, + ordering => return ordering, + } + } + + match entry.len().cmp(&value_bytes.len()) { + // If all bytes in the shared sub-slice match, the dictionary + // entry is allowed to be longer than the text being inserted + Ordering::Greater => Ordering::Equal, + ordering => ordering, + } + }); + + match index { + Ok(index) => { + let range = self.index[index]; + let len = value_len.min(range.len()); + TextRange::at(range.start(), len) + } + Err(index) => { + let start = TextSize::of(&self.edit.dictionary); + self.edit.dictionary.push_str(value); + + let range = TextRange::at(start, value_len); + self.index.insert(index, range); + range + } + } + } + + pub fn equal(&mut self, text: &str) { + match compress_equal_op(text) { + Some((start, mid, end)) => { + let start = self.intern(start); + self.edit + .ops + .push(CompressedOp::DiffOp(DiffOp::Equal { range: start })); + + self.edit + .ops + .push(CompressedOp::EqualLines { line_count: mid }); + + let end = self.intern(end); + self.edit + .ops + .push(CompressedOp::DiffOp(DiffOp::Equal { range: end })); + } + None => { + let range = self.intern(text); + self.edit + .ops + .push(CompressedOp::DiffOp(DiffOp::Equal { range })); + } + } + } + + pub fn insert(&mut self, text: &str) { + let range = self.intern(text); + self.edit + .ops + .push(CompressedOp::DiffOp(DiffOp::Insert { range })); + } + + pub fn delete(&mut self, text: &str) { + let range = self.intern(text); + self.edit + .ops + .push(CompressedOp::DiffOp(DiffOp::Delete { range })); + } + + pub fn replace(&mut self, old: &str, new: &str) { + self.delete(old); + self.insert(new); + } + + pub fn finish(self) -> TextEdit { + self.edit + } + + /// A higher level utility function for the text edit builder to generate + /// mutiple text edit steps (equal, delete and insert) to represent the + /// diff from the old string to the new string. + pub fn with_unicode_words_diff(&mut self, old: &str, new: &str) { + let diff = TextDiff::configure() + .newline_terminated(true) + .diff_unicode_words(old, new); + + let remapper = TextDiffRemapper::from_text_diff(&diff, old, new); + + for (tag, text) in diff.ops().iter().flat_map(|op| remapper.iter_slices(op)) { + match tag { + ChangeTag::Equal => { + self.equal(text); + } + ChangeTag::Delete => { + self.delete(text); + } + ChangeTag::Insert => { + self.insert(text); + } + } + } + } +} + +/// Number of lines to keep as [DiffOp::Equal] operations around a +/// [CompressedOp::EqualCompressedLines] operation. This has the effect of +/// making the compressed diff retain a few line of equal content around +/// changes, which is useful for display as it makes it possible to print a few +/// context lines around changes without having to keep the full original text +/// around. +const COMPRESSED_DIFFS_CONTEXT_LINES: usize = 2; + +fn compress_equal_op(text: &str) -> Option<(&str, NonZeroU32, &str)> { + let mut iter = text.split('\n'); + + let mut leading_len = COMPRESSED_DIFFS_CONTEXT_LINES; + for _ in 0..=COMPRESSED_DIFFS_CONTEXT_LINES { + leading_len += iter.next()?.len(); + } + + let mut trailing_len = COMPRESSED_DIFFS_CONTEXT_LINES; + for _ in 0..=COMPRESSED_DIFFS_CONTEXT_LINES { + trailing_len += iter.next_back()?.len(); + } + + let mid_count = iter.count(); + let mid_count = u32::try_from(mid_count).ok()?; + let mid_count = NonZeroU32::new(mid_count)?; + + let trailing_start = text.len().saturating_sub(trailing_len); + + Some((&text[..leading_len], mid_count, &text[trailing_start..])) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use crate::{compress_equal_op, TextEdit}; + + #[test] + fn compress_short() { + let output = compress_equal_op( + " +start 1 +start 2 +end 1 +end 2 +", + ); + + assert_eq!(output, None); + } + + #[test] + fn compress_long() { + let output = compress_equal_op( + " +start 1 +start 2 +mid 1 +mid 2 +mid 3 +end 1 +end 2 +", + ); + + assert_eq!( + output, + Some(( + "\nstart 1\nstart 2", + NonZeroU32::new(3).unwrap(), + "end 1\nend 2\n" + )) + ); + } + + #[test] + fn new_string_compressed() { + const OLD: &str = "line 1 old +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 old"; + + const NEW: &str = "line 1 new +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 new"; + + let diff = TextEdit::from_unicode_words(OLD, NEW); + let new_string = diff.new_string(OLD); + + assert_eq!(new_string, NEW); + } +} diff --git a/crates/pg_type_resolver/src/functions.rs b/crates/pg_type_resolver/src/functions.rs index 39ab52680..81fe39373 100644 --- a/crates/pg_type_resolver/src/functions.rs +++ b/crates/pg_type_resolver/src/functions.rs @@ -5,8 +5,8 @@ use crate::{ util::get_string_from_node, }; -pub fn resolve_func_call<'a, 'b>( - node: &'a pg_query_ext::protobuf::FuncCall, +pub fn resolve_func_call<'b>( + node: &pg_query_ext::protobuf::FuncCall, schema_cache: &'b SchemaCache, ) -> Option<&'b Function> { let (schema, name) = resolve_func_identifier(node); @@ -17,7 +17,7 @@ pub fn resolve_func_call<'a, 'b>( .filter(|f| { function_matches( f, - schema.as_ref().map(|s| s.as_str()), + schema.as_deref(), name.as_str(), node.args .iter() @@ -51,11 +51,11 @@ fn function_matches( name: &str, arg_types: Vec, ) -> bool { - if func.name.as_ref().map(|s| s.as_str()) != Some(name) { + if func.name.as_deref() != Some(name) { return false; } - if schema.is_some() && func.schema.as_ref().map(|s| s.as_str()) != schema { + if schema.is_some() && func.schema.as_deref() != schema { return false; } @@ -80,7 +80,7 @@ fn function_matches( PossibleType::AnyOf(types) => { if types .iter() - .all(|type_id| type_id.to_owned() != func_arg.type_id) + .all(|type_id| *type_id != func_arg.type_id) { return false; } diff --git a/crates/pg_type_resolver/src/types.rs b/crates/pg_type_resolver/src/types.rs index 104f82a5e..d3d35523f 100644 --- a/crates/pg_type_resolver/src/types.rs +++ b/crates/pg_type_resolver/src/types.rs @@ -17,7 +17,7 @@ pub fn resolve_type(node: &pg_query_ext::NodeEnum, schema_cache: &SchemaCache) - .expect("expected non-nullable AConst to have a value") { pg_query_ext::protobuf::a_const::Val::Ival(_) => { - let types: Vec = vec!["int2", "int4", "int8"] + let types: Vec = ["int2", "int4", "int8"] .iter() .map(|s| s.to_string()) .collect(); @@ -27,7 +27,7 @@ pub fn resolve_type(node: &pg_query_ext::NodeEnum, schema_cache: &SchemaCache) - .types .iter() .filter(|t| { - types.iter().find(|i| i == &&t.name).is_some() + types.iter().any(|i| &i == &&t.name) && t.schema == "pg_catalog" }) .map(|t| t.id) @@ -35,7 +35,7 @@ pub fn resolve_type(node: &pg_query_ext::NodeEnum, schema_cache: &SchemaCache) - ) } pg_query_ext::protobuf::a_const::Val::Fval(_) => { - let types: Vec = vec!["float4", "float8"] + let types: Vec = ["float4", "float8"] .iter() .map(|s| s.to_string()) .collect(); @@ -58,7 +58,7 @@ pub fn resolve_type(node: &pg_query_ext::NodeEnum, schema_cache: &SchemaCache) - .collect(), ), pg_query_ext::protobuf::a_const::Val::Sval(v) => { - let types: Vec = vec!["text", "varchar"] + let types: Vec = ["text", "varchar"] .iter() .map(|s| s.to_string()) .collect(); @@ -68,7 +68,7 @@ pub fn resolve_type(node: &pg_query_ext::NodeEnum, schema_cache: &SchemaCache) - .types .iter() .filter(|t| { - (types.iter().find(|i| i == &&t.name).is_some() + (types.iter().any(|i| &i == &&t.name) && t.schema == "pg_catalog") || t.enums.values.contains(&v.sval) }) diff --git a/crates/pg_typecheck/Cargo.toml b/crates/pg_typecheck/Cargo.toml index 091c1c535..9e6e5b949 100644 --- a/crates/pg_typecheck/Cargo.toml +++ b/crates/pg_typecheck/Cargo.toml @@ -8,10 +8,10 @@ pg_base_db.workspace = true pg_schema_cache.workspace = true pg_syntax.workspace = true pg_query_ext.workspace = true +text-size.workspace = true sqlx.workspace = true -text-size = "1.1.1" async-std = "1.12.0" diff --git a/crates/pg_workspace/Cargo.toml b/crates/pg_workspace/Cargo.toml index 6fa96d808..95fbbf3bd 100644 --- a/crates/pg_workspace/Cargo.toml +++ b/crates/pg_workspace/Cargo.toml @@ -10,7 +10,6 @@ async-std = "1.12.0" pg_base_db.workspace = true pg_fs.workspace = true -pg_diagnostics.workspace = true pg_query_ext.workspace = true pg_lint.workspace = true pg_syntax.workspace = true diff --git a/crates/pg_workspace/src/diagnostics.rs b/crates/pg_workspace/src/diagnostics.rs new file mode 100644 index 000000000..a1391f855 --- /dev/null +++ b/crates/pg_workspace/src/diagnostics.rs @@ -0,0 +1,27 @@ +use std::fmt::Debug; +use text_size::TextRange; + +#[derive(Debug, PartialEq, Eq)] +pub struct Diagnostic { + pub message: String, + pub description: Option, + pub severity: Severity, + pub source: String, + pub range: TextRange, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +/// The severity to associate to a diagnostic. +pub enum Severity { + /// Reports a hint. + Hint, + /// Reports an information. + #[default] + Information, + /// Reports a warning. + Warning, + /// Reports an error. + Error, + /// Reports a crash. + Fatal, +} diff --git a/crates/pg_workspace/src/lib.rs b/crates/pg_workspace/src/lib.rs index 3ea7b48a2..b0f60de31 100644 --- a/crates/pg_workspace/src/lib.rs +++ b/crates/pg_workspace/src/lib.rs @@ -1,3 +1,4 @@ +pub mod diagnostics; mod lint; mod pg_query; mod tree_sitter; @@ -5,6 +6,7 @@ mod typecheck; use std::sync::{RwLock, RwLockWriteGuard}; +use diagnostics::{Diagnostic, Severity}; use dashmap::{DashMap, DashSet}; use lint::Linter; use pg_base_db::{Document, DocumentChange, StatementRef}; @@ -27,6 +29,12 @@ pub struct Workspace { pub typechecker: Typechecker, } +impl Default for Workspace { + fn default() -> Self { + Self::new() + } +} + impl Workspace { pub fn new() -> Workspace { Workspace { @@ -97,10 +105,10 @@ impl Workspace { } /// Collects all diagnostics for a given document. It does not compute them, it just collects. - pub fn diagnostics(&self, url: &PgLspPath) -> Vec { - let mut diagnostics: Vec = vec![]; + pub fn diagnostics(&self, url: &PgLspPath) -> Vec { + let mut diagnostics: Vec = vec![]; - let doc = self.documents.get(&url); + let doc = self.documents.get(url); if doc.is_none() { return diagnostics; @@ -182,10 +190,9 @@ impl Workspace { mod tests { use pg_base_db::{Change, DocumentChange}; - use pg_diagnostics::Diagnostic; use text_size::{TextRange, TextSize}; - use crate::Workspace; + use crate::{diagnostics::{Diagnostic, Severity}, Workspace}; use pg_fs::PgLspPath; #[test] @@ -389,7 +396,7 @@ mod tests { Diagnostic { message: "Dropping a column may break existing clients.".to_string(), description: None, - severity: pg_diagnostics::Severity::Warning, + severity: Severity::Warning, source: "lint".to_string(), range: TextRange::new(TextSize::new(50), TextSize::new(64)), } diff --git a/crates/pg_workspace/src/lint.rs b/crates/pg_workspace/src/lint.rs index a874cff65..00604cb8b 100644 --- a/crates/pg_workspace/src/lint.rs +++ b/crates/pg_workspace/src/lint.rs @@ -1,14 +1,20 @@ use std::sync::Arc; +use crate::{Diagnostic, Severity}; use dashmap::DashMap; use pg_base_db::StatementRef; -use pg_diagnostics::{Diagnostic, Severity}; use text_size::TextRange; pub struct Linter { violations: DashMap>>, } +impl Default for Linter { + fn default() -> Self { + Self::new() + } +} + impl Linter { pub fn new() -> Linter { Linter { diff --git a/crates/pg_workspace/src/pg_query.rs b/crates/pg_workspace/src/pg_query.rs index 7158766ef..daa79e606 100644 --- a/crates/pg_workspace/src/pg_query.rs +++ b/crates/pg_workspace/src/pg_query.rs @@ -1,8 +1,8 @@ use std::sync::Arc; +use crate::{Diagnostic, Severity}; use dashmap::DashMap; use pg_base_db::{ChangedStatement, StatementRef}; -use pg_diagnostics::{Diagnostic, Severity}; use text_size::TextRange; pub struct PgQueryParser { @@ -12,6 +12,12 @@ pub struct PgQueryParser { cst_db: DashMap>, } +impl Default for PgQueryParser { + fn default() -> Self { + Self::new() + } +} + impl PgQueryParser { pub fn new() -> PgQueryParser { PgQueryParser { diff --git a/crates/pg_workspace/src/tree_sitter.rs b/crates/pg_workspace/src/tree_sitter.rs index e9da52151..b6ba8fa83 100644 --- a/crates/pg_workspace/src/tree_sitter.rs +++ b/crates/pg_workspace/src/tree_sitter.rs @@ -10,6 +10,12 @@ pub struct TreeSitterParser { parser: RwLock, } +impl Default for TreeSitterParser { + fn default() -> Self { + Self::new() + } +} + impl TreeSitterParser { pub fn new() -> TreeSitterParser { let mut parser = tree_sitter::Parser::new(); @@ -36,7 +42,7 @@ impl TreeSitterParser { } pub fn remove_statement(&self, statement: &StatementRef) { - self.db.remove(&statement); + self.db.remove(statement); } pub fn modify_statement(&self, change: &ChangedStatement) { @@ -52,7 +58,7 @@ impl TreeSitterParser { let mut tree = old.unwrap().1.as_ref().clone(); let edit = edit_from_change( - &change.statement.text.as_str(), + change.statement.text.as_str(), usize::from(change.range.start()), usize::from(change.range.end()), change.text.as_str(), diff --git a/crates/pg_workspace/src/typecheck.rs b/crates/pg_workspace/src/typecheck.rs index a2dbf8376..357418252 100644 --- a/crates/pg_workspace/src/typecheck.rs +++ b/crates/pg_workspace/src/typecheck.rs @@ -1,8 +1,8 @@ use std::sync::Arc; +use crate::{Diagnostic, Severity}; use dashmap::DashMap; use pg_base_db::StatementRef; -use pg_diagnostics::{Diagnostic, Severity}; use pg_typecheck::{check_sql, PgSeverity, TypeError, TypecheckerParams}; use text_size::TextRange; @@ -10,6 +10,12 @@ pub struct Typechecker { errors: DashMap>>, } +impl Default for Typechecker { + fn default() -> Self { + Self::new() + } +} + impl Typechecker { pub fn new() -> Typechecker { Typechecker { diff --git a/crates/pg_workspace_new/Cargo.toml b/crates/pg_workspace_new/Cargo.toml new file mode 100644 index 000000000..2c32ad8e3 --- /dev/null +++ b/crates/pg_workspace_new/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pg_workspace_new" +version = "0.0.0" +edition = "2021" + +[dependencies] +text-size.workspace = true +dashmap = "5.5.3" +sqlx.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["raw_value"] } +ignore = { workspace = true } +toml = { workspace = true } +pg_fs = { workspace = true, features = ["serde"] } +pg_diagnostics = { workspace = true } +biome_deserialize = "0.6.0" +pg_statement_splitter = { workspace = true } +pg_configuration = { workspace = true } +pg_query_ext = { workspace = true } +pg_console = { workspace = true } +pg_schema_cache = { workspace = true } +tracing = { workspace = true, features = ["attributes", "log"] } +tree-sitter.workspace = true +tree_sitter_sql.workspace = true +tokio = { workspace = true } +futures = "0.3.31" + +[dev-dependencies] + +[lib] +doctest = false + +[features] diff --git a/crates/pg_workspace_new/src/configuration.rs b/crates/pg_workspace_new/src/configuration.rs new file mode 100644 index 000000000..20c2e812d --- /dev/null +++ b/crates/pg_workspace_new/src/configuration.rs @@ -0,0 +1,176 @@ +use std::{ + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use pg_configuration::{ + ConfigurationDiagnostic, ConfigurationPathHint, ConfigurationPayload, PartialConfiguration, +}; +use pg_fs::{AutoSearchResult, ConfigName, FileSystem, OpenOptions}; + +use crate::{DynRef, WorkspaceError}; + +/// Information regarding the configuration that was found. +/// +/// This contains the expanded configuration including default values where no +/// configuration was present. +#[derive(Default, Debug)] +pub struct LoadedConfiguration { + /// If present, the path of the directory where it was found + pub directory_path: Option, + /// If present, the path of the file where it was found + pub file_path: Option, + /// The Deserialized configuration + pub configuration: PartialConfiguration, +} + +impl LoadedConfiguration { + /// Return the path of the **directory** where the configuration is + pub fn directory_path(&self) -> Option<&Path> { + self.directory_path.as_deref() + } + + /// Return the path of the **file** where the configuration is + pub fn file_path(&self) -> Option<&Path> { + self.file_path.as_deref() + } +} + +impl From> for LoadedConfiguration { + fn from(value: Option) -> Self { + let Some(value) = value else { + return LoadedConfiguration::default(); + }; + + let ConfigurationPayload { + configuration_file_path, + deserialized: partial_configuration, + .. + } = value; + + LoadedConfiguration { + configuration: partial_configuration, + directory_path: configuration_file_path.parent().map(PathBuf::from), + file_path: Some(configuration_file_path), + } + } +} + +/// Load the partial configuration for this session of the CLI. +pub fn load_configuration( + fs: &DynRef<'_, dyn FileSystem>, + config_path: ConfigurationPathHint, +) -> Result { + let config = load_config(fs, config_path)?; + Ok(LoadedConfiguration::from(config)) +} + +/// - [Result]: if an error occurred while loading the configuration file. +/// - [Option]: sometimes not having a configuration file should not be an error, so we need this type. +/// - [ConfigurationPayload]: The result of the operation +type LoadConfig = Result, WorkspaceError>; + +/// Load the configuration from the file system. +/// +/// The configuration file will be read from the `file_system`. A [path hint](ConfigurationPathHint) should be provided. +fn load_config( + file_system: &DynRef<'_, dyn FileSystem>, + base_path: ConfigurationPathHint, +) -> LoadConfig { + // This path is used for configuration resolution from external packages. + let external_resolution_base_path = match base_path { + // Path hint from LSP is always the workspace root + // we use it as the resolution base path. + ConfigurationPathHint::FromLsp(ref path) => path.clone(), + ConfigurationPathHint::FromWorkspace(ref path) => path.clone(), + // Path hint from user means the command is invoked from the CLI + // So we use the working directory (CWD) as the resolution base path + ConfigurationPathHint::FromUser(_) | ConfigurationPathHint::None => file_system + .working_directory() + .map_or(PathBuf::new(), |working_directory| working_directory), + }; + + // If the configuration path hint is from user and is a file path, + // we'll load it directly + if let ConfigurationPathHint::FromUser(ref config_file_path) = base_path { + if file_system.path_is_file(config_file_path) { + let content = file_system.read_file_from_path(config_file_path)?; + + let deserialized = toml::from_str::(&content) + .map_err(ConfigurationDiagnostic::new_deserialization_error)?; + + return Ok(Some(ConfigurationPayload { + deserialized, + configuration_file_path: PathBuf::from(config_file_path), + external_resolution_base_path, + })); + } + } + + // If the configuration path hint is not a file path + // we'll auto search for the configuration file + let should_error = base_path.is_from_user(); + let configuration_directory = match base_path { + ConfigurationPathHint::FromLsp(path) => path, + ConfigurationPathHint::FromUser(path) => path, + ConfigurationPathHint::FromWorkspace(path) => path, + ConfigurationPathHint::None => file_system.working_directory().unwrap_or_default(), + }; + + // We first search for `pgtoml.json` + if let Some(auto_search_result) = file_system.auto_search( + &configuration_directory, + ConfigName::file_names().as_slice(), + should_error, + )? { + let AutoSearchResult { content, file_path } = auto_search_result; + + let deserialized = toml::from_str::(&content) + .map_err(ConfigurationDiagnostic::new_deserialization_error)?; + + Ok(Some(ConfigurationPayload { + deserialized, + configuration_file_path: file_path, + external_resolution_base_path, + })) + } else { + Ok(None) + } +} + +/// Creates a new configuration on file system +/// +/// ## Errors +/// +/// It fails if: +/// - the configuration file already exists +/// - the program doesn't have the write rights +pub fn create_config( + fs: &mut DynRef, + configuration: PartialConfiguration, +) -> Result<(), WorkspaceError> { + let path = PathBuf::from(ConfigName::pglsp_toml()); + + if fs.path_exists(&path) { + return Err(ConfigurationDiagnostic::new_already_exists().into()); + } + + let options = OpenOptions::default().write(true).create_new(true); + + let mut config_file = fs.open_with_options(&path, options).map_err(|err| { + if err.kind() == ErrorKind::AlreadyExists { + ConfigurationDiagnostic::new_already_exists().into() + } else { + WorkspaceError::cant_read_file(format!("{}", path.display())) + } + })?; + + let contents = toml::ser::to_string_pretty(&configuration) + .map_err(|_| ConfigurationDiagnostic::new_serialization_error())?; + + config_file + .set_content(contents.as_bytes()) + .map_err(|_| WorkspaceError::cant_read_file(format!("{}", path.display())))?; + + Ok(()) +} diff --git a/crates/pg_workspace_new/src/diagnostics.rs b/crates/pg_workspace_new/src/diagnostics.rs new file mode 100644 index 000000000..a1a11a1e7 --- /dev/null +++ b/crates/pg_workspace_new/src/diagnostics.rs @@ -0,0 +1,347 @@ +use pg_configuration::ConfigurationDiagnostic; +use pg_console::fmt::Bytes; +use pg_console::markup; +use pg_diagnostics::{ + category, Advices, Category, Diagnostic, DiagnosticTags, LogCategory, Severity, Visit, +}; +use pg_fs::FileSystemDiagnostic; +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fmt; +use std::fmt::{Debug, Display, Formatter}; +use std::process::{ExitCode, Termination}; +use tokio::task::JoinError; + +/// Generic errors thrown during operations +#[derive(Deserialize, Diagnostic, Serialize)] +pub enum WorkspaceError { + /// Error thrown when validating the configuration. Once deserialized, further checks have to be done. + Configuration(ConfigurationDiagnostic), + /// Error when trying to access the database + DatabaseConnectionError(DatabaseConnectionError), + /// Diagnostics emitted when querying the file system + FileSystem(FileSystemDiagnostic), + /// Thrown when we can't read a generic directory + CantReadDirectory(CantReadDirectory), + /// Thrown when we can't read a generic file + CantReadFile(CantReadFile), + /// The file does not exist in the [crate::Workspace] + NotFound(NotFound), + /// Error emitted by the underlying transport layer for a remote Workspace + TransportError(TransportError), + /// Emitted when the file is ignored and should not be processed + FileIgnored(FileIgnored), + /// Emitted when a file could not be parsed because it's larger than the size limit + FileTooLarge(FileTooLarge), + /// Diagnostic raised when a file is protected + ProtectedFile(ProtectedFile), + /// Raised when there's an issue around the VCS integration + Vcs(VcsDiagnostic), + /// Error in the async runtime + RuntimeError(RuntimeError), +} + +impl WorkspaceError { + pub fn cant_read_file(path: String) -> Self { + Self::CantReadFile(CantReadFile { path }) + } + + pub fn not_found() -> Self { + Self::NotFound(NotFound) + } + + pub fn protected_file(file_path: impl Into) -> Self { + Self::ProtectedFile(ProtectedFile { + file_path: file_path.into(), + verbose_advice: ProtectedFileAdvice, + }) + } + + pub fn vcs_disabled() -> Self { + Self::Vcs(VcsDiagnostic::DisabledVcs(DisabledVcs {})) + } +} + +impl Error for WorkspaceError {} + +impl Debug for WorkspaceError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + std::fmt::Display::fmt(self, f) + } +} + +impl Display for WorkspaceError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Diagnostic::description(self, f) + } +} + +impl From for WorkspaceError { + fn from(err: TransportError) -> Self { + Self::TransportError(err) + } +} + +impl Termination for WorkspaceError { + fn report(self) -> ExitCode { + ExitCode::FAILURE + } +} + +impl From for WorkspaceError { + fn from(err: FileSystemDiagnostic) -> Self { + Self::FileSystem(err) + } +} + +impl From for WorkspaceError { + fn from(err: ConfigurationDiagnostic) -> Self { + Self::Configuration(err) + } +} + +#[derive(Debug, Serialize, Deserialize)] +/// Error emitted by the underlying transport layer for a remote Workspace +pub enum TransportError { + /// Error emitted by the transport layer if the connection was lost due to an I/O error + ChannelClosed, + /// Error emitted by the transport layer if a request timed out + Timeout, + /// Error caused by a serialization or deserialization issue + SerdeError(String), + /// Generic error type for RPC errors that can't be deserialized into RomeError + RPCError(String), +} + +impl Display for TransportError { + fn fmt(&self, fmt: &mut Formatter) -> fmt::Result { + self.description(fmt) + } +} + +impl Diagnostic for TransportError { + fn category(&self) -> Option<&'static Category> { + Some(category!("internalError/io")) + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn description(&self, fmt: &mut Formatter<'_>) -> fmt::Result { + match self { + TransportError::SerdeError(err) => write!(fmt, "serialization error: {err}"), + TransportError::ChannelClosed => fmt.write_str( + "a request to the remote workspace failed because the connection was interrupted", + ), + TransportError::Timeout => { + fmt.write_str("the request to the remote workspace timed out") + } + TransportError::RPCError(err) => fmt.write_str(err), + } + } + + fn message(&self, fmt: &mut pg_console::fmt::Formatter<'_>) -> std::io::Result<()> { + match self { + TransportError::SerdeError(err) => write!(fmt, "serialization error: {err}"), + TransportError::ChannelClosed => fmt.write_str( + "a request to the remote workspace failed because the connection was interrupted", + ), + TransportError::Timeout => { + fmt.write_str("the request to the remote workspace timed out") + } + TransportError::RPCError(err) => fmt.write_str(err), + } + } + fn tags(&self) -> DiagnosticTags { + DiagnosticTags::INTERNAL + } +} + +#[derive(Debug, Deserialize, Diagnostic, Serialize)] +pub enum VcsDiagnostic { + /// When the VCS folder couldn't be found + NoVcsFolderFound(NoVcsFolderFound), + /// VCS is disabled + DisabledVcs(DisabledVcs), +} + +#[derive(Debug, Diagnostic, Serialize, Deserialize)] +#[diagnostic( + category = "internalError/fs", + severity = Warning, + message = "Couldn't determine a directory for the VCS integration. VCS integration will be disabled." +)] +pub struct DisabledVcs {} + +#[derive(Debug, Diagnostic, Serialize, Deserialize)] +#[diagnostic( + category = "internalError/runtime", + severity = Error, + message = "An error occurred in the async runtime." +)] +pub struct RuntimeError { + message: String, +} + +impl From for WorkspaceError { + fn from(err: JoinError) -> Self { + Self::RuntimeError(RuntimeError { + message: err.to_string(), + }) + } +} + +#[derive(Debug, Diagnostic, Serialize, Deserialize)] +#[diagnostic( + category = "internalError/fs", + severity = Error, + message( + description = "Couldn't find the VCS folder at the following path: {path}", + message("Couldn't find the VCS folder at the following path: "{self.path}), + ) +)] +pub struct NoVcsFolderFound { + #[location(resource)] + pub path: String, +} + +impl From for WorkspaceError { + fn from(value: VcsDiagnostic) -> Self { + Self::Vcs(value) + } +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "database/connection", + message = "Database error: {message}" +)] +pub struct DatabaseConnectionError { + message: String, + code: Option, +} + +impl From for WorkspaceError { + fn from(err: sqlx::Error) -> Self { + let db_err = err.as_database_error(); + if let Some(db_err) = db_err { + Self::DatabaseConnectionError(DatabaseConnectionError { + message: db_err.message().to_string(), + code: db_err.code().map(|c| c.to_string()), + }) + } else { + Self::DatabaseConnectionError(DatabaseConnectionError { + message: err.to_string(), + code: None, + }) + } + } +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "internalError/fs", + message = "The file does not exist in the workspace.", + tags(INTERNAL) +)] +pub struct NotFound; + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "project", + severity = Information, + message( + message("The file "{self.file_path}" is protected because is handled by another tool. We won't process it."), + description = "The file {file_path} is protected because is handled by another tool. We won't process it.", + ), + tags(VERBOSE) +)] +pub struct ProtectedFile { + #[location(resource)] + pub file_path: String, + + #[verbose_advice] + pub verbose_advice: ProtectedFileAdvice, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProtectedFileAdvice; + +impl Advices for ProtectedFileAdvice { + fn record(&self, visitor: &mut dyn Visit) -> std::io::Result<()> { + visitor.record_log(LogCategory::Info, &markup! { "You can hide this diagnostic by using ""--diagnostic-level=warn"" to increase the diagnostic level shown by CLI." }) + } +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "internalError/fs", + message( + message("We couldn't read the following directory, maybe for permissions reasons or it doesn't exist: "{self.path}), + description = "We couldn't read the following directory, maybe for permissions reasons or it doesn't exist: {path}" + ) +)] +pub struct CantReadDirectory { + #[location(resource)] + path: String, +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "internalError/fs", + message( + message("We couldn't read the following file, maybe for permissions reasons or it doesn't exist: "{self.path}), + description = "We couldn't read the following file, maybe for permissions reasons or it doesn't exist: {path}" + ) +)] +pub struct CantReadFile { + #[location(resource)] + path: String, +} + +#[derive(Debug, Serialize, Deserialize, Diagnostic)] +#[diagnostic( + category = "internalError/fs", + message( + message("The file "{self.path}" was ignored."), + description = "The file {path} was ignored." + ), + severity = Warning, +)] +pub struct FileIgnored { + #[location(resource)] + path: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct FileTooLarge { + path: String, + size: usize, + limit: usize, +} + +impl Diagnostic for FileTooLarge { + fn category(&self) -> Option<&'static Category> { + Some(category!("internalError/fs")) + } + + fn message(&self, fmt: &mut pg_console::fmt::Formatter<'_>) -> std::io::Result<()> { + fmt.write_markup( + markup!{ + "Size of "{self.path}" is "{Bytes(self.size)}" which exceeds configured maximum of "{Bytes(self.limit)}" for this project. + The file size limit exists to prevent us inadvertently slowing down and loading large files that we shouldn't. + Use the `files.maxSize` configuration to change the maximum size of files processed." + } + ) + } + + fn description(&self, fmt: &mut Formatter<'_>) -> fmt::Result { + write!(fmt, + "Size of {} is {} which exceeds configured maximum of {} for this project.\n\ + The file size limit exists to prevent us inadvertently slowing down and loading large files that we shouldn't.\n\ + Use the `files.maxSize` configuration to change the maximum size of files processed.", + self.path, Bytes(self.size), Bytes(self.limit) + ) + } +} diff --git a/crates/pg_workspace_new/src/dome.rs b/crates/pg_workspace_new/src/dome.rs new file mode 100644 index 000000000..8cc8ed5e3 --- /dev/null +++ b/crates/pg_workspace_new/src/dome.rs @@ -0,0 +1,72 @@ +use pg_fs::PgLspPath; +use std::collections::btree_set::Iter; +use std::collections::BTreeSet; +use std::iter::{FusedIterator, Peekable}; + +/// A type that holds the evaluated paths, and provides an iterator to extract +/// specific paths like configuration files, manifests and more. +#[derive(Debug, Default)] +pub struct Dome { + paths: BTreeSet, +} + +impl Dome { + pub fn with_path(mut self, path: impl Into) -> Self { + self.paths.insert(path.into()); + self + } + + pub fn new(paths: BTreeSet) -> Self { + Self { paths } + } + + pub fn iter(&self) -> DomeIterator { + DomeIterator { + iter: self.paths.iter().peekable(), + } + } + + pub fn to_paths(self) -> BTreeSet { + self.paths + } +} + +pub struct DomeIterator<'a> { + iter: Peekable>, +} + +impl<'a> DomeIterator<'a> { + pub fn next_config(&mut self) -> Option<&'a PgLspPath> { + return if let Some(path) = self.iter.peek() { + if path.is_config() { + self.iter.next() + } else { + None + } + } else { + None + }; + } + + pub fn next_ignore(&mut self) -> Option<&'a PgLspPath> { + return if let Some(path) = self.iter.peek() { + if path.is_ignore() { + self.iter.next() + } else { + None + } + } else { + None + }; + } +} + +impl<'a> Iterator for DomeIterator<'a> { + type Item = &'a PgLspPath; + + fn next(&mut self) -> Option { + self.iter.next() + } +} + +impl<'a> FusedIterator for DomeIterator<'a> {} diff --git a/crates/pg_workspace_new/src/lib.rs b/crates/pg_workspace_new/src/lib.rs new file mode 100644 index 000000000..9467d1fb4 --- /dev/null +++ b/crates/pg_workspace_new/src/lib.rs @@ -0,0 +1,98 @@ +use std::ops::{Deref, DerefMut}; + +use pg_console::Console; +use pg_fs::{FileSystem, OsFileSystem}; + +pub mod configuration; +pub mod diagnostics; +pub mod dome; +pub mod matcher; +pub mod settings; +pub mod workspace; + +pub use crate::diagnostics::{TransportError, WorkspaceError}; +pub use crate::workspace::Workspace; + +/// This is the main entrypoint of the application. +pub struct App<'app> { + /// A reference to the internal virtual file system + pub fs: DynRef<'app, dyn FileSystem>, + /// A reference to the internal workspace + pub workspace: WorkspaceRef<'app>, + /// A reference to the internal console, where its buffer will be used to write messages and + /// errors + pub console: &'app mut dyn Console, +} + +impl<'app> App<'app> { + pub fn with_console(console: &'app mut dyn Console) -> Self { + Self::with_filesystem_and_console(DynRef::Owned(Box::::default()), console) + } + + /// Create a new instance of the app using the specified [FileSystem] and [Console] implementation + pub fn with_filesystem_and_console( + fs: DynRef<'app, dyn FileSystem>, + console: &'app mut dyn Console, + ) -> Self { + Self::new(fs, console, WorkspaceRef::Owned(workspace::server())) + } + + /// Create a new instance of the app using the specified [FileSystem], [Console] and [Workspace] implementation + pub fn new( + fs: DynRef<'app, dyn FileSystem>, + console: &'app mut dyn Console, + workspace: WorkspaceRef<'app>, + ) -> Self { + Self { + fs, + console, + workspace, + } + } +} + +pub enum WorkspaceRef<'app> { + Owned(Box), + Borrowed(&'app dyn Workspace), +} + +impl<'app> Deref for WorkspaceRef<'app> { + type Target = dyn Workspace + 'app; + + // False positive + #[allow(clippy::explicit_auto_deref)] + fn deref(&self) -> &Self::Target { + match self { + WorkspaceRef::Owned(inner) => &**inner, + WorkspaceRef::Borrowed(inner) => *inner, + } + } +} + +/// Clone of [std::borrow::Cow] specialized for storing a trait object and +/// holding a mutable reference in the `Borrowed` variant instead of requiring +/// the inner type to implement [std::borrow::ToOwned] +pub enum DynRef<'app, T: ?Sized + 'app> { + Owned(Box), + Borrowed(&'app mut T), +} + +impl<'app, T: ?Sized + 'app> Deref for DynRef<'app, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + match self { + DynRef::Owned(inner) => inner, + DynRef::Borrowed(inner) => inner, + } + } +} + +impl<'app, T: ?Sized + 'app> DerefMut for DynRef<'app, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + DynRef::Owned(inner) => inner, + DynRef::Borrowed(inner) => inner, + } + } +} diff --git a/crates/pg_workspace_new/src/matcher/LICENCE-APACHE b/crates/pg_workspace_new/src/matcher/LICENCE-APACHE new file mode 100644 index 000000000..4aca254d7 --- /dev/null +++ b/crates/pg_workspace_new/src/matcher/LICENCE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) 2023 Biome Developers and Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/crates/pg_workspace_new/src/matcher/LICENSE-MIT b/crates/pg_workspace_new/src/matcher/LICENSE-MIT new file mode 100644 index 000000000..17eebcc23 --- /dev/null +++ b/crates/pg_workspace_new/src/matcher/LICENSE-MIT @@ -0,0 +1,26 @@ +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + diff --git a/crates/pg_workspace_new/src/matcher/mod.rs b/crates/pg_workspace_new/src/matcher/mod.rs new file mode 100644 index 000000000..c4740ad00 --- /dev/null +++ b/crates/pg_workspace_new/src/matcher/mod.rs @@ -0,0 +1,199 @@ +pub mod pattern; + +pub use pattern::{MatchOptions, Pattern, PatternError}; +use pg_console::markup; +use pg_diagnostics::Diagnostic; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +/// A data structure to use when there's need to match a string or a path a against +/// a unix shell style patterns +#[derive(Debug, Default)] +pub struct Matcher { + root: Option, + patterns: Vec, + options: MatchOptions, + /// Whether the string was already checked + already_checked: RwLock>, +} + +impl Matcher { + /// Creates a new Matcher with given options. + /// + /// Check [glob website](https://docs.rs/glob/latest/glob/struct.MatchOptions.html) for [MatchOptions] + pub fn new(options: MatchOptions) -> Self { + Self { + root: None, + patterns: Vec::new(), + options, + already_checked: RwLock::new(HashMap::default()), + } + } + + pub fn empty() -> Self { + Self { + root: None, + patterns: Vec::new(), + options: MatchOptions::default(), + already_checked: RwLock::new(HashMap::default()), + } + } + + pub fn set_root(&mut self, root: PathBuf) { + self.root = Some(root); + } + + /// It adds a unix shell style pattern + pub fn add_pattern(&mut self, pattern: &str) -> Result<(), PatternError> { + let pattern = Pattern::new(pattern)?; + self.patterns.push(pattern); + Ok(()) + } + + /// It matches the given string against the stored patterns. + /// + /// It returns [true] if there's at least a match + pub fn matches(&self, source: &str) -> bool { + let mut already_ignored = self.already_checked.write().unwrap(); + if let Some(matches) = already_ignored.get(source) { + return *matches; + } + for pattern in &self.patterns { + if pattern.matches_with(source, self.options) || source.contains(pattern.as_str()) { + already_ignored.insert(source.to_string(), true); + return true; + } + } + already_ignored.insert(source.to_string(), false); + false + } + + pub fn is_empty(&self) -> bool { + self.patterns.is_empty() + } + + /// It matches the given path against the stored patterns + /// + /// It returns [true] if there's at least one match + pub fn matches_path(&self, source: &Path) -> bool { + if self.is_empty() { + return false; + } + let mut already_checked = self.already_checked.write().unwrap(); + let source_as_string = source.to_str(); + if let Some(source_as_string) = source_as_string { + if let Some(matches) = already_checked.get(source_as_string) { + return *matches; + } + } + let matches = self.run_match(source); + + if let Some(source_as_string) = source_as_string { + already_checked.insert(source_as_string.to_string(), matches); + } + + matches + } + + fn run_match(&self, source: &Path) -> bool { + for pattern in &self.patterns { + let matches = if pattern.matches_path_with(source, self.options) { + true + } else { + // Here we cover cases where the user specifies single files inside the patterns. + // The pattern library doesn't support single files, we here we just do a check + // on contains + // + // Given the pattern `out`: + // - `out/index.html` -> matches + // - `out/` -> matches + // - `layout.tsx` -> does not match + // - `routes/foo.ts` -> does not match + source + .ancestors() + .any(|ancestor| ancestor.ends_with(pattern.as_str())) + }; + + if matches { + return true; + } + } + false + } +} + +impl Diagnostic for PatternError { + fn description(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "{}", self.msg) + } + + fn message(&self, fmt: &mut pg_console::fmt::Formatter<'_>) -> std::io::Result<()> { + fmt.write_markup(markup!({ self.msg })) + } +} + +#[cfg(test)] +mod test { + use crate::matcher::pattern::MatchOptions; + use crate::matcher::Matcher; + use std::env; + + #[test] + fn matches() { + let current = env::current_dir().unwrap(); + let dir = format!("{}/**/*.rs", current.display()); + let mut ignore = Matcher::new(MatchOptions::default()); + ignore.add_pattern(&dir).unwrap(); + let path = env::current_dir().unwrap().join("src/workspace.rs"); + let result = ignore.matches(path.to_str().unwrap()); + + assert!(result); + } + + #[test] + fn matches_path() { + let current = env::current_dir().unwrap(); + let dir = format!("{}/**/*.rs", current.display()); + let mut ignore = Matcher::new(MatchOptions::default()); + ignore.add_pattern(&dir).unwrap(); + let path = env::current_dir().unwrap().join("src/workspace.rs"); + let result = ignore.matches_path(path.as_path()); + + assert!(result); + } + + #[test] + fn matches_path_for_single_file_or_directory_name() { + let dir = "inv"; + let valid_test_dir = "valid/"; + let mut ignore = Matcher::new(MatchOptions::default()); + ignore.add_pattern(dir).unwrap(); + ignore.add_pattern(valid_test_dir).unwrap(); + + let path = env::current_dir().unwrap().join("tests").join("invalid"); + let result = ignore.matches_path(path.as_path()); + + assert!(!result); + + let path = env::current_dir().unwrap().join("tests").join("valid"); + let result = ignore.matches_path(path.as_path()); + + assert!(result); + } + + #[test] + fn matches_single_path() { + let dir = "workspace.rs"; + let mut ignore = Matcher::new(MatchOptions { + require_literal_separator: true, + case_sensitive: true, + require_literal_leading_dot: true, + }); + ignore.add_pattern(dir).unwrap(); + let path = env::current_dir().unwrap().join("src/workspace.rs"); + let result = ignore.matches(path.to_str().unwrap()); + + assert!(result); + } +} diff --git a/crates/pg_workspace_new/src/matcher/pattern.rs b/crates/pg_workspace_new/src/matcher/pattern.rs new file mode 100644 index 000000000..afbaae34d --- /dev/null +++ b/crates/pg_workspace_new/src/matcher/pattern.rs @@ -0,0 +1,1053 @@ +use crate::matcher::pattern::CharSpecifier::{CharRange, SingleChar}; +use crate::matcher::pattern::MatchResult::{ + EntirePatternDoesntMatch, Match, SubPatternDoesntMatch, +}; +use crate::matcher::pattern::PatternToken::{ + AnyChar, AnyExcept, AnyPattern, AnyRecursiveSequence, AnySequence, AnyWithin, Char, +}; +use std::error::Error; +use std::path::Path; +use std::str::FromStr; +use std::{fmt, path}; + +/// A pattern parsing error. +#[derive(Debug)] +#[allow(missing_copy_implementations)] +pub struct PatternError { + /// The approximate character index of where the error occurred. + pub pos: usize, + + /// A message describing the error. + pub msg: &'static str, +} + +impl Error for PatternError { + fn description(&self) -> &str { + self.msg + } +} + +impl fmt::Display for PatternError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Pattern syntax error near position {}: {}", + self.pos, self.msg + ) + } +} + +/// A compiled Unix shell style pattern. +/// +/// - `?` matches any single character. +/// +/// - `*` matches any (possibly empty) sequence of characters. +/// +/// - `**` matches the current directory and arbitrary subdirectories. This +/// sequence **must** form a single path component, so both `**a` and `b**` +/// are invalid and will result in an error. A sequence of more than two +/// consecutive `*` characters is also invalid. +/// +/// - `[...]` matches any character inside the brackets. Character sequences +/// can also specify ranges of characters, as ordered by Unicode, so e.g. +/// `[0-9]` specifies any character between 0 and 9 inclusive. An unclosed +/// bracket is invalid. +/// +/// - `[!...]` is the negation of `[...]`, i.e. it matches any characters +/// **not** in the brackets. +/// +/// - The metacharacters `?`, `*`, `[`, `]` can be matched by using brackets +/// (e.g. `[?]`). When a `]` occurs immediately following `[` or `[!` then it +/// is interpreted as being part of, rather then ending, the character set, so +/// `]` and NOT `]` can be matched by `[]]` and `[!]]` respectively. The `-` +/// character can be specified inside a character sequence pattern by placing +/// it at the start or the end, e.g. `[abc-]`. +/// +/// - `{...}` can be used to specify multiple patterns separated by commas. For +/// example, `a/{b,c}/d` will match `a/b/d` and `a/c/d`. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)] +pub struct Pattern { + /// The original glob pattern that was parsed to create this `Pattern`. + original: String, + tokens: Vec, + is_recursive: bool, + /// Did this pattern come from an `.editorconfig` file? + /// + /// TODO: Remove this flag and support `{a,b}` globs in Biome 2.0 + is_editorconfig: bool, +} + +/// Show the original glob pattern. +impl fmt::Display for Pattern { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.original.fmt(f) + } +} + +impl FromStr for Pattern { + type Err = PatternError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +enum PatternToken { + Char(char), + AnyChar, + AnySequence, + AnyRecursiveSequence, + AnyWithin(Vec), + AnyExcept(Vec), + /// A set of patterns that at least one of them must match + AnyPattern(Vec), +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +enum CharSpecifier { + SingleChar(char), + CharRange(char, char), +} + +#[derive(Copy, Clone, PartialEq)] +enum MatchResult { + Match, + SubPatternDoesntMatch, + EntirePatternDoesntMatch, +} + +const ERROR_WILDCARDS: &str = "wildcards are either regular `*` or recursive `**`"; +const ERROR_RECURSIVE_WILDCARDS: &str = "recursive wildcards must form a single path \ + component"; +const ERROR_INVALID_RANGE: &str = "invalid range pattern"; + +impl Pattern { + /// This function compiles Unix shell style patterns. + /// + /// An invalid glob pattern will yield a `PatternError`. + pub fn new(pattern: &str) -> Result { + Self::parse(pattern, false) + } + + /// This function compiles Unix shell style patterns. + /// + /// An invalid glob pattern will yield a `PatternError`. + pub fn parse(pattern: &str, is_editorconfig: bool) -> Result { + let chars = pattern.chars().collect::>(); + let mut tokens = Vec::new(); + let mut is_recursive = false; + let mut i = 0; + + // A pattern is relative if it starts with "." followed by a separator, + // eg. "./test" or ".\test" + let is_relative = matches!(chars.get(..2), Some(['.', sep]) if path::is_separator(*sep)); + if is_relative { + // If a pattern starts with a relative prefix, strip it from the + // pattern and replace it with a "**" sequence + i += 2; + tokens.push(AnyRecursiveSequence); + } else { + // A pattern is absolute if it starts with a path separator, eg. "/home" or "\\?\C:\Users" + let mut is_absolute = chars.first().map_or(false, |c| path::is_separator(*c)); + + // On windows a pattern may also be absolute if it starts with a + // drive letter, a colon and a separator, eg. "c:/Users" or "G:\Users" + if cfg!(windows) && !is_absolute { + is_absolute = matches!(chars.get(..3), Some(['a'..='z' | 'A'..='Z', ':', sep]) if path::is_separator(*sep)); + } + + // If a pattern is not absolute, insert a "**" sequence in front + if !is_absolute { + tokens.push(AnyRecursiveSequence); + } + } + + while i < chars.len() { + match chars[i] { + '?' => { + tokens.push(AnyChar); + i += 1; + } + '*' => { + let old = i; + + while i < chars.len() && chars[i] == '*' { + i += 1; + } + + let count = i - old; + + match count { + count if count > 2 => { + return Err(PatternError { + pos: old + 2, + msg: ERROR_WILDCARDS, + }); + } + count if count == 2 => { + // ** can only be an entire path component + // i.e. a/**/b is valid, but a**/b or a/**b is not + // invalid matches are treated literally + let is_valid = if i == 2 || path::is_separator(chars[i - count - 1]) { + // it ends in a '/' + if i < chars.len() && path::is_separator(chars[i]) { + i += 1; + true + // or the pattern ends here + // this enables the existing globbing mechanism + } else if i == chars.len() { + true + // `**` ends in non-separator + } else { + return Err(PatternError { + pos: i, + msg: ERROR_RECURSIVE_WILDCARDS, + }); + } + // `**` begins with non-separator + } else { + return Err(PatternError { + pos: old - 1, + msg: ERROR_RECURSIVE_WILDCARDS, + }); + }; + + if is_valid { + // collapse consecutive AnyRecursiveSequence to a + // single one + + let tokens_len = tokens.len(); + + if !(tokens_len > 1 + && tokens[tokens_len - 1] == AnyRecursiveSequence) + { + is_recursive = true; + tokens.push(AnyRecursiveSequence); + } + } + } + _ => { + tokens.push(AnySequence); + } + } + } + '[' => { + if i + 4 <= chars.len() && chars[i + 1] == '!' { + match chars[i + 3..].iter().position(|x| *x == ']') { + None => (), + Some(j) => { + let chars = &chars[i + 2..i + 3 + j]; + let cs = parse_char_specifiers(chars); + tokens.push(AnyExcept(cs)); + i += j + 4; + continue; + } + } + } else if i + 3 <= chars.len() && chars[i + 1] != '!' { + match chars[i + 2..].iter().position(|x| *x == ']') { + None => (), + Some(j) => { + let cs = parse_char_specifiers(&chars[i + 1..i + 2 + j]); + tokens.push(AnyWithin(cs)); + i += j + 3; + continue; + } + } + } + + // if we get here then this is not a valid range pattern + return Err(PatternError { + pos: i, + msg: ERROR_INVALID_RANGE, + }); + } + '{' if is_editorconfig => { + let mut depth = 1; + let mut j = i + 1; + while j < chars.len() { + match chars[j] { + '{' => depth += 1, + '}' => depth -= 1, + _ => (), + } + if depth > 1 { + return Err(PatternError { + pos: j, + msg: "nested '{' in '{...}' is not allowed", + }); + } + if depth == 0 { + break; + } + j += 1; + } + + if depth != 0 { + return Err(PatternError { + pos: i, + msg: "unmatched '{'", + }); + } + + let mut subpatterns = Vec::new(); + for subpattern in pattern[i + 1..j].split(',') { + let mut pattern = Pattern::new(subpattern)?; + // HACK: remove the leading '**' if it exists + if pattern.tokens.first() == Some(&PatternToken::AnyRecursiveSequence) { + pattern.tokens.remove(0); + } + subpatterns.push(pattern); + } + tokens.push(AnyPattern(subpatterns)); + i = j + 1; + } + c => { + tokens.push(Char(c)); + i += 1; + } + } + } + + Ok(Self { + tokens, + original: pattern.to_string(), + is_recursive, + is_editorconfig, + }) + } + + fn from_tokens(tokens: Vec, original: String, is_recursive: bool) -> Self { + Self { + tokens, + original, + is_recursive, + is_editorconfig: false, + } + } + + /// Escape metacharacters within the given string by surrounding them in + /// brackets. The resulting string will, when compiled into a `Pattern`, + /// match the input string and nothing else. + pub fn escape(s: &str) -> String { + let mut escaped = String::new(); + for c in s.chars() { + match c { + // note that ! does not need escaping because it is only special + // inside brackets + '?' | '*' | '[' | ']' => { + escaped.push('['); + escaped.push(c); + escaped.push(']'); + } + c => { + escaped.push(c); + } + } + } + escaped + } + + /// Return if the given `str` matches this `Pattern` using the default + /// match options (i.e. `MatchOptions::new()`). + /// + /// # Examples + /// + /// ```rust,ignore + /// use crate::Pattern; + /// + /// assert!(Pattern::new("c?t").unwrap().matches("cat")); + /// assert!(Pattern::new("k[!e]tteh").unwrap().matches("kitteh")); + /// assert!(Pattern::new("d*g").unwrap().matches("doog")); + /// ``` + pub fn matches(&self, str: &str) -> bool { + self.matches_with(str, MatchOptions::new()) + } + + /// Return if the given `Path`, when converted to a `str`, matches this + /// `Pattern` using the default match options (i.e. `MatchOptions::new()`). + pub fn matches_path(&self, path: &Path) -> bool { + // FIXME (#9639): This needs to handle non-utf8 paths + path.to_str().map_or(false, |s| self.matches(s)) + } + + /// Return if the given `str` matches this `Pattern` using the specified + /// match options. + pub fn matches_with(&self, str: &str, options: MatchOptions) -> bool { + self.matches_from(true, str.chars(), 0, options) == Match + } + + /// Return if the given `Path`, when converted to a `str`, matches this + /// `Pattern` using the specified match options. + pub fn matches_path_with(&self, path: &Path, options: MatchOptions) -> bool { + // FIXME (#9639): This needs to handle non-utf8 paths + path.to_str() + .map_or(false, |s| self.matches_with(s, options)) + } + + /// Access the original glob pattern. + pub fn as_str(&self) -> &str { + &self.original + } + + fn matches_from( + &self, + mut follows_separator: bool, + mut file: std::str::Chars, + i: usize, + options: MatchOptions, + ) -> MatchResult { + for (ti, token) in self.tokens[i..].iter().enumerate() { + match token { + AnySequence | AnyRecursiveSequence => { + // ** must be at the start. + debug_assert!(match *token { + AnyRecursiveSequence => follows_separator, + _ => true, + }); + + // Empty match + match self.matches_from(follows_separator, file.clone(), i + ti + 1, options) { + SubPatternDoesntMatch => (), // keep trying + m => return m, + }; + + while let Some(c) = file.next() { + if follows_separator && options.require_literal_leading_dot && c == '.' { + return SubPatternDoesntMatch; + } + follows_separator = path::is_separator(c); + match *token { + AnyRecursiveSequence if !follows_separator => continue, + AnySequence + if options.require_literal_separator && follows_separator => + { + return SubPatternDoesntMatch + } + _ => (), + } + match self.matches_from( + follows_separator, + file.clone(), + i + ti + 1, + options, + ) { + SubPatternDoesntMatch => (), // keep trying + m => return m, + } + } + } + AnyPattern(patterns) => { + for pattern in patterns.iter() { + let mut tokens = pattern.tokens.clone(); + tokens.extend_from_slice(&self.tokens[(i + ti + 1)..]); + let new_pattern = Pattern::from_tokens( + tokens, + pattern.original.clone(), + pattern.is_recursive, + ); + if new_pattern.matches_from(follows_separator, file.clone(), 0, options) + == Match + { + return Match; + } + } + return SubPatternDoesntMatch; + } + _ => { + let c = match file.next() { + Some(c) => c, + None => return EntirePatternDoesntMatch, + }; + + let is_sep = path::is_separator(c); + + if !match *token { + AnyChar | AnyWithin(..) | AnyExcept(..) + if (options.require_literal_separator && is_sep) + || (follows_separator + && options.require_literal_leading_dot + && c == '.') => + { + false + } + AnyChar => true, + AnyWithin(ref specifiers) => in_char_specifiers(specifiers, c, options), + AnyExcept(ref specifiers) => !in_char_specifiers(specifiers, c, options), + Char(c2) => chars_eq(c, c2, options.case_sensitive), + AnySequence | AnyRecursiveSequence | AnyPattern(_) => unreachable!(), + } { + return SubPatternDoesntMatch; + } + follows_separator = is_sep; + } + } + } + + // Iter is fused. + if file.next().is_none() { + Match + } else { + SubPatternDoesntMatch + } + } +} + +fn parse_char_specifiers(s: &[char]) -> Vec { + let mut cs = Vec::new(); + let mut i = 0; + while i < s.len() { + if i + 3 <= s.len() && s[i + 1] == '-' { + cs.push(CharRange(s[i], s[i + 2])); + i += 3; + } else { + cs.push(SingleChar(s[i])); + i += 1; + } + } + cs +} + +fn in_char_specifiers(specifiers: &[CharSpecifier], c: char, options: MatchOptions) -> bool { + for &specifier in specifiers.iter() { + match specifier { + SingleChar(sc) => { + if chars_eq(c, sc, options.case_sensitive) { + return true; + } + } + CharRange(start, end) => { + // FIXME: work with non-ascii chars properly (issue #1347) + if !options.case_sensitive && c.is_ascii() && start.is_ascii() && end.is_ascii() { + let start = start.to_ascii_lowercase(); + let end = end.to_ascii_lowercase(); + + let start_up = start.to_uppercase().next().unwrap(); + let end_up = end.to_uppercase().next().unwrap(); + + // only allow case insensitive matching when + // both start and end are within a-z or A-Z + if start != start_up && end != end_up { + let c = c.to_ascii_lowercase(); + if c >= start && c <= end { + return true; + } + } + } + + if c >= start && c <= end { + return true; + } + } + } + } + + false +} + +/// A helper function to determine if two chars are (possibly case-insensitively) equal. +fn chars_eq(a: char, b: char, case_sensitive: bool) -> bool { + if cfg!(windows) && path::is_separator(a) && path::is_separator(b) { + true + } else if !case_sensitive && a.is_ascii() && b.is_ascii() { + // FIXME: work with non-ascii chars properly (issue #9084) + a.to_ascii_lowercase() == b.to_ascii_lowercase() + } else { + a == b + } +} + +/// Configuration options to modify the behaviour of `Pattern::matches_with(..)`. +#[allow(missing_copy_implementations)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct MatchOptions { + /// Whether or not patterns should be matched in a case-sensitive manner. + /// This currently only considers upper/lower case relationships between + /// ASCII characters, but in future this might be extended to work with + /// Unicode. + pub case_sensitive: bool, + + /// Whether or not path-component separator characters (e.g. `/` on + /// Posix) must be matched by a literal `/`, rather than by `*` or `?` or + /// `[...]`. + pub require_literal_separator: bool, + + /// Whether or not paths that contain components that start with a `.` + /// will require that `.` appears literally in the pattern; `*`, `?`, `**`, + /// or `[...]` will not match. This is useful because such files are + /// conventionally considered hidden on Unix systems and it might be + /// desirable to skip them when listing files. + pub require_literal_leading_dot: bool, +} + +impl MatchOptions { + /// Constructs a new `MatchOptions` with default field values. This is used + /// when calling functions that do not take an explicit `MatchOptions` + /// parameter. + /// + /// This function always returns this value: + /// + /// ```rust,ignore + /// MatchOptions { + /// case_sensitive: true, + /// require_literal_separator: false, + /// require_literal_leading_dot: false + /// } + /// ``` + pub fn new() -> Self { + Self { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, + } + } +} + +#[cfg(test)] +mod test { + use super::{MatchOptions, Pattern}; + use std::path::Path; + + #[test] + fn test_pattern_from_str() { + assert!("a*b".parse::().unwrap().matches("a_b")); + assert!("a/**b".parse::().unwrap_err().pos == 4); + } + + #[test] + fn test_wildcard_errors() { + assert!(Pattern::new("a/**b").unwrap_err().pos == 4); + assert!(Pattern::new("a/bc**").unwrap_err().pos == 3); + assert!(Pattern::new("a/*****").unwrap_err().pos == 4); + assert!(Pattern::new("a/b**c**d").unwrap_err().pos == 2); + assert!(Pattern::new("a**b").unwrap_err().pos == 0); + } + + #[test] + fn test_unclosed_bracket_errors() { + assert!(Pattern::new("abc[def").unwrap_err().pos == 3); + assert!(Pattern::new("abc[!def").unwrap_err().pos == 3); + assert!(Pattern::new("abc[").unwrap_err().pos == 3); + assert!(Pattern::new("abc[!").unwrap_err().pos == 3); + assert!(Pattern::new("abc[d").unwrap_err().pos == 3); + assert!(Pattern::new("abc[!d").unwrap_err().pos == 3); + assert!(Pattern::new("abc[]").unwrap_err().pos == 3); + assert!(Pattern::new("abc[!]").unwrap_err().pos == 3); + } + + #[test] + fn test_wildcards() { + assert!(Pattern::new("a*b").unwrap().matches("a_b")); + assert!(Pattern::new("a*b*c").unwrap().matches("abc")); + assert!(!Pattern::new("a*b*c").unwrap().matches("abcd")); + assert!(Pattern::new("a*b*c").unwrap().matches("a_b_c")); + assert!(Pattern::new("a*b*c").unwrap().matches("a___b___c")); + assert!(Pattern::new("abc*abc*abc") + .unwrap() + .matches("abcabcabcabcabcabcabc")); + assert!(!Pattern::new("abc*abc*abc") + .unwrap() + .matches("abcabcabcabcabcabcabca")); + assert!(Pattern::new("a*a*a*a*a*a*a*a*a") + .unwrap() + .matches("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + assert!(Pattern::new("a*b[xyz]c*d").unwrap().matches("abxcdbxcddd")); + } + + #[test] + fn test_recursive_wildcards() { + let pat = Pattern::new("some/**/needle.txt").unwrap(); + assert!(pat.matches("some/needle.txt")); + assert!(pat.matches("some/one/needle.txt")); + assert!(pat.matches("some/one/two/needle.txt")); + assert!(pat.matches("some/other/needle.txt")); + assert!(!pat.matches("some/other/notthis.txt")); + + // a single ** should be valid, for globs + // Should accept anything + let pat = Pattern::new("**").unwrap(); + assert!(pat.is_recursive); + assert!(pat.matches("abcde")); + assert!(pat.matches("")); + assert!(pat.matches(".asdf")); + assert!(pat.matches("/x/.asdf")); + + // collapse consecutive wildcards + let pat = Pattern::new("some/**/**/needle.txt").unwrap(); + assert!(pat.matches("some/needle.txt")); + assert!(pat.matches("some/one/needle.txt")); + assert!(pat.matches("some/one/two/needle.txt")); + assert!(pat.matches("some/other/needle.txt")); + assert!(!pat.matches("some/other/notthis.txt")); + + // ** can begin the pattern + let pat = Pattern::new("**/test").unwrap(); + assert!(pat.matches("one/two/test")); + assert!(pat.matches("one/test")); + assert!(pat.matches("test")); + + // /** can begin the pattern + let pat = Pattern::new("/**/test").unwrap(); + assert!(pat.matches("/one/two/test")); + assert!(pat.matches("/one/test")); + assert!(pat.matches("/test")); + assert!(!pat.matches("/one/notthis")); + assert!(!pat.matches("/notthis")); + + // Only start sub-patterns on start of path segment. + let pat = Pattern::new("**/.*").unwrap(); + assert!(pat.matches(".abc")); + assert!(pat.matches("abc/.abc")); + assert!(!pat.matches("ab.c")); + assert!(!pat.matches("abc/ab.c")); + } + + #[test] + fn test_range_pattern() { + let pat = Pattern::new("a[0-9]b").unwrap(); + for i in 0..10 { + assert!(pat.matches(&format!("a{i}b"))); + } + assert!(!pat.matches("a_b")); + + let pat = Pattern::new("a[!0-9]b").unwrap(); + for i in 0..10 { + assert!(!pat.matches(&format!("a{i}b"))); + } + assert!(pat.matches("a_b")); + + let pats = ["[a-z123]", "[1a-z23]", "[123a-z]"]; + for &p in pats.iter() { + let pat = Pattern::new(p).unwrap(); + for c in "abcdefghijklmnopqrstuvwxyz".chars() { + assert!(pat.matches(&c.to_string())); + } + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars() { + let options = MatchOptions { + case_sensitive: false, + ..MatchOptions::new() + }; + assert!(pat.matches_with(&c.to_string(), options)); + } + assert!(pat.matches("1")); + assert!(pat.matches("2")); + assert!(pat.matches("3")); + } + + let pats = ["[abc-]", "[-abc]", "[a-c-]"]; + for &p in pats.iter() { + let pat = Pattern::new(p).unwrap(); + assert!(pat.matches("a")); + assert!(pat.matches("b")); + assert!(pat.matches("c")); + assert!(pat.matches("-")); + assert!(!pat.matches("d")); + } + + let pat = Pattern::new("[2-1]").unwrap(); + assert!(!pat.matches("1")); + assert!(!pat.matches("2")); + + assert!(Pattern::new("[-]").unwrap().matches("-")); + assert!(!Pattern::new("[!-]").unwrap().matches("-")); + } + + #[test] + fn test_pattern_matches() { + let txt_pat = Pattern::new("*hello.txt").unwrap(); + assert!(txt_pat.matches("hello.txt")); + assert!(txt_pat.matches("gareth_says_hello.txt")); + assert!(txt_pat.matches("some/path/to/hello.txt")); + assert!(txt_pat.matches("some\\path\\to\\hello.txt")); + assert!(txt_pat.matches("/an/absolute/path/to/hello.txt")); + assert!(!txt_pat.matches("hello.txt-and-then-some")); + assert!(!txt_pat.matches("goodbye.txt")); + + let dir_pat = Pattern::new("*some/path/to/hello.txt").unwrap(); + assert!(dir_pat.matches("some/path/to/hello.txt")); + assert!(dir_pat.matches("a/bigger/some/path/to/hello.txt")); + assert!(!dir_pat.matches("some/path/to/hello.txt-and-then-some")); + assert!(!dir_pat.matches("some/other/path/to/hello.txt")); + } + + #[test] + fn test_pattern_escape() { + let s = "_[_]_?_*_!_"; + assert_eq!(Pattern::escape(s), "_[[]_[]]_[?]_[*]_!_".to_string()); + assert!(Pattern::new(&Pattern::escape(s)).unwrap().matches(s)); + } + + #[test] + fn test_pattern_matches_case_insensitive() { + let pat = Pattern::new("aBcDeFg").unwrap(); + let options = MatchOptions { + case_sensitive: false, + require_literal_separator: false, + require_literal_leading_dot: false, + }; + + assert!(pat.matches_with("aBcDeFg", options)); + assert!(pat.matches_with("abcdefg", options)); + assert!(pat.matches_with("ABCDEFG", options)); + assert!(pat.matches_with("AbCdEfG", options)); + } + + #[test] + fn test_pattern_matches_case_insensitive_range() { + let pat_within = Pattern::new("[a]").unwrap(); + let pat_except = Pattern::new("[!a]").unwrap(); + + let options_case_insensitive = MatchOptions { + case_sensitive: false, + require_literal_separator: false, + require_literal_leading_dot: false, + }; + let options_case_sensitive = MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, + }; + + assert!(pat_within.matches_with("a", options_case_insensitive)); + assert!(pat_within.matches_with("A", options_case_insensitive)); + assert!(!pat_within.matches_with("A", options_case_sensitive)); + + assert!(!pat_except.matches_with("a", options_case_insensitive)); + assert!(!pat_except.matches_with("A", options_case_insensitive)); + assert!(pat_except.matches_with("A", options_case_sensitive)); + } + + #[test] + fn test_pattern_matches_require_literal_separator() { + let options_require_literal = MatchOptions { + case_sensitive: true, + require_literal_separator: true, + require_literal_leading_dot: false, + }; + let options_not_require_literal = MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, + }; + + assert!(Pattern::new("abc/def") + .unwrap() + .matches_with("abc/def", options_require_literal)); + assert!(!Pattern::new("abc?def") + .unwrap() + .matches_with("abc/def", options_require_literal)); + assert!(!Pattern::new("abc*def") + .unwrap() + .matches_with("abc/def", options_require_literal)); + assert!(!Pattern::new("abc[/]def") + .unwrap() + .matches_with("abc/def", options_require_literal)); + + assert!(Pattern::new("abc/def") + .unwrap() + .matches_with("abc/def", options_not_require_literal)); + assert!(Pattern::new("abc?def") + .unwrap() + .matches_with("abc/def", options_not_require_literal)); + assert!(Pattern::new("abc*def") + .unwrap() + .matches_with("abc/def", options_not_require_literal)); + assert!(Pattern::new("abc[/]def") + .unwrap() + .matches_with("abc/def", options_not_require_literal)); + } + + #[test] + fn test_pattern_matches_require_literal_leading_dot() { + let options_require_literal_leading_dot = MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: true, + }; + let options_not_require_literal_leading_dot = MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, + }; + + let f = |options| { + Pattern::new("*.txt") + .unwrap() + .matches_with(".hello.txt", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(!f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new(".*.*") + .unwrap() + .matches_with(".hello.txt", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new("aaa/bbb/*") + .unwrap() + .matches_with("aaa/bbb/.ccc", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(!f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new("aaa/bbb/*") + .unwrap() + .matches_with("aaa/bbb/c.c.c.", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new("aaa/bbb/.*") + .unwrap() + .matches_with("aaa/bbb/.ccc", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new("aaa/?bbb") + .unwrap() + .matches_with("aaa/.bbb", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(!f(options_require_literal_leading_dot)); + + let f = |options| { + Pattern::new("aaa/[.]bbb") + .unwrap() + .matches_with("aaa/.bbb", options) + }; + assert!(f(options_not_require_literal_leading_dot)); + assert!(!f(options_require_literal_leading_dot)); + + let f = |options| Pattern::new("**/*").unwrap().matches_with(".bbb", options); + assert!(f(options_not_require_literal_leading_dot)); + assert!(!f(options_require_literal_leading_dot)); + } + + #[test] + fn test_matches_path() { + // on windows, (Path::new("a/b").as_str().unwrap() == "a\\b"), so this + // tests that / and \ are considered equivalent on windows + assert!(Pattern::new("a/b").unwrap().matches_path(Path::new("a/b"))); + } + + #[test] + fn test_path_join() { + let pattern = Path::new("one").join(Path::new("**/*.rs")); + assert!(Pattern::new(pattern.to_str().unwrap()).is_ok()); + } + + #[test] + fn test_pattern_relative() { + assert!(Pattern::new("./b").unwrap().matches_path(Path::new("a/b"))); + assert!(Pattern::new("b").unwrap().matches_path(Path::new("a/b"))); + + if cfg!(windows) { + assert!(Pattern::new(".\\b") + .unwrap() + .matches_path(Path::new("a\\b"))); + assert!(Pattern::new("b").unwrap().matches_path(Path::new("a\\b"))); + } + } + + #[test] + fn test_pattern_absolute() { + assert!(Pattern::new("/a/b") + .unwrap() + .matches_path(Path::new("/a/b"))); + + if cfg!(windows) { + assert!(Pattern::new("c:/a/b") + .unwrap() + .matches_path(Path::new("c:/a/b"))); + assert!(Pattern::new("C:\\a\\b") + .unwrap() + .matches_path(Path::new("C:\\a\\b"))); + + assert!(Pattern::new("\\\\?\\c:\\a\\b") + .unwrap() + .matches_path(Path::new("\\\\?\\c:\\a\\b"))); + assert!(Pattern::new("\\\\?\\C:/a/b") + .unwrap() + .matches_path(Path::new("\\\\?\\C:/a/b"))); + } + } + + #[test] + fn test_pattern_glob() { + assert!(Pattern::new("*.js") + .unwrap() + .matches_path(Path::new("b/c.js"))); + + assert!(Pattern::new("**/*.js") + .unwrap() + .matches_path(Path::new("b/c.js"))); + + assert!(Pattern::new("*.js") + .unwrap() + .matches_path(Path::new("/a/b/c.js"))); + + assert!(Pattern::new("**/*.js") + .unwrap() + .matches_path(Path::new("/a/b/c.js"))); + + if cfg!(windows) { + assert!(Pattern::new("*.js") + .unwrap() + .matches_path(Path::new("C:\\a\\b\\c.js"))); + + assert!(Pattern::new("**/*.js") + .unwrap() + .matches_path(Path::new("\\\\?\\C:\\a\\b\\c.js"))); + } + } + + #[test] + fn test_pattern_glob_brackets() { + let pattern = Pattern::parse("{foo.js,bar.js}", true).unwrap(); + assert!(pattern.matches_path(Path::new("foo.js"))); + assert!(pattern.matches_path(Path::new("bar.js"))); + assert!(!pattern.matches_path(Path::new("baz.js"))); + + let pattern = Pattern::parse("{foo,bar}.js", true).unwrap(); + assert!(pattern.matches_path(Path::new("foo.js"))); + assert!(pattern.matches_path(Path::new("bar.js"))); + assert!(!pattern.matches_path(Path::new("baz.js"))); + + assert!(Pattern::parse("**/{foo,bar}.js", true) + .unwrap() + .matches_path(Path::new("a/b/foo.js"))); + + let pattern = Pattern::parse("src/{a/foo,bar}.js", true).unwrap(); + assert!(pattern.matches_path(Path::new("src/a/foo.js"))); + assert!(pattern.matches_path(Path::new("src/bar.js"))); + assert!(!pattern.matches_path(Path::new("src/a/b/foo.js"))); + assert!(!pattern.matches_path(Path::new("src/a/bar.js"))); + + let pattern = Pattern::parse("src/{a,b}/{c,d}/foo.js", true).unwrap(); + assert!(pattern.matches_path(Path::new("src/a/c/foo.js"))); + assert!(pattern.matches_path(Path::new("src/a/d/foo.js"))); + assert!(pattern.matches_path(Path::new("src/b/c/foo.js"))); + assert!(pattern.matches_path(Path::new("src/b/d/foo.js"))); + assert!(!pattern.matches_path(Path::new("src/bar/foo.js"))); + + let _ = Pattern::parse("{{foo,bar},baz}", true) + .expect_err("should not allow curly brackets more than 1 level deep"); + } + + #[test] + fn test_pattern_glob_brackets_not_available_by_default() { + // RODO: Remove this test when we make brackets available by default in Biome 2.0 + let pattern = Pattern::parse("{foo.js,bar.js}", false).unwrap(); + assert!(!pattern.matches_path(Path::new("foo.js"))); + assert!(!pattern.matches_path(Path::new("bar.js"))); + assert!(!pattern.matches_path(Path::new("baz.js"))); + } +} diff --git a/crates/pg_workspace_new/src/settings.rs b/crates/pg_workspace_new/src/settings.rs new file mode 100644 index 000000000..c1b837127 --- /dev/null +++ b/crates/pg_workspace_new/src/settings.rs @@ -0,0 +1,302 @@ +use biome_deserialize::StringSet; +use std::{ + num::NonZeroU64, + path::{Path, PathBuf}, + sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}, +}; + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use pg_configuration::{ + database::PartialDatabaseConfiguration, + diagnostics::InvalidIgnorePattern, + files::FilesConfiguration, + ConfigurationDiagnostic, PartialConfiguration, +}; +use pg_fs::FileSystem; + +use crate::{matcher::Matcher, DynRef, WorkspaceError}; + +/// Global settings for the entire workspace +#[derive(Debug, Default)] +pub struct Settings { + /// Filesystem settings for the workspace + pub files: FilesSettings, + + /// Database settings for the workspace + pub db: DatabaseSettings, +} + +#[derive(Debug)] +pub struct SettingsHandleMut<'a> { + inner: RwLockWriteGuard<'a, Settings>, +} + +/// Handle object holding a temporary lock on the settings +#[derive(Debug)] +pub struct SettingsHandle<'a> { + inner: RwLockReadGuard<'a, Settings>, +} + +impl<'a> SettingsHandle<'a> { + pub(crate) fn new(settings: &'a RwLock) -> Self { + Self { + inner: settings.read().unwrap(), + } + } +} + +impl<'a> AsRef for SettingsHandle<'a> { + fn as_ref(&self) -> &Settings { + &self.inner + } +} + +impl<'a> SettingsHandleMut<'a> { + pub(crate) fn new(settings: &'a RwLock) -> Self { + Self { + inner: settings.write().unwrap(), + } + } +} + +impl<'a> AsMut for SettingsHandleMut<'a> { + fn as_mut(&mut self) -> &mut Settings { + &mut self.inner + } +} + +impl Settings { + /// The [PartialConfiguration] is merged into the workspace + #[tracing::instrument(level = "trace", skip(self))] + pub fn merge_with_configuration( + &mut self, + configuration: PartialConfiguration, + working_directory: Option, + vcs_path: Option, + gitignore_matches: &[String], + ) -> Result<(), WorkspaceError> { + // Filesystem settings + if let Some(files) = to_file_settings( + working_directory.clone(), + configuration.files.map(FilesConfiguration::from), + vcs_path, + gitignore_matches, + )? { + self.files = files; + } + + // db settings + if let Some(db) = configuration.db { + self.db = db.into() + } + + Ok(()) + } +} + +fn to_file_settings( + working_directory: Option, + config: Option, + vcs_config_path: Option, + gitignore_matches: &[String], +) -> Result, WorkspaceError> { + let config = if let Some(config) = config { + Some(config) + } else if vcs_config_path.is_some() { + Some(FilesConfiguration::default()) + } else { + None + }; + let git_ignore = if let Some(vcs_config_path) = vcs_config_path { + Some(to_git_ignore(vcs_config_path, gitignore_matches)?) + } else { + None + }; + Ok(if let Some(config) = config { + Some(FilesSettings { + max_size: config.max_size, + git_ignore, + ignored_files: to_matcher(working_directory.clone(), Some(&config.ignore))?, + included_files: to_matcher(working_directory, Some(&config.include))?, + }) + } else { + None + }) +} + +fn to_git_ignore(path: PathBuf, matches: &[String]) -> Result { + let mut gitignore_builder = GitignoreBuilder::new(path.clone()); + + for the_match in matches { + gitignore_builder + .add_line(Some(path.clone()), the_match) + .map_err(|err| { + ConfigurationDiagnostic::InvalidIgnorePattern(InvalidIgnorePattern { + message: err.to_string(), + file_path: path.to_str().map(|s| s.to_string()), + }) + })?; + } + let gitignore = gitignore_builder.build().map_err(|err| { + ConfigurationDiagnostic::InvalidIgnorePattern(InvalidIgnorePattern { + message: err.to_string(), + file_path: path.to_str().map(|s| s.to_string()), + }) + })?; + Ok(gitignore) +} + +/// Creates a [Matcher] from a [StringSet] +/// +/// ## Errors +/// +/// It can raise an error if the patterns aren't valid +pub fn to_matcher( + working_directory: Option, + string_set: Option<&StringSet>, +) -> Result { + let mut matcher = Matcher::empty(); + if let Some(working_directory) = working_directory { + matcher.set_root(working_directory) + } + if let Some(string_set) = string_set { + for pattern in string_set.iter() { + matcher.add_pattern(pattern).map_err(|err| { + ConfigurationDiagnostic::new_invalid_ignore_pattern( + pattern.to_string(), + err.msg.to_string(), + ) + })?; + } + } + Ok(matcher) +} + +/// Database settings for the entire workspace +#[derive(Debug)] +pub struct DatabaseSettings { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub database: String, +} + +impl Default for DatabaseSettings { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 5432, + username: "postgres".to_string(), + password: "postgres".to_string(), + database: "postgres".to_string(), + } + } +} + +impl DatabaseSettings { + pub fn to_connection_string(&self) -> String { + format!( + "postgres://{}:{}@{}:{}/{}", + self.username, self.password, self.host, self.port, self.database + ) + } +} + +impl From for DatabaseSettings { + fn from(value: PartialDatabaseConfiguration) -> Self { + let d = DatabaseSettings::default(); + Self { + host: value.host.unwrap_or(d.host), + port: value.port.unwrap_or(d.port), + username: value.username.unwrap_or(d.username), + password: value.password.unwrap_or(d.password), + database: value.database.unwrap_or(d.database), + } + } +} + +/// Filesystem settings for the entire workspace +#[derive(Debug)] +pub struct FilesSettings { + /// File size limit in bytes + pub max_size: NonZeroU64, + + /// List of paths/files to matcher + pub ignored_files: Matcher, + + /// List of paths/files to matcher + pub included_files: Matcher, + + /// gitignore file patterns + pub git_ignore: Option, +} + +/// Limit the size of files to 1.0 MiB by default +pub(crate) const DEFAULT_FILE_SIZE_LIMIT: NonZeroU64 = + // SAFETY: This constant is initialized with a non-zero value + unsafe { NonZeroU64::new_unchecked(1024 * 1024) }; + +impl Default for FilesSettings { + fn default() -> Self { + Self { + max_size: DEFAULT_FILE_SIZE_LIMIT, + ignored_files: Matcher::empty(), + included_files: Matcher::empty(), + git_ignore: None, + } + } +} + +pub trait PartialConfigurationExt { + fn retrieve_gitignore_matches( + &self, + file_system: &DynRef<'_, dyn FileSystem>, + vcs_base_path: Option<&Path>, + ) -> Result<(Option, Vec), WorkspaceError>; +} + +impl PartialConfigurationExt for PartialConfiguration { + /// This function checks if the VCS integration is enabled, and if so, it will attempts to resolve the + /// VCS root directory and the `.gitignore` file. + /// + /// ## Returns + /// + /// A tuple with VCS root folder and the contents of the `.gitignore` file + fn retrieve_gitignore_matches( + &self, + file_system: &DynRef<'_, dyn FileSystem>, + vcs_base_path: Option<&Path>, + ) -> Result<(Option, Vec), WorkspaceError> { + let Some(vcs) = &self.vcs else { + return Ok((None, vec![])); + }; + if vcs.is_enabled() { + let vcs_base_path = match (vcs_base_path, &vcs.root) { + (Some(vcs_base_path), Some(root)) => vcs_base_path.join(root), + (None, Some(root)) => PathBuf::from(root), + (Some(vcs_base_path), None) => PathBuf::from(vcs_base_path), + (None, None) => return Err(WorkspaceError::vcs_disabled()), + }; + if let Some(client_kind) = &vcs.client_kind { + if !vcs.ignore_file_disabled() { + let result = file_system + .auto_search(&vcs_base_path, &[client_kind.ignore_file()], false) + .map_err(WorkspaceError::from)?; + + if let Some(result) = result { + return Ok(( + result.file_path.parent().map(PathBuf::from), + result + .content + .lines() + .map(String::from) + .collect::>(), + )); + } + } + } + } + Ok((None, vec![])) + } +} diff --git a/crates/pg_workspace_new/src/workspace.rs b/crates/pg_workspace_new/src/workspace.rs new file mode 100644 index 000000000..1b8cb1ba8 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace.rs @@ -0,0 +1,253 @@ +use std::{panic::RefUnwindSafe, path::PathBuf, sync::Arc}; + +pub use self::client::{TransportRequest, WorkspaceClient, WorkspaceTransport}; +use pg_configuration::PartialConfiguration; +use pg_fs::PgLspPath; +use serde::{Deserialize, Serialize}; +use text_size::TextRange; + +use crate::WorkspaceError; + +mod client; +mod server; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct OpenFileParams { + pub path: PgLspPath, + pub content: String, + pub version: i32, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct CloseFileParams { + pub path: PgLspPath, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ChangeFileParams { + pub path: PgLspPath, + pub version: i32, + pub changes: Vec, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ChangeParams { + /// The range of the file that changed. If `None`, the whole file changed. + pub range: Option, + pub text: String, +} + +impl ChangeParams { + pub fn overwrite(text: String) -> Self { + Self { range: None, text } + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct IsPathIgnoredParams { + pub pglsp_path: PgLspPath, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct UpdateSettingsParams { + pub configuration: PartialConfiguration, + pub vcs_base_path: Option, + pub gitignore_matches: Vec, + pub workspace_directory: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct GetFileContentParams { + pub path: PgLspPath, +} + +#[derive(Debug, Eq, PartialEq, Clone, Default, Deserialize, Serialize)] +pub struct ServerInfo { + /// The name of the server as defined by the server. + pub name: String, + + /// The server's version as defined by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +pub trait Workspace: Send + Sync + RefUnwindSafe { + /// Refresh the schema cache for this workspace + fn refresh_schema_cache(&self) -> Result<(), WorkspaceError>; + + /// Update the global settings for this workspace + fn update_settings(&self, params: UpdateSettingsParams) -> Result<(), WorkspaceError>; + + /// Add a new file to the workspace + fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError>; + + /// Remove a file from the workspace + fn close_file(&self, params: CloseFileParams) -> Result<(), WorkspaceError>; + + /// Change the content of an open file + fn change_file(&self, params: ChangeFileParams) -> Result<(), WorkspaceError>; + + /// Returns information about the server this workspace is connected to or `None` if the workspace isn't connected to a server. + fn server_info(&self) -> Option<&ServerInfo>; + + /// Return the content of a file + fn get_file_content(&self, params: GetFileContentParams) -> Result; + + /// Checks if the current path is ignored by the workspace. + /// + /// Takes as input the path of the file that workspace is currently processing and + /// a list of paths to match against. + /// + /// If the file path matches, then `true` is returned, and it should be considered ignored. + fn is_path_ignored(&self, params: IsPathIgnoredParams) -> Result; +} + +/// Convenience function for constructing a server instance of [Workspace] +pub fn server() -> Box { + Box::new(server::WorkspaceServer::new()) +} + +/// Convenience function for constructing a server instance of [Workspace] +pub fn server_sync() -> Arc { + Arc::new(server::WorkspaceServer::new()) +} + +// Convenience function for constructing a client instance of [Workspace] +pub fn client(transport: T) -> Result, WorkspaceError> +where + T: WorkspaceTransport + RefUnwindSafe + Send + Sync + 'static, +{ + Ok(Box::new(client::WorkspaceClient::new(transport)?)) +} + +/// [RAII](https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization) +/// guard for an open file in a workspace, takes care of closing the file +/// automatically on drop +pub struct FileGuard<'app, W: Workspace + ?Sized> { + workspace: &'app W, + path: PgLspPath, +} + +impl<'app, W: Workspace + ?Sized> FileGuard<'app, W> { + pub fn open(workspace: &'app W, params: OpenFileParams) -> Result { + let path = params.path.clone(); + workspace.open_file(params)?; + Ok(Self { workspace, path }) + } + + pub fn change_file( + &self, + version: i32, + changes: Vec, + ) -> Result<(), WorkspaceError> { + self.workspace.change_file(ChangeFileParams { + path: self.path.clone(), + version, + changes, + }) + } + + pub fn get_file_content(&self) -> Result { + self.workspace.get_file_content(GetFileContentParams { + path: self.path.clone(), + }) + } + // + // pub fn pull_diagnostics( + // &self, + // categories: RuleCategories, + // max_diagnostics: u32, + // only: Vec, + // skip: Vec, + // ) -> Result { + // self.workspace.pull_diagnostics(PullDiagnosticsParams { + // path: self.path.clone(), + // categories, + // max_diagnostics: max_diagnostics.into(), + // only, + // skip, + // }) + // } + // + // pub fn pull_actions( + // &self, + // range: Option, + // only: Vec, + // skip: Vec, + // suppression_reason: Option, + // ) -> Result { + // self.workspace.pull_actions(PullActionsParams { + // path: self.path.clone(), + // range, + // only, + // skip, + // suppression_reason, + // }) + // } + // + // pub fn format_file(&self) -> Result { + // self.workspace.format_file(FormatFileParams { + // path: self.path.clone(), + // }) + // } + // + // pub fn format_range(&self, range: TextRange) -> Result { + // self.workspace.format_range(FormatRangeParams { + // path: self.path.clone(), + // range, + // }) + // } + // + // pub fn format_on_type(&self, offset: TextSize) -> Result { + // self.workspace.format_on_type(FormatOnTypeParams { + // path: self.path.clone(), + // offset, + // }) + // } + // + // pub fn fix_file( + // &self, + // fix_file_mode: FixFileMode, + // should_format: bool, + // rule_categories: RuleCategories, + // only: Vec, + // skip: Vec, + // suppression_reason: Option, + // ) -> Result { + // self.workspace.fix_file(FixFileParams { + // path: self.path.clone(), + // fix_file_mode, + // should_format, + // only, + // skip, + // rule_categories, + // suppression_reason, + // }) + // } + // + // pub fn organize_imports(&self) -> Result { + // self.workspace.organize_imports(OrganizeImportsParams { + // path: self.path.clone(), + // }) + // } + // + // pub fn search_pattern(&self, pattern: &PatternId) -> Result { + // self.workspace.search_pattern(SearchPatternParams { + // path: self.path.clone(), + // pattern: pattern.clone(), + // }) + // } +} + +impl<'app, W: Workspace + ?Sized> Drop for FileGuard<'app, W> { + fn drop(&mut self) { + self.workspace + .close_file(CloseFileParams { + path: self.path.clone(), + }) + // `close_file` can only error if the file was already closed, in + // this case it's generally better to silently matcher the error + // than panic (especially in a drop handler) + .ok(); + } +} diff --git a/crates/pg_workspace_new/src/workspace/client.rs b/crates/pg_workspace_new/src/workspace/client.rs new file mode 100644 index 000000000..2cb768eab --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/client.rs @@ -0,0 +1,123 @@ +use crate::workspace::ServerInfo; +use crate::{TransportError, Workspace, WorkspaceError}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::json; +use std::{ + panic::RefUnwindSafe, + sync::atomic::{AtomicU64, Ordering}, +}; + +use super::{CloseFileParams, GetFileContentParams, IsPathIgnoredParams, OpenFileParams}; + +pub struct WorkspaceClient { + transport: T, + request_id: AtomicU64, + server_info: Option, +} + +pub trait WorkspaceTransport { + fn request(&self, request: TransportRequest

) -> Result + where + P: Serialize, + R: DeserializeOwned; +} + +#[derive(Debug)] +pub struct TransportRequest

{ + pub id: u64, + pub method: &'static str, + pub params: P, +} + +#[derive(Debug, PartialEq, Eq, Clone, Default, Deserialize, Serialize)] +pub struct InitializeResult { + /// Information about the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, +} + +impl WorkspaceClient +where + T: WorkspaceTransport + RefUnwindSafe + Send + Sync, +{ + pub fn new(transport: T) -> Result { + let mut client = Self { + transport, + request_id: AtomicU64::new(0), + server_info: None, + }; + + // TODO: The current implementation of the JSON-RPC protocol in + // tower_lsp doesn't allow any request to be sent before a call to + // initialize, this is something we could be able to lift by using our + // own RPC protocol implementation + let value: InitializeResult = client.request( + "initialize", + json!({ + "capabilities": {}, + "clientInfo": { + "name": env!("CARGO_PKG_NAME"), + "version": pg_configuration::VERSION + }, + }), + )?; + + client.server_info = value.server_info; + + Ok(client) + } + + fn request(&self, method: &'static str, params: P) -> Result + where + P: Serialize, + R: DeserializeOwned, + { + let id = self.request_id.fetch_add(1, Ordering::Relaxed); + let request = TransportRequest { id, method, params }; + + let response = self.transport.request(request)?; + + Ok(response) + } + + pub fn shutdown(self) -> Result<(), WorkspaceError> { + self.request("pglsp/shutdown", ()) + } +} + +impl Workspace for WorkspaceClient +where + T: WorkspaceTransport + RefUnwindSafe + Send + Sync, +{ + fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError> { + self.request("pglsp/open_file", params) + } + + fn close_file(&self, params: CloseFileParams) -> Result<(), WorkspaceError> { + self.request("pglsp/close_file", params) + } + + fn change_file(&self, params: super::ChangeFileParams) -> Result<(), WorkspaceError> { + self.request("pglsp/change_file", params) + } + + fn update_settings(&self, params: super::UpdateSettingsParams) -> Result<(), WorkspaceError> { + self.request("pglsp/update_settings", params) + } + + fn is_path_ignored(&self, params: IsPathIgnoredParams) -> Result { + self.request("pglsp/is_path_ignored", params) + } + + fn server_info(&self) -> Option<&ServerInfo> { + self.server_info.as_ref() + } + + fn get_file_content(&self, params: GetFileContentParams) -> Result { + self.request("pglsp/get_file_content", params) + } + + fn refresh_schema_cache(&self) -> Result<(), WorkspaceError> { + self.request("pglsp/refresh_schema_cache", ()) + } +} diff --git a/crates/pg_workspace_new/src/workspace/server.rs b/crates/pg_workspace_new/src/workspace/server.rs new file mode 100644 index 000000000..653cea655 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server.rs @@ -0,0 +1,312 @@ +use std::{fs, future::Future, panic::RefUnwindSafe, path::Path, sync::RwLock}; + +use change::StatementChange; +use dashmap::{DashMap, DashSet}; +use document::{Document, StatementRef}; +use pg_fs::{ConfigName, PgLspPath}; +use pg_query::PgQueryStore; +use pg_schema_cache::SchemaCache; +use sqlx::PgPool; +use std::sync::LazyLock; +use store::Store; +use tokio::runtime::Runtime; +use tree_sitter::TreeSitterStore; + +use crate::{ + settings::{Settings, SettingsHandle, SettingsHandleMut}, + WorkspaceError, +}; + +use super::{ + GetFileContentParams, IsPathIgnoredParams, OpenFileParams, ServerInfo, UpdateSettingsParams, + Workspace, +}; + +mod change; +mod document; +mod pg_query; +mod store; +mod tree_sitter; + +/// Simple helper to manage the db connection and the associated connection string +#[derive(Default)] +struct DbConnection { + pool: Option, + connection_string: Option, +} + +// Global Tokio Runtime +static RUNTIME: LazyLock = + LazyLock::new(|| Runtime::new().expect("Failed to create Tokio runtime")); + +impl DbConnection { + pub(crate) fn get_pool(&self) -> Option { + self.pool.clone() + } + + pub(crate) fn set_connection(&mut self, connection_string: &str) -> Result<(), WorkspaceError> { + if self.connection_string.is_none() + || self.connection_string.as_ref().unwrap() != connection_string + { + self.connection_string = Some(connection_string.to_string()); + self.pool = Some(PgPool::connect_lazy(connection_string)?); + } + + Ok(()) + } +} + +pub(super) struct WorkspaceServer { + /// global settings object for this workspace + settings: RwLock, + + /// Stores the schema cache for this workspace + schema_cache: RwLock, + + /// Stores the document (text content + version number) associated with a URL + documents: DashMap, + + tree_sitter: TreeSitterStore, + pg_query: PgQueryStore, + + /// Stores the statements that have changed since the last analysis + changed_stmts: DashSet, + + connection: RwLock, +} + +/// The `Workspace` object is long-lived, so we want it to be able to cross +/// unwind boundaries. +/// In return, we have to make sure operations on the workspace either do not +/// panic, of that panicking will not result in any broken invariant (it would +/// not result in any undefined behavior as catching an unwind is safe, but it +/// could lead too hard to debug issues) +impl RefUnwindSafe for WorkspaceServer {} + +impl WorkspaceServer { + /// Create a new [Workspace] + /// + /// This is implemented as a crate-private method instead of using + /// [Default] to disallow instances of [Workspace] from being created + /// outside a [crate::App] + pub(crate) fn new() -> Self { + Self { + settings: RwLock::default(), + documents: DashMap::default(), + tree_sitter: TreeSitterStore::new(), + pg_query: PgQueryStore::new(), + changed_stmts: DashSet::default(), + schema_cache: RwLock::default(), + connection: RwLock::default(), + } + } + + /// Provides a reference to the current settings + fn settings(&self) -> SettingsHandle { + SettingsHandle::new(&self.settings) + } + + fn settings_mut(&self) -> SettingsHandleMut { + SettingsHandleMut::new(&self.settings) + } + + fn refresh_db_connection(&self) -> Result<(), WorkspaceError> { + let s = self.settings(); + + let connection_string = s.as_ref().db.to_connection_string(); + self.connection + .write() + .unwrap() + .set_connection(&connection_string)?; + + self.reload_schema_cache()?; + + Ok(()) + } + + fn reload_schema_cache(&self) -> Result<(), WorkspaceError> { + tracing::info!("Reloading schema cache"); + // TODO return error if db connection is not available + if let Some(c) = self.connection.read().unwrap().get_pool() { + let schema_cache = run_async(async move { + // TODO load should return a Result + SchemaCache::load(&c).await + })?; + + let mut cache = self.schema_cache.write().unwrap(); + *cache = schema_cache; + } else { + let mut cache = self.schema_cache.write().unwrap(); + *cache = SchemaCache::default(); + } + tracing::info!("Schema cache reloaded"); + + Ok(()) + } + + /// Check whether a file is ignored in the top-level config `files.ignore`/`files.include` + fn is_ignored(&self, path: &Path) -> bool { + let file_name = path.file_name().and_then(|s| s.to_str()); + // Never ignore Biome's config file regardless `include`/`ignore` + (file_name != Some(ConfigName::pglsp_toml())) && + // Apply top-level `include`/`ignore + (self.is_ignored_by_top_level_config(path)) + } + + /// Check whether a file is ignored in the top-level config `files.ignore`/`files.include` + fn is_ignored_by_top_level_config(&self, path: &Path) -> bool { + let set = self.settings(); + let settings = set.as_ref(); + let is_included = settings.files.included_files.is_empty() + || is_dir(path) + || settings.files.included_files.matches_path(path); + !is_included + || settings.files.ignored_files.matches_path(path) + || settings.files.git_ignore.as_ref().is_some_and(|ignore| { + // `matched_path_or_any_parents` panics if `source` is not under the gitignore root. + // This checks excludes absolute paths that are not a prefix of the base root. + if !path.has_root() || path.starts_with(ignore.path()) { + // Because Biome passes a list of paths, + // we use `matched_path_or_any_parents` instead of `matched`. + ignore + .matched_path_or_any_parents(path, path.is_dir()) + .is_ignore() + } else { + false + } + }) + } +} + +impl Workspace for WorkspaceServer { + #[tracing::instrument(level = "trace", skip(self))] + fn refresh_schema_cache(&self) -> Result<(), WorkspaceError> { + self.reload_schema_cache() + } + + /// Update the global settings for this workspace + /// + /// ## Panics + /// This function may panic if the internal settings mutex has been poisoned + /// by another thread having previously panicked while holding the lock + #[tracing::instrument(level = "trace", skip(self))] + fn update_settings(&self, params: UpdateSettingsParams) -> Result<(), WorkspaceError> { + tracing::info!("Updating settings in workspace"); + + self.settings_mut().as_mut().merge_with_configuration( + params.configuration, + params.workspace_directory, + params.vcs_base_path, + params.gitignore_matches.as_slice(), + )?; + + self.refresh_db_connection()?; + + tracing::info!("Updated settings in workspace"); + + Ok(()) + } + + /// Add a new file to the workspace + #[tracing::instrument(level = "trace", skip(self))] + fn open_file(&self, params: OpenFileParams) -> Result<(), WorkspaceError> { + tracing::info!("Opening file: {:?}", params.path); + self.documents.insert( + params.path.clone(), + Document::new(params.path, params.content, params.version), + ); + + Ok(()) + } + + /// Remove a file from the workspace + fn close_file(&self, params: super::CloseFileParams) -> Result<(), crate::WorkspaceError> { + let (_, doc) = self + .documents + .remove(¶ms.path) + .ok_or_else(WorkspaceError::not_found)?; + + for stmt in doc.statement_refs() { + self.tree_sitter.remove_statement(&stmt); + } + + Ok(()) + } + + /// Change the content of an open file + fn change_file(&self, params: super::ChangeFileParams) -> Result<(), WorkspaceError> { + let mut doc = self + .documents + .entry(params.path.clone()) + .or_insert(Document::new( + params.path.clone(), + "".to_string(), + params.version, + )); + + tracing::info!("Changing file: {:?}", params.path); + + for c in &doc.apply_file_change(¶ms) { + match c { + StatementChange::Added(s) => { + tracing::info!("Adding statement: {:?}", s); + self.tree_sitter.add_statement(s); + self.pg_query.add_statement(s); + + self.changed_stmts.insert(s.ref_.to_owned()); + } + StatementChange::Deleted(s) => { + tracing::info!("Deleting statement: {:?}", s); + self.tree_sitter.remove_statement(s); + self.pg_query.remove_statement(s); + + self.changed_stmts.remove(s); + } + StatementChange::Modified(s) => { + tracing::info!("Modifying statement: {:?}", s); + self.tree_sitter.modify_statement(s); + self.pg_query.modify_statement(s); + + self.changed_stmts.remove(&s.old.ref_); + self.changed_stmts.insert(s.new_ref.to_owned()); + } + } + } + + Ok(()) + } + + fn server_info(&self) -> Option<&ServerInfo> { + None + } + + fn get_file_content(&self, params: GetFileContentParams) -> Result { + let document = self + .documents + .get(¶ms.path) + .ok_or(WorkspaceError::not_found())?; + Ok(document.content.clone()) + } + + fn is_path_ignored(&self, params: IsPathIgnoredParams) -> Result { + Ok(self.is_ignored(params.pglsp_path.as_path())) + } +} + +/// Returns `true` if `path` is a directory or +/// if it is a symlink that resolves to a directory. +fn is_dir(path: &Path) -> bool { + path.is_dir() || (path.is_symlink() && fs::read_link(path).is_ok_and(|path| path.is_dir())) +} + +/// Use this function to run async functions in the workspace, which is a sync trait called from an +/// async context. +/// +/// Checkout https://greptime.com/blogs/2023-03-09-bridging-async-and-sync-rust for details. +fn run_async(future: F) -> Result +where + F: Future + Send + 'static, + R: Send + 'static, +{ + futures::executor::block_on(async { RUNTIME.spawn(future).await.map_err(|e| e.into()) }) +} diff --git a/crates/pg_workspace_new/src/workspace/server/change.rs b/crates/pg_workspace_new/src/workspace/server/change.rs new file mode 100644 index 000000000..f925b5901 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/change.rs @@ -0,0 +1,776 @@ +use std::ops::{Add, Sub}; +use text_size::{TextLen, TextRange, TextSize}; + +use crate::workspace::{ChangeFileParams, ChangeParams}; + +use super::{document::Statement, Document, StatementRef}; + +#[derive(Debug, PartialEq, Eq)] +pub enum StatementChange { + Added(Statement), + Deleted(StatementRef), + Modified(ChangedStatement), +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ChangedStatement { + pub old: Statement, + pub new_ref: StatementRef, + + pub range: TextRange, + pub text: String, +} + +impl ChangedStatement { + pub fn new_statement(&self) -> Statement { + Statement { + ref_: self.new_ref.clone(), + text: apply_text_change(&self.old.text, Some(self.range), &self.text), + } + } +} + +impl StatementChange { + #[allow(dead_code)] + pub fn statement_ref(&self) -> &StatementRef { + match self { + StatementChange::Added(stmt) => &stmt.ref_, + StatementChange::Deleted(ref_) => ref_, + StatementChange::Modified(changed) => &changed.new_ref, + } + } +} + +impl Document { + pub fn apply_file_change(&mut self, change: &ChangeFileParams) -> Vec { + let changes = change + .changes + .iter() + .flat_map(|c| self.apply_change(c)) + .collect(); + + self.version = change.version; + + changes + } + + fn apply_change(&mut self, change: &ChangeParams) -> Vec { + self.debug_statements(); + + let mut changed: Vec = Vec::with_capacity(self.statements.len()); + + tracing::info!("applying change: {:?}", change); + + if change.range.is_none() { + // apply full text change and return early + changed.extend( + self.statements + .drain(..) + .map(|(id, _)| { + StatementChange::Deleted(StatementRef { + id, + path: self.path.clone(), + }) + }) + .collect::>(), + ); + + self.content = change.text.clone(); + + for (id, range) in pg_statement_splitter::split(&self.content) + .ranges + .iter() + .map(|r| (self.id_generator.next(), *r)) + { + self.statements.push((id, range)); + changed.push(StatementChange::Added(Statement { + ref_: StatementRef { + path: self.path.clone(), + id, + }, + text: self.content[range].to_string(), + })) + } + + return changed; + } + + // no matter where the change is, we can never be sure if its a modification or a deletion/addition + // e.g. if a statement is "select 1", and the change is "select 2; select 2", its an addition even though its in the middle of the statement. + // hence we only have three "real" cases: + // 1. the change touches no statement at all (addition) + // 2. the change touches exactly one statement AND splitting the statement results in just + // one statement (modification) + // 3. the change touches more than one statement (addition/deletion) + + let new_content = change.apply_to_text(&self.content); + + println!("new content: '{}'", new_content); + + let mut affected = vec![]; + + for (idx, (id, r)) in self.statements.iter_mut().enumerate() { + if r.intersect(change.range.unwrap()).is_some() { + affected.push((idx, (*id, *r))); + } else if r.start() > change.range.unwrap().end() { + if change.is_addition() { + *r += change.diff_size(); + } else if change.is_deletion() { + *r -= change.diff_size(); + } + } + } + + // special case: if no statement is affected, the affected range is between the prev and + // the next statement + if affected.is_empty() { + let start = self + .statements + .iter() + .rev() + .find(|(_, r)| r.end() <= change.range.unwrap().start()) + .map(|(_, r)| r.end()) + .unwrap_or(TextSize::new(0)); + let end = self + .statements + .iter() + .find(|(_, r)| r.start() >= change.range.unwrap().end()) + .map(|(_, r)| r.start()) + .unwrap_or_else(|| self.content.text_len()); + + let affected = new_content + .as_str() + .get(usize::from(start)..usize::from(end)) + .unwrap(); + + // add new statements + for range in pg_statement_splitter::split(affected).ranges { + let doc_range = range + start; + match self + .statements + .binary_search_by(|(_, r)| r.start().cmp(&doc_range.start())) + { + Ok(_) => {} + Err(pos) => { + let new_id = self.id_generator.next(); + self.statements.insert(pos, (new_id, doc_range)); + changed.push(StatementChange::Added(Statement { + ref_: StatementRef { + path: self.path.clone(), + id: new_id, + }, + text: new_content[doc_range].to_string(), + })); + } + } + } + } else { + // get full affected range + let mut start = change.range.unwrap().start(); + let mut end = change.range.unwrap().end(); + + if end > new_content.text_len() { + end = new_content.text_len(); + } + + println!("affected: {:#?}", affected); + + for (_, (_, r)) in &affected { + // adjust the range to the new content + let adjusted_start = if r.start() >= change.range.unwrap().end() { + r.start() + change.diff_size() + } else { + r.start() + }; + println!("adjusted start: {:#?}", adjusted_start); + + println!("r.end(): {:#?}", r.end()); + println!("change.range(): {:#?}", change.range); + let adjusted_end = if r.end() >= change.range.unwrap().end() { + if change.is_addition() { + r.end() + change.diff_size() + } else { + r.end() - change.diff_size() + } + } else { + r.end() + }; + println!("adjusted end: {:#?}", adjusted_end); + + if adjusted_start < start { + start = adjusted_start; + } + if adjusted_end > end && adjusted_end <= new_content.text_len() { + end = adjusted_end; + } + } + + println!("affected range: {:#?}", TextRange::new(start, end)); + + let changed_content = new_content + .as_str() + .get(usize::from(start)..usize::from(end)) + .unwrap(); + + println!("changed content: '{}'", changed_content); + + let ranges = pg_statement_splitter::split(changed_content).ranges; + + if affected.len() == 1 && ranges.len() == 1 { + // from one to one, so we do a modification + let stmt = &affected[0]; + let new_stmt = &ranges[0]; + + println!("affected statement: {:#?}", stmt); + println!("new statement: {:#?}", new_stmt); + println!("diff: {:#?}", change.diff_size()); + + let new_id = self.id_generator.next(); + self.statements[stmt.0] = (new_id, new_stmt.add(start)); + + let changed_stmt = ChangedStatement { + old: self.statement(&stmt.1), + new_ref: self.statement_ref(&self.statements[stmt.0]), + // change must be relative to statement + range: change.range.unwrap().sub(stmt.1 .1.start()), + text: change.text.clone(), + }; + + println!("{:#?}", changed_stmt.new_statement()); + + changed.push(StatementChange::Modified(changed_stmt)); + } else { + // delete and add new ones + for (_, (id, r)) in &affected { + changed.push(StatementChange::Deleted(self.statement_ref(&(*id, *r)))); + } + + // remove affected statements + self.statements + .retain(|(id, _)| !affected.iter().any(|(affected_id, _)| id == affected_id)); + + // add new statements + for range in ranges { + match self + .statements + .binary_search_by(|(_, r)| r.start().cmp(&range.start())) + { + Ok(_) => {} + Err(pos) => { + let new_id = self.id_generator.next(); + self.statements.insert(pos, (new_id, range)); + changed.push(StatementChange::Added(Statement { + ref_: StatementRef { + path: self.path.clone(), + id: new_id, + }, + text: new_content[range].to_string(), + })); + } + } + } + } + } + + println!("changed: {:#?}", changed); + + self.content = new_content; + + self.debug_statements(); + + changed + } +} + +fn apply_text_change(text: &str, range: Option, change_text: &str) -> String { + if range.is_none() { + return change_text.to_string(); + } + + let range = range.unwrap(); + let start = usize::from(range.start()); + let end = usize::from(range.end()); + + let mut new_text = String::new(); + new_text.push_str(&text[..start]); + new_text.push_str(change_text); + if end < text.len() { + new_text.push_str(&text[end..]); + } + + new_text +} + +impl ChangeParams { + pub fn is_whitespace(&self) -> bool { + self.text.chars().all(char::is_whitespace) + } + + pub fn diff_size(&self) -> TextSize { + match self.range { + Some(range) => { + let range_length: usize = range.len().into(); + let text_length = self.text.chars().count(); + let diff = (text_length as i64 - range_length as i64).abs(); + TextSize::from(u32::try_from(diff).unwrap()) + } + None => TextSize::from(u32::try_from(self.text.chars().count()).unwrap()), + } + } + + pub fn is_addition(&self) -> bool { + self.range.is_some() && self.text.len() > self.range.unwrap().len().into() + } + + pub fn is_deletion(&self) -> bool { + self.range.is_some() && self.text.len() < self.range.unwrap().len().into() + } + + pub fn apply_to_text(&self, text: &str) -> String { + if self.range.is_none() { + return self.text.clone(); + } + + let range = self.range.unwrap(); + let start = usize::from(range.start()); + let end = usize::from(range.end()); + + let mut new_text = String::new(); + new_text.push_str(&text[..start]); + new_text.push_str(&self.text); + if end < text.len() { + new_text.push_str(&text[end..]); + } + + new_text + } +} + +#[cfg(test)] +mod tests { + use text_size::{TextRange, TextSize}; + + use crate::workspace::{server::document::Statement, ChangeFileParams, ChangeParams}; + + use super::{super::StatementRef, Document, StatementChange}; + use pg_fs::PgLspPath; + + #[test] + fn within_statements() { + let path = PgLspPath::new("test.sql"); + let input = "select id from users;\n\n\n\nselect * from contacts;"; + + let mut d = Document::new(PgLspPath::new("test.sql"), input.to_string(), 0); + + assert_eq!(d.statements.len(), 2); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: "select 1;".to_string(), + range: Some(TextRange::new(23.into(), 23.into())), + }], + }; + + let changed = d.apply_file_change(&change); + + assert_eq!(changed.len(), 1); + assert!( + matches!(&changed[0], StatementChange::Added(Statement { ref_: _, text }) if text == "select 1;") + ); + + assert_document_integrity(&d); + } + + #[test] + fn across_statements() { + let path = PgLspPath::new("test.sql"); + let input = "select id from users;\nselect * from contacts;"; + + let mut d = Document::new(PgLspPath::new("test.sql"), input.to_string(), 0); + + assert_eq!(d.statements.len(), 2); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: ",test from users;\nselect 1;".to_string(), + range: Some(TextRange::new(9.into(), 45.into())), + }], + }; + + let changed = d.apply_file_change(&change); + + assert_eq!(changed.len(), 4); + assert!(matches!( + changed[0], + StatementChange::Deleted(StatementRef { id: 0, .. }) + )); + assert!(matches!( + changed[1], + StatementChange::Deleted(StatementRef { id: 1, .. }) + )); + assert!( + matches!(&changed[2], StatementChange::Added(Statement { ref_: _, text }) if text == "select id,test from users;") + ); + assert!( + matches!(&changed[3], StatementChange::Added(Statement { ref_: _, text }) if text == "select 1;") + ); + + assert_document_integrity(&d); + } + + fn assert_document_integrity(d: &Document) { + let ranges = pg_statement_splitter::split(&d.content).ranges; + + assert!(ranges.len() == d.statements.len()); + + assert!(ranges + .iter() + .all(|r| { d.statements.iter().any(|(_, stmt_range)| stmt_range == r) })); + } + + #[test] + fn append_to_statement() { + let path = PgLspPath::new("test.sql"); + let input = "select id"; + + let mut d = Document::new(PgLspPath::new("test.sql"), input.to_string(), 0); + + assert_eq!(d.statements.len(), 1); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: " ".to_string(), + range: Some(TextRange::new(9.into(), 10.into())), + }], + }; + + let changed = d.apply_file_change(&change); + + assert_eq!(changed.len(), 1); + matches!(changed[0], StatementChange::Modified(_)); + + assert_document_integrity(&d); + } + + #[test] + fn apply_changes() { + let path = PgLspPath::new("test.sql"); + let input = "select id from users;\nselect * from contacts;"; + + let mut d = Document::new(PgLspPath::new("test.sql"), input.to_string(), 0); + + assert_eq!(d.statements.len(), 2); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: ",test from users\nselect 1;".to_string(), + range: Some(TextRange::new(9.into(), 45.into())), + }], + }; + + let changed = d.apply_file_change(&change); + + assert_eq!(changed.len(), 4); + + assert_eq!( + changed[0], + StatementChange::Deleted(StatementRef { + path: path.clone(), + id: 0 + }) + ); + assert_eq!( + changed[1], + StatementChange::Deleted(StatementRef { + path: path.clone(), + id: 1 + }) + ); + assert_eq!( + changed[2], + StatementChange::Added(Statement { + ref_: StatementRef { + path: path.clone(), + id: 2 + }, + text: "select id,test from users".to_string() + }) + ); + assert_eq!( + changed[3], + StatementChange::Added(Statement { + ref_: StatementRef { + path: path.clone(), + id: 3 + }, + text: "select 1;".to_string() + }) + ); + + assert_eq!("select id,test from users\nselect 1;", d.content); + assert_eq!(d.statements.len(), 2); + + for r in &pg_statement_splitter::split(&d.content).ranges { + assert!( + d.statements.iter().any(|x| r == &x.1), + "should have stmt with range {:#?}", + r + ); + } + + assert_eq!(d.statements[0].1, TextRange::new(0.into(), 25.into())); + assert_eq!(d.statements[1].1, TextRange::new(26.into(), 35.into())); + + assert_document_integrity(&d); + } + + #[test] + fn apply_changes_at_end_of_statement() { + let path = PgLspPath::new("test.sql"); + let input = "select id from\nselect * from contacts;"; + + let mut d = Document::new(path.clone(), input.to_string(), 1); + + assert_eq!(d.statements.len(), 2); + + let stmt_1_range = d.statements[0]; + let stmt_2_range = d.statements[1]; + + let update_text = " contacts;"; + + let update_range = TextRange::new(14.into(), 14.into()); + + let update_text_len = u32::try_from(update_text.chars().count()).unwrap(); + let update_addition = update_text_len - u32::from(update_range.len()); + + let change = ChangeFileParams { + path: path.clone(), + version: 2, + changes: vec![ChangeParams { + text: update_text.to_string(), + range: Some(update_range), + }], + }; + + let changes = d.apply_file_change(&change); + + assert_eq!(changes.len(), 1); + + assert!(matches!(changes[0], StatementChange::Modified(_))); + + assert_eq!( + "select id from contacts;\nselect * from contacts;", + d.content + ); + assert_eq!(d.statements.len(), 2); + assert_eq!(d.statements[0].1.start(), stmt_1_range.1.start()); + assert_eq!( + u32::from(d.statements[0].1.end()), + u32::from(stmt_1_range.1.end()) + update_addition + ); + assert_eq!( + u32::from(d.statements[1].1.start()), + u32::from(stmt_2_range.1.start()) + update_addition + ); + assert_eq!( + u32::from(d.statements[1].1.end()), + u32::from(stmt_2_range.1.end()) + update_addition + ); + + assert_document_integrity(&d); + } + + #[test] + fn apply_changes_replacement() { + let path = PgLspPath::new("test.sql"); + + let mut doc = Document::new(path.clone(), "".to_string(), 0); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: "select 1;\nselect 2;".to_string(), + range: None, + }], + }; + + doc.apply_file_change(&change); + + assert_eq!( + doc.statement(&doc.statements[0]).text, + "select 1;".to_string() + ); + assert_eq!( + doc.statement(&doc.statements[1]).text, + "select 2;".to_string() + ); + assert_eq!( + doc.statements[0].1, + TextRange::new(TextSize::new(0), TextSize::new(9)) + ); + assert_eq!( + doc.statements[1].1, + TextRange::new(TextSize::new(10), TextSize::new(19)) + ); + + let change_2 = ChangeFileParams { + path: path.clone(), + version: 2, + changes: vec![ChangeParams { + text: "".to_string(), + range: Some(TextRange::new(7.into(), 8.into())), + }], + }; + + doc.apply_file_change(&change_2); + + assert_eq!(doc.content, "select ;\nselect 2;"); + assert_eq!(doc.statements.len(), 2); + println!("{:#?}", doc.statements); + assert_eq!( + doc.statement(&doc.statements[0]).text, + "select ;".to_string() + ); + assert_eq!( + doc.statement(&doc.statements[1]).text, + "select 2;".to_string() + ); + assert_eq!( + doc.statements[0].1, + TextRange::new(TextSize::new(0), TextSize::new(8)) + ); + assert_eq!( + doc.statements[1].1, + TextRange::new(TextSize::new(9), TextSize::new(18)) + ); + + let change_3 = ChangeFileParams { + path: path.clone(), + version: 3, + changes: vec![ChangeParams { + text: "!".to_string(), + range: Some(TextRange::new(7.into(), 7.into())), + }], + }; + + doc.apply_file_change(&change_3); + + assert_eq!(doc.content, "select !;\nselect 2;"); + assert_eq!(doc.statements.len(), 2); + assert_eq!( + doc.statements[0].1, + TextRange::new(TextSize::new(0), TextSize::new(9)) + ); + assert_eq!( + doc.statements[1].1, + TextRange::new(TextSize::new(10), TextSize::new(19)) + ); + + let change_4 = ChangeFileParams { + path: path.clone(), + version: 4, + changes: vec![ChangeParams { + text: "".to_string(), + range: Some(TextRange::new(7.into(), 8.into())), + }], + }; + + doc.apply_file_change(&change_4); + + assert_eq!(doc.content, "select ;\nselect 2;"); + assert_eq!(doc.statements.len(), 2); + assert_eq!( + doc.statements[0].1, + TextRange::new(TextSize::new(0), TextSize::new(8)) + ); + assert_eq!( + doc.statements[1].1, + TextRange::new(TextSize::new(9), TextSize::new(18)) + ); + + let change_5 = ChangeFileParams { + path: path.clone(), + version: 5, + changes: vec![ChangeParams { + text: "1".to_string(), + range: Some(TextRange::new(7.into(), 7.into())), + }], + }; + + doc.apply_file_change(&change_5); + + assert_eq!(doc.content, "select 1;\nselect 2;"); + assert_eq!(doc.statements.len(), 2); + assert_eq!( + doc.statements[0].1, + TextRange::new(TextSize::new(0), TextSize::new(9)) + ); + assert_eq!( + doc.statements[1].1, + TextRange::new(TextSize::new(10), TextSize::new(19)) + ); + + assert_document_integrity(&doc); + } + + #[test] + fn apply_changes_within_statement() { + let input = "select id from users;\nselect * from contacts;"; + let path = PgLspPath::new("test.sql"); + + let mut doc = Document::new(path.clone(), input.to_string(), 0); + + assert_eq!(doc.statements.len(), 2); + + let stmt_1_range = doc.statements[0]; + let stmt_2_range = doc.statements[1]; + + let update_text = ",test"; + + let update_range = TextRange::new(9.into(), 10.into()); + + let update_text_len = u32::try_from(update_text.chars().count()).unwrap(); + let update_addition = update_text_len - u32::from(update_range.len()); + + let change = ChangeFileParams { + path: path.clone(), + version: 1, + changes: vec![ChangeParams { + text: update_text.to_string(), + range: Some(update_range), + }], + }; + + doc.apply_file_change(&change); + + assert_eq!( + "select id,test from users;\nselect * from contacts;", + doc.content + ); + assert_eq!(doc.statements.len(), 2); + assert_eq!(doc.statements[0].1.start(), stmt_1_range.1.start()); + assert_eq!( + u32::from(doc.statements[0].1.end()), + u32::from(stmt_1_range.1.end()) + update_addition + ); + assert_eq!( + u32::from(doc.statements[1].1.start()), + u32::from(stmt_2_range.1.start()) + update_addition + ); + assert_eq!( + u32::from(doc.statements[1].1.end()), + u32::from(stmt_2_range.1.end()) + update_addition + ); + + assert_document_integrity(&doc); + } +} diff --git a/crates/pg_workspace_new/src/workspace/server/document.rs b/crates/pg_workspace_new/src/workspace/server/document.rs new file mode 100644 index 000000000..044110a15 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/document.rs @@ -0,0 +1,154 @@ +use pg_fs::PgLspPath; +use text_size::{TextRange, TextSize}; + +/// Global unique identifier for a statement +#[derive(Debug, Hash, Eq, PartialEq, Clone)] +pub(crate) struct StatementRef { + /// Path of the document + pub(crate) path: PgLspPath, + /// Unique id within the document + pub(crate) id: StatementId, +} + +/// Represenation of a statement +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Statement { + pub(crate) ref_: StatementRef, + pub(crate) text: String, +} + +pub type StatementId = usize; + +type StatementPosition = (StatementId, TextRange); + +pub(crate) struct Document { + pub(crate) path: PgLspPath, + pub(crate) content: String, + pub(crate) version: i32, + /// List of statements sorted by range.start() + pub(super) statements: Vec, + + pub(super) id_generator: IdGenerator, +} + +impl Document { + pub(crate) fn new(path: PgLspPath, content: String, version: i32) -> Self { + let mut id_generator = IdGenerator::new(); + + let statements: Vec = pg_statement_splitter::split(&content) + .ranges + .iter() + .map(|r| (id_generator.next(), *r)) + .collect(); + + Self { + path, + statements, + content, + version, + + id_generator, + } + } + + pub fn debug_statements(&self) { + for (id, range) in self.statements.iter() { + tracing::info!( + "Document::debug_statements: statement: id: {}, range: {:?}, text: {:?}", + id, + range, + &self.content[*range] + ); + } + } + + #[allow(dead_code)] + pub fn get_statements(&self) -> &[StatementPosition] { + &self.statements + } + + pub fn statement_refs(&self) -> Vec { + self.statements + .iter() + .map(|inner_ref| self.statement_ref(inner_ref)) + .collect() + } + + #[allow(dead_code)] + /// Returns the statement ref at the given offset + pub fn statement_ref_at_offset(&self, offset: &TextSize) -> Option { + self.statements.iter().find_map(|r| { + if r.1.contains(*offset) { + Some(self.statement_ref(r)) + } else { + None + } + }) + } + + #[allow(dead_code)] + /// Returns the statement refs at the given range + pub fn statement_refs_at_range(&self, range: &TextRange) -> Vec { + self.statements + .iter() + .filter(|(_, r)| { + range.contains_range(r.to_owned().to_owned()) || r.contains_range(range.to_owned()) + }) + .map(|x| self.statement_ref(x)) + .collect() + } + + #[allow(dead_code)] + /// Returns the statement at the given offset + pub fn statement_at_offset(&self, offset: &TextSize) -> Option { + self.statements.iter().find_map(|r| { + if r.1.contains(*offset) { + Some(self.statement(r)) + } else { + None + } + }) + } + + #[allow(dead_code)] + /// Returns the statements at the given range + pub fn statements_at_range(&self, range: &TextRange) -> Vec { + self.statements + .iter() + .filter(|(_, r)| { + range.contains_range(r.to_owned().to_owned()) || r.contains_range(range.to_owned()) + }) + .map(|x| self.statement(x)) + .collect() + } + + pub(super) fn statement_ref(&self, inner_ref: &StatementPosition) -> StatementRef { + StatementRef { + id: inner_ref.0, + path: self.path.clone(), + } + } + + pub(super) fn statement(&self, inner_ref: &StatementPosition) -> Statement { + Statement { + ref_: self.statement_ref(inner_ref), + text: self.content[inner_ref.1].to_string(), + } + } +} + +pub(crate) struct IdGenerator { + pub(super) next_id: usize, +} + +impl IdGenerator { + fn new() -> Self { + Self { next_id: 0 } + } + + pub(super) fn next(&mut self) -> usize { + let id = self.next_id; + self.next_id += 1; + id + } +} diff --git a/crates/pg_workspace_new/src/workspace/server/pg_query.rs b/crates/pg_workspace_new/src/workspace/server/pg_query.rs new file mode 100644 index 000000000..401ce54f4 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/pg_query.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use dashmap::DashMap; + +use super::{ + change::ChangedStatement, + document::{Statement, StatementRef}, + store::Store, +}; + +pub struct PgQueryStore { + ast_db: DashMap>, + native_diagnostics: DashMap>, +} + +impl PgQueryStore { + pub fn new() -> PgQueryStore { + PgQueryStore { + ast_db: DashMap::new(), + native_diagnostics: DashMap::new(), + } + } +} + +impl Store for PgQueryStore { + fn fetch(&self, statement: &StatementRef) -> Option> { + self.ast_db.get(statement).map(|x| x.clone()) + } + + fn add_statement(&self, statement: &Statement) { + let r = pg_query_ext::parse(statement.text.as_str()); + if let Ok(ast) = r { + self.ast_db.insert(statement.ref_.clone(), Arc::new(ast)); + } else { + self.native_diagnostics + .insert(statement.ref_.clone(), Arc::new(r.unwrap_err())); + } + } + + fn remove_statement(&self, statement: &StatementRef) { + self.ast_db.remove(statement); + self.native_diagnostics.remove(statement); + } + + fn modify_statement(&self, change: &ChangedStatement) { + self.remove_statement(&change.old.ref_); + self.add_statement(&change.new_statement()); + } +} diff --git a/crates/pg_workspace_new/src/workspace/server/store.rs b/crates/pg_workspace_new/src/workspace/server/store.rs new file mode 100644 index 000000000..157c27731 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/store.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use super::{ + change::ChangedStatement, + document::{Statement, StatementRef}, +}; + +pub(crate) trait Store { + #[allow(dead_code)] + fn fetch(&self, statement: &StatementRef) -> Option>; + + fn add_statement(&self, statement: &Statement); + + fn remove_statement(&self, statement: &StatementRef); + + fn modify_statement(&self, change: &ChangedStatement); +} diff --git a/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs b/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs new file mode 100644 index 000000000..5518535a4 --- /dev/null +++ b/crates/pg_workspace_new/src/workspace/server/tree_sitter.rs @@ -0,0 +1,165 @@ +use std::sync::{Arc, RwLock}; + +use dashmap::DashMap; +use tree_sitter::InputEdit; + +use super::{ + change::ChangedStatement, + document::{Statement, StatementRef}, + store::Store, +}; + +pub struct TreeSitterStore { + db: DashMap>, + + parser: RwLock, +} + +impl TreeSitterStore { + pub fn new() -> TreeSitterStore { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(tree_sitter_sql::language()) + .expect("Error loading sql language"); + + TreeSitterStore { + db: DashMap::new(), + parser: RwLock::new(parser), + } + } +} + +impl Store for TreeSitterStore { + fn fetch(&self, statement: &StatementRef) -> Option> { + self.db.get(statement).map(|x| x.clone()) + } + + fn add_statement(&self, statement: &Statement) { + let mut guard = self.parser.write().expect("Error reading parser"); + // todo handle error + let tree = guard.parse(&statement.text, None).unwrap(); + drop(guard); + self.db.insert(statement.ref_.clone(), Arc::new(tree)); + } + + fn remove_statement(&self, statement: &StatementRef) { + self.db.remove(statement); + } + + fn modify_statement(&self, change: &ChangedStatement) { + let old = self.db.remove(&change.old.ref_); + + if old.is_none() { + self.add_statement(&change.new_statement()); + return; + } + + // we clone the three for now, lets see if that is sufficient or if we need to mutate the + // original tree instead but that will require some kind of locking + let mut tree = old.unwrap().1.as_ref().clone(); + + let edit = edit_from_change( + change.old.text.as_str(), + usize::from(change.range.start()), + usize::from(change.range.end()), + change.text.as_str(), + ); + + tree.edit(&edit); + + let new_stmt = change.new_statement(); + let new_text = new_stmt.text.clone(); + + let mut guard = self.parser.write().expect("Error reading parser"); + // todo handle error + self.db.insert( + new_stmt.ref_, + Arc::new(guard.parse(new_text, Some(&tree)).unwrap()), + ); + drop(guard); + } +} + +// i wont pretend to know whats going on here but it seems to work +fn edit_from_change( + text: &str, + start_char: usize, + end_char: usize, + replacement_text: &str, +) -> InputEdit { + let mut start_byte = 0; + let mut end_byte = 0; + let mut chars_counted = 0; + + let mut line = 0; + let mut current_line_char_start = 0; // Track start of the current line in characters + let mut column_start = 0; + let mut column_end = 0; + + for (idx, c) in text.char_indices() { + if chars_counted == start_char { + start_byte = idx; + column_start = chars_counted - current_line_char_start; + } + if chars_counted == end_char { + end_byte = idx; + // Calculate column_end based on replacement_text + let replacement_lines: Vec<&str> = replacement_text.split('\n').collect(); + if replacement_lines.len() > 1 { + // If replacement text spans multiple lines, adjust line and column_end accordingly + line += replacement_lines.len() - 1; + column_end = replacement_lines.last().unwrap().chars().count(); + } else { + // Single line replacement, adjust column_end based on replacement text length + column_end = column_start + replacement_text.chars().count(); + } + break; // Found both start and end + } + if c == '\n' { + line += 1; + current_line_char_start = chars_counted + 1; // Next character starts a new line + } + chars_counted += 1; + } + + // Adjust end_byte based on the byte length of the replacement text + if start_byte != end_byte { + // Ensure there's a range to replace + end_byte = start_byte + replacement_text.len(); + } else if chars_counted < text.chars().count() && end_char == chars_counted { + // For insertions at the end of text + end_byte += replacement_text.len(); + } + + let start_point = tree_sitter::Point::new(line, column_start); + let end_point = tree_sitter::Point::new(line, column_end); + + // Calculate the new end byte after the insertion + let new_end_byte = start_byte + replacement_text.len(); + + // Calculate the new end position + let new_lines = replacement_text.matches('\n').count(); // Count how many new lines are in the inserted text + let last_line_length = replacement_text + .lines() + .last() + .unwrap_or("") + .chars() + .count(); // Length of the last line in the insertion + + let new_end_position = if new_lines > 0 { + // If there are new lines, the row is offset by the number of new lines, and the column is the length of the last line + tree_sitter::Point::new(start_point.row + new_lines, last_line_length) + } else { + // If there are no new lines, the row remains the same, and the column is offset by the length of the insertion + tree_sitter::Point::new(start_point.row, start_point.column + last_line_length) + }; + + InputEdit { + start_byte, + old_end_byte: end_byte, + new_end_byte, + start_position: start_point, + old_end_position: end_point, + new_end_position, + } +} diff --git a/justfile b/justfile new file mode 100644 index 000000000..cbaec957a --- /dev/null +++ b/justfile @@ -0,0 +1,140 @@ +_default: + just --list -u + +alias f := format +alias t := test +# alias r := ready +# alias l := lint +# alias qt := test-quick + +# Installs the tools needed to develop +install-tools: + cargo install cargo-binstall + cargo binstall cargo-insta taplo-cli + +# Upgrades the tools needed to develop +upgrade-tools: + cargo install cargo-binstall --force + cargo binstall cargo-insta taplo-cli --force + +# Generate all files across crates and tools. You rarely want to use it locally. +# gen-all: +# cargo run -p xtask_codegen -- all +# cargo codegen-configuration +# cargo codegen-migrate +# just gen-bindings +# just format + +# Generates TypeScript types and JSON schema of the configuration +# gen-bindings: +# cargo codegen-schema +# cargo codegen-bindings + +# Generates code generated files for the linter +# gen-lint: +# cargo run -p xtask_codegen -- analyzer +# cargo codegen-configuration +# cargo codegen-migrate +# just gen-bindings +# cargo run -p rules_check +# just format + +# Generates the linter documentation and Rust documentation +# documentation: +# RUSTDOCFLAGS='-D warnings' cargo documentation + +# Creates a new lint rule in the given path, with the given name. Name has to be camel case. +# new-lintrule rulename: +# cargo run -p xtask_codegen -- new-lintrule --kind=js --category=lint --name={{rulename}} +# just gen-lint +# just documentation + +# Creates a new lint rule in the given path, with the given name. Name has to be camel case. +# new-assistrule rulename: +# cargo run -p xtask_codegen -- new-lintrule --kind=js --category=assist --name={{rulename}} +# just gen-lint +# just documentation + +# Promotes a rule from the nursery group to a new group +# promote-rule rulename group: +# cargo run -p xtask_codegen -- promote-rule --name={{rulename}} --group={{group}} +# just gen-lint +# just documentation +# -cargo test -p pg_analyze -- {{snakecase(rulename)}} +# cargo insta accept + + +# Format Rust files and TOML files +format: + cargo format + # taplo format + +[unix] +_touch file: + touch {{file}} + +[windows] +_touch file: + (gci {{file}}).LastWriteTime = Get-Date + +# Run tests of all crates +test: + cargo test run --no-fail-fast + +# Run tests for the crate passed as argument e.g. just test-create pg_cli +test-crate name: + cargo test run -p {{name}} --no-fail-fast + +# Run doc tests +test-doc: + cargo test --doc + +# Tests a lint rule. The name of the rule needs to be camel case +# test-lintrule name: +# just _touch crates/biome_js_analyze/tests/spec_tests.rs +# just _touch crates/biome_json_analyze/tests/spec_tests.rs +# just _touch crates/biome_css_analyze/tests/spec_tests.rs +# just _touch crates/biome_graphql_analyze/tests/spec_tests.rs +# cargo test -p biome_js_analyze -- {{snakecase(name)}} --show-output +# cargo test -p biome_json_analyze -- {{snakecase(name)}} --show-output +# cargo test -p biome_css_analyze -- {{snakecase(name)}} --show-output +# cargo test -p biome_graphql_analyze -- {{snakecase(name)}} --show-output + +# Tests a lint rule. The name of the rule needs to be camel case +# test-transformation name: +# just _touch crates/biome_js_transform/tests/spec_tests.rs +# cargo test -p biome_js_transform -- {{snakecase(name)}} --show-output + +# Run the quick_test for the given package. +# test-quick package: +# cargo test -p {{package}} --test quick_test -- quick_test --nocapture --ignored + + +# Alias for `cargo clippy`, it runs clippy on the whole codebase +lint: + cargo clippy + +# When you finished coding, run this command to run the same commands in the CI. +# ready: +# git diff --exit-code --quiet +# just gen-all +# just documentation +# #just format # format is already run in `just gen-all` +# just lint +# just test +# just test-doc +# git diff --exit-code --quiet + +# Creates a new crate +new-crate name: + cargo new --lib crates/{{snakecase(name)}} + cargo run -p xtask_codegen -- new-crate --name={{snakecase(name)}} + +# Creates a new changeset for the final changelog +# new-changeset: +# knope document-change + +# Dry-run of the release +# dry-run-release *args='': +# knope release --dry-run {{args}} + diff --git a/lib/line_index/Cargo.toml b/lib/line_index/Cargo.toml index 3fdeb0b74..55a438cb6 100644 --- a/lib/line_index/Cargo.toml +++ b/lib/line_index/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0" edition = "2021" [dependencies] -text-size = "1.1.1" +text-size.workspace = true [lib] doctest = false diff --git a/lib/tree_sitter_sql/build.rs b/lib/tree_sitter_sql/build.rs index 81fe8d939..022a7014a 100644 --- a/lib/tree_sitter_sql/build.rs +++ b/lib/tree_sitter_sql/build.rs @@ -1,7 +1,7 @@ fn main() { let src_dir = std::path::Path::new("./tree-sitter-sql/src"); let mut config = cc::Build::new(); - config.include(&src_dir); + config.include(src_dir); config .flag_if_supported("-Wno-unused-parameter") .flag_if_supported("-Wno-unused-but-set-variable") diff --git a/pglsp.toml b/pglsp.toml new file mode 100644 index 000000000..6055ed23e --- /dev/null +++ b/pglsp.toml @@ -0,0 +1,14 @@ +[vcs] +enabled = false +client_kind = "git" +use_ignore_file = false + +[files] +ignore = [] + +[db] +host = "127.0.0.1" +port = 54322 +username = "postgres" +password = "postgres" +database = "postgres" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 000000000..d69708395 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +newline_style = "Unix" + diff --git a/test.sql b/test.sql index 33d5f65c8..2999527e6 100644 --- a/test.sql +++ b/test.sql @@ -1,6 +1,8 @@ select id, name, test1231234123, unknown from co; -select 1123123; +select 14433313331333333333 + +select * from test; alter table test drop column id; diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a34617e2..9118df95b 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xtask" -version = "0.1.0" +version = "0.0.0" publish = false license = "MIT OR Apache-2.0" edition = "2021" diff --git a/xtask/codegen/Cargo.toml b/xtask/codegen/Cargo.toml new file mode 100644 index 000000000..65d71e99f --- /dev/null +++ b/xtask/codegen/Cargo.toml @@ -0,0 +1,11 @@ +[package] +edition = "2021" +name = "xtask_codegen" +publish = false +version = "0.0.0" + +[dependencies] +bpaf = { workspace = true, features = ["derive"] } +xtask = { path = '../', version = "0.0" } + + diff --git a/xtask/codegen/src/generate_crate.rs b/xtask/codegen/src/generate_crate.rs new file mode 100644 index 000000000..5fc46488d --- /dev/null +++ b/xtask/codegen/src/generate_crate.rs @@ -0,0 +1,64 @@ +use std::fs; +use xtask::*; + +fn cargo_template(name: &str) -> String { + format!( + r#" +[package] +authors.workspace = true +categories.workspace = true +description = "" +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +name = "{name}" +repository.workspace = true +version = "0.0.0" + +[lints] +workspace = true +"# + ) +} + +// fn knope_template(name: &str) -> String { +// format!( +// r#" +// [packages.{name}] +// versioned_files = ["crates/{name}/Cargo.toml"] +// changelog = "crates/{name}/CHANGELOG.md" +// "# +// ) +// } + +pub fn generate_crate(crate_name: String) -> Result<()> { + let crate_root = project_root().join("crates").join(crate_name.as_str()); + let cargo_file = crate_root.join("Cargo.toml"); + // let knope_config = project_root().join("knope.toml"); + + // let mut knope_contents = fs::read_to_string(&knope_config)?; + fs::write(cargo_file, cargo_template(crate_name.as_str()))?; + // let start_content = "## Rust crates. DO NOT CHANGE!\n"; + // let end_content = "\n## End of crates. DO NOT CHANGE!"; + // debug_assert!( + // knope_contents.contains(start_content), + // "The file knope.toml must contains `{start_content}`" + // ); + // debug_assert!( + // knope_contents.contains(end_content), + // "The file knope.toml must contains `{end_content}`" + // ); + + // let file_start_index = knope_contents.find(start_content).unwrap() + start_content.len(); + // let file_end_index = knope_contents.find(end_content).unwrap(); + // let crates_text = &knope_contents[file_start_index..file_end_index]; + // let template = knope_template(crate_name.as_str()); + // let new_crates_text: Vec<_> = crates_text.lines().chain(Some(&template[..])).collect(); + // let new_crates_text = new_crates_text.join("\n"); + // + // knope_contents.replace_range(file_start_index..file_end_index, &new_crates_text); + // fs::write(knope_config, knope_contents)?; + Ok(()) +} + diff --git a/xtask/codegen/src/lib.rs b/xtask/codegen/src/lib.rs new file mode 100644 index 000000000..59c83805b --- /dev/null +++ b/xtask/codegen/src/lib.rs @@ -0,0 +1,20 @@ +//! Codegen tools. Derived from Biome's codegen + +mod generate_crate; + +use bpaf::Bpaf; +pub use self::generate_crate::generate_crate; + +#[derive(Debug, Clone, Bpaf)] +#[bpaf(options)] +pub enum TaskCommand { + /// Creates a new crate + #[bpaf(command, long("new-crate"))] + NewCrate { + /// The name of the crate + #[bpaf(long("name"), argument("STRING"))] + name: String, + }, +} + + diff --git a/xtask/codegen/src/main.rs b/xtask/codegen/src/main.rs new file mode 100644 index 000000000..ff7b871ca --- /dev/null +++ b/xtask/codegen/src/main.rs @@ -0,0 +1,19 @@ +use xtask::{project_root, pushd, Result}; + +use xtask_codegen::{ + generate_crate, task_command, TaskCommand, +}; + +fn main() -> Result<()> { + let _d = pushd(project_root()); + let result = task_command().fallback_to_usage().run(); + + match result { + TaskCommand::NewCrate { name } => { + generate_crate(name)?; + } + } + + Ok(()) +} + diff --git a/xtask/src/glue.rs b/xtask/src/glue.rs new file mode 100644 index 000000000..b09285c6f --- /dev/null +++ b/xtask/src/glue.rs @@ -0,0 +1,214 @@ +//! A shell but bad, some cross platform glue code + +use std::{ + cell::RefCell, + env, + ffi::OsString, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use anyhow::{bail, Context, Result}; + +pub mod fs2 { + use std::{fs, path::Path}; + + use anyhow::{Context, Result}; + + pub fn read_dir>(path: P) -> Result { + let path = path.as_ref(); + fs::read_dir(path).with_context(|| format!("Failed to read {}", path.display())) + } + + pub fn read_to_string>(path: P) -> Result { + let path = path.as_ref(); + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display())) + } + + pub fn write, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> { + let path = path.as_ref(); + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display())) + } + + pub fn copy, Q: AsRef>(from: P, to: Q) -> Result { + let from = from.as_ref(); + let to = to.as_ref(); + fs::copy(from, to) + .with_context(|| format!("Failed to copy {} to {}", from.display(), to.display())) + } + + pub fn remove_file>(path: P) -> Result<()> { + let path = path.as_ref(); + fs::remove_file(path).with_context(|| format!("Failed to remove file {}", path.display())) + } + + pub fn remove_dir_all>(path: P) -> Result<()> { + let path = path.as_ref(); + fs::remove_dir_all(path).with_context(|| format!("Failed to remove dir {}", path.display())) + } + + pub fn create_dir_all>(path: P) -> Result<()> { + let path = path.as_ref(); + fs::create_dir_all(path).with_context(|| format!("Failed to create dir {}", path.display())) + } +} + +#[macro_export] +macro_rules! run { + ($($expr:expr),*) => { + run!($($expr),*; echo = true) + }; + ($($expr:expr),* ; echo = $echo:expr) => { + $crate::glue::run_process(format!($($expr),*), $echo, None) + }; + ($($expr:expr),* ; <$stdin:expr) => { + $crate::glue::run_process(format!($($expr),*), false, Some($stdin)) + }; +} +pub use crate::run; + +pub struct Pushd { + _p: (), +} + +pub fn pushd(path: impl Into) -> Pushd { + Env::with(|env| env.pushd(path.into())); + Pushd { _p: () } +} + +impl Drop for Pushd { + fn drop(&mut self) { + Env::with(|env| env.popd()) + } +} + +pub struct Pushenv { + _p: (), +} + +pub fn pushenv(var: &str, value: &str) -> Pushenv { + Env::with(|env| env.pushenv(var.into(), value.into())); + Pushenv { _p: () } +} + +impl Drop for Pushenv { + fn drop(&mut self) { + Env::with(|env| env.popenv()) + } +} + +pub fn rm_rf(path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + if !path.exists() { + return Ok(()); + } + if path.is_file() { + fs2::remove_file(path) + } else { + fs2::remove_dir_all(path) + } +} + +#[doc(hidden)] +pub fn run_process(cmd: String, echo: bool, stdin: Option<&[u8]>) -> Result { + run_process_inner(&cmd, echo, stdin).with_context(|| format!("process `{cmd}` failed")) +} + +pub fn date_iso() -> Result { + run!("date --iso --utc") +} + +fn run_process_inner(cmd: &str, echo: bool, stdin: Option<&[u8]>) -> Result { + let mut args = shelx(cmd); + let binary = args.remove(0); + let current_dir = Env::with(|it| it.cwd().to_path_buf()); + + if echo { + println!("> {cmd}") + } + + let mut command = Command::new(binary); + command + .args(args) + .current_dir(current_dir) + .stderr(Stdio::inherit()); + let output = match stdin { + None => command.stdin(Stdio::null()).output(), + Some(stdin) => { + command.stdin(Stdio::piped()).stdout(Stdio::piped()); + let mut process = command.spawn()?; + process.stdin.take().unwrap().write_all(stdin)?; + process.wait_with_output() + } + }?; + let stdout = String::from_utf8(output.stdout)?; + + if echo { + print!("{stdout}") + } + + if !output.status.success() { + bail!("{}", output.status) + } + + Ok(stdout.trim().to_string()) +} + +fn shelx(cmd: &str) -> Vec { + let mut res = Vec::new(); + for (string_piece, in_quotes) in cmd.split('\'').zip([false, true].iter().copied().cycle()) { + if in_quotes { + res.push(string_piece.to_string()) + } else if !string_piece.is_empty() { + res.extend( + string_piece + .split_ascii_whitespace() + .map(|it| it.to_string()), + ) + } + } + res +} + +struct Env { + pushd_stack: Vec, + pushenv_stack: Vec<(OsString, Option)>, +} + +impl Env { + fn with T, T>(f: F) -> T { + thread_local! { + static ENV: RefCell = RefCell::new(Env { + pushd_stack: vec![env::current_dir().unwrap()], + pushenv_stack: vec![], + }); + } + ENV.with(|it| f(&mut it.borrow_mut())) + } + + fn pushd(&mut self, dir: PathBuf) { + let dir = self.cwd().join(dir); + self.pushd_stack.push(dir); + env::set_current_dir(self.cwd()).unwrap(); + } + fn popd(&mut self) { + self.pushd_stack.pop().unwrap(); + env::set_current_dir(self.cwd()).unwrap(); + } + fn pushenv(&mut self, var: OsString, value: OsString) { + self.pushenv_stack.push((var.clone(), env::var_os(&var))); + env::set_var(var, value) + } + fn popenv(&mut self) { + let (var, value) = self.pushenv_stack.pop().unwrap(); + match value { + None => env::remove_var(var), + Some(value) => env::set_var(var, value), + } + } + fn cwd(&self) -> &Path { + self.pushd_stack.last().unwrap() + } +} + diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs new file mode 100644 index 000000000..7ad1ef147 --- /dev/null +++ b/xtask/src/lib.rs @@ -0,0 +1,78 @@ +//! Codegen tools mostly used to generate ast and syntax definitions. Adapted from rust analyzer's codegen + +pub mod glue; + +use std::{ + env, + fmt::Display, + path::{Path, PathBuf}, +}; + +pub use crate::glue::{pushd, pushenv}; + +pub use anyhow::{anyhow, bail, ensure, Context as _, Error, Result}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Mode { + Overwrite, + Verify, +} + +pub fn project_root() -> PathBuf { + Path::new( + &env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()), + ) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +pub fn run_rustfmt(mode: Mode) -> Result<()> { + let _dir = pushd(project_root()); + let _e = pushenv("RUSTUP_TOOLCHAIN", "stable"); + ensure_rustfmt()?; + match mode { + Mode::Overwrite => run!("cargo fmt"), + Mode::Verify => run!("cargo fmt -- --check"), + }?; + Ok(()) +} + +pub fn reformat(text: impl Display) -> Result { + reformat_without_preamble(text).map(prepend_generated_preamble) +} + +pub fn reformat_with_command(text: impl Display, command: impl Display) -> Result { + reformat_without_preamble(text).map(|formatted| { + format!("//! This is a generated file. Don't modify it by hand! Run '{command}' to re-generate the file.\n\n{formatted}") + }) +} + +pub const PREAMBLE: &str = "Generated file, do not edit by hand, see `xtask/codegen`"; +pub fn prepend_generated_preamble(content: impl Display) -> String { + format!("//! {PREAMBLE}\n\n{content}") +} + +pub fn reformat_without_preamble(text: impl Display) -> Result { + let _e = pushenv("RUSTUP_TOOLCHAIN", "stable"); + ensure_rustfmt()?; + let output = run!( + "rustfmt --config newline_style=Unix"; + Result<()> { + let out = run!("rustfmt --version")?; + if !out.contains("stable") { + bail!( + "Failed to run rustfmt from toolchain 'stable'. \ + Please run `rustup component add rustfmt --toolchain stable` to install it.", + ) + } + Ok(()) +} +