debian-lsp-0.1.8/.cargo_vcs_info.json0000644000000001361046102023000131020ustar { "git": { "sha1": "cb949efee5d0769ba5da59215aa2ef70fad9837b" }, "path_in_vcs": "" }debian-lsp-0.1.8/Cargo.lock0000644000003563471046102023000110770ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-trait" version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ "num-traits", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", ] [[package]] name = "aws-lc-sys" version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", "dunce", "fs_extra", ] [[package]] name = "backtrace" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-link", ] [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ "bit-vec 0.6.3", ] [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ "bit-vec 0.8.0", ] [[package]] name = "bit-vec" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bit-vec" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "borrow-or-share" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "boxcar" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cargo_toml" version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", "toml", ] [[package]] name = "cc" version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] name = "cesu8" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "charset" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" dependencies = [ "base64", "encoding_rs", ] [[package]] name = "chrono" version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-link", ] [[package]] name = "clap" version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "clap_lex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", ] [[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] [[package]] name = "configparser" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57e3272f0190c3f1584272d613719ba5fc7df7f4942fe542e63d949cf3a649b" [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[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.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 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.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "csv" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ "csv-core", "itoa", "ryu", "serde_core", ] [[package]] name = "csv-core" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ "memchr", ] [[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 = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deb822-derive" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e83ef29a094bcb2b7dd0f609ace7f5a34ef9a62e0731ebd350637640320a3b15" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "deb822-fast" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "114c474fa4cd5d6d24bb5e68b36fa4ef70f5b830e3cc14a9b66a12e71a15aeb9" dependencies = [ "deb822-derive", ] [[package]] name = "deb822-lossless" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3010ab21b879670c60b013a500a9afe82094921707b151a4dac4a17f5676134f" dependencies = [ "regex", "rowan", "serde", ] [[package]] name = "debbugs" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ec91295e2d33b5e0d4eb4e3cc6c52c06d6a92392d0fc1c0cbb8ca4d6e3992d" dependencies = [ "debversion", "lazy-regex", "log", "mailparse", "maplit", "reqwest", "tokio", "xmltree", ] [[package]] name = "debian-changelog" version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e861544f3732a63762386518e660af2dc5637cdaf6d8f0091faf9ac00cc10755" dependencies = [ "chrono", "debversion", "lazy-regex", "log", "rowan", "textwrap", "whoami", ] [[package]] name = "debian-control" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f530d98a170c3ff21b1c7001a640cd0c038fe18814e6b1fed41ac52d9074b98d" dependencies = [ "chrono", "deb822-fast", "deb822-lossless", "debversion", "regex", "rowan", "url", ] [[package]] name = "debian-copyright" version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3424ff8ed8521aa2ad9ef969000ed34cd7d8bd62098d4ca382f5d9f5ca277a" dependencies = [ "deb822-fast", "deb822-lossless", "debversion", "regex", ] [[package]] name = "debian-lsp" version = "0.1.8" dependencies = [ "async-trait", "chrono", "clap", "deb822-fast", "deb822-lossless", "debian-changelog", "debian-control", "debian-copyright", "debian-watch", "dep3", "distro-info", "futures", "launchpadlib", "lintian-overrides", "lru", "makefile-lossless", "openssl", "patchkit", "rowan", "salsa", "serde", "serde_json", "sqlx", "tempfile", "text-size", "tokio", "tokio-test", "tower", "tower-lsp-server", "tower-service", "tracing", "tracing-subscriber", "upstream-ontologist", "yaml-edit", ] [[package]] name = "debian-watch" version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990fc0b20c98392dc7e83e164f2603f6627dab8f17466fa33ead80a6cb428373" dependencies = [ "clap", "deb822-lossless", "debversion", "m_lexer", "regex", "rowan", "url", ] [[package]] name = "debversion" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8ba0e270fb9f27dbb4c46e08d2ad27e69501d6ca573bfdf9e0aa793e7377929" dependencies = [ "chrono", "lazy-regex", "num-bigint", "serde", ] [[package]] name = "dep3" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "385da1b4c02ecd376ce7751ed555b8204e8e139c160f9c870e5a66920010d839" dependencies = [ "chrono", "deb822-fast", "deb822-lossless", "url", ] [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", "zeroize", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "const-oid", "crypto-common", "subtle", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "distro-info" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef12237f2ced990e453ec0b69230752e73be0a357817448c50a62f8bbbe0ca71" dependencies = [ "chrono", "csv", "failure", ] [[package]] name = "document_tree" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6742722dd3e6cd908bc522283cb5502e25f696d1c9904fb251ec266b6b3f9cce" dependencies = [ "anyhow", "regex", "serde", "serde_derive", "url", ] [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] [[package]] name = "encoding_rs" version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ "cfg-if", ] [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "etcetera" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ "cfg-if", "home", "windows-sys 0.48.0", ] [[package]] name = "event-listener" version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "failure" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" dependencies = [ "backtrace", "failure_derive", ] [[package]] name = "failure_derive" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", "synstructure 0.12.6", ] [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fluent-uri" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", "serde", ] [[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", "spin", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" dependencies = [ "mac", "new_debug_unreachable", ] [[package]] name = "futures" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", "futures-executor", "futures-io", "futures-sink", "futures-task", "futures-util", ] [[package]] name = "futures-channel" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-intrusive" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", "parking_lot", ] [[package]] name = "futures-io" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "futures-sink" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "getopts" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] [[package]] name = "getrandom" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "wasip2", "wasip3", ] [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "h2" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", ] [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ "hashbrown 0.15.5", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ "hmac", ] [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest", ] [[package]] name = "home" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "html2md" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" dependencies = [ "html5ever 0.27.0", "jni 0.19.0", "lazy_static", "markup5ever_rcdom 0.3.0", "percent-encoding", "regex", ] [[package]] name = "html5ever" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" dependencies = [ "log", "mac", "markup5ever 0.11.0", "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "html5ever" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" dependencies = [ "log", "mac", "markup5ever 0.12.1", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "html5ever" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever 0.39.0", ] [[package]] name = "http" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", "itoa", ] [[package]] name = "http-body" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", ] [[package]] name = "http-body-util" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite", ] [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "system-configuration", "tokio", "tower-service", "tracing", "windows-registry", ] [[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "indexmap" version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", "serde", "serde_core", ] [[package]] name = "intrusive-collections" version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" dependencies = [ "memoffset", ] [[package]] name = "inventory" version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" dependencies = [ "rustversion", ] [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" dependencies = [ "cesu8", "combine", "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", ] [[package]] name = "jni" version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ "cfg-if", "combine", "jni-macros", "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", "walkdir", "windows-link", ] [[package]] name = "jni-macros" version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ "proc-macro2", "quote", "rustc_version", "simd_cesu8", "syn 2.0.117", ] [[package]] name = "jni-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ "jni-sys 0.4.1", ] [[package]] name = "jni-sys" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ "jni-sys-macros", ] [[package]] name = "jni-sys-macros" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", "syn 2.0.117", ] [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "launchpadlib" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff3adfceea2d22a21fcdff64ef03fade0ab7ae9653c4c15bd31072f8bc684ce6" dependencies = [ "async-trait", "chrono", "debversion", "form_urlencoded", "futures", "lazy_static", "log", "mime", "percent-encoding", "rand 0.9.4", "reqwest", "serde", "serde_json", "url", "wadl", ] [[package]] name = "lazy-regex" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" dependencies = [ "lazy-regex-proc_macros", "once_cell", "regex", ] [[package]] name = "lazy-regex-proc_macros" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" dependencies = [ "proc-macro2", "quote", "regex", "syn 2.0.117", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ "spin", ] [[package]] name = "leb128fmt" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", "redox_syscall 0.7.5", ] [[package]] name = "libsqlite3-sys" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "pkg-config", "vcpkg", ] [[package]] name = "lintian-overrides" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ab5dd1d9f9b3be127370268a4ad0362c544de968f898a80c24b6fd899e7ead" dependencies = [ "lazy_static", "regex", "rowan", ] [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "ls-types" version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "896e16b8e17d8732b9efe4d5b66cb0cc162b3023a2d8122f2aea6f7f185e0a67" dependencies = [ "bitflags", "fluent-uri", "percent-encoding", "serde", "serde_json", ] [[package]] name = "m_lexer" version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7e51ebf91162d585a5bae05e4779efc4a276171cb880d61dd6fab11c98467a7" dependencies = [ "regex", ] [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mailparse" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60819a97ddcb831a5614eb3b0174f3620e793e97e09195a395bfa948fd68ed2f" dependencies = [ "charset", "data-encoding", "quoted_printable", ] [[package]] name = "makefile-lossless" version = "0.3.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc2e15c31f3937e9ee0b6229c03cdd682add67d4a29f5415e58a65923b19626" dependencies = [ "log", "rowan", ] [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" dependencies = [ "log", "phf 0.10.1", "phf_codegen 0.10.0", "string_cache 0.8.9", "string_cache_codegen 0.5.4", "tendril 0.4.3", ] [[package]] name = "markup5ever" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", "string_cache 0.8.9", "string_cache_codegen 0.5.4", "tendril 0.4.3", ] [[package]] name = "markup5ever" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril 0.5.0", "web_atoms", ] [[package]] name = "markup5ever_rcdom" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2" dependencies = [ "html5ever 0.26.0", "markup5ever 0.11.0", "tendril 0.4.3", "xml5ever 0.17.0", ] [[package]] name = "markup5ever_rcdom" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" dependencies = [ "html5ever 0.27.0", "markup5ever 0.12.1", "tendril 0.4.3", "xml5ever 0.18.1", ] [[package]] name = "md-5" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", "digest", ] [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", ] [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-bigint-dig" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-iter" version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", ] [[package]] name = "object" version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] [[package]] name = "openssl-sys" version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", "openssl-src", "pkg-config", "vcpkg", ] [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link", ] [[package]] name = "patchkit" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cb14fe8c3e5ae17fb7ad888c15a3297aebe316e4f0f49d1788318a331b26670" dependencies = [ "chrono", "lazy-regex", "lazy_static", "once_cell", "proc-macro2", "regex", "rowan", ] [[package]] name = "pem-rfc7468" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ "base64ct", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", ] [[package]] name = "pest_derive" version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", ] [[package]] name = "pest_generator" version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "pest_meta" version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", ] [[package]] name = "phf" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ "phf_shared 0.10.0", ] [[package]] name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_shared 0.11.3", ] [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_shared 0.13.1", "serde", ] [[package]] name = "phf_codegen" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" dependencies = [ "phf_generator 0.10.0", "phf_shared 0.10.0", ] [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", ] [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", ] [[package]] name = "phf_generator" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", "rand 0.8.6", ] [[package]] name = "phf_generator" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", "rand 0.8.6", ] [[package]] name = "phf_generator" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", "phf_shared 0.13.1", ] [[package]] name = "phf_shared" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" dependencies = [ "siphasher 0.3.11", ] [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher 1.0.3", ] [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher 1.0.3", ] [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ "der", "pkcs8", "spki", ] [[package]] name = "pkcs8" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", "spki", ] [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "prettyplease" version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn 2.0.117", ] [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pulldown-cmark" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags", "getopts", "memchr", "pulldown-cmark-escape", "unicase", ] [[package]] name = "pulldown-cmark-escape" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", "tokio", "tracing", "web-time", ] [[package]] name = "quinn-proto" version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", "tinyvec", "tracing", "web-time", ] [[package]] name = "quinn-udp" version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "quoted_printable" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core 0.6.4", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.5", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom 0.2.17", ] [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "rayon" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] [[package]] name = "redox_syscall" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ "bitflags", ] [[package]] name = "ref-cast" version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "regex" version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", "encoding_rs", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rowan" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown 0.14.5", "rustc-hash 1.1.0", "text-size", ] [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] [[package]] name = "rst_parser" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f3029872a42c0be67d86e3e88bf8c1e73d1da3d714da00b9c29f60a4605bfb1" dependencies = [ "anyhow", "document_tree", "pest", "pest_derive", ] [[package]] name = "rst_renderer" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf982408766e5055367c60382b78dcee50c83b2b731e036a8b510e0aedf1efa1" dependencies = [ "anyhow", "document_tree", "serde-xml-rs", "serde_json", ] [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pki-types" version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", ] [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salsa" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4612ff789805e65c87e9b38cb749a293212a615af065bed8a2001086801498c3" dependencies = [ "boxcar", "crossbeam-queue", "crossbeam-utils", "hashbrown 0.17.0", "hashlink", "indexmap", "intrusive-collections", "inventory", "parking_lot", "portable-atomic", "rayon", "rustc-hash 2.1.2", "salsa-macro-rules", "salsa-macros", "smallvec", "thin-vec", "tracing", "typeid", ] [[package]] name = "salsa-macro-rules" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58e354cbac6939b9b09cd9c11fb419a53e64b4a0f755d929f56a09f4cc752e41" [[package]] name = "salsa-macros" version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3067861075c2b80608f84ad49fb88f2c7610b94cdf8b4201e79ddee87f8980c8" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "synstructure 0.13.2", ] [[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 = "schannel" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "select" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5910c1d91bd7e6e178c0f8eb9e4ad01f814064b4a1c0ae3c906224a3cbf12879" dependencies = [ "bit-set 0.5.3", "html5ever 0.26.0", "markup5ever_rcdom 0.2.0", ] [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", ] [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde-xml-rs" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65162e9059be2f6a3421ebbb4fef3e74b7d9e7c60c50a0e292c6239f19f1edfa" dependencies = [ "log", "serde", "thiserror 1.0.69", "xml-rs", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "serde_spanned" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml", ] [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ "errno", "libc", ] [[package]] name = "signature" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", "rand_core 0.6.4", ] [[package]] name = "simd_cesu8" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ "rustc_version", "simdutf8", ] [[package]] name = "simdutf8" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] name = "smawk" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ "lock_api", ] [[package]] name = "spki" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", "der", ] [[package]] name = "sqlx" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", ] [[package]] name = "sqlx-core" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", "crc", "crossbeam-queue", "either", "event-listener", "futures-core", "futures-intrusive", "futures-io", "futures-util", "hashbrown 0.15.5", "hashlink", "indexmap", "log", "memchr", "once_cell", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", "url", ] [[package]] name = "sqlx-macros" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", "syn 2.0.117", ] [[package]] name = "sqlx-macros-core" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", "heck", "hex", "once_cell", "proc-macro2", "quote", "serde", "serde_json", "sha2", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", "tokio", "url", ] [[package]] name = "sqlx-mysql" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", "bitflags", "byteorder", "bytes", "crc", "digest", "dotenvy", "either", "futures-channel", "futures-core", "futures-io", "futures-util", "generic-array", "hex", "hkdf", "hmac", "itoa", "log", "md-5", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", "sha1", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "whoami", ] [[package]] name = "sqlx-postgres" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", "bitflags", "byteorder", "crc", "dotenvy", "etcetera", "futures-channel", "futures-core", "futures-util", "hex", "hkdf", "hmac", "home", "itoa", "log", "md-5", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", "whoami", ] [[package]] name = "sqlx-sqlite" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "flume", "futures-channel", "futures-core", "futures-executor", "futures-intrusive", "futures-util", "libsqlite3-sys", "log", "percent-encoding", "serde", "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", "tracing", "url", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "string_cache" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.11.3", "precomputed-hash", "serde", ] [[package]] name = "string_cache" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.13.1", "precomputed-hash", ] [[package]] name = "string_cache_codegen" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", ] [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", ] [[package]] name = "stringprep" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ "unicode-bidi", "unicode-normalization", "unicode-properties", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] [[package]] name = "synstructure" version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", "unicode-xid", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "tendril" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" dependencies = [ "futf", "mac", "utf-8", ] [[package]] name = "tendril" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" dependencies = [ "new_debug_unreachable", "utf-8", ] [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" [[package]] name = "textwrap" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", "unicode-width", ] [[package]] name = "thin-vec" version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl 2.0.18", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "thiserror-impl" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "thread_local" version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", ] [[package]] name = "tinystr" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tinyvec" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] [[package]] name = "tokio-stream" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] [[package]] name = "tokio-test" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" dependencies = [ "futures-core", "tokio", "tokio-stream", ] [[package]] name = "tokio-util" version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "toml" version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", "toml_writer", "winnow 0.7.15", ] [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.2", ] [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", ] [[package]] name = "tower-http" version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", "pin-project-lite", "tower", "tower-layer", "tower-service", "url", ] [[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-lsp-server" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0e711655c89181a6bc6a2cc348131fcd9680085f5b06b6af13427a393a6e72" dependencies = [ "bytes", "dashmap", "futures", "httparse", "ls-types", "memchr", "serde", "serde_json", "tokio", "tokio-util", "tower", "tracing", ] [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", ] [[package]] name = "tracing-log" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", "tracing-core", ] [[package]] name = "tracing-subscriber" version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", "smallvec", "thread_local", "tracing-core", "tracing-log", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "upstream-ontologist" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "620d4d2a9f8d5c90b57a83c70a3efd26eaae084be7c653bdbf041bb6c07da745" dependencies = [ "async-trait", "bit-set 0.8.0", "bit-vec 0.8.0", "cargo_toml", "chrono", "configparser", "debbugs", "debian-changelog", "debian-control", "debian-copyright", "debian-watch", "debversion", "futures", "html5ever 0.39.0", "lazy-regex", "lazy_static", "log", "makefile-lossless", "maplit", "openssl", "percent-encoding", "pulldown-cmark", "quote", "regex", "reqwest", "rst_parser", "rst_renderer", "select", "semver", "serde", "serde_json", "serde_yaml", "shlex", "tendril 0.5.0", "textwrap", "tokio", "url", "xmltree", ] [[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", "serde_derive", ] [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wadl" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30392766f64a5e989f9455dfb1d999a7cc5af8be9d3db601bd4953ef993e61b7" dependencies = [ "async-trait", "form_urlencoded", "html2md", "iri-string", "lazy_static", "log", "mime", "proc-macro2", "quote", "reqwest", "serde_json", "syn 2.0.117", "url", "xmltree", ] [[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 = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen 0.57.1", ] [[package]] name = "wasip3" version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen 0.51.0", ] [[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-encoder" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", "wasmparser", ] [[package]] name = "wasm-metadata" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser", ] [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver", ] [[package]] name = "web-sys" version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web-time" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web_atoms" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "string_cache 0.9.0", "string_cache_codegen 0.6.1", ] [[package]] name = "webpki-root-certs" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", "wasite", ] [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.5", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" [[package]] name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "wit-bindgen-rust-macro", ] [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wit-bindgen-core" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", "wit-parser", ] [[package]] name = "wit-bindgen-rust" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", ] [[package]] name = "wit-bindgen-rust-macro" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] [[package]] name = "wit-component" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser", "wit-parser", ] [[package]] name = "wit-parser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser", ] [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xml" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xml5ever" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" dependencies = [ "log", "mac", "markup5ever 0.11.0", ] [[package]] name = "xml5ever" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" dependencies = [ "log", "mac", "markup5ever 0.12.1", ] [[package]] name = "xmltree" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc04313cab124e498ab1724e739720807b6dc405b9ed0edc5860164d2e4ff70" dependencies = [ "xml", ] [[package]] name = "yaml-edit" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c249044401e064d1f4d7ec0937c343b283e9da13de92298435ff6bf0ad53552" dependencies = [ "base64", "rowan", ] [[package]] name = "yoke" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "synstructure 0.13.2", ] [[package]] name = "zerocopy" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zerofrom" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", "synstructure 0.13.2", ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" debian-lsp-0.1.8/Cargo.toml0000644000000065771046102023000111170ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "debian-lsp" version = "0.1.8" authors = ["Jelmer Vernooij "] build = false include = [ "ale-debian-lsp.vim", "emacs-lspconfig/README.md", "helix-lspconfig/README.md", "nvim-lspconfig/README.md", "src/**", "tests/**", "examples/**", "Cargo.toml", "README.md", "nvim-lspconfig/README.md", "nvim-lspconfig/lsp/debian_lsp.lua", ] autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Language Server Protocol implementation for Debian control files with field completion, diagnostics, and quickfixes" readme = "README.md" keywords = [ "lsp", "debian", "control", "language-server", ] categories = [ "development-tools", "text-editors", ] license = "MIT OR Apache-2.0" repository = "https://github.com/jelmer/debian-lsp" [features] default = ["launchpad"] launchpad = ["dep:launchpadlib"] openssl-vendored = ["dep:openssl"] [[bin]] name = "debian-lsp" path = "src/main.rs" test = true [dependencies.async-trait] version = "0.1" [dependencies.chrono] version = "0.4" [dependencies.clap] version = "4" features = ["derive"] [dependencies.deb822-fast] version = "0.2" [dependencies.deb822-lossless] version = "0.5.15" [dependencies.debian-changelog] version = "0.2.18" [dependencies.debian-control] version = "0.3.7" [dependencies.debian-copyright] version = "0.1.47" features = ["lossless"] [dependencies.debian-watch] version = "0.4.10" features = [ "linebased", "deb822", ] [dependencies.dep3] version = "0.2.2" [dependencies.distro-info] version = "0.4" [dependencies.futures] version = "0.3" [dependencies.launchpadlib] version = "0.5.7" features = [ "async", "api-v1_0", "bugs", "packages", ] optional = true default-features = false [dependencies.lintian-overrides] version = "0.1.2" [dependencies.lru] version = "0.16" [dependencies.makefile-lossless] version = "0.3.34" [dependencies.openssl] version = ">=0.10.64, <0.11" features = ["vendored"] optional = true [dependencies.patchkit] version = "0.2.4" [dependencies.rowan] version = "0.16.1" [dependencies.salsa] version = ">=0.23, <0.27" [dependencies.serde] version = "1" features = ["derive"] [dependencies.serde_json] version = "1" [dependencies.sqlx] version = "0.8" features = [ "runtime-tokio", "postgres", ] [dependencies.text-size] version = "1.1" [dependencies.tokio] version = "1" features = ["full"] [dependencies.tower-lsp-server] version = "0.23" [dependencies.tracing] version = "0.1" [dependencies.tracing-subscriber] version = "0.3" [dependencies.upstream-ontologist] version = "0.3.17" features = [ "cargo", "debian", ] default-features = false [dependencies.yaml-edit] version = "0.2.0" [dev-dependencies.tempfile] version = "3" [dev-dependencies.tokio-test] version = "0.4" [dev-dependencies.tower] version = "0.5" [dev-dependencies.tower-service] version = "0.3" debian-lsp-0.1.8/Cargo.toml.orig000064400000000000000000000041611046102023000145410ustar 00000000000000[package] name = "debian-lsp" version = "0.1.8" edition = "2021" authors = ["Jelmer Vernooij "] description = "Language Server Protocol implementation for Debian control files with field completion, diagnostics, and quickfixes" readme = "README.md" license = "MIT OR Apache-2.0" repository = "https://github.com/jelmer/debian-lsp" keywords = ["lsp", "debian", "control", "language-server"] categories = ["development-tools", "text-editors"] include = ["ale-debian-lsp.vim", "emacs-lspconfig/README.md", "helix-lspconfig/README.md", "nvim-lspconfig/README.md", "src/**", "tests/**", "examples/**", "Cargo.toml", "README.md", "nvim-lspconfig/README.md", "nvim-lspconfig/lsp/debian_lsp.lua"] [features] default = ["launchpad"] launchpad = ["dep:launchpadlib"] openssl-vendored = ["dep:openssl"] [dependencies] # debian-analyzer = "0.159.0" async-trait = "0.1" chrono = "0.4" debian-changelog = "0.2.18" debian-control = { version = "0.3.7" } debian-copyright = { version = "0.1.47", features = ["lossless"] } debian-watch = { version = "0.4.10", features = ["linebased", "deb822"] } dep3 = "0.2.2" distro-info = "0.4" rowan = "0.16.1" sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] } makefile-lossless = "0.3.34" deb822-fast = "0.2" lintian-overrides = { version = "0.1.2" } deb822-lossless = { version = "0.5.15" } yaml-edit = { version = "0.2.0" } upstream-ontologist = { version = "0.3.17", default-features = false, features = ["cargo", "debian"] } clap = { version = "4", features = ["derive"] } futures = "0.3" tower-lsp-server = "0.23" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" salsa = ">=0.23, <0.27" text-size = "1.1" patchkit = "0.2.4" launchpadlib = { version = "0.5.7", optional = true, default-features = false, features = ["async", "api-v1_0", "bugs", "packages"] } lru = "0.16" openssl = { version = ">=0.10.64, <0.11", features = ["vendored"], optional = true } [dev-dependencies] tempfile = "3" tokio-test = "0.4" tower = "0.5" tower-service = "0.3" [[bin]] name = "debian-lsp" test = true debian-lsp-0.1.8/README.md000064400000000000000000000240141046102023000131300ustar 00000000000000# debian-lsp [![CI](https://github.com/jelmer/debian-lsp/actions/workflows/ci.yml/badge.svg)](https://github.com/jelmer/debian-lsp/actions/workflows/ci.yml) [![Tests](https://github.com/jelmer/debian-lsp/actions/workflows/test.yml/badge.svg)](https://github.com/jelmer/debian-lsp/actions/workflows/test.yml) Language Server Protocol implementation for Debian packaging files. ## Supported Files - `debian/control` - Package control files - `debian/copyright` - DEP-5 copyright files - `debian/watch` - Upstream watch files (v1-4 line-based and v5 deb822 formats) - `debian/changelog` - Package changelog files - `debian/source/format` - Source format declaration files - `debian/source/options` - dpkg-source options files - `debian/source/local-options` - Local dpkg-source options files - `debian/tests/control` - Autopkgtest control files (basic support) - `debian/upstream/metadata` - DEP-12 upstream metadata files - `debian/rules` - Package build rules (Makefile) - `debian/patches/series` - List of patches applied by dpkg-source ## Features ### Completions **debian/control:** - Field name completions for all standard source and binary package fields - Package name completions for relationship fields (Depends, Build-Depends, Recommends, etc.) using the system package cache - Value completions for Section (all Debian sections including area-qualified), Priority, and architecture fields **debian/copyright:** - Field name completions for header, files, and license paragraphs - Value completions for Format and License (from `/usr/share/common-licenses`) **debian/watch:** - Field name completions for watch file fields - Version number completions - Option value completions (compression, mode, pgpmode, searchmode, gitmode, gitexport, component) **debian/changelog:** - Distribution completions (unstable, stable, testing, experimental, UNRELEASED, plus release codenames) - Urgency level completions (low, medium, high, critical, emergency) **debian/source/format:** - Format value completions (3.0 (quilt), 3.0 (native), 3.0 (git), 1.0, etc.) **debian/source/options and debian/source/local-options:** - Option name completions for all dpkg-source long options (compression, single-debian-patch, etc.) - Value completions for compression and compression-level options - Filters options by file type (some options are local-options only) **debian/upstream/metadata:** - Field name completions for all DEP-12 fields (Repository, Bug-Database, Contact, etc.) **debian/rules:** - Target name completions for standard Debian Policy targets (clean, build, binary, etc.) and debhelper override/execute targets - Variable name completions for common build variables (DEB_BUILD_OPTIONS, DEB_HOST_MULTIARCH, etc.) - Excludes already-defined targets from completions **debian/patches/series:** - Patch name completions based on files present in the `debian/patches/` directory - Package name completions for patch entries, excluding already listed patches - Option value completions for patch application flags (`-p0`, `-p1`, `-p2`, etc.) ### Diagnostics - Field casing validation (e.g. `source` instead of `Source`) - Parse error reporting with position information ### Code Actions - **Fix field casing** - automatically correct field names to canonical casing - **Wrap and sort** - wrap long fields to 79 characters and sort dependency lists (control and copyright files) - **Add changelog entry** - create a new changelog entry with incremented version, UNRELEASED distribution, and auto-populated maintainer - **Mark for upload** - replace UNRELEASED with the target distribution ### On-Type Formatting For deb822-based files (control, copyright, watch, tests/control), the server provides on-type formatting: - Automatically inserts a space after typing `:` at the end of a field name - Inserts continuation-line indentation after pressing Enter inside a field value This requires the editor to have format-on-type enabled: - **VS Code**: Enabled by default via the extension's `configurationDefaults` - **coc.nvim**: Set `"coc.preferences.formatOnType": true` in your coc-settings.json (`:CocConfig`) - **Native Neovim LSP**: Pass `on_type_formatting = true` in your client capabilities, or call `vim.lsp.buf.format()` manually - **ALE**: Not supported (ALE does not handle `textDocument/onTypeFormatting`) ### Inlay Hints **debian/control:** - Archive versions per suite for packages in dependency fields - Providers for virtual packages - Resolved values for substitution variables (`${shlibs:Depends}`, etc.) **debian/changelog:** - Distribution-to-suite mappings (e.g. `unstable = sid`, `UNRELEASED -> unstable`) ### Code Lenses **debian/control:** - Standards-Version: shows the latest version when outdated - debhelper-compat: shows stable and maximum compat levels (via `dh_assistant`) - Vcs-Git: shows the packaged version from UDD vcswatch ### Document Symbols - **debian/control** - source and binary package paragraphs - **debian/copyright** - header, files, and license paragraphs - **debian/changelog** - changelog entries ### Folding Ranges Paragraph-level folding for deb822-based files (control, copyright, watch, tests/control) and entry-level folding for changelog files. ### Document Formatting Wrap-and-sort formatting for debian/control, debian/copyright, and debian/watch (deb822 format) files. ### Semantic Highlighting Custom token types for syntax highlighting of Debian-specific constructs: - Control/copyright/watch/upstream-metadata/source-options/rules files: field names, unknown fields, values, comments - Changelog files: package name, version, distribution, urgency, maintainer, timestamp ## Installation ### Building the LSP server ```bash cargo build --release ``` The binary will be available at `target/release/debian-lsp`. ### Using with VS Code A dedicated VS Code extension is available in the `vscode-debian` directory. See [vscode-debian/README.md](vscode-debian/README.md) for installation and configuration instructions. ### Using with Vim/Neovim #### coc.nvim A coc.nvim extension is available in the `coc-debian` directory. See [coc-debian/README.md](coc-debian/README.md) for installation and configuration instructions. #### ALE Source the provided configuration file in your `.vimrc` or `init.vim`: ```vim source /path/to/debian-lsp/ale-debian-lsp.vim ``` By default, the configuration will look for the `debian-lsp` executable in the same directory as the vim file. To use a custom path, set `g:debian_lsp_executable` before sourcing: ```vim let g:debian_lsp_executable = '/custom/path/to/debian-lsp' source /path/to/debian-lsp/ale-debian-lsp.vim ``` You can trigger code actions in ALE with `:ALECodeAction` when your cursor is on a diagnostic. #### vim-lsp Add the following configuration to your `.vimrc` or `init.vim`: ```vim " Configure vim-lsp for debian-lsp function! s:config_debian_lsp() if executable('debian-lsp') augroup debian_lsp autocmd! autocmd User lsp_setup call lsp#register_server({ \ 'name': 'debian-lsp', \ 'cmd': {server_info -> ['debian-lsp']}, \ 'allowlist': ['debcontrol', 'debcopyright', 'debchangelog', 'debsources', 'debsourceoptions', 'debwatch', 'debupstream', 'autopkgtest', 'debrules', 'debpatches'], \ 'blocklist': [], \ 'enabled': 1, \ }) augroup END endif endfunction call s:config_debian_lsp() " Set filetypes for Debian packaging files (if not already set by ftdetect) augroup debian_filetypes autocmd! autocmd BufNewFile,BufRead */debian/control setfiletype debcontrol autocmd BufNewFile,BufRead */debian/copyright setfiletype debcopyright autocmd BufNewFile,BufRead */debian/changelog setfiletype debchangelog autocmd BufNewFile,BufRead */debian/changelog.dch setfiletype debchangelog autocmd BufNewFile,BufRead */debian/source/format setfiletype debsources autocmd BufNewFile,BufRead */debian/source/options setfiletype debsourceoptions autocmd BufNewFile,BufRead */debian/source/local-options setfiletype debsourceoptions autocmd BufNewFile,BufRead */debian/watch setfiletype debwatch autocmd BufNewFile,BufRead */debian/upstream/metadata setfiletype debupstream autocmd BufNewFile,BufRead */debian/rules setfiletype debrules autocmd BufNewFile,BufRead */debian/patches/series setfiletype debpatches augroup END ``` Replace `debian-lsp` with the full path to the executable if it's not on your PATH. You can then use vim-lsp commands like: - `:LspDocumentDiagnostics` - Show diagnostics - `:LspCodeAction` - Show code actions - `:LspDefinition` - Go to definition - `:LspHover` - Show hover information #### Neovim 0.11+ with bundled config A bundled LSP config is provided in the `nvim-lspconfig/` directory. Copy it to your Neovim config: ```sh mkdir -p ~/.config/nvim/lsp cp nvim-lspconfig/lsp/debian_lsp.lua ~/.config/nvim/lsp/ ``` Then enable it in your `init.lua`: ```lua vim.lsp.enable('debian_lsp') ``` To use a custom path to the `debian-lsp` binary: ```lua vim.lsp.config('debian_lsp', { cmd = { '/path/to/debian-lsp' }, }) vim.lsp.enable('debian_lsp') ``` #### Native Neovim LSP (without nvim-lspconfig) If you don't use nvim-lspconfig, add the following to your `init.lua`: ```lua vim.api.nvim_create_autocmd({'BufEnter', 'BufWinEnter'}, { pattern = { '*/debian/control', '*/debian/copyright', '*/debian/changelog', '*/debian/changelog.dch', '*/debian/source/format', '*/debian/source/options', '*/debian/source/local-options', '*/debian/watch', '*/debian/tests/control', '*/debian/upstream/metadata', '*/debian/rules', '*/debian/patches/series', }, callback = function() vim.lsp.start({ name = 'debian-lsp', cmd = {'debian-lsp'}, root_dir = vim.fn.getcwd(), }) end, }) ``` ### Using with Helix See [helix-lspconfig/README.md](helix-lspconfig/README.md) for installation and configuration instructions. ### Using with Emacs See [emacs-lspconfig/README.md](emacs-lspconfig/README.md) for installation and configuration instructions. ## Development To run the LSP in development mode: ```bash cargo run ``` To watch and rebuild the coc plugin: ```bash cd coc-debian npm run watch ``` debian-lsp-0.1.8/ale-debian-lsp.vim000064400000000000000000000113141046102023000151420ustar 00000000000000" ALE configuration for debian-lsp " Source this file in your .vimrc or init.vim: " source /path/to/debian-lsp/ale-debian-lsp.vim " " You can customize the executable path by setting g:debian_lsp_executable " before sourcing this file: " let g:debian_lsp_executable = '/custom/path/to/debian-lsp' " source /path/to/debian-lsp/ale-debian-lsp.vim " Set default executable path if not already configured if !exists('g:debian_lsp_executable') let g:debian_lsp_executable = expand(':p:h') . '/target/release/debian-lsp' endif " Register debian-lsp with ALE for all supported file types let g:ale_linters = get(g:, 'ale_linters', {}) let g:ale_linters.debcontrol = ['debian-lsp'] let g:ale_linters.debcopyright = ['debian-lsp'] let g:ale_linters.debchangelog = ['debian-lsp'] let g:ale_linters.debsources = ['debian-lsp'] let g:ale_linters.debsourceoptions = ['debian-lsp'] let g:ale_linters.debwatch = ['debian-lsp'] let g:ale_linters.debupstream = ['debian-lsp'] let g:ale_linters.autopkgtest = ['debian-lsp'] let g:ale_linters.debrules = ['debian-lsp'] let g:ale_linters.debpatches = ['debian-lsp'] " Define debian-lsp for debian/control files call ale#linter#Define('debcontrol', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/copyright files call ale#linter#Define('debcopyright', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/changelog files call ale#linter#Define('debchangelog', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/source/format files call ale#linter#Define('debsources', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/source/options files call ale#linter#Define('debsourceoptions', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/watch files call ale#linter#Define('debwatch', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/upstream/metadata files call ale#linter#Define('debupstream', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/rules files call ale#linter#Define('debrules', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/tests/control files call ale#linter#Define('autopkgtest', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Define debian-lsp for debian/patches/series files call ale#linter#Define('debpatches', { \ 'name': 'debian-lsp', \ 'lsp': 'stdio', \ 'executable': g:debian_lsp_executable, \ 'command': '%e', \ 'project_root': function('ale#handlers#lsp#GetProjectRoot'), \}) " Set filetypes for Debian packaging files " Note: Vim already detects debcontrol, debcopyright, debchangelog, " debsources, and autopkgtest. augroup debian_filetypes autocmd! autocmd BufNewFile,BufRead */debian/control setfiletype debcontrol autocmd BufNewFile,BufRead */debian/copyright setfiletype debcopyright autocmd BufNewFile,BufRead */debian/changelog setfiletype debchangelog autocmd BufNewFile,BufRead */debian/changelog.dch setfiletype debchangelog autocmd BufNewFile,BufRead */debian/source/format setfiletype debsources autocmd BufNewFile,BufRead */debian/source/options setfiletype debsourceoptions autocmd BufNewFile,BufRead */debian/source/local-options setfiletype debsourceoptions autocmd BufNewFile,BufRead */debian/watch setfiletype debwatch autocmd BufNewFile,BufRead */debian/upstream/metadata setfiletype debupstream autocmd BufNewFile,BufRead */debian/rules setfiletype debrules autocmd BufNewFile,BufRead */debian/patches/series setfiletype debpatches augroup END debian-lsp-0.1.8/coc-debian/README.md000064400000000000000000000055731046102023000151250ustar 00000000000000# coc-debian A coc.nvim extension that provides Language Server Protocol support for Debian control files. ## Features - Auto-completion for Debian control file field names - Package name suggestions - Automatic activation for `debian/control` files ## Installation ### Prerequisites - [coc.nvim](https://github.com/neoclide/coc.nvim) installed in Vim/Neovim - Node.js and npm - The debian-lsp server built and available ### Local Installation 1. **Build the debian-lsp server first:** ```bash cd /path/to/debian-lsp cargo build --release ``` 2. **Install and build the coc extension:** ```bash cd coc-debian npm install npm run build ``` 3. **Install the extension in coc.nvim:** ```vim :CocInstall file:///absolute/path/to/debian-lsp/coc-debian ``` Or alternatively, create a symlink in your coc extensions directory: ```bash ln -s /absolute/path/to/debian-lsp/coc-debian ~/.config/coc/extensions/node_modules/coc-debian ``` 4. **Configure the LSP server path:** Add the following to your coc-settings.json (`:CocConfig` in Vim): ```json { "debian.enable": true, "debian.serverPath": "/absolute/path/to/debian-lsp/target/release/debian-lsp" } ``` ### Verify Installation 1. Open a Debian control file (`debian/control` or any file named `control`) 2. Try typing field names and you should see completions 3. Check `:CocList extensions` to see if `coc-debian` is listed and active ## Configuration Available settings in coc-settings.json: - `debian.enable` (boolean, default: true) - Enable/disable the extension - `debian.serverPath` (string, default: "debian-lsp") - Path to the debian-lsp executable ### On-Type Formatting The server supports on-type formatting for deb822-based files (auto-inserting a space after `:` in field names, continuation-line indentation on Enter). To enable this, add the following to your coc-settings.json (`:CocConfig`): ```json { "coc.preferences.formatOnType": true } ``` ### Code Lenses The server provides code lenses for `debian/control` files, showing information like the latest Standards-Version, current debhelper compat levels, and the packaged version from vcswatch. To enable code lenses, add the following to your coc-settings.json (`:CocConfig`): ```json { "codeLens.enable": true } ``` By default, code lenses are shown above the relevant line. To show them at the end of the line instead, set: ```json { "codeLens.position": "eol" } ``` ## Development To work on the extension: ```bash # Watch for changes and rebuild npm run watch # After making changes, restart coc :CocRestart ``` ## Troubleshooting - **LSP not starting:** Check that the `debian.serverPath` points to the correct executable - **No completions:** Verify the file is named `control` or is in a `debian/` directory - **Extension not loading:** Check `:CocList extensions` and look for any error messagesdebian-lsp-0.1.8/emacs-lspconfig/README.md000064400000000000000000000072621046102023000162100ustar 00000000000000# Emacs ## Prerequisites - Emacs 29+ (ships with `eglot` built-in) - The `debian-lsp` binary available on your system ## Installation ### 1. Make sure `debian-lsp` is on your PATH ```bash which debian-lsp ``` If the command returns nothing, copy the binary to a directory on your PATH: ```bash sudo cp /path/to/debian-lsp /usr/local/bin/ sudo chmod +x /usr/local/bin/debian-lsp ``` ### 2. Configure your `init.el` Your `init.el` should be located at `~/.emacs.d/init.el`. Create it if it doesn't exist: ```bash mkdir -p ~/.emacs.d touch ~/.emacs.d/init.el ``` Add the following configuration: ```elisp ;; MELPA – package repository (require 'package) (add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t) (package-initialize) ;; eglot is built-in since Emacs 29 (require 'eglot) ;; company – completion frontend ;; eglot speaks LSP but does not display completion suggestions on its own. ;; A frontend like company (or corfu) is required for completions to appear. (unless (package-installed-p 'company) (package-refresh-contents) (package-install 'company)) (require 'company) (setq company-idle-delay 0.2) (setq company-minimum-prefix-length 1) ;; Define Debian-specific major modes ;; Emacs does not recognise these file types natively. (define-derived-mode debcontrol-mode fundamental-mode "debcontrol") (define-derived-mode debcopyright-mode fundamental-mode "debcopyright") (define-derived-mode debchangelog-mode fundamental-mode "debchangelog") (define-derived-mode debwatch-mode fundamental-mode "debwatch") (define-derived-mode debrules-mode fundamental-mode "debrules") (define-derived-mode debsources-mode fundamental-mode "debsources") (define-derived-mode debsourceoptions-mode fundamental-mode "debsourceoptions") (define-derived-mode debupstream-mode fundamental-mode "debupstream") (define-derived-mode debpatches-mode fundamental-mode "debpatches") (define-derived-mode autopkgtest-mode fundamental-mode "autopkgtest") ;; Associate Debian packaging files with their modes (add-to-list 'auto-mode-alist '("debian/control\\'" . debcontrol-mode)) (add-to-list 'auto-mode-alist '("debian/copyright\\'" . debcopyright-mode)) (add-to-list 'auto-mode-alist '("debian/changelog\\'" . debchangelog-mode)) (add-to-list 'auto-mode-alist '("debian/changelog\\.dch\\'" . debchangelog-mode)) (add-to-list 'auto-mode-alist '("debian/watch\\'" . debwatch-mode)) (add-to-list 'auto-mode-alist '("debian/rules\\'" . debrules-mode)) (add-to-list 'auto-mode-alist '("debian/source/format\\'" . debsources-mode)) (add-to-list 'auto-mode-alist '("debian/source/options\\'" . debsourceoptions-mode)) (add-to-list 'auto-mode-alist '("debian/source/local-options\\'" . debsourceoptions-mode)) (add-to-list 'auto-mode-alist '("debian/upstream/metadata\\'" . debupstream-mode)) (add-to-list 'auto-mode-alist '("debian/patches/series\\'" . debpatches-mode)) (add-to-list 'auto-mode-alist '("debian/tests/control\\'" . autopkgtest-mode)) ;; Register debian-lsp and enable company for all Debian modes (dolist (mode '(debcontrol-mode debcopyright-mode debchangelog-mode debwatch-mode debrules-mode debsources-mode debsourceoptions-mode debupstream-mode debpatches-mode autopkgtest-mode)) (add-to-list 'eglot-server-programs `(,mode . ("debian-lsp"))) (add-hook (intern (concat (symbol-name mode) "-hook")) #'eglot-ensure) (add-hook (intern (concat (symbol-name mode) "-hook")) #'company-mode)) ``` debian-lsp-0.1.8/helix-lspconfig/README.md000064400000000000000000000044211046102023000162230ustar 00000000000000# Helix Add the following to your Helix language configuration file: - **Linux / Mac**: `~/.config/helix/languages.toml` - **Windows**: `%AppData%\helix\languages.toml` > **Note:** This goes in `languages.toml`, not `config.toml`. If `debian-lsp` is not on your `PATH`, replace `command = "debian-lsp"` with the full path to the binary (e.g. `command = "/path/to/debian-lsp"`). Add the following to `languages.toml`: ```toml [language-server.debian-lsp] command = "debian-lsp" [[language]] name = "debcontrol" grammar = "debian" scope = "text.debian.control" file-types = [{ glob = "debian/control" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debcopyright" scope = "text.debian.copyright" file-types = [{ glob = "debian/copyright" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debwatch" scope = "text.debian.watch" file-types = [{ glob = "debian/watch" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debchangelog" scope = "text.debian.changelog" file-types = [ { glob = "debian/changelog" }, { glob = "debian/changelog.dch" } ] language-servers = ["debian-lsp"] [[language]] name = "debsources" scope = "text.debian.source.format" file-types = [{ glob = "debian/source/format" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debsourceoptions" scope = "text.debian.source.options" file-types = [ { glob = "debian/source/options" }, { glob = "debian/source/local-options" } ] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "autopkgtest" scope = "text.debian.tests.control" file-types = [{ glob = "debian/tests/control" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debupstream" scope = "text.debian.upstream" file-types = [{ glob = "debian/upstream/metadata" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debrules" scope = "text.debian.rules" file-types = [{ glob = "debian/rules" }] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debseries" scope = "text.debian.series" file-types = [{ glob = "debian/patches/series"}] language-servers = ["debian-lsp"] comment-tokens = "#" [[language]] name = "debian" language-servers = ["debian-lsp"] ``` debian-lsp-0.1.8/nvim-lspconfig/README.md000064400000000000000000000007441046102023000160670ustar 00000000000000# nvim-lspconfig configuration for debian-lsp This directory contains configuration for using debian-lsp with Neovim 0.11+. ## Installation Copy (or symlink) `lsp/debian_lsp.lua` to your Neovim LSP config directory: ```sh mkdir -p ~/.config/nvim/lsp cp lsp/debian_lsp.lua ~/.config/nvim/lsp/ ``` Then enable the LSP in your Neovim config: ```lua vim.lsp.enable('debian_lsp') ``` ## File - `lsp/debian_lsp.lua` - LSP config for Neovim 0.11+ (`vim.lsp.config`/`vim.lsp.enable`) debian-lsp-0.1.8/nvim-lspconfig/lsp/debian_lsp.lua000064400000000000000000000024611046102023000202070ustar 00000000000000---@brief --- --- https://github.com/jelmer/debian-lsp --- --- Language Server Protocol implementation for Debian packaging files. --- --- Supports debian/control, debian/copyright, debian/changelog, debian/watch, --- debian/source/format, debian/source/options, debian/source/local-options, --- debian/tests/control, and debian/upstream/metadata. --- --- Features include completions, diagnostics, code actions (wrap and sort, --- fix field casing, add changelog entry), on-type formatting, folding ranges, --- inlay hints, and semantic highlighting. --- --- `debian-lsp` can be installed via `cargo`: --- ```sh --- cargo install debian-lsp --- ``` --- --- To enable inlay hints (e.g. distribution -> suite mapping in changelog): --- ```lua --- vim.lsp.inlay_hint.enable() --- ``` --- --- To enable on-type formatting (auto-insert space after `:` and continuation --- line indentation): --- ```lua --- vim.lsp.on_type_formatting.enable() --- ``` --- --- To use LSP-based folding: --- ```lua --- vim.o.foldmethod = 'expr' --- vim.o.foldexpr = 'v:lua.vim.lsp.foldexpr()' --- ``` ---@type vim.lsp.Config return { cmd = { 'debian-lsp' }, filetypes = { 'debcontrol', 'debcopyright', 'debchangelog', 'debsources', 'debsourceoptions', 'debwatch', 'debupstream', 'autopkgtest' }, root_markers = { 'debian/control', 'debian' }, } debian-lsp-0.1.8/src/architecture.rs000064400000000000000000000023041046102023000154660ustar 00000000000000use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::RwLock; /// Thread-safe shared architecture list. pub type SharedArchitectureList = Arc>>; /// Create a new empty shared architecture list. pub fn new_shared_list() -> SharedArchitectureList { Arc::new(RwLock::new(Vec::new())) } /// Stream architecture names from `dpkg-architecture -L` into the shared list. /// /// Each architecture name is inserted (in sorted order) as soon as it is /// read, so completions are available immediately while still loading. pub async fn stream_into(list: &SharedArchitectureList) { let Ok(mut child) = Command::new("dpkg-architecture") .arg("-L") .stdout(std::process::Stdio::piped()) .spawn() else { return; }; let Some(stdout) = child.stdout.take() else { return; }; let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { if !line.is_empty() { let mut arches = list.write().await; let pos = arches.binary_search(&line).unwrap_or_else(|p| p); arches.insert(pos, line); } } } debian-lsp-0.1.8/src/bugs/debbugs.rs000064400000000000000000000342371046102023000153710ustar 00000000000000use super::{BugCache, BugRow, CachedDebbugsBugDetails}; /// Debian bug data returned to completion providers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DebbugsBugSummary { /// Numeric Debian bug ID. pub id: u32, /// Bug title, when available. pub title: Option, /// Bug severity (e.g. "serious", "normal", "wishlist"). pub severity: Option, /// Whether the bug has been marked as done/resolved. pub done: bool, /// Tags associated with the bug (e.g. "patch", "confirmed"). pub tags: Option, /// Where the bug has been forwarded to, if anywhere. pub forwarded: Option, /// Email address of the person who reported the bug. pub originator: Option, } impl BugCache { /// Fetch bug IDs and details for a source package from UDD in a single query. async fn fetch_bugs_for_source_package(&mut self, source_package: &str) { let key = format!("src:{}", source_package); if self.bug_ids_by_package.contains(&key) { return; } let rows: Vec = match sqlx::query_as( "SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM bugs_tags t WHERE t.id = b.id) AS tags \ FROM bugs b WHERE b.source = $1 \ UNION ALL \ SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM archived_bugs_tags t WHERE t.id = b.id) AS tags \ FROM archived_bugs b WHERE b.source = $1 \ ORDER BY id", ) .bind(source_package) .fetch_all(&*self.pool) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(source_package, error = %e, "UDD bug query failed"); self.last_udd_error = Some(e.to_string()); return; } }; let mut ids = Vec::new(); for row in rows { let Some(id) = u32::try_from(row.id).ok() else { continue; }; ids.push(id); self.bug_details_by_id.put( id, CachedDebbugsBugDetails { title: row.title, severity: row.severity, done: row.done.as_ref().is_some_and(|d| !d.is_empty()), tags: row.tags, forwarded: row.forwarded, originator: row.submitter, }, ); } self.bug_ids_by_package.put(key, ids); } /// Return Debian bug summaries for a source `package` that match a decimal prefix. pub async fn get_bug_summaries_with_prefix( &mut self, package: &str, prefix: &str, ) -> Vec { self.fetch_bugs_for_source_package(package).await; let normalized_prefix = prefix.trim(); let key = format!("src:{}", package); // Snapshot the matching IDs to release the borrow on // `bug_ids_by_package` before walking `bug_details_by_id` // (each `.get()` on the LRU is `&mut self`, so the two // can't be borrowed simultaneously). let matching_ids: Vec = match self.bug_ids_by_package.get(&key) { Some(ids) => ids .iter() .copied() .filter(|id| id.to_string().starts_with(normalized_prefix)) .collect(), None => return Vec::new(), }; matching_ids .into_iter() .map(|id| self.make_summary(id)) .collect() } fn make_summary(&mut self, id: u32) -> DebbugsBugSummary { match self.bug_details_by_id.get(&id) { Some(details) => DebbugsBugSummary { id, title: details.title.clone(), severity: details.severity.clone(), done: details.done, tags: details.tags.clone(), forwarded: details.forwarded.clone(), originator: details.originator.clone(), }, None => DebbugsBugSummary { id, title: None, severity: None, done: false, tags: None, forwarded: None, originator: None, }, } } /// Return a single Debian bug summary by ID, fetching from UDD if not /// already cached. /// Return a cached summary if present, without hitting the network. pub fn get_cached_debian_bug_summary(&mut self, id: u32) -> Option { if self.bug_details_by_id.contains(&id) { Some(self.make_summary(id)) } else { None } } /// Query UDD for a single bug without touching the cache. Returns the raw /// row so the caller can insert it after re-acquiring the lock. pub async fn query_bug_by_id(pool: &crate::udd::SharedPool, id: u32) -> Option { match sqlx::query_as( "SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM bugs_tags t WHERE t.id = b.id) AS tags \ FROM bugs b WHERE b.id = $1 \ UNION ALL \ SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM archived_bugs_tags t WHERE t.id = b.id) AS tags \ FROM archived_bugs b WHERE b.id = $1 \ LIMIT 1", ) .bind(id as i32) .fetch_optional(&**pool) .await { Ok(row) => row, Err(e) => { tracing::warn!(id, error = %e, "UDD single bug query failed"); None } } } /// Insert a raw `BugRow` into the cache. pub fn insert_bug_row(&mut self, id: u32, row: BugRow) { self.bug_details_by_id.put( id, CachedDebbugsBugDetails { title: row.title, severity: row.severity, done: row.done.as_ref().is_some_and(|d| !d.is_empty()), tags: row.tags, forwarded: row.forwarded, originator: row.submitter, }, ); } /// Count open bugs for a source package from cache only, without fetching. /// Read-only: uses `peek` so the cache LRU order is unchanged. /// /// Returns `None` if the source package has not been fetched yet. pub fn get_cached_open_bug_count(&self, source_package: &str) -> Option { let key = format!("src:{}", source_package); let ids = self.bug_ids_by_package.peek(&key)?; Some( ids.iter() .filter(|id| self.bug_details_by_id.peek(id).is_some_and(|d| !d.done)) .count(), ) } /// Count open bugs filed against a binary package from cache only. /// Read-only: uses `peek` so the cache LRU order is unchanged. /// /// Returns `None` if the binary package has not been fetched yet. pub fn get_cached_open_binary_bug_count(&self, binary_package: &str) -> Option { let ids = self.bug_ids_by_package.peek(binary_package)?; Some( ids.iter() .filter(|id| self.bug_details_by_id.peek(id).is_some_and(|d| !d.done)) .count(), ) } /// Return whether bug data for a source package is already in the cache. pub fn is_source_package_cached(&self, source_package: &str) -> bool { let key = format!("src:{}", source_package); self.bug_ids_by_package.contains(&key) } /// Pre-fetch open bug IDs and their details for a source package. /// /// Call this in the background so the data is cached before the user /// triggers completion. pub async fn prefetch_bugs_for_package(&mut self, package: &str) { self.fetch_bugs_for_source_package(package).await; } /// Fetch bugs filed against a binary package name from UDD and cache them. async fn fetch_bugs_for_binary_package(&mut self, binary_package: &str) { if self.bug_ids_by_package.contains(binary_package) { return; } let rows: Vec = match sqlx::query_as( "SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM bugs_tags t WHERE t.id = b.id) AS tags \ FROM bugs b WHERE b.package = $1 \ UNION ALL \ SELECT b.id, b.title, b.severity::text, b.done, b.forwarded, b.submitter, \ (SELECT string_agg(t.tag, ', ') FROM archived_bugs_tags t WHERE t.id = b.id) AS tags \ FROM archived_bugs b WHERE b.package = $1 \ ORDER BY id", ) .bind(binary_package) .fetch_all(&*self.pool) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(binary_package, error = %e, "UDD binary bug query failed"); self.last_udd_error = Some(e.to_string()); return; } }; let mut ids = Vec::new(); for row in rows { let Some(id) = u32::try_from(row.id).ok() else { continue; }; ids.push(id); // Don't overwrite a previously-cached entry — bugs filed // against a binary may already have been seen via the // source package and shouldn't lose their (richer) record. if !self.bug_details_by_id.contains(&id) { self.bug_details_by_id.put( id, CachedDebbugsBugDetails { title: row.title, severity: row.severity, done: row.done.as_ref().is_some_and(|d| !d.is_empty()), tags: row.tags, forwarded: row.forwarded, originator: row.submitter, }, ); } } self.bug_ids_by_package.put(binary_package.to_string(), ids); } /// Pre-fetch bugs filed against a binary package name. pub async fn prefetch_bugs_for_binary_package(&mut self, binary_package: &str) { self.fetch_bugs_for_binary_package(binary_package).await; } #[cfg(test)] pub(crate) fn insert_cached_open_bugs_for_package( &mut self, source_package: &str, bugs: Vec<(u32, Option<&str>)>, ) { let mut sorted_unique_ids = std::collections::BTreeSet::new(); for (id, title) in bugs { sorted_unique_ids.insert(id); self.bug_details_by_id.put( id, CachedDebbugsBugDetails { title: title.map(ToString::to_string), severity: None, done: false, tags: None, forwarded: None, originator: None, }, ); } self.bug_ids_by_package.put( format!("src:{}", source_package), sorted_unique_ids.into_iter().collect(), ); } #[cfg(test)] pub(crate) fn insert_cached_open_bugs_for_binary_package( &mut self, binary_package: &str, bugs: Vec<(u32, Option<&str>)>, ) { let mut sorted_unique_ids = std::collections::BTreeSet::new(); for (id, title) in bugs { sorted_unique_ids.insert(id); self.bug_details_by_id.put( id, CachedDebbugsBugDetails { title: title.map(ToString::to_string), severity: None, done: false, tags: None, forwarded: None, originator: None, }, ); } self.bug_ids_by_package.put( binary_package.to_string(), sorted_unique_ids.into_iter().collect(), ); } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_bug_summaries_with_prefix_from_cache() { let mut cache = BugCache::new(crate::udd::shared_pool()); cache.insert_cached_open_bugs_for_package( "foo", vec![ (123456, Some("Fix crash on startup")), (123499, None), (888888, Some("Unrelated issue")), ], ); let summaries = cache.get_bug_summaries_with_prefix("foo", "1234").await; assert_eq!(summaries.len(), 2); assert_eq!(summaries[0].id, 123456); assert_eq!(summaries[0].title.as_deref(), Some("Fix crash on startup")); assert_eq!(summaries[1].id, 123499); assert_eq!(summaries[1].title, None); } #[tokio::test] #[ignore] // requires network access to UDD async fn test_fetch_bugs_from_udd() { let mut cache = BugCache::new(crate::udd::shared_pool()); let summaries = cache.get_bug_summaries_with_prefix("lintian", "").await; assert!(!summaries.is_empty(), "lintian should have bugs in UDD"); // Every summary should have a title assert!( summaries.iter().any(|s| s.title.is_some()), "at least some bugs should have titles" ); } #[tokio::test] #[ignore] // requires network access to UDD async fn test_query_bug_by_id_from_udd() { let pool = crate::udd::shared_pool(); // Pick a known open lintian bug; if it gets closed pick a different one. let summaries = { let mut cache = BugCache::new(pool.clone()); cache.get_bug_summaries_with_prefix("lintian", "").await }; let open = summaries .iter() .find(|s| !s.done) .expect("lintian should have an open bug"); let id = open.id; let row = BugCache::query_bug_by_id(&pool, id) .await .unwrap_or_else(|| panic!("query_bug_by_id returned None for open bug #{id}")); assert_eq!(row.id as u32, id); assert!(row.title.is_some(), "open bug #{id} should have a title"); } } debian-lsp-0.1.8/src/bugs/launchpad.rs000064400000000000000000000350331046102023000157100ustar 00000000000000#[cfg(feature = "launchpad")] use std::collections::BTreeMap; #[cfg(feature = "launchpad")] use launchpadlib::r#async::v1_0::{Distribution, DistributionSourcePackage}; #[cfg(feature = "launchpad")] use launchpadlib::Resource; use super::BugCache; #[cfg(feature = "launchpad")] use super::CachedLaunchpadBugDetails; /// Launchpad bug data returned to completion providers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchpadBugSummary { /// Numeric Launchpad bug ID. pub id: u32, /// Bug title, when available. pub title: Option, /// Most relevant Launchpad task status, when available. pub status: Option, /// Whether the package-specific Launchpad task is complete. pub done: bool, } #[cfg(feature = "launchpad")] impl BugCache { /// Return a single Launchpad bug summary by ID, fetching directly from the /// Launchpad API if not already cached. pub async fn get_launchpad_bug_summary(&mut self, id: u32) -> Option { if !self.launchpad_bug_details_by_id.contains(&id) { self.fetch_launchpad_bug_by_id(id).await; } if self.launchpad_bug_details_by_id.contains(&id) { Some(self.make_launchpad_summary(id)) } else { None } } /// Fetch a single Launchpad bug by ID and cache it. async fn fetch_launchpad_bug_by_id(&mut self, id: u32) { let service_root = match launchpadlib::r#async::v1_0::service_root(&self.launchpad_client).await { Ok(sr) => sr, Err(e) => { tracing::warn!(error = %e, "Launchpad service root lookup failed"); return; } }; let bugs = match service_root.bugs() { Some(bugs) => bugs, None => { tracing::warn!("Launchpad service root missing bugs link"); return; } }; let bug = match bugs.get_by_id(&self.launchpad_client, id).await { Ok(bug) => bug, Err(e) => { tracing::warn!(id, error = %e, "Launchpad single bug lookup failed"); return; } }; let title = if bug.title.trim().is_empty() { None } else { Some(bug.title.clone()) }; self.launchpad_bug_details_by_id.put( id, CachedLaunchpadBugDetails { title, // Single-bug fetch doesn't give us task status. status: None, done: false, }, ); } /// Return Launchpad bug summaries for `package` that match a decimal prefix. pub async fn get_launchpad_bug_summaries_with_prefix( &mut self, package: &str, prefix: &str, ) -> Vec { self.fetch_launchpad_bugs_for_package(package).await; let normalized_prefix = prefix.trim(); // Snapshot matching IDs so the borrow on // `launchpad_bug_ids_by_package` is released before each // `make_launchpad_summary` call (which mutably borrows // `launchpad_bug_details_by_id` via `LruCache::get`). let matching_ids: Vec = match self.launchpad_bug_ids_by_package.get(package) { Some(ids) => ids .iter() .copied() .filter(|id| id.to_string().starts_with(normalized_prefix)) .collect(), None => return Vec::new(), }; matching_ids .into_iter() .map(|id| self.make_launchpad_summary(id)) .collect() } /// Build a Launchpad bug summary from cached details for `id`. fn make_launchpad_summary(&mut self, id: u32) -> LaunchpadBugSummary { match self.launchpad_bug_details_by_id.get(&id) { Some(details) => LaunchpadBugSummary { id, title: details.title.clone(), status: details.status.clone(), done: details.done, }, None => LaunchpadBugSummary { id, title: None, status: None, done: false, }, } } /// Fetch Launchpad bug IDs and details for an Ubuntu source package. async fn fetch_launchpad_bugs_for_package(&mut self, package: &str) { if self.launchpad_bug_ids_by_package.contains(package) { return; } let distribution = match self.launchpad_ubuntu_distribution().await { Some(distribution) => distribution, None => { return; } }; let source_package_full = match distribution .get_source_package(&self.launchpad_client, package) .await { Ok(source_package) => source_package, Err(e) => { tracing::warn!(package, error = %e, "Launchpad source package lookup failed"); return; } }; let source_package = match source_package_full.self_() { Some(source_package) => source_package, None => { tracing::warn!( package, "Launchpad source package response missing self link" ); return; } }; let bug_details_by_id = match self.search_launchpad_tasks(&source_package).await { Some(details) => details, None => return, }; let mut ids: Vec = bug_details_by_id.keys().copied().collect(); ids.sort_unstable(); for (id, details) in bug_details_by_id { self.launchpad_bug_details_by_id.put(id, details); } self.launchpad_bug_ids_by_package .put(package.to_string(), ids); } /// Query Launchpad bug tasks for a source package and index them by bug ID. async fn search_launchpad_tasks( &self, source_package: &DistributionSourcePackage, ) -> Option> { let mut tasks = match source_package .search_tasks( &self.launchpad_client, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, ) .await { Ok(tasks) => tasks, Err(e) => { tracing::warn!(error = %e, "Launchpad bug query failed"); return None; } }; let mut by_id: BTreeMap = BTreeMap::new(); let source_package_url = source_package.url().clone(); let mut index = 0usize; loop { let task = match tasks.get(index).await { Ok(task) => task, Err(e) => { tracing::warn!(error = %e, "Launchpad bug page iteration failed"); return None; } }; let Some(task) = task else { break; }; index += 1; // Keep only the task for the source package we're querying. if task.target_link != source_package_url { continue; } let bug = match task.bug().get(&self.launchpad_client).await { Ok(bug) => bug, Err(e) => { tracing::warn!(error = %e, "Launchpad bug lookup failed"); continue; } }; let Some(id) = u32::try_from(bug.id).ok() else { continue; }; let title = if bug.title.trim().is_empty() { None } else { Some(bug.title.clone()) }; let status_value = task.status.to_string(); let status = if status_value.trim().is_empty() { None } else { Some(status_value) }; let done = task.is_complete; match by_id.get_mut(&id) { Some(existing) => { existing.done = done; if existing.title.is_none() && title.is_some() { existing.title = title.clone(); } if existing.status.is_none() && status.is_some() { existing.status = status.clone(); } } None => { by_id.insert( id, CachedLaunchpadBugDetails { title, status, done, }, ); } } } Some(by_id) } /// Resolve the Launchpad `ubuntu` distribution resource. async fn launchpad_ubuntu_distribution(&self) -> Option { let service_root = match launchpadlib::r#async::v1_0::service_root(&self.launchpad_client).await { Ok(service_root) => service_root, Err(e) => { tracing::warn!(error = %e, "Launchpad service root lookup failed"); return None; } }; let distributions = match service_root.distributions() { Some(distributions) => distributions, None => { tracing::warn!("Launchpad service root missing distributions link"); return None; } }; let distribution_full = match distributions .get_by_name(&self.launchpad_client, "ubuntu") .await { Ok(distribution) => distribution, Err(e) => { tracing::warn!(error = %e, "Launchpad ubuntu distribution lookup failed"); return None; } }; let distribution = match distribution_full.self_() { Some(distribution) => distribution, None => { tracing::warn!("Launchpad ubuntu distribution response missing self link"); return None; } }; Some(distribution) } /// Return whether Launchpad bug data for a package is already in the cache. pub fn is_launchpad_package_cached(&self, package: &str) -> bool { self.launchpad_bug_ids_by_package.contains(package) } /// Pre-fetch Launchpad bug IDs and their details for an Ubuntu source package. pub async fn prefetch_launchpad_bugs_for_package(&mut self, package: &str) { self.fetch_launchpad_bugs_for_package(package).await; } #[cfg(test)] pub(crate) fn insert_cached_launchpad_bugs_for_package( &mut self, package: &str, bugs: Vec<(u32, Option<&str>, Option<&str>, bool)>, ) { let mut sorted_unique_ids = std::collections::BTreeSet::new(); for (id, title, status, done) in bugs { sorted_unique_ids.insert(id); self.launchpad_bug_details_by_id.put( id, CachedLaunchpadBugDetails { title: title.map(ToString::to_string), status: status.map(ToString::to_string), done, }, ); } self.launchpad_bug_ids_by_package .put(package.to_string(), sorted_unique_ids.into_iter().collect()); } } #[cfg(not(feature = "launchpad"))] impl BugCache { /// Return a single Launchpad bug summary by ID. pub async fn get_launchpad_bug_summary(&mut self, _id: u32) -> Option { None } /// Return Launchpad bug summaries for `package` that match a decimal prefix. pub async fn get_launchpad_bug_summaries_with_prefix( &mut self, _package: &str, _prefix: &str, ) -> Vec { Vec::new() } /// Return whether Launchpad bug data for a package is already in the cache. pub fn is_launchpad_package_cached(&self, _package: &str) -> bool { false } /// Pre-fetch Launchpad bug IDs and their details for an Ubuntu source package. pub async fn prefetch_launchpad_bugs_for_package(&mut self, _package: &str) {} #[cfg(test)] pub(crate) fn insert_cached_launchpad_bugs_for_package( &mut self, _package: &str, _bugs: Vec<(u32, Option<&str>, Option<&str>, bool)>, ) { } } #[cfg(all(test, feature = "launchpad"))] mod tests { use super::*; #[tokio::test] async fn test_get_launchpad_bug_summaries_with_prefix_from_cache() { let mut cache = BugCache::new(crate::udd::shared_pool()); cache.insert_cached_launchpad_bugs_for_package( "foo", vec![ (123456, Some("Launchpad crash report"), Some("New"), false), (123499, None, Some("Fix Released"), true), (888888, Some("Unrelated issue"), Some("Confirmed"), false), ], ); let summaries = cache .get_launchpad_bug_summaries_with_prefix("foo", "1234") .await; assert_eq!(summaries.len(), 2); assert_eq!(summaries[0].id, 123456); assert_eq!( summaries[0].title.as_deref(), Some("Launchpad crash report") ); assert_eq!(summaries[0].status.as_deref(), Some("New")); assert!(!summaries[0].done); assert_eq!(summaries[1].id, 123499); assert_eq!(summaries[1].title, None); assert_eq!(summaries[1].status.as_deref(), Some("Fix Released")); assert!(summaries[1].done); } #[tokio::test] async fn test_launchpad_bug_summary_done_is_package_specific() { let mut cache = BugCache::new(crate::udd::shared_pool()); cache.insert_cached_launchpad_bugs_for_package( "foo", vec![(123456, Some("Launchpad crash report"), Some("New"), false)], ); let summaries = cache .get_launchpad_bug_summaries_with_prefix("foo", "") .await; assert_eq!(summaries.len(), 1); assert!(!summaries[0].done); } } debian-lsp-0.1.8/src/bugs.rs000064400000000000000000000071211046102023000137460ustar 00000000000000use std::num::NonZeroUsize; use std::sync::Arc; use lru::LruCache; use tokio::sync::RwLock; mod debbugs; mod launchpad; pub use debbugs::DebbugsBugSummary; pub use launchpad::LaunchpadBugSummary; /// Thread-safe shared cache for bug tracker lookups. pub type SharedBugCache = Arc>; #[cfg(feature = "launchpad")] const LAUNCHPAD_CONSUMER_KEY: &str = "debian-lsp"; /// Maximum number of distinct package keys cached. Each entry stores /// a `Vec` of bug IDs (typically <100), so the cap is generous; /// the LRU exists to bound long-session growth, not steady-state size. const BUG_IDS_CACHE_CAPACITY: usize = 1024; /// Maximum number of bug detail records cached. Each record holds a /// handful of `Option` fields. Bugs are referenced repeatedly /// once seen, so the cap is sized to comfortably hold every bug from /// a few hundred packages without evictions. const BUG_DETAILS_CACHE_CAPACITY: usize = 32_768; /// Cached bug data used by changelog completions. pub struct BugCache { pub pool: crate::udd::SharedPool, bug_ids_by_package: LruCache>, bug_details_by_id: LruCache, /// Last UDD connection error, set by fetch methods and drained by callers /// that want to surface it (e.g. via `window/showMessage`). pub last_udd_error: Option, #[cfg(feature = "launchpad")] launchpad_client: launchpadlib::r#async::Client, #[cfg(feature = "launchpad")] launchpad_bug_ids_by_package: LruCache>, #[cfg(feature = "launchpad")] launchpad_bug_details_by_id: LruCache, } /// Cached details for a single Debian bug report. #[derive(Debug, Clone, PartialEq, Eq)] struct CachedDebbugsBugDetails { title: Option, severity: Option, done: bool, tags: Option, forwarded: Option, originator: Option, } /// Cached details for a Launchpad bug relevant to completion. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg(feature = "launchpad")] struct CachedLaunchpadBugDetails { title: Option, status: Option, done: bool, } #[derive(sqlx::FromRow)] pub struct BugRow { id: i32, title: Option, severity: Option, done: Option, tags: Option, forwarded: Option, submitter: Option, } impl BugCache { /// Create a new bug cache using the given UDD connection pool. pub fn new(pool: crate::udd::SharedPool) -> Self { Self { pool, bug_ids_by_package: LruCache::new( NonZeroUsize::new(BUG_IDS_CACHE_CAPACITY).expect("non-zero capacity"), ), bug_details_by_id: LruCache::new( NonZeroUsize::new(BUG_DETAILS_CACHE_CAPACITY).expect("non-zero capacity"), ), last_udd_error: None, #[cfg(feature = "launchpad")] launchpad_client: launchpadlib::r#async::Client::anonymous(LAUNCHPAD_CONSUMER_KEY), #[cfg(feature = "launchpad")] launchpad_bug_ids_by_package: LruCache::new( NonZeroUsize::new(BUG_IDS_CACHE_CAPACITY).expect("non-zero capacity"), ), #[cfg(feature = "launchpad")] launchpad_bug_details_by_id: LruCache::new( NonZeroUsize::new(BUG_DETAILS_CACHE_CAPACITY).expect("non-zero capacity"), ), } } } /// Create a new shared cache for bug data from UDD. pub fn new_shared_bug_cache(pool: crate::udd::SharedPool) -> SharedBugCache { Arc::new(RwLock::new(BugCache::new(pool))) } debian-lsp-0.1.8/src/changelog/actions.rs000064400000000000000000000172321046102023000164010ustar 00000000000000use chrono::Local; use rowan::ast::AstNode; use tower_lsp_server::ls_types::*; use crate::position::Source; pub const ADD_CHANGELOG_ENTRY_COMMAND: &str = "debian-lsp.addChangelogEntry"; /// Return a palette command entry for "Add new changelog entry". /// /// This is intentionally a `Command` (not a `CodeAction`) so that VS Code /// only surfaces it via the command palette, not the automatic lightbulb. pub fn get_add_changelog_entry_command(uri: &Uri) -> CodeActionOrCommand { CodeActionOrCommand::Command(Command { title: "Add new changelog entry".to_string(), command: ADD_CHANGELOG_ENTRY_COMMAND.to_string(), arguments: Some(vec![serde_json::json!(uri.as_str())]), }) } /// Generate a TextEdit that updates the timestamp of the first UNRELEASED entry /// to the current time. Returns `None` if the first entry is not UNRELEASED or /// has no timestamp. pub fn generate_timestamp_update_edit( changelog: &debian_changelog::ChangeLog, src: Source<'_>, ) -> Option { let entry = changelog.iter().next()?; // Only update UNRELEASED entries let dists = entry.distributions()?; if dists.is_empty() || dists[0] != "UNRELEASED" { return None; } // Find the Timestamp node in the entry footer let footer = entry.syntax().children().find_map(|n| { if n.kind() == debian_changelog::SyntaxKind::ENTRY_FOOTER { Some(n) } else { None } })?; let timestamp_node = footer .children() .find(|n| n.kind() == debian_changelog::SyntaxKind::TIMESTAMP)?; let old_timestamp = timestamp_node.text().to_string(); let new_timestamp = Local::now().format("%a, %d %b %Y %H:%M:%S %z").to_string(); // Don't generate an edit if the timestamp hasn't changed (same second) if old_timestamp.trim() == new_timestamp.trim() { return None; } let range = src.text_range_to_lsp_range(timestamp_node.text_range()); Some(TextEdit { range, new_text: new_timestamp, }) } /// Determine the appropriate distribution to use when marking an entry for upload pub fn get_target_distribution(changelog: &debian_changelog::ChangeLog) -> String { // Look for the most recent released entry (not UNRELEASED) changelog .iter() .skip(1) // Skip the first entry .find_map(|entry| { entry.distributions().and_then(|dists| { if !dists.is_empty() && dists[0] != "UNRELEASED" { Some(dists[0].clone()) } else { None } }) }) .unwrap_or_else(|| "unstable".to_string()) } /// Generates a new changelog entry text with incremented debian revision pub fn generate_new_changelog_entry( current_changelog: &debian_changelog::ChangeLog, ) -> Result { let mut changelog = current_changelog.clone(); let entry = changelog .new_entry() .urgency(debian_changelog::Urgency::Medium) .change_line("* ".to_string()) .finish(); Ok(format!("{}\n", entry)) } #[cfg(test)] mod tests { use super::*; use std::env; #[test] fn test_generate_timestamp_update_edit_unreleased() { let changelog_text = r#"foo (1.0-2) UNRELEASED; urgency=medium * New changes. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let idx = crate::position::LineIndex::new(changelog_text); let edit = generate_timestamp_update_edit(&changelog, Source::new(changelog_text, &idx)); assert!( edit.is_some(), "should generate an edit for UNRELEASED entry" ); let edit = edit.unwrap(); assert!( edit.new_text.contains(", "), "new timestamp should be a valid date: {}", edit.new_text ); // Verify the range points to the timestamp on line 4 assert_eq!(edit.range.start.line, 4); } #[test] fn test_generate_timestamp_update_edit_released() { let changelog_text = r#"foo (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let idx = crate::position::LineIndex::new(changelog_text); let edit = generate_timestamp_update_edit(&changelog, Source::new(changelog_text, &idx)); assert!( edit.is_none(), "should not generate an edit for released entry" ); } #[test] fn test_generate_new_changelog_entry() { let changelog_text = r#"foo (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); // Set environment variables for predictable maintainer info env::set_var("DEBFULLNAME", "Test User"); env::set_var("DEBEMAIL", "test@example.com"); let new_entry = generate_new_changelog_entry(&changelog).unwrap(); // Parse the generated entry to verify structure let lines: Vec<&str> = new_entry.lines().collect(); // Check the header line has correct package and version with UNRELEASED assert_eq!(lines[0], "foo (1.0-2) UNRELEASED; urgency=medium"); // Check empty line after header assert_eq!(lines[1], ""); // Check bullet point line assert_eq!(lines[2], " * "); // Check empty line before signature assert_eq!(lines[3], ""); // Check signature line starts correctly assert!(lines[4].starts_with(" -- Test User ")); // Clean up environment env::remove_var("DEBFULLNAME"); env::remove_var("DEBEMAIL"); } #[test] fn test_get_target_distribution_from_previous_entry() { let changelog_text = r#"foo (1.0-2) UNRELEASED; urgency=medium * New changes. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let target = get_target_distribution(&changelog); assert_eq!(target, "unstable"); } #[test] fn test_get_target_distribution_defaults_to_unstable() { let changelog_text = r#"foo (1.0-1) UNRELEASED; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let target = get_target_distribution(&changelog); assert_eq!(target, "unstable"); } #[test] fn test_get_target_distribution_skips_unreleased_entries() { let changelog_text = r#"foo (1.0-3) UNRELEASED; urgency=medium * Latest changes. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 foo (1.0-2) UNRELEASED; urgency=medium * More changes. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 foo (1.0-1) experimental; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let target = get_target_distribution(&changelog); assert_eq!(target, "experimental"); } } debian-lsp-0.1.8/src/changelog/completion.rs000064400000000000000000001147111046102023000171120ustar 00000000000000use std::collections::BTreeSet; use debian_changelog::bugs::BugTracker; use rowan::ast::AstNode; use text_size::{TextRange, TextSize}; use tower_lsp_server::ls_types::{ CompletionItem, CompletionItemKind, CompletionItemTag, Documentation, Position, }; use super::fields::{get_debian_distributions, URGENCY_LEVELS}; use crate::bugs::{DebbugsBugSummary, LaunchpadBugSummary, SharedBugCache}; use crate::position::Source; #[derive(Debug, Clone, PartialEq, Eq)] enum CursorContext { Package { value_prefix: String, }, Distribution { value_prefix: String, }, Urgency { value_prefix: String, }, BugNumber { tracker: BugTracker, package_name: Option, value_prefix: String, }, } /// Get completion items for a changelog file at the given cursor position. /// /// Uses changelog CST context to return only relevant value completions: /// package names at header start, distributions in header distribution /// position, urgency levels for `urgency=` metadata values, and local /// changelog bug numbers in `Closes: #...` and `LP: #...` detail contexts. pub fn get_completions( parse: &debian_changelog::Parse, src: Source<'_>, position: Position, ) -> Vec { // Use syntax_node() + cast so completion keeps working on syntactically // invalid/incomplete input while the user is typing. let Some(changelog) = debian_changelog::ChangeLog::cast(parse.syntax_node()) else { return Vec::new(); }; match get_cursor_context(&changelog, src, position) { Some(CursorContext::Package { value_prefix }) => { get_package_completions(&changelog, &value_prefix) } Some(CursorContext::Distribution { value_prefix }) => { get_distribution_completions(&value_prefix) } Some(CursorContext::Urgency { value_prefix }) => get_urgency_completions(&value_prefix), Some(CursorContext::BugNumber { tracker, value_prefix, .. }) => match tracker { BugTracker::Debian => get_local_debian_bug_completions(&changelog, &value_prefix), BugTracker::Launchpad => get_local_launchpad_bug_completions(&changelog, &value_prefix), }, None => Vec::new(), } } /// Get bug-number completions for changelog bug-reference contexts /// (`Closes: #...` and `LP: #...`) using local changelog data and /// cached tracker lookups. /// /// Returns `None` when the cursor is not in bug-number context. When the /// tracker cache is cold the local-only completions are returned immediately /// with `is_incomplete = true` so the client re-requests once the background /// prefetch has warmed the cache. pub async fn get_async_bug_completions( parse: &debian_changelog::Parse, src: Source<'_>, position: Position, bug_cache: &SharedBugCache, ) -> Option<(Vec, bool)> { // Keep all CST-backed values in a short scope and drop them before await // so this future remains Send for tower-lsp. let (tracker, package_name, value_prefix, local) = { let changelog = debian_changelog::ChangeLog::cast(parse.syntax_node())?; let CursorContext::BugNumber { tracker, package_name, value_prefix, } = get_cursor_context(&changelog, src, position)? else { return None; }; let local = match tracker { BugTracker::Debian => get_local_debian_bug_completions(&changelog, &value_prefix), BugTracker::Launchpad => get_local_launchpad_bug_completions(&changelog, &value_prefix), }; (tracker, package_name, value_prefix, local) }; let Some(package_name) = package_name else { return Some((local, false)); }; // Check whether the tracker cache is already warm for this package. // If cold, return local results immediately and let the client re-request // once the background prefetch completes. let cache_warm = { let cache = bug_cache.read().await; match tracker { BugTracker::Debian => cache.is_source_package_cached(&package_name), BugTracker::Launchpad => cache.is_launchpad_package_cached(&package_name), } }; if !cache_warm { return Some((local, true)); } let normalized_prefix = value_prefix.trim(); let remote_completions = match tracker { BugTracker::Debian => { let mut summaries = bug_cache .write() .await .get_bug_summaries_with_prefix(&package_name, &value_prefix) .await; // Sort: open bugs first, then by bug ID descending (highest/newest first). summaries.sort_by(|a, b| a.done.cmp(&b.done).then(b.id.cmp(&a.id))); summaries .into_iter() .map(|summary| remote_debian_bug_completion(&summary, normalized_prefix)) .collect() } BugTracker::Launchpad => { let mut summaries = bug_cache .write() .await .get_launchpad_bug_summaries_with_prefix(&package_name, &value_prefix) .await; // Sort: open bugs first, then by bug ID descending (highest/newest first). summaries.sort_by(|a, b| a.done.cmp(&b.done).then(b.id.cmp(&a.id))); summaries .into_iter() .map(|summary| remote_launchpad_bug_completion(&summary, normalized_prefix)) .collect() } }; Some((merge_unique_completions(remote_completions, local), false)) } fn get_cursor_context( changelog: &debian_changelog::ChangeLog, src: Source<'_>, position: Position, ) -> Option { let offset = src.try_position_to_offset(position)?; let entry = changelog.entry_at_offset(offset)?; if let Some(header) = entry.header() { if let Some(value_prefix) = package_prefix_at_offset(&header, offset) { return Some(CursorContext::Package { value_prefix }); } if let Some(value_prefix) = distribution_prefix_at_offset(&header, offset) { return Some(CursorContext::Distribution { value_prefix }); } if let Some(value_prefix) = urgency_prefix_at_offset(&header, offset) { return Some(CursorContext::Urgency { value_prefix }); } } if let Some((tracker, value_prefix)) = entry.bug_prefix_at_offset(offset) { return Some(CursorContext::BugNumber { tracker, package_name: entry.package(), value_prefix, }); } None } fn package_prefix_at_offset( header: &debian_changelog::EntryHeader, offset: TextSize, ) -> Option { let header_range = header.syntax().text_range(); let version_start = header.syntax().children_with_tokens().find_map(|it| { let token = it.as_token()?; if token.kind() == debian_changelog::SyntaxKind::VERSION { Some(token.text_range().start()) } else { None } }); let package_slot_end = version_start.unwrap_or_else(|| header_range.end()); // Package appears at the start of the header, before VERSION. if header_range.start() == package_slot_end { return if offset == header_range.start() { Some(String::new()) } else { None }; } if offset < header_range.start() || offset > package_slot_end { return None; } if version_start.is_some_and(|start| offset >= start) { return None; } let package_token = match header.syntax().token_at_offset(offset) { rowan::TokenAtOffset::Single(token) => Some(token), rowan::TokenAtOffset::Between(left, right) => { if left.kind() == debian_changelog::SyntaxKind::IDENTIFIER && left.text_range().end() <= package_slot_end { Some(left) } else if right.kind() == debian_changelog::SyntaxKind::IDENTIFIER && right.text_range().end() <= package_slot_end { Some(right) } else { None } } rowan::TokenAtOffset::None => None, }; if let Some(token) = package_token { if token.kind() != debian_changelog::SyntaxKind::IDENTIFIER || token.text_range().end() > package_slot_end { return Some(String::new()); } if offset <= token.text_range().start() { return Some(String::new()); } let prefix_end = std::cmp::min(offset, token.text_range().end()); return Some(token_prefix(token.text(), token.text_range(), prefix_end)); } Some(String::new()) } /// Check whether `offset` falls within `range`, inclusive at both ends. /// /// Unlike [`TextRange::contains`] (which is end-exclusive), this returns /// `true` when the cursor is right at the end of the range — the typical /// position when the user has finished typing a token and the cursor sits /// between the last character and the next delimiter. fn range_contains_inclusive(range: TextRange, offset: TextSize) -> bool { if range.start() == range.end() { offset == range.start() } else { offset >= range.start() && offset <= range.end() } } fn distribution_prefix_at_offset( header: &debian_changelog::EntryHeader, offset: TextSize, ) -> Option { let distributions = header .syntax() .children() .find(|n| n.kind() == debian_changelog::SyntaxKind::DISTRIBUTIONS)?; let range = distributions.text_range(); if !range_contains_inclusive(range, offset) { // If distributions are currently empty, still offer completions when the // cursor is between the version and semicolon (on whitespaces). if range.start() == range.end() && offset < range.start() { let version_end = header.syntax().children_with_tokens().find_map(|it| { let token = it.as_token()?; if token.kind() == debian_changelog::SyntaxKind::VERSION { Some(token.text_range().end()) } else { None } }); if version_end.is_some_and(|end| offset >= end) { return Some(String::new()); } } return None; } if range.start() == range.end() { return Some(String::new()); } let token = match distributions.token_at_offset(offset) { rowan::TokenAtOffset::Single(token) => Some(token), rowan::TokenAtOffset::Between(left, right) => { if left.kind() == debian_changelog::SyntaxKind::IDENTIFIER { Some(left) } else { Some(right) } } rowan::TokenAtOffset::None => None, }; if let Some(token) = token { if token.kind() == debian_changelog::SyntaxKind::IDENTIFIER { return Some(token_prefix(token.text(), token.text_range(), offset)); } } Some(String::new()) } fn urgency_prefix_at_offset( header: &debian_changelog::EntryHeader, offset: TextSize, ) -> Option { for metadata in header.metadata_nodes() { if !metadata .key() .is_some_and(|key| key.eq_ignore_ascii_case("urgency")) { continue; } let value_node = metadata .syntax() .children() .find(|n| n.kind() == debian_changelog::SyntaxKind::METADATA_VALUE); if let Some(value_node) = value_node { let value_range = value_node.text_range(); if !range_contains_inclusive(value_range, offset) { continue; } if value_range.start() == value_range.end() { return Some(String::new()); } let prefix_end = std::cmp::min(offset, value_range.end()); let token = match value_node.token_at_offset(prefix_end) { rowan::TokenAtOffset::Single(token) => Some(token), rowan::TokenAtOffset::Between(left, right) => { if left.kind() == debian_changelog::SyntaxKind::IDENTIFIER { Some(left) } else if right.kind() == debian_changelog::SyntaxKind::IDENTIFIER { Some(right) } else { None } } rowan::TokenAtOffset::None => None, }; if let Some(token) = token { if token.kind() == debian_changelog::SyntaxKind::IDENTIFIER { return Some(token_prefix(token.text(), token.text_range(), prefix_end)); } } return Some(String::new()); } // In incomplete input like `urgency=`, parser may produce no METADATA_VALUE. // Offer urgency completions when cursor is positioned right after '='. let equals = metadata .syntax() .children_with_tokens() .filter_map(|it| it.as_token().cloned()) .find(|token| token.kind() == debian_changelog::SyntaxKind::EQUALS); if let Some(equals) = equals { let metadata_range = metadata.syntax().text_range(); if offset >= equals.text_range().end() && offset <= metadata_range.end() { return Some(String::new()); } } else { continue; } } None } fn token_prefix(token_text: &str, token_range: TextRange, offset: TextSize) -> String { let relative_end: usize = (std::cmp::min(offset, token_range.end()) - token_range.start()).into(); let mut prefix_end = std::cmp::min(relative_end, token_text.len()); while !token_text.is_char_boundary(prefix_end) { prefix_end -= 1; } token_text[..prefix_end].to_string() } /// Get completion items for package names from existing changelog entries. fn get_package_completions( changelog: &debian_changelog::ChangeLog, prefix: &str, ) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); let mut package_names = BTreeSet::new(); for entry in changelog.iter() { if let Some(package_name) = entry.package() { package_names.insert(package_name.to_string()); } } package_names .into_iter() .filter(|name| name.to_ascii_lowercase().starts_with(&normalized_prefix)) .map(|name| CompletionItem { label: name.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some("Debian source package".to_string()), documentation: Some(Documentation::String(format!( "Debian source package: {}", name ))), insert_text: Some(name), ..Default::default() }) .collect() } /// Suggest Debian bug references found in changelog history. /// /// These bugs appeared in `Closes:` lines in past entries, so they were /// closed at release time. Mark them struck-through so the user can /// distinguish them from currently-open bugs once UDD data arrives. fn get_local_debian_bug_completions( changelog: &debian_changelog::ChangeLog, prefix: &str, ) -> Vec { let mut bug_ids = BTreeSet::new(); for entry in changelog.iter() { let lines: Vec<_> = entry.change_lines().collect(); let line_refs: Vec<_> = lines.iter().map(|line| line.as_str()).collect(); bug_ids.extend(debian_changelog::changes::find_closed_debian_bugs( &line_refs, )); } let normalized_prefix = prefix.trim(); bug_ids .into_iter() .map(|id| id.to_string()) .filter(|id| id.starts_with(normalized_prefix)) .map(|id| CompletionItem { label: format!("#{}", id), kind: Some(CompletionItemKind::REFERENCE), detail: Some("Debian bug (from changelog history)".to_string()), tags: Some(vec![CompletionItemTag::DEPRECATED]), insert_text: Some( id.strip_prefix(normalized_prefix) .unwrap_or(&id) .to_string(), ), ..Default::default() }) .collect() } /// Suggest Launchpad bug references found in changelog history. /// /// These bugs appeared in `LP:` lines in past entries, so they were /// closed at release time. Mark them struck-through so the user can /// distinguish them from currently-open bugs once Launchpad data arrives. fn get_local_launchpad_bug_completions( changelog: &debian_changelog::ChangeLog, prefix: &str, ) -> Vec { let mut bug_ids = BTreeSet::new(); for entry in changelog.iter() { let lines: Vec<_> = entry.change_lines().collect(); let line_refs: Vec<_> = lines.iter().map(|line| line.as_str()).collect(); bug_ids.extend(debian_changelog::changes::find_closed_launchpad_bugs( &line_refs, )); } let normalized_prefix = prefix.trim(); bug_ids .into_iter() .map(|id| id.to_string()) .filter(|id| id.starts_with(normalized_prefix)) .map(|id| CompletionItem { label: format!("#{}", id), kind: Some(CompletionItemKind::REFERENCE), detail: Some("Launchpad bug (from changelog history)".to_string()), tags: Some(vec![CompletionItemTag::DEPRECATED]), insert_text: Some( id.strip_prefix(normalized_prefix) .unwrap_or(&id) .to_string(), ), ..Default::default() }) .collect() } fn merge_unique_completions( first: Vec, second: Vec, ) -> Vec { let mut seen = BTreeSet::new(); let mut items: Vec = first .into_iter() .chain(second) .filter(|item| seen.insert(item.label.clone())) .collect(); // Re-assign sort_text after merging so open bugs always precede closed // ones regardless of source. Closed items carry CompletionItemTag::DEPRECATED. for item in &mut items { let closed = item .tags .as_deref() .is_some_and(|tags| tags.contains(&CompletionItemTag::DEPRECATED)); item.sort_text = Some(format!("{}:{}", if closed { 1 } else { 0 }, item.label)); } items } /// Build a completion item from a Debian bug summary. fn remote_debian_bug_completion( summary: &DebbugsBugSummary, normalized_prefix: &str, ) -> CompletionItem { let id_str = summary.id.to_string(); let detail_text = match &summary.title { Some(title) => format!("Debian bug (from UDD): {}", title), None => "Debian bug (from UDD)".to_string(), }; CompletionItem { label: format!("#{}", id_str), kind: Some(CompletionItemKind::REFERENCE), detail: Some(detail_text), documentation: Some(Documentation::String(debian_bug_summary_documentation( summary, ))), tags: if summary.done { Some(vec![CompletionItemTag::DEPRECATED]) } else { None }, insert_text: Some( id_str .strip_prefix(normalized_prefix) .unwrap_or(&id_str) .to_string(), ), ..Default::default() } } /// Build a completion item from a Launchpad bug summary. fn remote_launchpad_bug_completion( summary: &LaunchpadBugSummary, normalized_prefix: &str, ) -> CompletionItem { let id_str = summary.id.to_string(); let detail_text = match &summary.title { Some(title) => format!("Launchpad bug: {}", title), None => "Launchpad bug".to_string(), }; CompletionItem { label: format!("#{}", id_str), kind: Some(CompletionItemKind::REFERENCE), detail: Some(detail_text), documentation: Some(Documentation::String(launchpad_bug_summary_documentation( summary, ))), tags: if summary.done { Some(vec![CompletionItemTag::DEPRECATED]) } else { None }, insert_text: Some( id_str .strip_prefix(normalized_prefix) .unwrap_or(&id_str) .to_string(), ), ..Default::default() } } fn debian_bug_summary_documentation(summary: &DebbugsBugSummary) -> String { let mut parts = Vec::new(); parts.push(format!("https://bugs.debian.org/{}", summary.id)); if let Some(severity) = &summary.severity { parts.push(format!("Severity: {}", severity)); } if summary.done { parts.push("Status: done".to_string()); } if let Some(originator) = &summary.originator { if !originator.is_empty() { parts.push(format!("Reported by: {}", originator)); } } if let Some(tags) = &summary.tags { if !tags.is_empty() { parts.push(format!("Tags: {}", tags)); } } if let Some(forwarded) = &summary.forwarded { if !forwarded.is_empty() { parts.push(format!("Forwarded: {}", forwarded)); } } parts.join("\n") } fn launchpad_bug_summary_documentation(summary: &LaunchpadBugSummary) -> String { let mut parts = Vec::new(); parts.push(format!("https://bugs.launchpad.net/bugs/{}", summary.id)); if let Some(status) = &summary.status { parts.push(format!("Status: {}", status)); } parts.push(if summary.done { "Completion: complete".to_string() } else { "Completion: open".to_string() }); parts.join("\n") } /// Get completion items for Debian distributions. pub fn get_distribution_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); get_debian_distributions() .iter() .filter(|dist| dist.to_ascii_lowercase().starts_with(&normalized_prefix)) .map(|dist| CompletionItem { label: dist.clone(), kind: Some(CompletionItemKind::VALUE), detail: crate::distros::get_distribution_detail(dist), documentation: Some(Documentation::String(format!( "Target distribution: {}", dist ))), insert_text: Some(dist.clone()), ..Default::default() }) .collect() } /// Get completion items for urgency levels. pub fn get_urgency_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); URGENCY_LEVELS .iter() .filter(|level| level.name.starts_with(&normalized_prefix)) .map(|level| CompletionItem { label: level.name.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some("Urgency level".to_string()), documentation: Some(Documentation::String(level.description.to_string())), insert_text: Some(level.name.to_string()), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; fn parse_for(text: &str) -> debian_changelog::Parse { debian_changelog::ChangeLog::parse(text) } fn position_at(text: &str, byte_offset: usize) -> Position { let idx = crate::position::LineIndex::new(text); idx.offset_to_position(text, TextSize::try_from(byte_offset).unwrap()) } #[test] fn test_get_completions_on_distribution_value() { let text = "foo (1.0-1) un; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("un;").unwrap() + 2; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); assert!(!completions.is_empty()); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"unstable")); assert!(labels.contains(&"UNRELEASED")); } #[test] fn test_get_completions_on_package_value() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("foo (").unwrap() + 2; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["foo"]); assert_eq!( completions[0].detail.as_deref(), Some("Debian source package") ); } #[test] fn test_get_completions_on_empty_distribution_slot_with_whitespace() { let text = "foo (1.0-1) ; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find(" ;").unwrap() + 1; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); assert!(!completions.is_empty()); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"unstable")); } #[test] fn test_get_completions_on_urgency_value() { let text = "foo (1.0-1) unstable; urgency=me\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("urgency=me").unwrap() + "urgency=me".len(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["medium"]); assert_eq!(completions[0].insert_text.as_deref(), Some("medium")); } #[test] fn test_get_completions_on_closes_bug_value() { let text = "\ foo (1.0-2) unstable; urgency=medium * Follow-up release. -- John Doe Mon, 02 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Fix issue. Closes: #123456 -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let offset = text.find("Closes: #12").unwrap() + "Closes: #12".len(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"#123456")); } #[test] fn test_get_completions_on_lp_bug_value() { let text = "\ foo (1.0-2) unstable; urgency=medium * Follow-up release. -- John Doe Mon, 02 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Fix issue. LP: #123456 -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let offset = text.find("LP: #12").unwrap() + "LP: #12".len(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"#123456")); } #[test] fn test_get_completions_on_non_closes_bug_context_returns_empty() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Ref #123456 without closes tag.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("#123456").unwrap() + 4; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); assert!(completions.is_empty()); } #[test] fn test_get_completions_on_non_lp_bug_context_returns_empty() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Ref LP #123456 without colon.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("#123456").unwrap() + 4; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); assert!(completions.is_empty()); } #[test] fn test_get_completions_in_body_returns_empty() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let offset = text.find("Initial").unwrap() + 2; let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); assert!(completions.is_empty()); } #[test] fn test_get_completions_invalid_position_returns_empty() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); // Invalid character on an existing line. let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 5000)); assert!(completions.is_empty()); // Invalid line beyond the end of file. let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(5000, 0)); assert!(completions.is_empty()); } #[test] fn test_get_completions_with_parse_errors_still_returns_contextual_results() { let text = "foo (1.0-1) unstable; urgency=\n"; let parsed = parse_for(text); assert!(!parsed.ok(), "test setup expects parse errors"); let offset = text.find("urgency=").unwrap() + "urgency=".len(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), position_at(text, offset)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"medium")); // Invalid line beyond the end of file. let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(5000, 0)); assert!(completions.is_empty()); } #[test] fn test_distribution_completions_with_prefix() { let completions = get_distribution_completions("un"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"unstable")); assert!(labels.contains(&"UNRELEASED")); } #[test] fn test_package_completions_with_prefix() { let text = "\ foo (2.0-1) unstable; urgency=medium * New release. -- John Doe Mon, 01 Jan 2025 12:00:00 +0000 bar (1.0-1) unstable; urgency=low * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let changelog = parsed.tree(); let completions = get_package_completions(&changelog, "fo"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["foo"]); } #[test] fn test_urgency_completions_with_prefix() { let completions = get_urgency_completions("me"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["medium"]); } #[tokio::test] async fn test_get_async_bug_completions_merges_local_and_remote() { let text = "\ foo (1.0-2) unstable; urgency=medium * Follow-up work. Closes: #12 -- John Doe Mon, 02 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Older fix. Closes: #123456 -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let bug_cache = crate::bugs::new_shared_bug_cache(crate::udd::shared_pool()); { let mut cache = bug_cache.write().await; cache.insert_cached_open_bugs_for_package( "foo", vec![ (123456, Some("Older fix from BTS")), (129999, Some("New regression in foo")), ], ); } let offset = text.find("Closes: #12").unwrap() + "Closes: #12".len(); let idx = crate::position::LineIndex::new(text); let (completions, is_incomplete) = get_async_bug_completions( &parsed, Source::new(text, &idx), position_at(text, offset), &bug_cache, ) .await .expect("bug context should return Some"); assert!( !is_incomplete, "cache was pre-populated, result should be complete" ); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"#123456")); assert!(labels.contains(&"#129999")); let count_123456 = labels.iter().filter(|label| **label == "#123456").count(); assert_eq!(count_123456, 1); assert!(completions.iter().any(|item| { item.label == "#129999" && item .detail .as_deref() .is_some_and(|detail| detail.contains("New regression in foo")) })); assert!(completions.iter().any(|item| { item.label == "#123456" && item .detail .as_deref() .is_some_and(|detail| detail.contains("Older fix from BTS")) })); } #[cfg(feature = "launchpad")] #[tokio::test] async fn test_get_async_bug_completions_merges_local_and_remote_launchpad() { let text = "\ foo (1.0-2) unstable; urgency=medium * Follow-up work. LP: #12 -- John Doe Mon, 02 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Older fix. LP: #123456 -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let bug_cache = crate::bugs::new_shared_bug_cache(crate::udd::shared_pool()); { let mut cache = bug_cache.write().await; cache.insert_cached_launchpad_bugs_for_package( "foo", vec![ ( 123456, Some("Older fix from Launchpad"), Some("Fix Released"), true, ), (129999, Some("New regression in foo"), Some("New"), false), ], ); } let offset = text.find("LP: #12").unwrap() + "LP: #12".len(); let idx = crate::position::LineIndex::new(text); let (completions, is_incomplete) = get_async_bug_completions( &parsed, Source::new(text, &idx), position_at(text, offset), &bug_cache, ) .await .expect("bug context should return Some"); assert!( !is_incomplete, "cache was pre-populated, result should be complete" ); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"#123456")); assert!(labels.contains(&"#129999")); let count_123456 = labels.iter().filter(|label| **label == "#123456").count(); assert_eq!(count_123456, 1); assert!(completions.iter().any(|item| { item.label == "#129999" && item .detail .as_deref() .is_some_and(|detail: &str| detail.contains("New regression in foo")) })); assert!(completions.iter().any(|item| { item.label == "#123456" && item .detail .as_deref() .is_some_and(|detail: &str| detail.contains("Older fix from Launchpad")) })); } #[tokio::test] async fn test_get_async_bug_completions_local_only_no_cache() { let text = "\ foo (1.0-2) unstable; urgency=medium * Fix regression. Closes: # -- John Doe Mon, 02 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Initial fix. Closes: #654321 -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = parse_for(text); let bug_cache = crate::bugs::new_shared_bug_cache(crate::udd::shared_pool()); let offset = text.find("Closes: #\n").unwrap() + "Closes: #".len(); let idx = crate::position::LineIndex::new(text); let (completions, is_incomplete) = get_async_bug_completions( &parsed, Source::new(text, &idx), position_at(text, offset), &bug_cache, ) .await .expect("bug context should return Some"); assert!(is_incomplete, "cache is cold, result should be incomplete"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.contains(&"#654321"), "expected #654321, got {:?}", labels ); } #[tokio::test] async fn test_get_async_bug_completions_returns_none_outside_bug_context() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = parse_for(text); let bug_cache = crate::bugs::new_shared_bug_cache(crate::udd::shared_pool()); let offset = text.find("Initial").unwrap() + 2; let idx = crate::position::LineIndex::new(text); let completions = get_async_bug_completions( &parsed, Source::new(text, &idx), position_at(text, offset), &bug_cache, ) .await; assert!(completions.is_none()); } } debian-lsp-0.1.8/src/changelog/detection.rs000064400000000000000000000031061046102023000167120ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian changelog file pub fn is_changelog_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/changelog") || path.ends_with("/debian/changelog") || path.ends_with("/changelog.dch") || path.ends_with("/debian/changelog.dch") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_changelog_file() { let changelog_paths = vec![ "file:///path/to/debian/changelog", "file:///project/debian/changelog", "file:///changelog", "file:///some/path/changelog", "file:///path/to/debian/changelog.dch", "file:///project/debian/changelog.dch", "file:///changelog.dch", "file:///some/path/changelog.dch", ]; let non_changelog_paths = vec![ "file:///path/to/other.txt", "file:///path/to/changelog.txt", "file:///path/to/mychangelog", "file:///path/to/debian/changelog.backup", ]; for path in changelog_paths { let uri = path.parse::().unwrap(); assert!( is_changelog_file(&uri), "Should detect changelog file: {}", path ); } for path in non_changelog_paths { let uri = path.parse::().unwrap(); assert!( !is_changelog_file(&uri), "Should not detect as changelog file: {}", path ); } } } debian-lsp-0.1.8/src/changelog/fields.rs000064400000000000000000000045331046102023000162070ustar 00000000000000//! Debian changelog field definitions and common values /// Debian urgency levels for changelog entries pub struct UrgencyLevel { pub name: &'static str, pub description: &'static str, } impl UrgencyLevel { pub const fn new(name: &'static str, description: &'static str) -> Self { Self { name, description } } } /// All available urgency levels pub const URGENCY_LEVELS: &[UrgencyLevel] = &[ UrgencyLevel::new("low", "Low urgency update"), UrgencyLevel::new("medium", "Medium urgency update"), UrgencyLevel::new("high", "High urgency update"), UrgencyLevel::new("critical", "Critical urgency update"), UrgencyLevel::new("emergency", "Emergency urgency update"), ]; /// Get Debian distribution names from distro-info-data /// Returns a slice of distribution names (codenames and aliases) pub fn get_debian_distributions() -> &'static [String] { crate::distros::get_all_distributions() } #[cfg(test)] mod tests { use super::*; #[test] fn test_urgency_levels() { assert!(!URGENCY_LEVELS.is_empty()); assert_eq!(URGENCY_LEVELS.len(), 5); let urgency_names: Vec<_> = URGENCY_LEVELS.iter().map(|u| u.name).collect(); assert!(urgency_names.contains(&"low")); assert!(urgency_names.contains(&"medium")); assert!(urgency_names.contains(&"high")); assert!(urgency_names.contains(&"critical")); assert!(urgency_names.contains(&"emergency")); } #[test] fn test_urgency_level_validity() { for level in URGENCY_LEVELS { assert!(!level.name.is_empty()); assert!(!level.description.is_empty()); assert!( level.name.chars().all(|c| c.is_ascii_lowercase()), "Urgency level {} should be lowercase", level.name ); } } #[test] fn test_get_debian_distributions() { let distributions = get_debian_distributions(); assert!(!distributions.is_empty()); // Check that common aliases are present assert!(distributions.contains(&"unstable".to_string())); assert!(distributions.contains(&"stable".to_string())); assert!(distributions.contains(&"testing".to_string())); assert!(distributions.contains(&"UNRELEASED".to_string())); assert!(distributions.contains(&"sid".to_string())); } } debian-lsp-0.1.8/src/changelog/folding.rs000064400000000000000000000073361046102023000163670ustar 00000000000000//! Folding range generation for Debian changelog files. //! //! Each changelog entry becomes a foldable region. use crate::position::Source; use debian_changelog::{ChangeLog, Parse}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{FoldingRange, FoldingRangeKind}; /// Generate folding ranges for a changelog file. /// /// Each entry that spans more than one line produces a `Region` folding range. pub fn generate_folding_ranges(parse: &Parse, src: Source<'_>) -> Vec { let changelog = parse.tree(); changelog .iter() .filter_map(|entry| { let range = src.text_range_to_lsp_range(entry.syntax().text_range()); let end_line = if range.end.character == 0 && range.end.line > range.start.line { range.end.line - 1 } else { range.end.line }; if range.start.line == end_line { return None; } Some(FoldingRange { start_line: range.start.line, start_character: None, end_line, end_character: None, kind: Some(FoldingRangeKind::Region), collapsed_text: None, }) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_single_entry() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges.len(), 1); assert_eq!(ranges[0].start_line, 0); assert_eq!(ranges[0].end_line, 4); } #[test] fn test_multiple_entries() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second release. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) experimental; urgency=low * First release. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges.len(), 2); assert_eq!(ranges[0].start_line, 0); assert_eq!(ranges[0].end_line, 4); assert_eq!(ranges[1].start_line, 6); assert_eq!(ranges[1].end_line, 10); } #[test] fn test_empty_changelog() { let text = ""; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges.len(), 0); } #[test] fn test_ranges_do_not_overlap() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) unstable; urgency=low * First. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges.len(), 2); assert!(ranges[0].end_line < ranges[1].start_line); } #[test] fn test_folding_kind_is_region() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges[0].kind, Some(FoldingRangeKind::Region)); } } debian-lsp-0.1.8/src/changelog/hover.rs000064400000000000000000000255631046102023000160720ustar 00000000000000//! Hover information for debian/changelog files. //! //! Shows bug details when hovering over `Closes: #NNN` or `LP: #NNN` //! references in changelog detail lines. When a UDD/Launchpad connection //! is available the hover includes title, severity/status and other metadata; //! otherwise a plain link to the bug tracker is shown. use debian_changelog::bugs::Bug; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position}; use crate::bugs::{DebbugsBugSummary, LaunchpadBugSummary, SharedBugCache}; use crate::position::Source; /// Get hover information for a bug reference in a changelog file. /// /// Fetches bug details from the cache (populating it from UDD/Launchpad on /// first access for the package). Returns `None` when the cursor is not on a /// bug reference. pub async fn get_hover( parse: &debian_changelog::Parse, src: Source<'_>, position: Position, bug_cache: &SharedBugCache, ) -> Option { // Extract bug ref in a non-Send scope, then drop all CST values before // the first await so the future remains Send. let bug = { let changelog = debian_changelog::ChangeLog::cast(parse.syntax_node())?; let offset = src.try_position_to_offset(position)?; let entry = changelog.entry_at_offset(offset)?; entry.bug_at_offset(offset)? }; match &bug { Bug::Debian(id) => { let summary = debian_bug_summary(bug_cache, *id).await; Some(match summary { Some(s) => make_debian_hover(&s), None => make_fallback_hover(&bug), }) } Bug::Launchpad(id) => { let summary = launchpad_bug_summary(bug_cache, *id).await; Some(match summary { Some(s) => make_launchpad_hover(&s), None => make_fallback_hover(&bug), }) } } } /// Look up a Debian bug summary without holding the cache lock across the /// network call. Checks the in-memory cache first; if absent, clones the /// pool, drops the lock, queries UDD, then re-acquires to insert. async fn debian_bug_summary( bug_cache: &SharedBugCache, id: u32, ) -> Option { // Fast path: already cached. if let Some(s) = bug_cache.write().await.get_cached_debian_bug_summary(id) { return Some(s); } // Slow path: fetch from UDD. The write guard is dropped here before the // network await so other callers aren't blocked. let pool = bug_cache.read().await.pool.clone(); let row = crate::bugs::BugCache::query_bug_by_id(&pool, id).await?; let mut cache = bug_cache.write().await; cache.insert_bug_row(id, row); cache.get_cached_debian_bug_summary(id) } #[cfg(feature = "launchpad")] async fn launchpad_bug_summary(bug_cache: &SharedBugCache, id: u32) -> Option { bug_cache.write().await.get_launchpad_bug_summary(id).await } #[cfg(not(feature = "launchpad"))] async fn launchpad_bug_summary( _bug_cache: &SharedBugCache, _id: u32, ) -> Option { None } /// Minimal hover shown when bug details are not available. fn make_fallback_hover(bug: &Bug) -> Hover { let label = match bug { Bug::Debian(id) => format!("Debian Bug #{}", id), Bug::Launchpad(id) => format!("Launchpad Bug #{}", id), }; Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: format!("**[{}]({})** ", label, bug.url()), }), range: None, } } fn make_debian_hover(summary: &DebbugsBugSummary) -> Hover { let title = summary.title.as_deref().unwrap_or("(no title)"); let mut lines = vec![format!( "**[Debian Bug #{}](https://bugs.debian.org/{})** — {}", summary.id, summary.id, title )]; if let Some(severity) = &summary.severity { lines.push(format!("**Severity:** {}", severity)); } if summary.done { lines.push("**Status:** done".to_string()); } else { lines.push("**Status:** open".to_string()); } if let Some(originator) = &summary.originator { if !originator.is_empty() { lines.push(format!("**Reported by:** {}", originator)); } } if let Some(tags) = &summary.tags { if !tags.is_empty() { lines.push(format!("**Tags:** {}", tags)); } } if let Some(forwarded) = &summary.forwarded { if !forwarded.is_empty() { lines.push(format!("**Forwarded:** {}", forwarded)); } } Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: lines.join("\n\n"), }), range: None, } } fn make_launchpad_hover(summary: &LaunchpadBugSummary) -> Hover { let title = summary.title.as_deref().unwrap_or("(no title)"); let mut lines = vec![format!( "**[Launchpad Bug #{}](https://bugs.launchpad.net/bugs/{})** — {}", summary.id, summary.id, title )]; if let Some(status) = &summary.status { lines.push(format!("**Status:** {}", status)); } lines.push(if summary.done { "**Completion:** complete".to_string() } else { "**Completion:** open".to_string() }); Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: lines.join("\n\n"), }), range: None, } } #[cfg(test)] mod tests { use super::*; use text_size::TextSize; fn parse_for(text: &str) -> debian_changelog::Parse { debian_changelog::ChangeLog::parse(text) } /// Helper: detect the bug ref without going through the async path. fn find_bug_ref(text: &str, byte_offset: usize) -> Option { let parsed = parse_for(text); let changelog = debian_changelog::ChangeLog::cast(parsed.syntax_node())?; let offset = TextSize::try_from(byte_offset).ok()?; let entry = changelog.entry_at_offset(offset)?; entry.bug_at_offset(offset) } #[test] fn test_detect_closes_bug() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Fixed a bug. (Closes: #123456)\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let offset = text.find("#123456").unwrap() + 1; assert_eq!(find_bug_ref(text, offset), Some(Bug::Debian(123456))); } #[test] fn test_detect_lp_bug() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Fixed a bug. (LP: #987654)\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let offset = text.find("#987654").unwrap() + 1; assert_eq!(find_bug_ref(text, offset), Some(Bug::Launchpad(987654))); } #[test] fn test_detect_multiple_closes_first() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Fixed bugs. (Closes: #111, #222)\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let offset = text.find("#111").unwrap() + 1; assert_eq!(find_bug_ref(text, offset), Some(Bug::Debian(111))); } #[test] fn test_detect_multiple_closes_second() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Fixed bugs. (Closes: #111, #222)\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let offset = text.find("#222").unwrap() + 1; assert_eq!(find_bug_ref(text, offset), Some(Bug::Debian(222))); } #[test] fn test_no_bug_on_regular_text() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Just a regular change.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let offset = text.find("regular").unwrap(); assert_eq!(find_bug_ref(text, offset), None); } #[test] fn test_no_bug_on_header() { let text = "foo (1.0-1) unstable; urgency=medium\n\n * Fixed. (Closes: #123)\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; assert_eq!(find_bug_ref(text, 0), None); } /// Extract the markdown value from a Hover, panicking if not markup. fn hover_markdown(hover: &Hover) -> &str { match &hover.contents { HoverContents::Markup(m) => { assert_eq!(m.kind, MarkupKind::Markdown); &m.value } other => panic!("Expected markup content, got {:?}", other), } } #[test] fn test_make_debian_hover_with_details() { let summary = DebbugsBugSummary { id: 123456, title: Some("FTBFS with GCC 14".to_string()), severity: Some("serious".to_string()), done: false, tags: Some("patch".to_string()), forwarded: None, originator: Some("someone@example.com".to_string()), }; let hover = make_debian_hover(&summary); assert_eq!( hover_markdown(&hover), "**[Debian Bug #123456](https://bugs.debian.org/123456)** — FTBFS with GCC 14\n\ \n\ **Severity:** serious\n\ \n\ **Status:** open\n\ \n\ **Reported by:** someone@example.com\n\ \n\ **Tags:** patch" ); } #[test] fn test_make_debian_hover_done() { let summary = DebbugsBugSummary { id: 1, title: Some("Fixed".to_string()), severity: None, done: true, tags: None, forwarded: None, originator: None, }; let hover = make_debian_hover(&summary); assert_eq!( hover_markdown(&hover), "**[Debian Bug #1](https://bugs.debian.org/1)** — Fixed\n\ \n\ **Status:** done" ); } #[test] fn test_make_launchpad_hover_with_details() { let summary = LaunchpadBugSummary { id: 987654, title: Some("Crash on startup".to_string()), status: Some("Confirmed".to_string()), done: false, }; let hover = make_launchpad_hover(&summary); assert_eq!( hover_markdown(&hover), "**[Launchpad Bug #987654](https://bugs.launchpad.net/bugs/987654)** — Crash on startup\n\ \n\ **Status:** Confirmed\n\ \n\ **Completion:** open" ); } #[test] fn test_fallback_hover_debian() { let hover = make_fallback_hover(&Bug::Debian(42)); assert_eq!( hover_markdown(&hover), "**[Debian Bug #42](https://bugs.debian.org/42)** " ); } #[test] fn test_fallback_hover_launchpad() { let hover = make_fallback_hover(&Bug::Launchpad(42)); assert_eq!( hover_markdown(&hover), "**[Launchpad Bug #42](https://bugs.launchpad.net/bugs/42)** " ); } } debian-lsp-0.1.8/src/changelog/inlay_hints.rs000064400000000000000000000154201046102023000172570ustar 00000000000000//! Inlay hints for debian/changelog files. //! //! Shows distribution-to-suite mappings as inlay hints: //! - When distribution is UNRELEASED, shows the target distribution from the previous entry //! - When distribution is an alias (e.g. "unstable"), shows the codename (e.g. "sid") //! - When distribution is a codename (e.g. "trixie"), shows the alias (e.g. "testing") //! //! Suite resolution is date-aware: an entry from 2020 with "stable" will //! resolve to "buster", not the current stable release. use rowan::ast::AstNode; use tower_lsp_server::ls_types::{InlayHint, InlayHintKind, InlayHintLabel}; use crate::position::Source; /// Generate inlay hints for changelog distribution fields. pub fn generate_inlay_hints( parsed: &debian_changelog::Parse, src: Source<'_>, range: &tower_lsp_server::ls_types::Range, ) -> Vec { let changelog = parsed.tree(); let mut hints = Vec::new(); let target_distribution = super::get_target_distribution(&changelog); let text_range = match src.try_lsp_range_to_text_range(range) { Some(r) => r, None => return hints, }; for entry in changelog.entries_in_range(text_range) { let Some(dists) = entry.distributions() else { continue; }; if dists.is_empty() { continue; } let dist = &dists[0]; let hint_text = if dist == "UNRELEASED" { Some(format!("-> {}", target_distribution)) } else { // Use the entry's timestamp for date-aware suite resolution, // falling back to today if the timestamp can't be parsed. let date = entry .datetime() .map(|dt| dt.date_naive()) .unwrap_or_else(|| chrono::Local::now().date_naive()); crate::distros::get_distribution_mapping_at(dist, date) .map(|mapped| format!("= {}", mapped)) }; let Some(hint_text) = hint_text else { continue; }; // Find the position of the distribution text in the entry let entry_text = entry.syntax().text().to_string(); // The distribution appears after ") " in the header let Some(close_paren_offset) = entry_text.find(") ") else { continue; }; let dist_start = close_paren_offset + 2; let dist_end = dist_start + dist.len(); let entry_range = entry.syntax().text_range(); let abs_end = entry_range.start() + text_size::TextSize::from(dist_end as u32); let lsp_range = src.text_range_to_lsp_range(text_size::TextRange::new(abs_end, abs_end)); hints.push(InlayHint { position: lsp_range.start, label: InlayHintLabel::String(hint_text), kind: Some(InlayHintKind::TYPE), text_edits: None, tooltip: None, padding_left: Some(true), padding_right: None, data: None, }); } hints } #[cfg(test)] mod tests { use super::*; #[test] fn test_inlay_hint_for_unreleased() { let changelog_text = r#"foo (1.0-2) UNRELEASED; urgency=medium * New changes. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 foo (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(11, 0), }; let idx = crate::position::LineIndex::new(changelog_text); let hints = generate_inlay_hints(&parsed, Source::new(changelog_text, &idx), &range); // Should have hints for both UNRELEASED (-> unstable) and unstable (= sid) assert_eq!(hints.len(), 2); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "-> unstable"), _ => panic!("Expected string label"), } match &hints[1].label { InlayHintLabel::String(s) => assert_eq!(s, "= sid"), _ => panic!("Expected string label"), } } #[test] fn test_inlay_hint_for_unstable_only() { let changelog_text = r#"foo (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(4, 0), }; let idx = crate::position::LineIndex::new(changelog_text); let hints = generate_inlay_hints(&parsed, Source::new(changelog_text, &idx), &range); assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "= sid"), _ => panic!("Expected string label"), } } #[test] fn test_no_inlay_hint_for_experimental() { let changelog_text = r#"foo (1.0-1) experimental; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(4, 0), }; let idx = crate::position::LineIndex::new(changelog_text); let hints = generate_inlay_hints(&parsed, Source::new(changelog_text, &idx), &range); assert_eq!(hints.len(), 0); } #[test] fn test_stable_resolves_to_date_of_entry() { if !crate::distros::has_distro_info() { return; // distro-info-data not available (e.g. Windows) } // An entry from 2020 with "stable" should resolve to "buster" let changelog_text = r#"foo (1.0-1) stable; urgency=medium * Stable update. -- John Doe Mon, 01 Jun 2020 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(4, 0), }; let idx = crate::position::LineIndex::new(changelog_text); let hints = generate_inlay_hints(&parsed, Source::new(changelog_text, &idx), &range); assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "= buster"), _ => panic!("Expected string label"), } } } debian-lsp-0.1.8/src/changelog/mod.rs000064400000000000000000000010421046102023000155100ustar 00000000000000pub mod actions; pub mod completion; pub mod detection; pub mod fields; pub mod folding; pub mod hover; pub mod inlay_hints; pub mod on_type_formatting; pub mod selection_range; pub mod semantic; pub mod symbols; pub use actions::*; pub use completion::*; pub use detection::is_changelog_file; pub use folding::generate_folding_ranges; pub use hover::get_hover; pub use inlay_hints::generate_inlay_hints; pub use selection_range::generate_selection_ranges; pub use semantic::generate_semantic_tokens; pub use symbols::generate_document_symbols; debian-lsp-0.1.8/src/changelog/on_type_formatting.rs000064400000000000000000000224651046102023000206540ustar 00000000000000use debian_changelog::{ChangeLog, SyntaxKind}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{Position, TextEdit}; /// Generate on-type formatting edits for debian/changelog files. /// /// Handles: /// - After typing a newline inside a changelog entry body, insert appropriate indentation /// (` * ` for a new bullet or ` ` for continuation of the previous bullet) /// - After typing `-` completing ` --` on a line inside an entry, insert a trailing space /// to start the signature line (` -- `) pub fn on_type_formatting( parse: &debian_changelog::Parse, source_text: &str, position: Position, ch: &str, ) -> Option> { match ch { "\n" => on_type_newline(parse, source_text, position), "-" => on_type_dash(parse, source_text, position), _ => None, } } /// After typing `-`, check if the current line is ` --` following an entry without a footer. /// If so, insert a trailing space to start the signature line. fn on_type_dash( parse: &debian_changelog::Parse, source_text: &str, position: Position, ) -> Option> { let lines: Vec<&str> = source_text.lines().collect(); let line = lines.get(position.line as usize)?; // Check that the line so far is exactly " --" if line.trim_end() != " --" { return None; } // Find the nearest entry that ends at or before this line. let line_start: usize = source_text .lines() .take(position.line as usize) .map(|l| l.len() + 1) .sum(); let line_offset = text_size::TextSize::from(line_start as u32); let changelog = parse.tree(); // Look for an entry that contains this offset, or the last entry that ends // at or just before this offset. let entry = changelog .iter() .filter(|e| e.syntax().text_range().start() <= line_offset) .last()?; // Only offer signature completion if the entry doesn't already have a footer. if entry.footer().is_some() { return None; } // Insert a space after the "--" Some(vec![TextEdit { range: tower_lsp_server::ls_types::Range { start: position, end: position, }, new_text: " ".to_string(), }]) } /// After typing a newline, check if the cursor is inside an entry body and insert /// appropriate indentation. fn on_type_newline( parse: &debian_changelog::Parse, source_text: &str, position: Position, ) -> Option> { if position.line == 0 { return None; } let changelog = parse.tree(); // Compute the byte range of the previous line. let prev_line_idx = (position.line - 1) as usize; let prev_line_start: usize = source_text .lines() .take(prev_line_idx) .map(|l| l.len() + 1) // +1 for newline .sum(); let prev_line = source_text.lines().nth(prev_line_idx)?; let prev_line_end = prev_line_start + prev_line.len(); let prev_line_range = text_size::TextRange::new( text_size::TextSize::from(prev_line_start as u32), text_size::TextSize::from(prev_line_end as u32), ); // Find the entry whose range contains the previous line. let entry = changelog.iter().find(|e| { let range = e.syntax().text_range(); range.start() <= prev_line_range.start() && prev_line_range.start() < range.end() })?; // Find the last DETAIL token across all ENTRY_BODY nodes that overlaps // with the previous line. let mut last_detail_text = None; for element in entry.syntax().children() { if element.kind() != SyntaxKind::ENTRY_BODY { continue; } for child in element.descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = child { if token.kind() == SyntaxKind::DETAIL { let token_range = token.text_range(); if token_range.start() < prev_line_range.end() && token_range.end() > prev_line_range.start() { last_detail_text = Some(token.text().to_string()); } } } } } let detail_text = last_detail_text?; // Determine what to insert: if the detail starts with "* " or "- ", it's a bullet; // otherwise it's a continuation line. let new_text = if detail_text.starts_with("* ") || detail_text.starts_with("- ") { " * " } else { " " }; // Don't insert if the current line already has non-whitespace content. let current_line = source_text .lines() .nth(position.line as usize) .unwrap_or(""); if !current_line.trim().is_empty() { return None; } // Replace any existing whitespace on the current line (e.g. editor auto-indent). let line_start = Position { line: position.line, character: 0, }; let line_end = Position { line: position.line, character: current_line.len() as u32, }; Some(vec![TextEdit { range: tower_lsp_server::ls_types::Range { start: line_start, end: line_end, }, new_text: new_text.to_string(), }]) } #[cfg(test)] mod tests { use super::*; fn parse(text: &str) -> debian_changelog::Parse { ChangeLog::parse(text) } #[test] fn test_newline_after_bullet_inserts_new_bullet() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * First change.\n\n"; let parsed = parse(text); let edits = on_type_formatting(&parsed, text, Position::new(3, 0), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " * "); } #[test] fn test_newline_after_bullet_with_auto_indent() { // Simulates VSCode adding 2 spaces of auto-indent on the new line. let text = "pkg (1.0-1) unstable; urgency=medium\n\n * First change.\n \n"; let parsed = parse(text); let edits = on_type_formatting(&parsed, text, Position::new(3, 2), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " * "); // Should replace the auto-indent assert_eq!(edits[0].range.start.character, 0); assert_eq!(edits[0].range.end.character, 2); } #[test] fn test_newline_after_continuation_inserts_continuation() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * A long change that\n continues here.\n\n"; let parsed = parse(text); let edits = on_type_formatting(&parsed, text, Position::new(4, 0), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_after_header_no_edit() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(1, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_after_signature_no_edit() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * Change.\n\n -- Foo Mon, 01 Jan 2024 00:00:00 +0000\n\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(5, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_at_start_of_file_no_edit() { let text = "\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(0, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_current_line_has_content_no_edit() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * Change.\nfoo\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(3, 0), "\n"); assert!(result.is_none()); } #[test] fn test_colon_ignored() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * Change.\n\n -- Foo Mon, 01 Jan 2024 00:00:00 +0000\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(0, 10), ":"); assert!(result.is_none()); } #[test] fn test_dash_completing_signature_prefix() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * Change.\n\n --\n"; let parsed = parse(text); let edits = on_type_formatting(&parsed, text, Position::new(4, 3), "-").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); assert_eq!(edits[0].range.start, Position::new(4, 3)); } #[test] fn test_dash_in_entry_with_footer_no_edit() { let text = "pkg (1.0-1) unstable; urgency=medium\n\n * Change.\n\n -- Foo Mon, 01 Jan 2024 00:00:00 +0000\n"; let parsed = parse(text); // Typing "--" on line 4 where the footer already exists let result = on_type_formatting(&parsed, text, Position::new(4, 3), "-"); assert!(result.is_none()); } #[test] fn test_dash_not_signature_prefix() { // Just a dash somewhere in a bullet line let text = "pkg (1.0-1) unstable; urgency=medium\n\n * foo-\n"; let parsed = parse(text); let result = on_type_formatting(&parsed, text, Position::new(2, 8), "-"); assert!(result.is_none()); } } debian-lsp-0.1.8/src/changelog/selection_range.rs000064400000000000000000000144231046102023000201010ustar 00000000000000//! Selection range generation for Debian changelog files. //! //! Provides hierarchical selection expansion: //! 1. Entry header, body, or footer //! 2. Entire changelog entry //! 3. Complete file use crate::position::Source; use debian_changelog::{ChangeLog, Parse}; use rowan::ast::AstNode; use text_size::TextSize; use tower_lsp_server::ls_types::{Position, Range, SelectionRange}; /// Generate selection ranges for the given positions in a changelog file. pub fn generate_selection_ranges( parse: &Parse, src: Source<'_>, positions: &[Position], ) -> Vec { let changelog = parse.tree(); let file_range = Range::new( Position::new(0, 0), src.offset_to_position(TextSize::from(src.text.len() as u32)), ); positions .iter() .map(|pos| { let file_sel = SelectionRange { range: file_range, parent: None, }; let Some(offset) = src.try_position_to_offset(*pos) else { return file_sel; }; // Find which entry contains this position. let Some(entry) = changelog.iter().find(|e| { let r = e.syntax().text_range(); r.contains(offset) || r.end() == offset }) else { return file_sel; }; let entry_range = src.text_range_to_lsp_range(entry.syntax().text_range()); let entry_sel = SelectionRange { range: entry_range, parent: Some(Box::new(file_sel)), }; // Try to narrow to header, body, or footer. if let Some(header) = entry.header() { let r = header.syntax().text_range(); if r.contains(offset) || r.end() == offset { return SelectionRange { range: src.text_range_to_lsp_range(r), parent: Some(Box::new(entry_sel)), }; } } if let Some(body) = entry.body() { let r = body.syntax().text_range(); if r.contains(offset) || r.end() == offset { return SelectionRange { range: src.text_range_to_lsp_range(r), parent: Some(Box::new(entry_sel)), }; } } if let Some(footer) = entry.footer() { let r = footer.syntax().text_range(); if r.contains(offset) || r.end() == offset { return SelectionRange { range: src.text_range_to_lsp_range(r), parent: Some(Box::new(entry_sel)), }; } } entry_sel }) .collect() } #[cfg(test)] mod tests { use super::*; const ENTRY: &str = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; #[test] fn test_selection_in_header() { let parsed = ChangeLog::parse(ENTRY); let idx = crate::position::LineIndex::new(ENTRY); let src = Source::new(ENTRY, &idx); let ranges = generate_selection_ranges(&parsed, src, &[Position::new(0, 5)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Innermost: header (includes trailing newline, so ends on line 1) assert_eq!(sel.range.start.line, 0); // Parent: entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 0); // Grandparent: file assert!(entry_sel.parent.as_ref().unwrap().parent.is_none()); } #[test] fn test_selection_in_body() { let parsed = ChangeLog::parse(ENTRY); let idx = crate::position::LineIndex::new(ENTRY); let src = Source::new(ENTRY, &idx); // Line 2: " * Change." let ranges = generate_selection_ranges(&parsed, src, &[Position::new(2, 4)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Innermost: body (starts after header) assert_eq!(sel.range.start.line, 2); // Parent: entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 0); } #[test] fn test_selection_in_footer() { let parsed = ChangeLog::parse(ENTRY); let idx = crate::position::LineIndex::new(ENTRY); let src = Source::new(ENTRY, &idx); // Line 4: " -- T ..." let ranges = generate_selection_ranges(&parsed, src, &[Position::new(4, 5)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Innermost: footer assert_eq!(sel.range.start.line, 4); // Parent: entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 0); } #[test] fn test_multiple_entries() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second release. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) experimental; urgency=low * First release. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position in the second entry header let ranges = generate_selection_ranges(&parsed, src, &[Position::new(6, 3)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Header of second entry assert_eq!(sel.range.start.line, 6); // Parent: second entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 6); // Grandparent: file let file_sel = entry_sel.parent.as_ref().unwrap(); assert_eq!(file_sel.range.start.line, 0); assert!(file_sel.parent.is_none()); } #[test] fn test_empty_changelog() { let text = ""; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let ranges = generate_selection_ranges(&parsed, src, &[Position::new(0, 0)]); assert_eq!(ranges.len(), 1); assert!(ranges[0].parent.is_none()); } } debian-lsp-0.1.8/src/changelog/semantic.rs000064400000000000000000000342561046102023000165510ustar 00000000000000//! Semantic token generation for Debian changelog files. use debian_changelog::SyntaxKind; use tower_lsp_server::ls_types::SemanticToken; use crate::deb822::semantic::{SemanticTokensBuilder, TokenType}; use crate::position::Source; /// Generate semantic tokens for a changelog file pub fn generate_semantic_tokens( parse: &debian_changelog::Parse, src: Source<'_>, ) -> Vec { let mut builder = SemanticTokensBuilder::new(); // Use syntax_node() to get tokens even with parse errors let syntax = parse.syntax_node(); // Track whether the previous DETAIL token ended inside a bug reference // (e.g. "Closes: #111,\n" or "Closes:\n"), so we can continue // highlighting on the next DETAIL line. let mut bug_ref_continues = false; for element in syntax.descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = element { let kind = token.kind(); let range = token.text_range(); match kind { SyntaxKind::IDENTIFIER => { let parent_kind = token.parent().map(|p| p.kind()); let token_type = match parent_kind { Some(SyntaxKind::ENTRY_HEADER) => Some(TokenType::ChangelogPackage), Some(SyntaxKind::METADATA_KEY) => Some(TokenType::ChangelogUrgency), Some(SyntaxKind::DISTRIBUTIONS) => Some(TokenType::ChangelogDistribution), Some(SyntaxKind::METADATA_VALUE) => Some(TokenType::ChangelogMetadataValue), _ => None, }; if let Some(tt) = token_type { push_token(&mut builder, src, range.start(), token.text(), tt); } } SyntaxKind::VERSION => { push_token( &mut builder, src, range.start(), token.text(), TokenType::ChangelogVersion, ); } SyntaxKind::COMMENT => { push_token( &mut builder, src, range.start(), token.text(), TokenType::Comment, ); } SyntaxKind::DETAIL => { bug_ref_continues = push_bug_references( &mut builder, src, range.start(), token.text(), bug_ref_continues, ); } _ => { let parent_kind = token.parent().map(|p| p.kind()); let token_type = match parent_kind { Some(SyntaxKind::METADATA_VALUE) => Some(TokenType::ChangelogMetadataValue), Some(SyntaxKind::TIMESTAMP) => Some(TokenType::ChangelogTimestamp), Some(SyntaxKind::MAINTAINER) => Some(TokenType::ChangelogMaintainer), Some(SyntaxKind::EMAIL) => Some(TokenType::ChangelogMaintainer), _ => None, }; if let Some(tt) = token_type { push_token(&mut builder, src, range.start(), token.text(), tt); } } } } } builder.build() } fn push_token( builder: &mut SemanticTokensBuilder, src: Source<'_>, start: text_size::TextSize, text: &str, token_type: TokenType, ) { let start_pos = src.offset_to_position(start); let length = crate::position::utf16_len(text); if length > 0 { builder.push(start_pos.line, start_pos.character, length, token_type, 0); } } /// Emit semantic tokens for bug references within a DETAIL token. /// /// Highlights `Closes: #NNN, #NNN` and `LP: #NNN, #NNN` spans, including /// references that wrap across DETAIL tokens (continuation lines). /// /// Returns `true` if the reference continues past the end of this token. fn push_bug_references( builder: &mut SemanticTokensBuilder, src: Source<'_>, token_start: text_size::TextSize, text: &str, continues_from_prev: bool, ) -> bool { let start: usize = token_start.into(); let spans = debian_changelog::bugs::bug_ref_spans(text, continues_from_prev); let mut last_continues = false; for span in &spans { let matched_text = &text[span.start..span.end]; let abs_start = text_size::TextSize::from((start + span.start) as u32); let start_pos = src.offset_to_position(abs_start); let length = crate::position::utf16_len(matched_text); if length > 0 { builder.push( start_pos.line, start_pos.character, length, TokenType::ChangelogBugReference, 0, ); } if span.continues { last_continues = true; } } last_continues } #[cfg(test)] mod tests { use super::*; /// Helper to collect (token_type, length) pairs for easier assertions fn token_summary(tokens: &[SemanticToken]) -> Vec<(u32, u32)> { tokens.iter().map(|t| (t.token_type, t.length)).collect() } #[test] fn test_all_token_types_in_entry() { let text = "test-package (1.0-1) unstable; urgency=medium\n\n * Initial release.\n\n -- John Doe Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let summary = token_summary(&tokens); // Verify we see all expected token types let types: Vec = summary.iter().map(|(tt, _)| *tt).collect(); assert!( types.contains(&(TokenType::ChangelogPackage as u32)), "Missing ChangelogPackage in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogVersion as u32)), "Missing ChangelogVersion in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogDistribution as u32)), "Missing ChangelogDistribution in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogUrgency as u32)), "Missing ChangelogUrgency in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogMetadataValue as u32)), "Missing Value (metadata value) in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogMaintainer as u32)), "Missing ChangelogMaintainer in {types:?}" ); assert!( types.contains(&(TokenType::ChangelogTimestamp as u32)), "Missing ChangelogTimestamp in {types:?}" ); // First token should be the package name assert_eq!(tokens[0].delta_line, 0); assert_eq!(tokens[0].delta_start, 0); assert_eq!(tokens[0].token_type, TokenType::ChangelogPackage as u32); assert_eq!(tokens[0].length, 12); } #[test] fn test_maintainer_and_timestamp() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- Test User Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let summary = token_summary(&tokens); let has_maintainer = summary .iter() .any(|(tt, _)| *tt == TokenType::ChangelogMaintainer as u32); assert!(has_maintainer, "Should have a maintainer token"); let has_timestamp = summary .iter() .any(|(tt, _)| *tt == TokenType::ChangelogTimestamp as u32); assert!(has_timestamp, "Should have a timestamp token"); } #[test] fn test_multiple_entries() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second release. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) unstable; urgency=low * First release. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); // Should have package tokens for both entries let package_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogPackage as u32) .collect(); assert_eq!(package_tokens.len(), 2, "Should have 2 package name tokens"); // Should have version tokens for both entries let version_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogVersion as u32) .collect(); assert_eq!(version_tokens.len(), 2, "Should have 2 version tokens"); } #[test] fn test_multiple_distributions() { let text = "pkg (1.0-1) unstable testing; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let dist_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogDistribution as u32) .collect(); assert_eq!( dist_tokens.len(), 2, "Should have 2 distribution tokens for 'unstable testing'" ); } #[test] fn test_bug_reference_closes() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Fix bug. (Closes: #123456)\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); assert_eq!(bug_tokens.len(), 1); // "Closes: #123456" is 15 chars assert_eq!(bug_tokens[0].length, 15); } #[test] fn test_bug_reference_lp() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Fix bug. (LP: #987654)\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); assert_eq!(bug_tokens.len(), 1); // "LP: #987654" is 11 chars assert_eq!(bug_tokens[0].length, 11); } #[test] fn test_bug_reference_multiple() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Fix bugs. (Closes: #111, #222)\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); // Single span covering "Closes: #111, #222" assert_eq!(bug_tokens.len(), 1); assert_eq!(bug_tokens[0].length, 18); } #[test] fn test_no_bug_reference() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Regular change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); assert_eq!(bug_tokens.len(), 0); } #[test] fn test_bug_reference_multiline() { // "Closes:" on one line, bug number on the next let text = "pkg (1.0-1) unstable; urgency=low\n\n * Fix bug. Closes:\n #123456\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); // Two tokens: "Closes:" on line 2, "#123456" on line 3 assert_eq!(bug_tokens.len(), 2, "bug tokens: {bug_tokens:?}"); assert_eq!(bug_tokens[0].length, 7); // "Closes:" assert_eq!(bug_tokens[1].length, 7); // "#123456" } #[test] fn test_bug_reference_multiline_with_comma() { // Bugs split across lines with comma let text = "pkg (1.0-1) unstable; urgency=low\n\n * Fix bugs. Closes: #111,\n #222\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = debian_changelog::ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let bug_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::ChangelogBugReference as u32) .collect(); // Two tokens: "Closes: #111" on first line, "#222" on second assert_eq!(bug_tokens.len(), 2, "bug tokens: {bug_tokens:?}"); assert_eq!(bug_tokens[0].length, 12); // "Closes: #111" assert_eq!(bug_tokens[1].length, 4); // "#222" } } debian-lsp-0.1.8/src/changelog/symbols.rs000064400000000000000000000115301046102023000164240ustar 00000000000000//! Document symbol generation for Debian changelog files. use crate::position::Source; use debian_changelog::{ChangeLog, Parse}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{DocumentSymbol, SymbolKind}; /// Generate document symbols for a changelog file. /// /// Each changelog entry becomes a symbol with the package name and version /// as its label, allowing breadcrumb navigation. #[allow(deprecated)] // DocumentSymbol::deprecated field pub fn generate_document_symbols(parse: &Parse, src: Source<'_>) -> Vec { let changelog = parse.tree(); let mut symbols = Vec::new(); for entry in changelog.entries() { let package = entry.package().unwrap_or_default(); let version = entry.version().map(|v| v.to_string()).unwrap_or_default(); let name = format!("{package} ({version})"); let entry_range = src.text_range_to_lsp_range(entry.syntax().text_range()); // The selection range is the header line (package + version) let selection_range = entry .header() .map(|h| src.text_range_to_lsp_range(h.syntax().text_range())) .unwrap_or(entry_range); symbols.push(DocumentSymbol { name, detail: entry.distributions().map(|d| d.join(" ")), kind: SymbolKind::PACKAGE, tags: None, deprecated: None, range: entry_range, selection_range, children: None, }); } symbols } #[cfg(test)] mod tests { use super::*; #[test] fn test_single_entry() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 1); assert_eq!(symbols[0].name, "pkg (1.0-1)"); assert_eq!(symbols[0].detail.as_deref(), Some("unstable")); assert_eq!(symbols[0].kind, SymbolKind::PACKAGE); } #[test] fn test_multiple_entries() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second release. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) experimental; urgency=low * First release. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 2); assert_eq!(symbols[0].name, "pkg (2.0-1)"); assert_eq!(symbols[0].detail.as_deref(), Some("unstable")); assert_eq!(symbols[1].name, "pkg (1.0-1)"); assert_eq!(symbols[1].detail.as_deref(), Some("experimental")); } #[test] fn test_selection_range_is_header() { let text = "pkg (1.0-1) unstable; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); // Selection range should start at the header assert_eq!(symbols[0].selection_range.start.line, 0); // Entry range should span more lines than the selection range assert!(symbols[0].range.end.line > symbols[0].selection_range.start.line); } #[test] fn test_empty_changelog() { let text = ""; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 0); } #[test] fn test_multiple_distributions() { let text = "pkg (1.0-1) unstable testing; urgency=low\n\n * Change.\n\n -- T Mon, 01 Jan 2024 12:00:00 +0000\n"; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols[0].detail.as_deref(), Some("unstable testing")); } #[test] fn test_entry_ranges_do_not_overlap() { let text = "\ pkg (2.0-1) unstable; urgency=medium * Second. -- A Mon, 01 Jan 2025 12:00:00 +0000 pkg (1.0-1) unstable; urgency=low * First. -- B Mon, 01 Jan 2024 12:00:00 +0000 "; let parsed = ChangeLog::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 2); // First entry ends before second entry starts assert!(symbols[0].range.end.line <= symbols[1].range.start.line); } } debian-lsp-0.1.8/src/control/actions.rs000064400000000000000000000331241046102023000161300ustar 00000000000000use crate::position::Source; use crate::workspace::FieldCasingIssue; use text_size::TextRange; use tower_lsp_server::ls_types::*; pub const ADD_BINARY_PACKAGE_COMMAND: &str = "debian-lsp.addBinaryPackage"; /// Format an entire control file using wrap-and-sort /// /// # Arguments /// * `src.text` - The source text of the file /// * `parsed` - The parsed control file /// /// # Returns /// A list of text edits to apply, or None if the file is already formatted pub fn format_control( src: Source<'_>, parsed: &debian_control::lossless::Parse, ) -> Option> { let mut control = parsed.clone().to_result().ok()?; control.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, Some(79)); let formatted = control.to_string(); if formatted == src.text { return None; } let full_range = src.text_range_to_lsp_range(text_size::TextRange::new( 0.into(), (src.text.len() as u32).into(), )); Some(vec![TextEdit { range: full_range, new_text: formatted, }]) } /// Generate a wrap-and-sort code action for a control file /// /// This function creates a code action that wraps and sorts fields in paragraphs /// that overlap with the requested text range. /// /// # Arguments /// * `uri` - The URI of the control file /// * `src.text` - The source text of the file /// * `parsed` - The parsed control file /// * `text_range` - The text range to operate on /// /// # Returns /// A code action if applicable paragraphs are found, None otherwise pub fn get_wrap_and_sort_action( uri: &Uri, src: Source<'_>, parsed: &debian_control::lossless::Parse, text_range: TextRange, ) -> Option { let control = parsed.clone().to_result().ok()?; let mut edits = Vec::new(); // Check if source paragraph is in range if let Some(source) = control.source_in_range(text_range) { let para_range = source.as_deb822().text_range(); let mut source = source.clone(); source.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, Some(79)); let lsp_range = src.text_range_to_lsp_range(para_range); edits.push(TextEdit { range: lsp_range, new_text: source.to_string(), }); } // Check each binary paragraph in range for binary in control.binaries_in_range(text_range) { let para_range = binary.as_deb822().text_range(); let mut binary = binary.clone(); binary.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, Some(79)); let lsp_range = src.text_range_to_lsp_range(para_range); edits.push(TextEdit { range: lsp_range, new_text: binary.to_string(), }); } if edits.is_empty() { return None; } let workspace_edit = WorkspaceEdit { changes: Some(vec![(uri.clone(), edits)].into_iter().collect()), ..Default::default() }; let action = CodeAction { title: "Wrap and sort".to_string(), kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), edit: Some(workspace_edit), ..Default::default() }; Some(CodeActionOrCommand::CodeAction(action)) } /// Build the workspace edit that appends a new binary package stanza. /// /// Used both by the command handler (`execute_command`) and tests. pub fn build_add_binary_package_edit( uri: &Uri, src: Source<'_>, parsed: &debian_control::lossless::Parse, ) -> Option { let control = parsed.clone().to_result().ok()?; let source = control.source()?; let source_name = source.name()?; let mut new_control = debian_control::lossless::Control::new(); let mut binary = new_control.add_binary(&source_name); binary .as_mut_deb822() .set("Depends", "${shlibs:Depends}, ${misc:Depends}"); binary.set_description(Some(&format!("", source_name))); let binary_text = new_control .binaries() .next() .unwrap() .as_deb822() .to_string(); let end_offset = src.text.len() as u32; let end_position = src.offset_to_position(end_offset.into()); Some(WorkspaceEdit { changes: Some( vec![( uri.clone(), vec![TextEdit { range: Range { start: end_position, end: end_position, }, new_text: format!("\n{}", binary_text), }], )] .into_iter() .collect(), ), ..Default::default() }) } /// Return a palette command entry for "Add binary package". /// /// This is intentionally a `Command` (not a `CodeAction`) so that VS Code /// only surfaces it via the command palette, not the automatic lightbulb. pub fn get_add_binary_package_command(uri: &Uri) -> CodeActionOrCommand { CodeActionOrCommand::Command(Command { title: "Add binary package".to_string(), command: ADD_BINARY_PACKAGE_COMMAND.to_string(), arguments: Some(vec![serde_json::json!(uri.as_str())]), }) } /// Generate field casing fix actions for a control file /// /// # Arguments /// * `uri` - The URI of the control file /// * `src.text` - The source text of the file /// * `issues` - The field casing issues found /// * `diagnostics` - The diagnostics from the context /// /// # Returns /// A vector of code actions for fixing field casing pub fn get_field_casing_actions( uri: &Uri, src: Source<'_>, issues: Vec, diagnostics: &[Diagnostic], ) -> Vec { let mut actions = Vec::new(); for issue in issues { let lsp_range = src.text_range_to_lsp_range(issue.field_range); // Check if there's a matching diagnostic in the context let matching_diagnostics = diagnostics .iter() .filter(|d| { d.range == lsp_range && d.code == Some(NumberOrString::String("field-casing".to_string())) }) .cloned() .collect::>(); // Create a code action to fix the casing let edit = TextEdit { range: lsp_range, new_text: issue.standard_name.clone(), }; let workspace_edit = WorkspaceEdit { changes: Some(vec![(uri.clone(), vec![edit])].into_iter().collect()), ..Default::default() }; let action = CodeAction { title: format!( "Fix field casing: {} -> {}", issue.field_name, issue.standard_name ), kind: Some(CodeActionKind::QUICKFIX), edit: Some(workspace_edit), diagnostics: if !matching_diagnostics.is_empty() { Some(matching_diagnostics) } else { None }, ..Default::default() }; actions.push(CodeActionOrCommand::CodeAction(action)); } actions } #[cfg(test)] mod tests { use super::*; #[test] fn test_wrap_and_sort_action() { let input = r#"Source: test-package Maintainer: Test User Build-Depends: debhelper-compat (= 13), foo, bar, baz Package: test-package Architecture: any Depends: libc6, libfoo, libbar Description: A test package This is a test package. "#; let parsed = debian_control::lossless::Control::parse(input); let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let uri: Uri = "file:///debian/control".parse().unwrap(); let text_range = TextRange::new(0.into(), (input.len() as u32).into()); let action = get_wrap_and_sort_action(&uri, src, &parsed, text_range); // Should return a code action assert!(action.is_some()); let CodeActionOrCommand::CodeAction(action) = action.unwrap() else { panic!("Expected CodeAction"); }; assert_eq!(action.title, "Wrap and sort"); assert_eq!(action.kind, Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS)); // Extract the edits let workspace_edit = action.edit.expect("Should have an edit"); let changes = workspace_edit.changes.expect("Should have changes"); let edits = changes.get(&uri).expect("Should have edits for the URI"); // Should have edits for both source and binary paragraphs assert_eq!(edits.len(), 2); // Get the formatted source paragraph let formatted_source = &edits[0].new_text; // Get the formatted binary paragraph let formatted_binary = &edits[1].new_text; // Print the actual output for debugging println!("Formatted source:\n{}", formatted_source); println!("Formatted binary:\n{}", formatted_binary); // Verify the exact formatted output for source paragraph let expected_source = "Source: test-package\nMaintainer: Test User \nBuild-Depends:bar, baz, debhelper-compat (= 13), foo\n"; assert_eq!(formatted_source, expected_source); // Verify the exact formatted output for binary paragraph let expected_binary = "Package: test-package\nArchitecture: any\nDepends:libbar, libc6, libfoo\nDescription: A test package\n This is a test package.\n"; assert_eq!(formatted_binary, expected_binary); } #[test] fn test_field_casing_actions() { let input = r#"source: test-package maintainer: Test User "#; let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let uri: Uri = "file:///debian/control".parse().unwrap(); let issues = vec![ FieldCasingIssue { field_name: "source".to_string(), standard_name: "Source".to_string(), field_range: TextRange::new(0.into(), 6.into()), }, FieldCasingIssue { field_name: "maintainer".to_string(), standard_name: "Maintainer".to_string(), field_range: TextRange::new(21.into(), 31.into()), }, ]; let actions = get_field_casing_actions(&uri, src, issues, &[]); assert_eq!(actions.len(), 2); let CodeActionOrCommand::CodeAction(ref action) = actions[0] else { panic!("Expected CodeAction"); }; assert_eq!(action.title, "Fix field casing: source -> Source"); let CodeActionOrCommand::CodeAction(ref action) = actions[1] else { panic!("Expected CodeAction"); }; assert_eq!(action.title, "Fix field casing: maintainer -> Maintainer"); } #[test] fn test_add_binary_package_action() { let input = "Source: my-package\nSection: utils\nPriority: optional\nMaintainer: Test User \n"; let parsed = debian_control::lossless::Control::parse(input); let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let uri: Uri = "file:///debian/control".parse().unwrap(); let edit = build_add_binary_package_edit(&uri, src, &parsed); assert!(edit.is_some()); let changes = edit.unwrap().changes.expect("Should have changes"); let edits = changes.get(&uri).expect("Should have edits for the URI"); assert_eq!(edits.len(), 1); // Should be inserted at the end of the file let end_line = input.lines().count() as u32; assert_eq!(edits[0].range.start.line, end_line); assert_eq!( edits[0].new_text, "\nPackage: my-package\nDepends: ${shlibs:Depends}, ${misc:Depends}\nDescription: \n" ); } #[test] fn test_format_control() { let input = "Source: test-package\nMaintainer: Test User \nBuild-Depends: debhelper-compat (= 13), foo, bar, baz\n\nPackage: test-package\nArchitecture: any\nDepends: libc6, libfoo, libbar\nDescription: A test package\n This is a test package.\n"; let parsed = debian_control::lossless::Control::parse(input); let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let edits = format_control(src, &parsed); assert!(edits.is_some()); let edits = edits.unwrap(); assert_eq!(edits.len(), 1); // The single edit should cover the entire document assert_eq!(edits[0].range.start.line, 0); assert_eq!(edits[0].range.start.character, 0); // Verify the formatted output is different from input assert_ne!(edits[0].new_text, input); } #[test] fn test_format_control_already_formatted() { // Create a file, format it, then verify formatting again returns None let input = "Source: test-package\nMaintainer: Test User \n"; let parsed = debian_control::lossless::Control::parse(input); let idx = crate::position::LineIndex::new(input); let first_format = format_control(Source::new(input, &idx), &parsed); // Apply the first format (or use original if already formatted) let formatted = match first_format { Some(edits) => edits[0].new_text.clone(), None => input.to_string(), }; // Format again - should return None since already formatted let parsed2 = debian_control::lossless::Control::parse(&formatted); let idx2 = crate::position::LineIndex::new(&formatted); let second_format = format_control(Source::new(&formatted, &idx2), &parsed2); assert!(second_format.is_none()); } } debian-lsp-0.1.8/src/control/code_lens.rs000064400000000000000000001027561046102023000164330ustar 00000000000000//! Code lenses for debian/control files. //! //! - Standards-Version: shows "latest: 4.7.0" when outdated, with an action to update //! - debhelper-compat: shows compat level info from dh_assistant //! - Vcs-Git: shows packaged version from UDD vcswatch use crate::position::Source; use debian_control::relations::VersionConstraint; use tower_lsp_server::ls_types::{CodeLens, Command, Range}; /// Command name for opening a URL via `window/showDocument`. pub const OPEN_URL_COMMAND: &str = "debian-lsp.openUrl"; /// Create a code lens that opens the given URL when clicked. fn make_link_lens(range: Range, title: String, url: String) -> CodeLens { CodeLens { range, command: Some(Command { title, command: OPEN_URL_COMMAND.to_string(), arguments: Some(vec![serde_json::Value::String(url)]), }), data: None, } } /// Create an informational code lens (not clickable). /// /// Uses a dummy command name because some editors silently drop /// code lenses whose command string is empty. fn make_info_lens(range: Range, title: String) -> CodeLens { CodeLens { range, command: Some(Command { title, command: "debian-lsp.noop".to_string(), arguments: None, }), data: None, } } /// Context for generating code lenses. pub struct LensContext<'a> { /// Cache for package version lookups. pub package_cache: &'a crate::package_cache::SharedPackageCache, /// Cache for VCS watch lookups from UDD. pub vcswatch_cache: &'a crate::vcswatch::SharedVcsWatchCache, /// Cache for bug lookups from UDD. pub bug_cache: &'a crate::bugs::SharedBugCache, /// Cache for popcon lookups from UDD. pub popcon_cache: &'a crate::popcon::SharedPopconCache, /// Cache for reverse dependency lookups from UDD. pub rdeps_cache: &'a crate::rdeps::SharedRdepsCache, } /// Info about a Standards-Version field found in the control file. struct StandardsVersionField { /// The value of the Standards-Version field (trimmed). value: String, /// The LSP range of the entire field entry. range: Range, } /// Info about a debhelper-compat relation found in the control file. struct DebhelperCompatField { /// The LSP range of the relation. range: Range, } /// Info about a binary package paragraph found in the control file. struct BinaryPackageField { /// The binary package name. name: String, /// The LSP range of the Package field entry. range: Range, } /// Info about the source package paragraph. struct SourcePackageField { /// The source package name. name: String, /// The LSP range of the Source field entry. range: Range, } /// Extract the Standards-Version (first 3 components) from a debian-policy /// package version string like "4.7.3.0" → "4.7.3". fn policy_version_to_standards_version(policy_version: &str) -> Option<&str> { let mut dots = 0; for (i, c) in policy_version.char_indices() { if c == '.' { dots += 1; if dots == 3 { return Some(&policy_version[..i]); } } } if dots >= 2 { Some(policy_version) } else { None } } /// Compat level info from dh_assistant. #[derive(serde::Deserialize)] struct CompatLevels { /// The highest compat level available. #[serde(rename = "MAX_COMPAT_LEVEL")] max: u32, /// The highest compat level considered stable. #[serde(rename = "HIGHEST_STABLE_COMPAT_LEVEL")] highest_stable: u32, } /// Query dh_assistant for supported compat levels. async fn get_compat_levels() -> Option { let output = tokio::process::Command::new("dh_assistant") .arg("supported-compat-levels") .output() .await .ok()?; if !output.status.success() { return None; } serde_json::from_slice(&output.stdout).ok() } /// Compare two dotted version strings and return true if `current` is older /// than `latest`. fn is_outdated(current: &str, latest: &str) -> bool { let current_parts: Vec = current.split('.').filter_map(|s| s.parse().ok()).collect(); let latest_parts: Vec = latest.split('.').filter_map(|s| s.parse().ok()).collect(); for (c, l) in current_parts.iter().zip(latest_parts.iter()) { match c.cmp(l) { std::cmp::Ordering::Less => return true, std::cmp::Ordering::Greater => return false, std::cmp::Ordering::Equal => continue, } } current_parts.len() < latest_parts.len() } /// All extracted lens data from a parsed control file. struct LensData { standards_versions: Vec, debhelper_compats: Vec, source_package: Option, binary_packages: Vec, } /// Extract Standards-Version, debhelper-compat, and package fields from a parsed control file. fn extract_lens_data( parsed: &debian_control::lossless::Parse, src: Source<'_>, ) -> LensData { let control = parsed.tree(); let mut standards_versions = Vec::new(); let mut debhelper_compats = Vec::new(); let mut source_package = None; let mut binary_packages = Vec::new(); for paragraph in control.as_deb822().paragraphs() { for entry in paragraph.entries() { let Some(field_name) = entry.key() else { continue; }; if field_name.eq_ignore_ascii_case("Source") { let value = entry.value().trim().to_string(); if !value.is_empty() { let range = src.text_range_to_lsp_range(entry.text_range()); source_package = Some(SourcePackageField { name: value, range }); } continue; } if field_name.eq_ignore_ascii_case("Package") { let value = entry.value().trim().to_string(); if !value.is_empty() { let range = src.text_range_to_lsp_range(entry.text_range()); binary_packages.push(BinaryPackageField { name: value, range }); } continue; } if field_name.eq_ignore_ascii_case("Standards-Version") { let value = entry.value().trim().to_string(); if !value.is_empty() { let range = src.text_range_to_lsp_range(entry.text_range()); standards_versions.push(StandardsVersionField { value, range }); } continue; } if !super::relation_completion::is_relationship_field(&field_name) { continue; } let value = entry.value(); let (parsed_rels, _errors) = debian_control::lossless::relations::Relations::parse_relaxed(&value, true); let line_ranges = entry.value_line_ranges(); for rel_entry in parsed_rels.entries() { for relation in rel_entry.relations() { let Some(name) = relation.try_name() else { continue; }; if name != "debhelper-compat" { continue; } if matches!(relation.version(), Some((VersionConstraint::Equal, _))) { let rel_range = relation.syntax().text_range(); let rel_end: usize = rel_range.end().into(); if let Some(abs_end) = super::inlay_hints::joined_offset_to_source_offset( &line_ranges, rel_end, ) { let rel_start: usize = rel_range.start().into(); if let Some(abs_start) = super::inlay_hints::joined_offset_to_source_offset( &line_ranges, rel_start, ) { let range = src.text_range_to_lsp_range(text_size::TextRange::new( abs_start, abs_end, )); debhelper_compats.push(DebhelperCompatField { range }); } } } } } } } LensData { standards_versions, debhelper_compats, source_package, binary_packages, } } /// Find the Vcs-Git field in a parsed control file and return its URL and range. fn find_vcs_git_field( parsed: &debian_control::lossless::Parse, src: Source<'_>, ) -> Option<(String, Range)> { let control = parsed.clone().to_result().ok()?; let source = control.source()?; let vcs_git_value = source.vcs_git()?; let parsed_vcs = vcs_git_value .parse::() .ok()?; let entry_range = source .as_deb822() .entries() .find(|e| e.key().is_some_and(|k| k.eq_ignore_ascii_case("Vcs-Git")))? .text_range(); let range = src.text_range_to_lsp_range(entry_range); Some((parsed_vcs.repo_url, range)) } /// Format a count for display, using k/M suffixes for large numbers. fn format_count(count: u32) -> String { if count >= 1_000_000 { format!("{:.1}M", count as f64 / 1_000_000.0) } else if count >= 1_000 { format!("{:.1}k", count as f64 / 1_000.0) } else { count.to_string() } } /// Items that need background fetching before lenses can be generated. #[derive(Default)] pub struct UncachedLensData { /// Source package name needing bug count lookup. pub source_package: Option, /// Binary package names needing popcon/rdeps lookups. pub binary_packages: Vec, /// Whether the Standards-Version policy package needs fetching. pub needs_policy_version: bool, /// Vcs-Git URL needing vcswatch lookup. pub vcs_git_url: Option, } impl UncachedLensData { /// Returns `true` if there is nothing to fetch. pub fn is_empty(&self) -> bool { self.source_package.is_none() && self.binary_packages.is_empty() && !self.needs_policy_version && self.vcs_git_url.is_none() } } /// Generate code lenses for a control file, using only cached data. /// /// Returns the lenses that can be produced immediately plus a description of /// what data is still missing. The caller should fetch the missing data in /// the background and request a code lens refresh when done. /// /// The debhelper-compat lens requires running `dh_assistant`, which is fast /// and local, so it is always awaited inline. pub async fn generate_code_lenses( parsed: &debian_control::lossless::Parse, src: Source<'_>, ctx: &LensContext<'_>, ) -> (Vec, UncachedLensData) { let data = extract_lens_data(parsed, src); let mut lenses = Vec::new(); let mut uncached = UncachedLensData::default(); // Standards-Version lens (cache-only read for policy version) if !data.standards_versions.is_empty() { let cache = ctx.package_cache.read().await; let latest_standards = cache.get_cached_versions("debian-policy").and_then(|vs| { vs.first() .and_then(|v| policy_version_to_standards_version(&v.version)) .map(|s| s.to_string()) }); drop(cache); if let Some(latest) = latest_standards { for sv in &data.standards_versions { if sv.value == latest || !is_outdated(&sv.value, &latest) { continue; } lenses.push(make_link_lens( sv.range, format!("latest: {}", latest), format!( "https://www.debian.org/doc/debian-policy/upgrading-checklist.html#version-{}", latest.replace('.', "-") ), )); } } else { uncached.needs_policy_version = true; } } // debhelper-compat lens (local dh_assistant call, always awaited) if !data.debhelper_compats.is_empty() { if let Some(levels) = get_compat_levels().await { for dh in &data.debhelper_compats { let title = if levels.max == levels.highest_stable { format!("stable: {}", levels.highest_stable) } else { format!("stable: {}, max: {}", levels.highest_stable, levels.max) }; lenses.push(make_info_lens(dh.range, title)); } } } // Vcs-Git lens (cache-only) if let Some((url, range)) = find_vcs_git_field(parsed, src) { let cache = ctx.vcswatch_cache.read().await; let version = cache .get_cached_version_for_url(&url) .map(|s| s.to_string()); let is_cached = cache.is_cached(&url); drop(cache); if let Some(version) = version { lenses.push(make_link_lens( range, format!("git: {}", version), url.clone(), )); } else if !is_cached { uncached.vcs_git_url = Some(url); } } // Source package bug count lens (cache-only) if let Some(source) = &data.source_package { let cache = ctx.bug_cache.read().await; let bug_count = cache.get_cached_open_bug_count(&source.name); drop(cache); match bug_count { Some(count) if count > 0 => { lenses.push(make_link_lens( source.range, format!("{} open {}", count, if count == 1 { "bug" } else { "bugs" }), format!("https://bugs.debian.org/src:{}", source.name), )); } None => { uncached.source_package = Some(source.name.clone()); } _ => {} } } // Binary package lenses: bugs + popcon + rdeps (cache-only, separate clickable lenses) for pkg in &data.binary_packages { let mut needs_fetch = false; { let cache = ctx.bug_cache.read().await; let bug_count = cache.get_cached_open_binary_bug_count(&pkg.name); match bug_count { Some(count) if count > 0 => { lenses.push(make_link_lens( pkg.range, format!("{} open {}", count, if count == 1 { "bug" } else { "bugs" }), format!("https://bugs.debian.org/{}", pkg.name), )); } None => { needs_fetch = true; } _ => {} } } { let cache = ctx.popcon_cache.read().await; if let Some(count) = cache.get_cached_inst_count(&pkg.name) { lenses.push(make_link_lens( pkg.range, format!("popcon: {} installs", format_count(count)), format!( "https://qa.debian.org/popcon-graph.php?packages={}", pkg.name ), )); } else if !cache.is_cached(&pkg.name) { needs_fetch = true; } } { let cache = ctx.rdeps_cache.read().await; if let Some(count) = cache.get_cached_rdeps_count(&pkg.name) { if count > 0 { lenses.push(make_link_lens( pkg.range, format!("{} reverse deps", format_count(count)), format!("https://tracker.debian.org/pkg/{}", pkg.name), )); } } else if !cache.is_cached(&pkg.name) { needs_fetch = true; } } if needs_fetch { uncached.binary_packages.push(pkg.name.clone()); } } (lenses, uncached) } #[cfg(test)] mod tests { use super::*; fn make_shared_vcswatch_cache() -> crate::vcswatch::SharedVcsWatchCache { use crate::vcswatch::VcsWatchCache; use std::sync::Arc; use tokio::sync::RwLock; Arc::new(RwLock::new(VcsWatchCache::new(crate::udd::shared_pool()))) } fn make_shared_bug_cache() -> crate::bugs::SharedBugCache { crate::bugs::new_shared_bug_cache(crate::udd::shared_pool()) } fn make_shared_popcon_cache() -> crate::popcon::SharedPopconCache { crate::popcon::new_shared_popcon_cache(crate::udd::shared_pool()) } fn make_shared_rdeps_cache() -> crate::rdeps::SharedRdepsCache { crate::rdeps::new_shared_rdeps_cache(crate::udd::shared_pool()) } #[tokio::test] async fn test_code_lens_outdated_standards_version() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache.versions.insert( "debian-policy".to_string(), vec![VersionInfo { version: "4.7.3.0".to_string(), suites: vec!["unstable".to_string()], }], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let content = "Source: test-package\nStandards-Version: 4.6.2\nMaintainer: Test \n"; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "latest: 4.7.3"); } #[tokio::test] async fn test_no_code_lens_when_current() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache.versions.insert( "debian-policy".to_string(), vec![VersionInfo { version: "4.7.3.0".to_string(), suites: vec!["unstable".to_string()], }], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let content = "Source: test-package\nStandards-Version: 4.7.3\nMaintainer: Test \n"; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 0); } #[tokio::test] async fn test_code_lens_debhelper_compat() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; // Skip if dh_assistant is not installed if tokio::process::Command::new("dh_assistant") .arg("supported-compat-levels") .output() .await .is_err() { return; } let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let content = "\ Source: test-package Build-Depends: debhelper-compat (= 13), pkg-config Maintainer: Test "; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); let title = &lenses[0].command.as_ref().unwrap().title; assert!( title.starts_with("stable: "), "expected title starting with 'stable: ', got: {}", title ); } #[tokio::test] async fn test_both_standards_version_and_debhelper_compat_lenses() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; // Skip if dh_assistant is not installed if tokio::process::Command::new("dh_assistant") .arg("supported-compat-levels") .output() .await .is_err() { return; } let mut cache = TestPackageCache::default(); cache.versions.insert( "debian-policy".to_string(), vec![VersionInfo { version: "4.7.3.0".to_string(), suites: vec!["unstable".to_string()], }], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let content = "\ Source: test-package Standards-Version: 4.6.2 Build-Depends: debhelper-compat (= 13), pkg-config Maintainer: Test "; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 2); assert_eq!(lenses[0].command.as_ref().unwrap().title, "latest: 4.7.3"); assert!(lenses[1] .command .as_ref() .unwrap() .title .starts_with("stable: ")); } #[tokio::test] async fn test_code_lens_vcs_git() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let mut vcs_cache = crate::vcswatch::VcsWatchCache::new(crate::udd::shared_pool()); vcs_cache.insert_cached( "https://salsa.debian.org/python-team/packages/dulwich.git", "1.1.0-1", ); let vcswatch_cache: crate::vcswatch::SharedVcsWatchCache = Arc::new(RwLock::new(vcs_cache)); let content = "Source: dulwich\nVcs-Git: https://salsa.debian.org/python-team/packages/dulwich.git\nVcs-Browser: https://salsa.debian.org/python-team/packages/dulwich\n"; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "git: 1.1.0-1"); } #[tokio::test] async fn test_code_lens_vcs_git_with_branch_suffix() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let mut vcs_cache = crate::vcswatch::VcsWatchCache::new(crate::udd::shared_pool()); vcs_cache.insert_cached("https://salsa.debian.org/team/pkg.git", "2.0-1"); let vcswatch_cache: crate::vcswatch::SharedVcsWatchCache = Arc::new(RwLock::new(vcs_cache)); let content = "Source: pkg\nVcs-Git: https://salsa.debian.org/team/pkg.git -b debian/latest\n"; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "git: 2.0-1"); } #[tokio::test] async fn test_no_code_lens_vcs_git_when_not_in_vcswatch() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let content = "Source: unknown\nVcs-Git: https://example.com/unknown.git\n"; let parsed = debian_control::lossless::Control::parse(content); let bug_cache = make_shared_bug_cache(); let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 0); } #[tokio::test] async fn test_code_lens_source_bug_count() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let bug_cache = { let mut bc = crate::bugs::BugCache::new(crate::udd::shared_pool()); bc.insert_cached_open_bugs_for_package( "test-package", vec![ (100001, Some("Bug one")), (100002, Some("Bug two")), (100003, Some("Bug three")), ], ); Arc::new(RwLock::new(bc)) }; let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let content = "Source: test-package\nMaintainer: Test \n"; let parsed = debian_control::lossless::Control::parse(content); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "3 open bugs"); assert_eq!( lenses[0].command.as_ref().unwrap().command, OPEN_URL_COMMAND ); } #[tokio::test] async fn test_code_lens_binary_package_popcon_and_rdeps() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let bug_cache = make_shared_bug_cache(); let popcon_cache = { let mut pc = crate::popcon::PopconCache::new(crate::udd::shared_pool()); pc.insert_cached("libfoo1", 42000); Arc::new(RwLock::new(pc)) }; let rdeps_cache = { let mut rc = crate::rdeps::RdepsCache::new(crate::udd::shared_pool()); rc.insert_cached("libfoo1", 150); Arc::new(RwLock::new(rc)) }; let content = "\ Source: foo Maintainer: Test Package: libfoo1 Architecture: any Description: Foo library "; let parsed = debian_control::lossless::Control::parse(content); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 2); assert_eq!( lenses[0].command.as_ref().unwrap().title, "popcon: 42.0k installs" ); assert_eq!( lenses[0].command.as_ref().unwrap().command, OPEN_URL_COMMAND ); assert_eq!( lenses[1].command.as_ref().unwrap().title, "150 reverse deps" ); assert_eq!( lenses[1].command.as_ref().unwrap().command, OPEN_URL_COMMAND ); } #[tokio::test] async fn test_code_lens_binary_package_bug_count() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let vcswatch_cache = make_shared_vcswatch_cache(); let bug_cache = { let mut bc = crate::bugs::BugCache::new(crate::udd::shared_pool()); bc.insert_cached_open_bugs_for_binary_package( "libfoo1", vec![(200001, Some("Crash on load")), (200002, Some("Typo"))], ); Arc::new(RwLock::new(bc)) }; let popcon_cache = make_shared_popcon_cache(); let rdeps_cache = make_shared_rdeps_cache(); let content = "\ Source: foo Maintainer: Test Package: libfoo1 Architecture: any Description: Foo library "; let parsed = debian_control::lossless::Control::parse(content); let ctx = LensContext { package_cache: &shared_cache, vcswatch_cache: &vcswatch_cache, bug_cache: &bug_cache, popcon_cache: &popcon_cache, rdeps_cache: &rdeps_cache, }; let idx = crate::position::LineIndex::new(content); let (lenses, _uncached) = generate_code_lenses(&parsed, Source::new(content, &idx), &ctx).await; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "2 open bugs"); assert_eq!( lenses[0].command.as_ref().unwrap().command, OPEN_URL_COMMAND ); let args = lenses[0] .command .as_ref() .unwrap() .arguments .as_ref() .unwrap(); assert_eq!( args[0], serde_json::json!("https://bugs.debian.org/libfoo1") ); } #[test] fn test_format_count() { assert_eq!(format_count(0), "0"); assert_eq!(format_count(999), "999"); assert_eq!(format_count(1000), "1.0k"); assert_eq!(format_count(42000), "42.0k"); assert_eq!(format_count(1_500_000), "1.5M"); } } debian-lsp-0.1.8/src/control/completion.rs000064400000000000000000001212361046102023000166430ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, InsertTextFormat}; use super::fields::{ CONTROL_FIELDS, CONTROL_PRIORITY_VALUES, CONTROL_SECTION_AREAS, CONTROL_SECTION_VALUES, CONTROL_SPECIAL_SECTION_VALUES, ESSENTIAL_VALUES, MULTI_ARCH_VALUES, RULES_REQUIRES_ROOT_VALUES, TESTSUITE_VALUES, }; use super::relation_completion; use crate::architecture::SharedArchitectureList; use crate::maintainers::SharedMaintainerCache; use crate::package_cache::SharedPackageCache; use crate::position::Source; /// Get completions for a control file at the given cursor position. /// /// Uses the parsed deb822 document for position-aware completions: /// if on a field value, returns value completions; otherwise returns /// field name completions. Relationship field completions are not /// included here because they require async access to the package cache; /// use [`get_async_field_value_completions`] for those. pub fn get_completions( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: tower_lsp_server::ls_types::Position, ) -> Vec { let mut completions = crate::deb822::completion::get_completions( deb822, src, position, CONTROL_FIELDS, get_field_value_completions, ); let context = crate::deb822::completion::get_cursor_context(deb822, src, position); match context { Some(crate::deb822::completion::CursorContext::StartOfLine) => { if src.text.trim().is_empty() { completions.extend(get_snippet_completions()); } else { completions.extend(get_paragraph_snippet_completions()); } } Some(crate::deb822::completion::CursorContext::FieldKey) if src.text.trim().is_empty() => { completions.extend(get_snippet_completions()); } _ => {} } completions } /// Get snippet completions for scaffolding a new control file from scratch. fn get_snippet_completions() -> Vec { let mut snippets = vec![CompletionItem { label: "Source package".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold a source + binary control file".to_string()), insert_text: Some( "Source: ${1:package}\n\ Section: ${2:misc}\n\ Priority: ${3:optional}\n\ Maintainer: ${4:name }\n\ Build-Depends: ${5:debhelper-compat (= 13)}\n\ Standards-Version: ${6:4.7.0}\n\ Rules-Requires-Root: no\n\ \n\ Package: ${7:$1}\n\ Architecture: ${8:any}\n\ Depends: ${9:\\${shlibs:Depends\\}, \\${misc:Depends\\}}\n\ Description: ${10:short description}\n\ ${11: long description}\n" .to_string(), ), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("0".to_string()), ..Default::default() }]; snippets.extend(get_paragraph_snippet_completions()); snippets } /// Get snippet completions for adding new paragraphs to an existing control file. fn get_paragraph_snippet_completions() -> Vec { vec![CompletionItem { label: "Binary package".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold a binary package paragraph".to_string()), insert_text: Some( "Package: ${1:package}\n\ Architecture: ${2:any}\n\ Depends: ${3:\\${shlibs:Depends\\}, \\${misc:Depends\\}}\n\ Description: ${4:short description}\n\ ${5: long description}\n" .to_string(), ), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("1".to_string()), ..Default::default() }] } /// Get value completions for specific control file fields (sync only). /// /// Returns completions for Section and Priority fields. /// Returns empty for relationship fields (handled async separately) /// and for unknown fields. pub fn get_field_value_completions(field_name: &str, prefix: &str) -> Vec { if field_name.eq_ignore_ascii_case("Section") { get_section_value_completions(prefix) } else if field_name.eq_ignore_ascii_case("Priority") { get_priority_value_completions(prefix) } else if field_name.eq_ignore_ascii_case("Essential") { get_essential_value_completions(prefix) } else if field_name.eq_ignore_ascii_case("Multi-Arch") { get_multiarch_value_completions(prefix) } else if field_name.eq_ignore_ascii_case("Rules-Requires-Root") { get_rules_requires_root_value_completions(prefix) } else if field_name.eq_ignore_ascii_case("Testsuite") { get_testsuite_value_completions(prefix) } else { vec![] } } /// Get async value completions for control file fields that need the package cache. /// /// Returns `Some` with completions for relationship fields, `None` for other fields. pub async fn get_async_field_value_completions( field_name: &str, prefix: &str, position: tower_lsp_server::ls_types::Position, package_cache: &SharedPackageCache, architecture_list: &SharedArchitectureList, maintainer_cache: &SharedMaintainerCache, ) -> Option> { if relation_completion::is_relationship_field(field_name) { Some( relation_completion::get_relationship_completions( prefix, position, package_cache, architecture_list, ) .await, ) } else if field_name.eq_ignore_ascii_case("Architecture") { Some(get_architecture_value_completions(prefix, architecture_list).await) } else if field_name.eq_ignore_ascii_case("Maintainer") || field_name.eq_ignore_ascii_case("Uploaders") { Some(get_maintainer_completions(prefix, maintainer_cache).await) } else { None } } /// Special architecture values that are always available. const ARCHITECTURE_SPECIAL_VALUES: &[(&str, &str)] = &[ ("all", "Architecture-independent package"), ("any", "Build for any supported architecture"), ]; /// Get completion items for "Architecture" control fields. /// /// Handles space-separated multiple architectures and the `!` negation prefix. pub async fn get_architecture_value_completions( prefix: &str, architecture_list: &SharedArchitectureList, ) -> Vec { // The prefix is the entire field value up to the cursor. // For multiple architectures (space-separated), we only complete the last token. let current_token = prefix.rsplit(' ').next().unwrap_or("").trim(); // Handle negation prefix let (negated, arch_prefix) = if let Some(rest) = current_token.strip_prefix('!') { (true, rest) } else { (false, current_token) }; let normalized_prefix = arch_prefix.to_ascii_lowercase(); let arches = architecture_list.read().await; let mut completions = Vec::new(); // Add special values ("all", "any") — only when not negated if !negated { for &(value, description) in ARCHITECTURE_SPECIAL_VALUES { if value.starts_with(&normalized_prefix) { completions.push(CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), ..Default::default() }); } } } // Add matching architectures, with or without negation prefix for arch in arches.iter() { if arch.starts_with(&normalized_prefix) { let label = if negated { format!("!{}", arch) } else { arch.clone() }; completions.push(CompletionItem { label, kind: Some(CompletionItemKind::VALUE), ..Default::default() }); } } completions } /// Get completion items for "Maintainer" and "Uploaders" control fields. /// /// Suggests the user's identity (from `$DEBEMAIL`/`$DEBFULLNAME`) first, /// then known maintainer identities from the UDD `sources` table. /// The Uploaders field is comma-separated, so we complete only the last entry. pub async fn get_maintainer_completions( prefix: &str, maintainer_cache: &SharedMaintainerCache, ) -> Vec { // For comma-separated fields (Uploaders), complete the last entry let current_token = prefix.rsplit(',').next().unwrap_or("").trim(); let normalized_prefix = current_token.to_ascii_lowercase(); let mut completions = Vec::new(); // Suggest user's own identity first if let Some(identity) = crate::maintainers::get_user_identity() { if identity .to_ascii_lowercase() .starts_with(&normalized_prefix) { completions.push(CompletionItem { label: identity, kind: Some(CompletionItemKind::VALUE), detail: Some("Your identity".to_string()), sort_text: Some("0".to_string()), ..Default::default() }); } } // Add known maintainers from UDD let mut cache = maintainer_cache.write().await; for maintainer in cache.get_maintainers().await { if maintainer .to_ascii_lowercase() .starts_with(&normalized_prefix) { completions.push(CompletionItem { label: maintainer.clone(), kind: Some(CompletionItemKind::VALUE), ..Default::default() }); } } completions } /// Get completion items for "Priority" control field. pub fn get_priority_value_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); CONTROL_PRIORITY_VALUES .iter() .filter(|(value, _)| value.starts_with(&normalized_prefix)) .map(|&(value, description)| CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(value.to_string()), ..Default::default() }) .collect() } /// Get completion items for "Section" control field. /// /// Includes both `section` and `area/section` forms. pub fn get_section_value_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); let mut completions = Vec::new(); for &(section, description) in CONTROL_SECTION_VALUES { if section.starts_with(&normalized_prefix) { completions.push(CompletionItem { label: section.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(section.to_string()), ..Default::default() }); } } for &area in CONTROL_SECTION_AREAS { for &(section, description) in CONTROL_SECTION_VALUES { let qualified = format!("{}/{}", area, section); if qualified.starts_with(&normalized_prefix) { completions.push(CompletionItem { label: qualified.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(qualified), ..Default::default() }); } } } for &(special, description) in CONTROL_SPECIAL_SECTION_VALUES { if special.starts_with(&normalized_prefix) { completions.push(CompletionItem { label: special.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(special.to_string()), ..Default::default() }); } } completions } /// Get completion items for "Essential" control field. pub fn get_essential_value_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); ESSENTIAL_VALUES .iter() .filter(|(value, _)| value.starts_with(&normalized_prefix)) .map(|&(value, description)| CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(value.to_string()), ..Default::default() }) .collect() } /// Get completion items for "Multi-Arch" control fields. pub fn get_multiarch_value_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); MULTI_ARCH_VALUES .iter() .filter(|(value, _)| value.starts_with(&normalized_prefix)) .map(|&(value, description)| CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(value.to_string()), ..Default::default() }) .collect() } /// Get completion items for "Testsuite" control field. /// /// The Testsuite field is comma-separated, so we complete the last token. pub fn get_testsuite_value_completions(prefix: &str) -> Vec { let current_token = prefix.rsplit(',').next().unwrap_or("").trim(); let normalized_prefix = current_token.to_ascii_lowercase(); TESTSUITE_VALUES .iter() .filter(|(value, _)| value.starts_with(&normalized_prefix)) .map(|&(value, description)| CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(value.to_string()), ..Default::default() }) .collect() } /// Get completion items for "Rules-Requires-Root" control field. pub fn get_rules_requires_root_value_completions(prefix: &str) -> Vec { let normalized_prefix = prefix.trim().to_ascii_lowercase(); RULES_REQUIRES_ROOT_VALUES .iter() .filter(|(value, _)| value.starts_with(&normalized_prefix)) .map(|&(value, description)| CompletionItem { label: value.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(description.to_string()), insert_text: Some(value.to_string()), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; use crate::architecture::SharedArchitectureList; use crate::package_cache::TestPackageCache; use std::sync::Arc; use tower_lsp_server::ls_types::Position; fn test_cache() -> SharedPackageCache { TestPackageCache::new_shared(&[ ("cmake", Some("cross-platform make")), ("debhelper-compat", None), ( "dh-python", Some("Debian helper tools for packaging Python"), ), ("libssl-dev", None), ("pkg-config", None), ]) } fn test_maintainer_cache() -> SharedMaintainerCache { let mut cache = crate::maintainers::MaintainerCache::new(crate::udd::shared_pool()); cache.insert_cached(vec![ "Alice ".to_string(), "Debian QA Group ".to_string(), ]); Arc::new(tokio::sync::RwLock::new(cache)) } fn test_arch_list() -> SharedArchitectureList { Arc::new(tokio::sync::RwLock::new(vec![ "amd64".to_string(), "arm64".to_string(), "armhf".to_string(), "i386".to_string(), ])) } #[test] fn test_get_completions_on_field_key() { let text = "Source: test\nSection: py\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(1, 3)); // Should have field completions only assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::FIELD))); } #[test] fn test_get_completions_on_section_value() { let text = "Source: test\nSection: py\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(1, 11)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"python")); } #[test] fn test_get_completions_on_priority_value() { let text = "Source: test\nPriority: op\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(1, 12)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["optional"]); } #[test] fn test_priority_value_completions() { let completions = get_priority_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"required")); assert!(labels.contains(&"important")); assert!(labels.contains(&"standard")); assert!(labels.contains(&"optional")); assert!(labels.contains(&"extra")); } #[test] fn test_priority_value_completions_with_prefix() { let completions = get_priority_value_completions("op"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["optional"]); } #[test] fn test_priority_value_completions_with_uppercase_prefix() { let completions = get_priority_value_completions("OP"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["optional"]); } #[test] fn test_section_value_completions() { let completions = get_section_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"admin")); assert!(labels.contains(&"python")); assert!(labels.contains(&"debian-installer")); assert!(labels.contains(&"non-free/python")); assert!(!labels.contains(&"non-free/debian-installer")); // Check that descriptions are present let admin = completions.iter().find(|c| c.label == "admin").unwrap(); assert_eq!( admin.detail.as_deref(), Some("System administration utilities") ); } #[test] fn test_section_value_completions_with_area_prefix() { let completions = get_section_value_completions("non-free/"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"non-free/python")); assert!(!labels.contains(&"python")); assert!(!labels.contains(&"non-free/debian-installer")); // Area-qualified sections use the same description as the base section let nf_python = completions .iter() .find(|c| c.label == "non-free/python") .unwrap(); assert_eq!( nf_python.detail.as_deref(), Some("Python programming language") ); } #[test] fn test_get_field_value_completions_for_section() { let completions = get_field_value_completions("Section", "py"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"python")); } #[test] fn test_get_field_value_completions_for_priority() { let completions = get_field_value_completions("Priority", "op"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["optional"]); } #[test] fn test_get_field_value_completions_for_essential() { let completions = get_field_value_completions("Essential", "y"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["yes"]); } #[test] fn test_get_field_value_completions_for_multiarch() { let completions = get_field_value_completions("Multi-Arch", "all"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["allowed"]); } #[test] fn test_get_field_value_completions_for_testsuite() { let completions = get_field_value_completions("Testsuite", "autopkgtest-pkg-p"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!( labels, vec!["autopkgtest-pkg-perl", "autopkgtest-pkg-python"] ); } #[test] fn test_testsuite_value_completions() { let completions = get_testsuite_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"autopkgtest")); assert!(labels.contains(&"autopkgtest-pkg-python")); } #[test] fn test_testsuite_value_completions_with_prefix() { let completions = get_testsuite_value_completions("autopkgtest-pkg-r"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["autopkgtest-pkg-r", "autopkgtest-pkg-ruby"]); } #[test] fn test_testsuite_value_completions_after_comma() { let completions = get_testsuite_value_completions("autopkgtest, autopkgtest-pkg-g"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["autopkgtest-pkg-go"]); } #[test] fn test_get_field_value_completions_for_rules_requires_root() { let completions = get_field_value_completions("Rules-Requires-Root", "n"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["no"]); } #[test] fn test_rules_requires_root_value_completions() { let completions = get_rules_requires_root_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"no")); assert!(labels.contains(&"binary-targets")); } #[test] fn test_rules_requires_root_value_completions_with_prefix() { let completions = get_rules_requires_root_value_completions("bi"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["binary-targets"]); } #[test] fn test_get_field_value_completions_for_unknown_field() { let completions = get_field_value_completions("Homepage", "http"); assert!(completions.is_empty()); } #[test] fn test_get_completions_on_essential_value() { let text = "Source: test\nEssential: y\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(1, 12)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["yes"]); } #[test] fn test_essential_value_completions() { let completions = get_essential_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"yes")); assert!(labels.contains(&"no")); } #[test] fn test_essential_value_completions_with_prefix() { let completions = get_essential_value_completions("n"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["no"]); } #[test] fn test_essential_value_completions_with_uppercase_prefix() { let completions = get_essential_value_completions("YE"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["yes"]); } #[test] fn test_multiarch_value_completions() { let completions = get_multiarch_value_completions(""); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"allowed")); assert!(labels.contains(&"foreign")); assert!(labels.contains(&"no")); assert!(labels.contains(&"same")); } #[test] fn test_multiarch_value_completions_with_prefix() { let completions = get_multiarch_value_completions("all"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["allowed"]); } #[test] fn test_multiarch_value_completions_with_uppercase_prefix() { let completions = get_multiarch_value_completions("ALL"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["allowed"]); } #[tokio::test] async fn test_async_field_value_completions_for_depends() { let cache = test_cache(); let completions = get_async_field_value_completions( "Depends", "cm", Position::new(0, 2), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["cmake"]); } #[tokio::test] async fn test_async_field_value_completions_for_build_depends() { let cache = test_cache(); let completions = get_async_field_value_completions( "Build-Depends", "", Position::new(0, 0), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions"); assert!(!completions.is_empty()); } #[tokio::test] async fn test_async_field_value_completions_for_non_relationship() { let cache = test_cache(); let completions = get_async_field_value_completions( "Homepage", "http", Position::new(0, 4), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await; assert!(completions.is_none()); } /// End-to-end test: get_cursor_context → get_async_field_value_completions /// for a single-line Build-Depends field with cursor after ": ". #[tokio::test] async fn test_end_to_end_build_depends_empty_value() { let text = "Build-Depends: \n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = crate::deb822::completion::get_cursor_context( &deb822, crate::position::Source::new(text, &idx), tower_lsp_server::ls_types::Position::new(0, 15), ) .expect("Should have context"); match ctx { crate::deb822::completion::CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Build-Depends"); assert_eq!(value_prefix, ""); let cache = test_cache(); let completions = get_async_field_value_completions( &field_name, &value_prefix, tower_lsp_server::ls_types::Position::new(0, 15), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions for relationship field"); assert!(!completions.is_empty(), "Should have package completions"); } other => panic!("Expected FieldValue, got {:?}", other), } } /// End-to-end test: cursor in middle of Build-Depends value. #[tokio::test] async fn test_end_to_end_build_depends_partial_name() { let text = "Build-Depends: dh\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = crate::deb822::completion::get_cursor_context( &deb822, crate::position::Source::new(text, &idx), tower_lsp_server::ls_types::Position::new(0, 17), ) .expect("Should have context"); match ctx { crate::deb822::completion::CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Build-Depends"); assert_eq!(value_prefix, "dh"); let cache = test_cache(); let completions = get_async_field_value_completions( &field_name, &value_prefix, tower_lsp_server::ls_types::Position::new(0, 17), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions for relationship field"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["dh-python"]); } other => panic!("Expected FieldValue, got {:?}", other), } } /// End-to-end test: substvar completion after comma should not eat the comma. #[tokio::test] async fn test_end_to_end_substvar_after_comma() { let text = "Depends: gpg,${misc:\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let position = Position::new(0, 20); let idx = crate::position::LineIndex::new(text); let ctx = crate::deb822::completion::get_cursor_context( &deb822, crate::position::Source::new(text, &idx), position, ) .expect("Should have context"); match ctx { crate::deb822::completion::CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Depends"); assert_eq!(value_prefix, "gpg,${misc:"); let cache = test_cache(); let completions = get_async_field_value_completions( &field_name, &value_prefix, position, &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions"); let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .expect("Should have ${misc:Depends} completion"); // The text_edit range must start at the "$" (col 13), NOT at the comma (col 12) let edit = match &misc_depends.text_edit { Some(tower_lsp_server::ls_types::CompletionTextEdit::Edit(e)) => e, _ => panic!("Expected TextEdit"), }; assert_eq!(edit.range.start, Position::new(0, 13)); assert_eq!(edit.range.end, position); assert_eq!(edit.new_text, "${misc:Depends}"); } other => panic!("Expected FieldValue, got {:?}", other), } } #[tokio::test] async fn test_architecture_value_completions_empty_prefix() { let completions = get_architecture_value_completions("", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"all")); assert!(labels.contains(&"any")); assert!(labels.contains(&"amd64")); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); assert!(labels.contains(&"i386")); } #[tokio::test] async fn test_architecture_value_completions_with_prefix() { let completions = get_architecture_value_completions("arm", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 2); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); } #[tokio::test] async fn test_architecture_value_completions_uppercase_prefix() { let completions = get_architecture_value_completions("ARM", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 2); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); } #[tokio::test] async fn test_architecture_value_completions_special_values() { let completions = get_architecture_value_completions("a", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"all")); assert!(labels.contains(&"any")); assert!(labels.contains(&"amd64")); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); } #[tokio::test] async fn test_architecture_value_completions_special_all() { let completions = get_architecture_value_completions("al", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["all"]); assert!(completions[0].detail.is_some()); } #[tokio::test] async fn test_architecture_value_completions_negated() { let completions = get_architecture_value_completions("!arm", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 2); assert!(labels.contains(&"!arm64")); assert!(labels.contains(&"!armhf")); } #[tokio::test] async fn test_architecture_value_completions_negated_no_special() { let completions = get_architecture_value_completions("!", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); // Negation should not include "all" or "any" assert!(!labels.contains(&"!all")); assert!(!labels.contains(&"!any")); assert!(labels.contains(&"!amd64")); } #[tokio::test] async fn test_architecture_value_completions_multiple_arches() { // When user has typed "amd64 arm", we should complete the last token "arm" let completions = get_architecture_value_completions("amd64 arm", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 2); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); } #[tokio::test] async fn test_architecture_value_completions_multiple_with_negation() { let completions = get_architecture_value_completions("any !i", &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["!i386"]); } #[tokio::test] async fn test_maintainer_completions_from_cache() { let cache = test_maintainer_cache(); let completions = get_maintainer_completions("", &cache).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"Alice ")); assert!(labels.contains(&"Debian QA Group ")); } #[tokio::test] async fn test_maintainer_completions_with_prefix() { let cache = test_maintainer_cache(); let completions = get_maintainer_completions("Deb", &cache).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Debian QA Group "]); } #[tokio::test] async fn test_maintainer_completions_after_comma() { let cache = test_maintainer_cache(); let completions = get_maintainer_completions("Alice , Deb", &cache).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Debian QA Group "]); } #[tokio::test] async fn test_async_field_value_completions_for_maintainer() { let cache = test_cache(); let completions = get_async_field_value_completions( "Maintainer", "Ali", Position::new(0, 3), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions for Maintainer field"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Alice "]); } #[tokio::test] async fn test_async_field_value_completions_for_uploaders() { let cache = test_cache(); let completions = get_async_field_value_completions( "Uploaders", "", Position::new(0, 0), &cache, &test_arch_list(), &test_maintainer_cache(), ) .await .expect("Should return completions for Uploaders field"); assert!(!completions.is_empty()); } #[test] fn test_snippet_completions_on_empty_file() { let text = ""; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(0, 0)); let mut snippet_labels: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .map(|c| c.label.as_str()) .collect(); snippet_labels.sort(); assert_eq!(snippet_labels, vec!["Binary package", "Source package"]); for snippet in completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) { assert_eq!(snippet.insert_text_format, Some(InsertTextFormat::SNIPPET)); assert!(snippet.insert_text.is_some()); } } #[test] fn test_paragraph_snippets_on_non_empty_file() { let text = "Source: test\nSection: misc\n\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(2, 0)); let snippet_labels: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .map(|c| c.label.as_str()) .collect(); assert_eq!(snippet_labels, vec!["Binary package"]); } #[test] fn test_no_snippets_on_field_value() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&deb822, Source::new(text, &idx), Position::new(0, 10)); let snippet_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .count(); assert_eq!(snippet_count, 0); } #[test] fn test_source_package_snippet_content() { let snippets = get_snippet_completions(); let source = snippets .iter() .find(|c| c.label == "Source package") .expect("Should have Source package snippet"); assert_eq!( source.insert_text.as_deref().unwrap(), "Source: ${1:package}\n\ Section: ${2:misc}\n\ Priority: ${3:optional}\n\ Maintainer: ${4:name }\n\ Build-Depends: ${5:debhelper-compat (= 13)}\n\ Standards-Version: ${6:4.7.0}\n\ Rules-Requires-Root: no\n\ \n\ Package: ${7:$1}\n\ Architecture: ${8:any}\n\ Depends: ${9:\\${shlibs:Depends\\}, \\${misc:Depends\\}}\n\ Description: ${10:short description}\n\ ${11: long description}\n" ); } } debian-lsp-0.1.8/src/control/definition.rs000064400000000000000000000215401046102023000166170ustar 00000000000000//! Go-to-definition for package names in debian/control relationship fields. use debian_control::lossless::relations::Relations; use debian_control::lossless::{Control, Parse}; use debian_control::relations::SyntaxKind as RelSyntaxKind; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{Location, Position, Uri}; use super::relation_completion::is_relationship_field; use crate::deb822::completion::{get_cursor_context, CursorContext}; use crate::position::Source; /// Find the package name at the cursor position within a relationship field value. /// /// Parses the full field value, then walks the CST to find the RELATION node /// whose IDENT token covers the cursor byte offset. fn find_package_at_offset(value: &str, offset_in_value: usize) -> Option { let (relations, _errors) = Relations::parse_relaxed(value, false); let syntax = relations.syntax(); // Walk all tokens to find the IDENT inside a RELATION node that covers the offset. let mut tok = syntax.first_token(); while let Some(t) = tok { if t.kind() == RelSyntaxKind::IDENT { let range = t.text_range(); let start: usize = range.start().into(); let end: usize = range.end().into(); if start <= offset_in_value && offset_in_value <= end { // Check this IDENT is a package name (direct child of RELATION node) if let Some(parent) = t.parent() { if parent.kind() == RelSyntaxKind::RELATION { return Some(t.text().to_string()); } } } } tok = t.next_token(); } None } /// Try to resolve go-to-definition for a package name in a relationship field. /// /// Returns a `Location` pointing to the binary package paragraph in the same /// control file, if the cursor is on a package name that matches one of the /// binary packages defined in the file. pub fn goto_definition( parse: &Parse, src: Source<'_>, position: Position, uri: &Uri, ) -> Option { let control = parse.tree(); let deb822 = control.as_deb822(); // Determine cursor context — we only handle relationship field values. let ctx = get_cursor_context(deb822, src, position)?; let (field_name, _value_prefix) = match ctx { CursorContext::FieldValue { field_name, value_prefix, } => (field_name, value_prefix), _ => return None, }; if !is_relationship_field(&field_name) { return None; } // Find the entry that contains the cursor to get the full value and its byte range. let offset = src.try_position_to_offset(position)?; let entry = deb822 .paragraphs() .flat_map(|p| p.entries().collect::>()) .find(|entry| { let r = entry.text_range(); r.start() <= offset && offset < r.end() })?; let value_range = entry.value_range()?; let value_start: usize = value_range.start().into(); let offset_usize: usize = offset.into(); // Get the raw value text from the source, stripping continuation-line // leading whitespace the same way the completion code does. let value_end: usize = value_range.end().into(); let raw_value = &src.text[value_start..value_end]; // The value_prefix gives us text up to cursor with continuation indentation // stripped. But for CST offset computation we need the offset within the // raw value text (which includes continuation indentation). let offset_in_value = offset_usize - value_start; let package_name = find_package_at_offset(raw_value, offset_in_value)?; // Look for a matching binary package paragraph. for binary in control.binaries() { if binary.name().as_deref() == Some(package_name.as_str()) { let para = binary.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); return Some(Location { uri: uri.clone(), range, }); } } // Also check the source paragraph name. if let Some(source) = control.source() { if source.name().as_deref() == Some(package_name.as_str()) { let para = source.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); return Some(Location { uri: uri.clone(), range, }); } } None } #[cfg(test)] mod tests { use super::*; fn test_uri() -> Uri { if cfg!(windows) { Uri::from_file_path("C:\\tmp\\debian\\control").unwrap() } else { Uri::from_file_path("/tmp/debian/control").unwrap() } } #[test] fn test_goto_definition_binary_package() { let text = "\ Source: mypackage Maintainer: Test Build-Depends: debhelper, mypackage-dev Package: mypackage Architecture: any Depends: mypackage-dev Description: Main package Package: mypackage-dev Architecture: any Description: Development files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "mypackage-dev" in Build-Depends (line 2, on the 'm' of mypackage-dev) let result = goto_definition(&parsed, src, Position::new(2, 26), &uri); assert!(result.is_some(), "Should find definition for mypackage-dev"); let loc = result.unwrap(); // Should point to the "Package: mypackage-dev" paragraph assert_eq!(loc.range.start.line, 9); } #[test] fn test_goto_definition_in_depends() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Depends: foo-dev Description: Main Package: foo-dev Architecture: any Description: Dev files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "foo-dev" in Depends field (line 5, character 10) let result = goto_definition(&parsed, src, Position::new(5, 10), &uri); assert!(result.is_some(), "Should find definition for foo-dev"); let loc = result.unwrap(); assert_eq!(loc.range.start.line, 8); } #[test] fn test_goto_definition_not_on_relationship_field() { let text = "\ Source: mypackage Maintainer: Test Package: mypackage Architecture: any Description: A package "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "any" in Architecture field — not a relationship field let result = goto_definition(&parsed, src, Position::new(4, 16), &uri); assert!(result.is_none()); } #[test] fn test_goto_definition_external_package() { let text = "\ Source: mypackage Maintainer: Test Build-Depends: debhelper Package: mypackage Architecture: any Description: A package "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "debhelper" — not defined in this control file let result = goto_definition(&parsed, src, Position::new(2, 18), &uri); assert!(result.is_none()); } #[test] fn test_goto_definition_multiline_depends() { let text = "\ Source: foo Maintainer: Test Build-Depends: debhelper-compat (= 13), foo-dev Package: foo Architecture: any Description: Main Package: foo-dev Architecture: any Description: Dev "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "foo-dev" in multiline Build-Depends (line 4, character 1) let result = goto_definition(&parsed, src, Position::new(4, 2), &uri); assert!( result.is_some(), "Should find definition for foo-dev in multiline field" ); let loc = result.unwrap(); assert_eq!(loc.range.start.line, 10); } #[test] fn test_find_package_at_offset_simple() { let name = find_package_at_offset("debhelper, foo-dev", 12); assert_eq!(name.as_deref(), Some("foo-dev")); } #[test] fn test_find_package_at_offset_first() { let name = find_package_at_offset("debhelper, foo-dev", 3); assert_eq!(name.as_deref(), Some("debhelper")); } #[test] fn test_find_package_at_offset_with_version() { let name = find_package_at_offset("debhelper-compat (= 13), foo-dev", 26); assert_eq!(name.as_deref(), Some("foo-dev")); } } debian-lsp-0.1.8/src/control/detection.rs000064400000000000000000000023731046102023000164500ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian control file pub fn is_control_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/control") || path.ends_with("/debian/control") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_control_file() { let control_paths = vec![ "file:///path/to/debian/control", "file:///project/debian/control", "file:///control", "file:///some/path/control", ]; let non_control_paths = vec![ "file:///path/to/other.txt", "file:///path/to/control.txt", "file:///path/to/mycontrol", "file:///path/to/debian/control.backup", ]; for path in control_paths { let uri = path.parse::().unwrap(); assert!( is_control_file(&uri), "Should detect control file: {}", path ); } for path in non_control_paths { let uri = path.parse::().unwrap(); assert!( !is_control_file(&uri), "Should not detect as control file: {}", path ); } } } debian-lsp-0.1.8/src/control/diagnostics.rs000064400000000000000000000143671046102023000170070ustar 00000000000000use text_size::TextRange; use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Position, Range}; use crate::position::Source; use crate::workspace::FieldCasingIssue; /// All types of diagnostic issues that can be found in a control file #[derive(Debug, Clone)] pub enum DiagnosticIssue { FieldCasing(FieldCasingIssue), ParseError { message: String, range: Option, }, } /// Find all diagnostic issues in a control file, optionally within a specific range pub fn find_all_issues( parsed: &debian_control::lossless::Parse, range: Option, ) -> Vec { let mut issues = Vec::new(); // Add parse errors with position information for error in parsed.positioned_errors() { // If we have a range filter, check if this error is in range if let Some(filter_range) = range { if error.range.start() >= filter_range.end() || error.range.end() <= filter_range.start() { continue; // Skip errors outside the range } } issues.push(DiagnosticIssue::ParseError { message: error.message.to_string(), range: Some(error.range), }); } // Add field casing issues if let Ok(control) = parsed.clone().to_result() { for paragraph in control.as_deb822().paragraphs() { for entry in paragraph.entries() { let entry_range = entry.text_range(); // If a range is specified, check if this entry is within it if let Some(filter_range) = range { if entry_range.start() >= filter_range.end() || entry_range.end() <= filter_range.start() { continue; // Skip entries outside the range } } if let Some(field_name) = entry.key() { if let Some(standard_name) = crate::control::get_standard_field_name(&field_name) { if field_name != standard_name { let field_range = TextRange::new( entry_range.start(), entry_range.start() + text_size::TextSize::of(field_name.as_str()), ); issues.push(DiagnosticIssue::FieldCasing(FieldCasingIssue { field_name, standard_name: standard_name.to_string(), field_range, })); } } } } } } issues } /// Convert a DiagnosticIssue to an LSP Diagnostic pub fn issue_to_diagnostic(issue: DiagnosticIssue, src: Source<'_>) -> Diagnostic { match issue { DiagnosticIssue::ParseError { message, range } => { let lsp_range = if let Some(range) = range { src.text_range_to_lsp_range(range) } else { // Fallback to (0,0) if no range is available Range { start: Position { line: 0, character: 0, }, end: Position { line: 0, character: 0, }, } }; Diagnostic { range: lsp_range, severity: Some(DiagnosticSeverity::ERROR), message, ..Default::default() } } DiagnosticIssue::FieldCasing(casing) => { let lsp_range = src.text_range_to_lsp_range(casing.field_range); Diagnostic { range: lsp_range, severity: Some(DiagnosticSeverity::WARNING), code: Some(NumberOrString::String("field-casing".to_string())), source: Some("debian-lsp".to_string()), message: format!( "Field name '{}' should be '{}'", casing.field_name, casing.standard_name ), ..Default::default() } } } } /// Find all field casing issues in a control file, optionally within a specific range pub fn find_field_casing_issues( parsed: &debian_control::lossless::Parse, range: Option, ) -> Vec { find_all_issues(parsed, range) .into_iter() .filter_map(|issue| match issue { DiagnosticIssue::FieldCasing(casing) => Some(casing), _ => None, }) .collect() } /// Get all LSP diagnostics for a control file pub fn get_diagnostics( src: Source<'_>, parsed: &debian_control::lossless::Parse, ) -> Vec { find_all_issues(parsed, None) .into_iter() .map(|issue| issue_to_diagnostic(issue, src)) .collect() } #[cfg(test)] mod tests { use super::*; use crate::workspace::Workspace; #[test] fn test_find_all_issues_correct_casing() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/control").unwrap(); let content = "Source: test-package\nMaintainer: Test \n\nPackage: test-package\nArchitecture: amd64\nDescription: A test package\n"; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_control(file); let issues = find_all_issues(&parsed, None); assert!(issues.is_empty()); } #[test] fn test_find_all_issues_incorrect_casing() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/control").unwrap(); let content = "source: test-package\nmaintainer: Test \n\npackage: test-package\narchitecture: amd64\ndescription: A test package\n"; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_control(file); let issues = find_all_issues(&parsed, None); assert!(!issues.is_empty()); } } debian-lsp-0.1.8/src/control/fields.rs000064400000000000000000000231451046102023000157400ustar 00000000000000use crate::deb822::completion::FieldInfo; /// All available Debian control file fields pub const CONTROL_FIELDS: &[FieldInfo] = &[ FieldInfo::new("Source", "Name of the source package"), FieldInfo::new("Section", "Classification of the package"), FieldInfo::new("Priority", "Priority of the package"), FieldInfo::new("Maintainer", "Package maintainer's name and email"), FieldInfo::new("Uploaders", "Additional maintainers"), FieldInfo::new("Build-Depends", "Build dependencies"), FieldInfo::new( "Build-Depends-Indep", "Architecture-independent build dependencies", ), FieldInfo::new( "Build-Depends-Arch", "Architecture-specific build dependencies", ), FieldInfo::new("Build-Conflicts", "Packages that conflict during build"), FieldInfo::new( "Build-Conflicts-Indep", "Architecture-independent build conflicts", ), FieldInfo::new( "Build-Conflicts-Arch", "Architecture-specific build conflicts", ), FieldInfo::new("Standards-Version", "Debian Policy version"), FieldInfo::new("Homepage", "Upstream project homepage"), FieldInfo::new("Vcs-Browser", "Web interface for VCS"), FieldInfo::new("Vcs-Git", "Git repository URL"), FieldInfo::new("Package", "Binary package name"), FieldInfo::new("Architecture", "Supported architectures"), FieldInfo::new("Multi-Arch", "Multi-architecture support"), FieldInfo::new("Depends", "Package dependencies"), FieldInfo::new("Pre-Depends", "Pre-installation dependencies"), FieldInfo::new("Recommends", "Recommended packages"), FieldInfo::new("Suggests", "Suggested packages"), FieldInfo::new("Enhances", "Packages enhanced by this one"), FieldInfo::new("Conflicts", "Conflicting packages"), FieldInfo::new("Breaks", "Packages broken by this one"), FieldInfo::new("Provides", "Virtual packages provided"), FieldInfo::new("Replaces", "Packages replaced by this one"), FieldInfo::new("Description", "Package description"), FieldInfo::new("Essential", "Essential package flag"), FieldInfo::new("Rules-Requires-Root", "Root privileges requirement"), FieldInfo::new("Testsuite", "DEP-8 test suite"), ]; /// Get the standard casing for a field name pub fn get_standard_field_name(field_name: &str) -> Option<&'static str> { crate::deb822::completion::get_standard_field_name(CONTROL_FIELDS, field_name) } /// Debian policy-recognized priority values for control files. /// Each entry is (value, description). pub const CONTROL_PRIORITY_VALUES: &[(&str, &str)] = &[ ("required", "Essential for the system to function"), ( "important", "Important programs, including those expected on a Unix-like system", ), ( "standard", "Reasonably small but not too limited character-mode system", ), ( "optional", "All packages not required for a reasonably functional system", ), ("extra", "Deprecated alias for optional"), ]; /// Debian policy section values for normal packages. /// Each entry is (value, description). // TODO: Read section list from an external file or Debian policy data instead of hardcoding. pub const CONTROL_SECTION_VALUES: &[(&str, &str)] = &[ ("admin", "System administration utilities"), ("cli-mono", "Mono/CLI based programs"), ("comm", "Communication programs"), ("database", "Database servers and tools"), ("debug", "Debug packages"), ("devel", "Development tools and libraries"), ("doc", "Documentation"), ("editors", "Text editors"), ("education", "Educational software"), ("electronics", "Electronics and electrical engineering"), ("embedded", "Embedded systems software"), ("fonts", "Font packages"), ("games", "Games and amusements"), ("gnome", "GNOME desktop environment"), ("gnu-r", "GNU R statistical system"), ("gnustep", "GNUstep environment"), ("graphics", "Graphics tools"), ("hamradio", "Ham radio software"), ("haskell", "Haskell programming language"), ("httpd", "Web servers"), ("interpreters", "Interpreted languages"), ("introspection", "GObject introspection data"), ("java", "Java programming language"), ("javascript", "JavaScript programming"), ("kde", "KDE desktop environment"), ("kernel", "Kernel and kernel modules"), ("libdevel", "Development libraries"), ("libs", "Shared libraries"), ("lisp", "Lisp programming language"), ("localization", "Localization and internationalization"), ("mail", "Email programs"), ("math", "Mathematics and numerical computation"), ("metapackages", "Metapackages"), ("misc", "Miscellaneous"), ("net", "Networking tools"), ("news", "Usenet news"), ("ocaml", "OCaml programming language"), ("oldlibs", "Obsolete libraries"), ("otherosfs", "Other OS file systems"), ("perl", "Perl programming language"), ("php", "PHP programming language"), ("python", "Python programming language"), ("ruby", "Ruby programming language"), ("rust", "Rust programming language"), ("science", "Scientific software"), ("shells", "Command-line shells"), ("sound", "Sound and audio"), ("tasks", "Task packages for installation"), ("tex", "TeX typesetting system"), ("text", "Text processing utilities"), ("utils", "General-purpose utilities"), ("vcs", "Version control systems"), ("video", "Video tools"), ("web", "Web browsers and tools"), ("x11", "X Window System"), ("xfce", "Xfce desktop environment"), ("zope", "Zope/Plone framework"), ]; /// Debian archive areas used as section prefixes in control fields. /// /// Section field values can be `area/section` for non-main archive areas. pub const CONTROL_SECTION_AREAS: &[&str] = &["contrib", "non-free", "non-free-firmware"]; /// Debian policy special section values. /// /// `debian-installer` is used for installer packages and not normal packages. pub const CONTROL_SPECIAL_SECTION_VALUES: &[(&str, &str)] = &[("debian-installer", "Debian installer components")]; /// Debian essential values. pub const ESSENTIAL_VALUES: &[(&str, &str)] = &[ ("yes", "Essential package for the system"), ("no", "Not an essential package"), ]; /// Debian Rules-Requires-Root values. pub const RULES_REQUIRES_ROOT_VALUES: &[(&str, &str)] = &[ ("no", "Build process does not require root"), ( "binary-targets", "Only the binary targets require (fake)root", ), ]; /// Debian Testsuite values. pub const TESTSUITE_VALUES: &[(&str, &str)] = &[ ("autopkgtest", "Standard autopkgtest test suite"), ( "autopkgtest-pkg-dkms", "Autopkgtest for DKMS kernel module packages", ), ( "autopkgtest-pkg-elpa", "Autopkgtest for Emacs Lisp packages", ), ("autopkgtest-pkg-go", "Autopkgtest for Go packages"), ("autopkgtest-pkg-nodejs", "Autopkgtest for Node.js packages"), ("autopkgtest-pkg-octave", "Autopkgtest for Octave packages"), ("autopkgtest-pkg-perl", "Autopkgtest for Perl packages"), ("autopkgtest-pkg-python", "Autopkgtest for Python packages"), ("autopkgtest-pkg-r", "Autopkgtest for R packages"), ("autopkgtest-pkg-ruby", "Autopkgtest for Ruby packages"), ]; /// Debian multi-architecture values. pub const MULTI_ARCH_VALUES: &[(&str, &str)] = &[ ("allowed", "Multi-arch allowed"), ("foreign", "Usable by other architectures"), ("no", "Not multi-arch"), ("same", "Must match dependent architecture"), ]; #[cfg(test)] mod tests { use super::*; #[test] fn test_control_fields() { assert!(!CONTROL_FIELDS.is_empty()); assert!(CONTROL_FIELDS.len() >= 20); // Test specific fields exist let field_names: Vec<_> = CONTROL_FIELDS.iter().map(|f| f.name).collect(); assert!(field_names.contains(&"Source")); assert!(field_names.contains(&"Package")); assert!(field_names.contains(&"Depends")); assert!(field_names.contains(&"Build-Depends")); } #[test] fn test_control_field_validity() { for field in CONTROL_FIELDS { assert!(!field.name.is_empty()); assert!(!field.description.is_empty()); assert!( field .name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-'), "Field {} contains invalid characters", field.name ); } } #[test] fn test_control_priority_values() { let names: Vec<_> = CONTROL_PRIORITY_VALUES.iter().map(|(n, _)| *n).collect(); assert_eq!( names, &["required", "important", "standard", "optional", "extra"] ); for (_, desc) in CONTROL_PRIORITY_VALUES { assert!(!desc.is_empty()); } } #[test] fn test_control_section_values() { let names: Vec<_> = CONTROL_SECTION_VALUES.iter().map(|(n, _)| *n).collect(); assert!(!names.is_empty()); assert!(names.contains(&"admin")); assert!(names.contains(&"python")); assert!(names.contains(&"xfce")); assert!(!names.contains(&"debian-installer")); for (_, desc) in CONTROL_SECTION_VALUES { assert!(!desc.is_empty()); } } #[test] fn test_control_section_areas() { assert_eq!( CONTROL_SECTION_AREAS, &["contrib", "non-free", "non-free-firmware"] ); } #[test] fn test_control_special_section_values() { let names: Vec<_> = CONTROL_SPECIAL_SECTION_VALUES .iter() .map(|(n, _)| *n) .collect(); assert_eq!(names, &["debian-installer"]); } } debian-lsp-0.1.8/src/control/hover.rs000064400000000000000000000022531046102023000156120ustar 00000000000000use tower_lsp_server::ls_types::{Hover, Position}; use super::fields::CONTROL_FIELDS; use crate::position::Source; /// Get hover information for a debian/control file at the given cursor position. pub fn get_hover( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, ) -> Option { crate::deb822::hover::get_hover(deb822, src, position, CONTROL_FIELDS) } #[cfg(test)] mod tests { use super::*; use crate::position::LineIndex; #[test] fn test_hover_on_build_depends() { let text = "Source: test\nBuild-Depends: debhelper\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(1, 5)); assert!(hover.is_some()); } #[test] fn test_hover_on_unknown_field() { let text = "Source: test\nX-Custom: value\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(1, 3)); assert!(hover.is_none()); } } debian-lsp-0.1.8/src/control/inlay_hints.rs000064400000000000000000000635371046102023000170240ustar 00000000000000//! Inlay hints for debian/control files. //! //! - Archive versions: `[unstable: 13.31 | bullseye: 13.3.4]` per suite //! - Virtual packages: `→ [exim4 (4.99) | postfix (3.11)]` with providers //! - Substvars: `[= libc6 (>= 2.17), ...]` showing resolved values use std::collections::HashMap; use debian_control::lossless::relations::Relations; use text_size::TextSize; use tower_lsp_server::ls_types::{InlayHint, InlayHintKind, InlayHintLabel}; use crate::position::Source; /// Create an inlay hint at the given source offset with the given label. /// /// Converts the offset to an LSP position and constructs the hint with /// standard padding and kind settings. fn make_hint(src: Source<'_>, offset: TextSize, label: String) -> InlayHint { let lsp_range = src.text_range_to_lsp_range(text_size::TextRange::new(offset, offset)); InlayHint { position: lsp_range.start, label: InlayHintLabel::String(label), kind: Some(InlayHintKind::TYPE), text_edits: None, tooltip: None, padding_left: Some(true), padding_right: None, data: None, } } /// Info about a relation in a dependency field. struct RelationInfo { /// The package name. name: String, /// The end position of the relation in the source text. relation_end: TextSize, } /// Info about a substvar in a dependency field. struct SubstvarInfo { /// The substvar name (e.g. "binary:Version"). name: String, /// The end position of the substvar in the source text. substvar_end: TextSize, } /// All hint-relevant data extracted from a parsed control file in a single pass. struct HintData { relations: Vec, substvars: Vec, } /// Map an offset in the joined value string (as produced by `entry.value()`) /// back to an absolute source position using the individual VALUE token ranges. /// /// `entry.value()` joins VALUE tokens with `\n`, so for multi-line values the /// offsets in the joined string don't correspond 1:1 to source positions. pub(super) fn joined_offset_to_source_offset( line_ranges: &[text_size::TextRange], joined_offset: usize, ) -> Option { let mut remaining = joined_offset; for (i, lr) in line_ranges.iter().enumerate() { let line_len: usize = lr.len().into(); if remaining <= line_len { return Some(lr.start() + TextSize::from(remaining as u32)); } remaining -= line_len; // Account for the '\n' separator that entry.value() inserts // between VALUE tokens (except after the last one) if i < line_ranges.len() - 1 { if remaining == 0 { // The offset points exactly at the '\n' separator; // map to the end of this line return Some(lr.end()); } remaining -= 1; // skip the '\n' } } None } /// Extract all hint-relevant data from a parsed control file in a single CST walk. /// /// Collects relations and substvars from all paragraphs within the given range. fn extract_hint_data( parsed: &debian_control::lossless::Parse, src: Source<'_>, range: &tower_lsp_server::ls_types::Range, ) -> HintData { let control = parsed.tree(); let Some(text_range) = src.try_lsp_range_to_text_range(range) else { return HintData { relations: Vec::new(), substvars: Vec::new(), }; }; let mut relations = Vec::new(); let mut substvars = Vec::new(); for paragraph in control.as_deb822().paragraphs() { for entry in paragraph.entries() { let entry_range = entry.text_range(); if entry_range.start() >= text_range.end() || entry_range.end() <= text_range.start() { continue; } let Some(field_name) = entry.key() else { continue; }; if !super::relation_completion::is_relationship_field(&field_name) { continue; } let value = entry.value(); let (parsed_rels, _errors) = Relations::parse_relaxed(&value, true); let line_ranges = entry.value_line_ranges(); for rel_entry in parsed_rels.entries() { for relation in rel_entry.relations() { let Some(name) = relation.try_name() else { continue; }; let rel_end: usize = relation.syntax().text_range().end().into(); let Some(absolute_end) = joined_offset_to_source_offset(&line_ranges, rel_end) else { continue; }; relations.push(RelationInfo { name, relation_end: absolute_end, }); } } for sv in parsed_rels.substvar_nodes() { let sv_end: usize = sv.syntax().text_range().end().into(); let Some(absolute_end) = joined_offset_to_source_offset(&line_ranges, sv_end) else { continue; }; let raw = sv.to_string(); let name = raw .strip_prefix("${") .and_then(|s| s.strip_suffix('}')) .unwrap_or(&raw) .to_string(); substvars.push(SubstvarInfo { name, substvar_end: absolute_end, }); } } } HintData { relations, substvars, } } /// Format version info as a compact string without brackets. /// /// Examples: /// `"sid,trixie: 13.31 | bullseye: 13.3.4"` /// `"available: 13.31"` (no suite info) fn format_version_info(versions: &[crate::package_cache::VersionInfo]) -> Option { if versions.is_empty() { return None; } let has_suites = versions.iter().any(|v| !v.suites.is_empty()); if !has_suites { return Some(format!("available: {}", versions[0].version)); } Some( versions .iter() .filter(|v| !v.suites.is_empty()) .map(|v| format!("{}: {}", v.suites.join(","), v.version)) .collect::>() .join(" | "), ) } /// Format a compact version hint from cached version info, wrapped in brackets. /// /// Examples: /// `[sid,trixie: 13.31 | bullseye: 13.3.4]` /// `[available: 13.31]` (no suite info) fn format_version_hint(versions: &[crate::package_cache::VersionInfo]) -> Option { format_version_info(versions).map(|s| format!("[{}]", s)) } /// Format a version string for a single package, showing just the candidate /// (first/newest) version without suite detail. fn format_short_version(versions: &[crate::package_cache::VersionInfo]) -> Option { versions.first().map(|v| v.version.clone()) } /// Format a compact provider hint for a virtual package, including version /// info for each provider when cached. /// /// Strategy for keeping the hint within `max_len` characters: /// 1. Try full per-suite version info for each provider /// 2. If too long, fall back to just the candidate version /// 3. If still too long, truncate the provider list with `...` fn format_provider_hint( providers: &[String], cache: &dyn crate::package_cache::PackageCache, uncached: &mut Vec, max_len: usize, ) -> String { // Annotate each provider with its version info let annotated: Vec<(String, Option, Option)> = providers .iter() .map(|p| { if let Some(versions) = cache.get_cached_versions(p) { let full = format_version_info(versions); let short = format_short_version(versions); (p.clone(), full, short) } else { uncached.push(p.clone()); (p.clone(), None, None) } }) .collect(); // Try 1: full suite detail for all providers let full_parts: Vec = annotated .iter() .map(|(name, full, _)| match full { Some(v) => format!("{} ({})", name, v), None => name.clone(), }) .collect(); let candidate = format!("→ [{}]", full_parts.join(" | ")); if candidate.len() <= max_len { return candidate; } // Try 2: just candidate version for all providers let short_parts: Vec = annotated .iter() .map(|(name, _, short)| match short { Some(v) => format!("{} ({})", name, v), None => name.clone(), }) .collect(); // Try fitting all short parts, then progressively fewer for n in (1..=short_parts.len()).rev() { let suffix = if n < short_parts.len() { " | ..." } else { "" }; let candidate = format!("→ [{}{}]", short_parts[..n].join(" | "), suffix); if candidate.len() <= max_len { return candidate; } } // Last resort: just the first provider format!("→ [{} | ...]", short_parts[0]) } /// Context for generating inlay hints, bundling all external data sources. pub struct HintContext<'a> { /// Cache for package version and provider lookups. pub package_cache: &'a crate::package_cache::SharedPackageCache, /// Resolved substvar values (e.g. `"binary:Version"` → `"1.2.3-1"`). pub resolved_substvars: &'a HashMap, } /// Generate inlay hints for a control file. /// /// Currently provides hints for: /// - Archive versions: shows `[available: X.Y.Z]` for real packages /// - Virtual packages: shows `→ [provider1 | provider2 | ...]` /// - Substvars: shows `[= value]` for known substitution variables /// /// Returns `(hints, uncached_packages)`. The caller should load versions and /// providers for uncached packages in the background and then send /// `workspace/inlayHint/refresh` to the client. pub async fn generate_inlay_hints( parsed: &debian_control::lossless::Parse, src: Source<'_>, range: &tower_lsp_server::ls_types::Range, ctx: &HintContext<'_>, ) -> (Vec, Vec) { // Extract info synchronously (CST types are not Send) let data = extract_hint_data(parsed, src, range); if data.relations.is_empty() && data.substvars.is_empty() { return (Vec::new(), Vec::new()); } let mut hints = Vec::new(); // Per-relation hints (archive versions, virtual package providers). // Use only cached data (read lock) for archive lookups to avoid // blocking the LSP response. Returns uncached package names so the // caller can trigger background loading and an inlayHint/refresh. let mut uncached_packages = Vec::new(); { let cache = ctx.package_cache.read().await; for rel in &data.relations { let cached_versions = cache.get_cached_versions(&rel.name); let cached_providers = cache.get_cached_providers(&rel.name); if let Some(versions) = cached_versions { if !versions.is_empty() { // Real package with version info — show archive versions if let Some(label) = format_version_hint(versions) { hints.push(make_hint(src, rel.relation_end, label)); } } else if let Some(providers) = cached_providers { // Versions cached but empty = virtual package; show providers // with their available versions if !providers.is_empty() { let label = format_provider_hint(providers, &*cache, &mut uncached_packages, 80); hints.push(make_hint(src, rel.relation_end, label)); } } // else: versions empty, no providers cached — will be loaded in background } else { // Versions not cached yet uncached_packages.push(rel.name.clone()); } } } // Deduplicate uncached packages since the same package can appear in // multiple dependency fields. uncached_packages.sort(); uncached_packages.dedup(); // Substvar hints for sv in &data.substvars { if let Some(value) = ctx.resolved_substvars.get(&sv.name) { hints.push(make_hint(src, sv.substvar_end, format!("[= {}]", value))); } } (hints, uncached_packages) } #[cfg(test)] mod tests { use super::*; fn default_ctx<'a>( package_cache: &'a crate::package_cache::SharedPackageCache, resolved_substvars: &'a HashMap, ) -> HintContext<'a> { HintContext { package_cache, resolved_substvars, } } #[tokio::test] async fn test_inlay_hint_multiline_build_depends() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache.versions.insert( "debhelper".to_string(), vec![VersionInfo { version: "14.2".to_string(), suites: vec!["unstable".to_string()], }], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Build-Depends: debhelper (>= 13.5), debhelper-compat (= 13), pkg-config Maintainer: Test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; // debhelper-compat is skipped (handled by code lenses), // only debhelper version hint expected assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "[unstable: 14.2]"), _ => panic!("Expected string label"), } assert_eq!(hints[0].position.line, 1); } #[tokio::test] async fn test_inlay_hint_virtual_package() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); // default-mta is virtual: empty versions, has providers cache.versions.insert("default-mta".to_string(), Vec::new()); cache.providers.insert( "default-mta".to_string(), vec![ "exim4-daemon-light".to_string(), "postfix".to_string(), "sendmail-bin".to_string(), ], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: default-mta, libc6 Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => { assert_eq!(s, "→ [exim4-daemon-light | postfix | sendmail-bin]") } _ => panic!("Expected string label"), } } #[tokio::test] async fn test_no_provider_hint_for_real_package() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); // libc6 is a real package with versions AND providers cache .packages .push(("libc6".to_string(), Some("C library".to_string()))); cache.versions.insert( "libc6".to_string(), vec![VersionInfo { version: "2.40-4".to_string(), suites: vec!["unstable".to_string()], }], ); cache .providers .insert("libc6".to_string(), vec!["libc6-udeb".to_string()]); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: libc6 Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; // Should show version hint, NOT provider hint assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "[unstable: 2.40-4]"), _ => panic!("Expected string label"), } } #[tokio::test] async fn test_inlay_hint_virtual_package_truncated() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache .versions .insert("mail-transport-agent".to_string(), Vec::new()); cache.providers.insert( "mail-transport-agent".to_string(), vec![ "courier-mta".to_string(), "exim4-daemon-heavy".to_string(), "exim4-daemon-light".to_string(), "postfix".to_string(), "sendmail-bin".to_string(), ], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: mail-transport-agent Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => { assert_eq!( s, "→ [courier-mta | exim4-daemon-heavy | exim4-daemon-light | postfix | ...]" ) } _ => panic!("Expected string label"), } } #[tokio::test] async fn test_inlay_hint_archive_version() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache .packages .push(("python3-all".to_string(), Some("Python 3".to_string()))); cache.versions.insert( "python3-all".to_string(), vec![VersionInfo { version: "3.12.8-1".to_string(), suites: vec!["unstable".to_string()], }], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: python3-all Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => assert_eq!(s, "[unstable: 3.12.8-1]"), _ => panic!("Expected string label"), } } #[tokio::test] async fn test_inlay_hint_archive_version_not_shown_without_cache() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); // Package is known but versions are not cached yet cache .packages .push(("python3-all".to_string(), Some("Python 3".to_string()))); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: python3-all Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; // No hint yet — versions will be loaded in background assert_eq!(hints.len(), 0); } #[tokio::test] async fn test_inlay_hint_archive_version_multiple_suites() { use crate::package_cache::{TestPackageCache, VersionInfo}; use std::sync::Arc; use tokio::sync::RwLock; let mut cache = TestPackageCache::default(); cache .packages .push(("debhelper".to_string(), Some("helper".to_string()))); cache.versions.insert( "debhelper".to_string(), vec![ VersionInfo { version: "13.31".to_string(), suites: vec!["unstable".to_string(), "testing".to_string()], }, VersionInfo { version: "13.3.4".to_string(), suites: vec!["bullseye".to_string()], }, ], ); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let content = "\ Source: test-package Package: test-package Depends: debhelper Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &HashMap::new()), ) .await; assert_eq!(hints.len(), 1); match &hints[0].label { InlayHintLabel::String(s) => { assert_eq!(s, "[unstable,testing: 13.31 | bullseye: 13.3.4]") } _ => panic!("Expected string label"), } } #[tokio::test] async fn test_inlay_hint_substvar() { use crate::package_cache::TestPackageCache; use std::sync::Arc; use tokio::sync::RwLock; let cache = TestPackageCache::default(); let shared_cache: crate::package_cache::SharedPackageCache = Arc::new(RwLock::new(cache)); let mut resolved = HashMap::new(); resolved.insert( "shlibs:Depends".to_string(), "libc6 (>= 2.17), libfoo1 (>= 1.0)".to_string(), ); let content = "\ Source: test-package Package: test-package Depends: ${shlibs:Depends}, ${misc:Depends} Description: A test "; let parsed = debian_control::lossless::Control::parse(content); let range = tower_lsp_server::ls_types::Range { start: tower_lsp_server::ls_types::Position::new(0, 0), end: tower_lsp_server::ls_types::Position::new(5, 0), }; let idx = crate::position::LineIndex::new(content); let (hints, _uncached) = generate_inlay_hints( &parsed, Source::new(content, &idx), &range, &default_ctx(&shared_cache, &resolved), ) .await; // Should have hint for ${shlibs:Depends} only (not ${misc:Depends}) let sv_hints: Vec<_> = hints .iter() .filter(|h| matches!(&h.label, InlayHintLabel::String(s) if s.starts_with("[= "))) .collect(); assert_eq!(sv_hints.len(), 1); match &sv_hints[0].label { InlayHintLabel::String(s) => { assert_eq!(s, "[= libc6 (>= 2.17), libfoo1 (>= 1.0)]") } _ => panic!("Expected string label"), } } } debian-lsp-0.1.8/src/control/mod.rs000064400000000000000000000014041046102023000152430ustar 00000000000000pub mod actions; pub mod code_lens; pub mod completion; pub mod definition; pub mod detection; pub mod diagnostics; pub mod fields; pub mod hover; pub mod inlay_hints; pub mod references; mod relation_completion; pub mod rename; pub mod semantic; pub mod symbols; pub use actions::*; pub use code_lens::generate_code_lenses; pub use completion::*; pub use definition::goto_definition; pub use detection::is_control_file; pub use fields::get_standard_field_name; pub use hover::get_hover; pub use inlay_hints::generate_inlay_hints; pub use references::find_references; pub use rename::{ collect_package_file_renames, collect_tests_control_edits, find_package_name_at_position, }; pub use semantic::generate_semantic_tokens; pub use symbols::generate_document_symbols; debian-lsp-0.1.8/src/control/references.rs000064400000000000000000000277721046102023000166250ustar 00000000000000//! Find-references for binary package names in debian/control. //! //! When the cursor is on a `Package:` field value, finds all locations in the //! same control file where that package name appears in relationship fields //! (Depends, Build-Depends, etc.). use debian_control::lossless::relations::Relations; use debian_control::lossless::{Control, Parse}; use debian_control::relations::SyntaxKind as RelSyntaxKind; use tower_lsp_server::ls_types::{Location, Position, Uri}; use super::relation_completion::is_relationship_field; use crate::position::Source; /// Find all locations in a control file where the given package name appears /// in relationship fields. fn find_package_references_in_control( parse: &Parse, src: Source<'_>, package_name: &str, uri: &Uri, include_declaration: bool, ) -> Vec { let control = parse.tree(); let mut locations = Vec::new(); // Optionally include the Package: field declaration itself. if include_declaration { for binary in control.binaries() { if binary.name().as_deref() == Some(package_name) { let para = binary.as_deb822(); if let Some(entry) = para.get_entry("Package") { if let Some(value_range) = entry.value_range() { let range = src.text_range_to_lsp_range(value_range); locations.push(Location { uri: uri.clone(), range, }); } } } } } // Scan all relationship fields for references to the package name. for paragraph in control.as_deb822().paragraphs() { for entry in paragraph.entries() { let Some(field_name) = entry.key() else { continue; }; if !is_relationship_field(&field_name) { continue; } let Some(value_range) = entry.value_range() else { continue; }; let value_start: usize = value_range.start().into(); let value_end: usize = value_range.end().into(); let raw_value = &src.text[value_start..value_end]; let (relations, _errors) = Relations::parse_relaxed(raw_value, false); let syntax = relations.syntax(); let mut tok = syntax.first_token(); while let Some(t) = tok { if t.kind() == RelSyntaxKind::IDENT { if let Some(parent) = t.parent() { if parent.kind() == RelSyntaxKind::RELATION && t.text() == package_name { let rel_start: usize = t.text_range().start().into(); let rel_end: usize = t.text_range().end().into(); let abs_range = rowan::TextRange::new( ((value_start + rel_start) as u32).into(), ((value_start + rel_end) as u32).into(), ); let range = src.text_range_to_lsp_range(abs_range); locations.push(Location { uri: uri.clone(), range, }); } } } tok = t.next_token(); } } } locations } /// Find references to a binary package name at the given cursor position. /// /// The cursor must be on a `Package:` field value or on a package name /// in a relationship field. Returns all locations in the control file /// where that package is referenced. pub fn find_references( parse: &Parse, src: Source<'_>, position: Position, uri: &Uri, include_declaration: bool, ) -> Vec { let control = parse.tree(); let Some(offset) = src.try_position_to_offset(position) else { return Vec::new(); }; // Check if cursor is on a Package: field value. for binary in control.binaries() { let para = binary.as_deb822(); let Some(entry) = para.get_entry("Package") else { continue; }; let Some(value_range) = entry.value_range() else { continue; }; if value_range.contains(offset) || value_range.end() == offset { if let Some(name) = binary.name() { return find_package_references_in_control( parse, src, &name, uri, include_declaration, ); } } } // Check if cursor is on a package name inside a relationship field. let deb822 = control.as_deb822(); let entry = deb822 .paragraphs() .flat_map(|p| p.entries().collect::>()) .find(|entry| { let r = entry.text_range(); r.start() <= offset && offset < r.end() }); let Some(entry) = entry else { return Vec::new(); }; let Some(field_name) = entry.key() else { return Vec::new(); }; if !is_relationship_field(&field_name) { return Vec::new(); } let Some(value_range) = entry.value_range() else { return Vec::new(); }; let value_start: usize = value_range.start().into(); let value_end: usize = value_range.end().into(); let raw_value = &src.text[value_start..value_end]; let offset_in_value = usize::from(offset) - value_start; // Find the package name at the cursor offset. let (relations, _errors) = Relations::parse_relaxed(raw_value, false); let syntax = relations.syntax(); let mut package_name = None; let mut tok = syntax.first_token(); while let Some(t) = tok { if t.kind() == RelSyntaxKind::IDENT { let start: usize = t.text_range().start().into(); let end: usize = t.text_range().end().into(); if start <= offset_in_value && offset_in_value <= end { if let Some(parent) = t.parent() { if parent.kind() == RelSyntaxKind::RELATION { package_name = Some(t.text().to_string()); break; } } } } tok = t.next_token(); } let Some(name) = package_name else { return Vec::new(); }; // Only find references for packages defined in this control file. let is_local = control .binaries() .any(|b| b.name().as_deref() == Some(&name)); if !is_local { return Vec::new(); } find_package_references_in_control(parse, src, &name, uri, include_declaration) } #[cfg(test)] mod tests { use super::*; fn test_uri() -> Uri { if cfg!(windows) { Uri::from_file_path("C:\\tmp\\debian\\control").unwrap() } else { Uri::from_file_path("/tmp/debian/control").unwrap() } } #[test] fn test_references_from_package_field() { let text = "\ Source: mypackage Maintainer: Test Build-Depends: debhelper, mypackage-dev Package: mypackage Architecture: any Depends: mypackage-dev Description: Main package Package: mypackage-dev Architecture: any Description: Development files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "mypackage-dev" in Package: field (line 9) let refs = find_references(&parsed, src, Position::new(9, 10), &uri, true); // Should find: the Package: declaration, Build-Depends ref, Depends ref assert_eq!(refs.len(), 3); // Declaration assert_eq!(refs[0].range.start.line, 9); // Build-Depends assert_eq!(refs[1].range.start.line, 2); // Depends assert_eq!(refs[2].range.start.line, 6); } #[test] fn test_references_from_package_field_no_declaration() { let text = "\ Source: mypackage Maintainer: Test Build-Depends: debhelper, mypackage-dev Package: mypackage Architecture: any Depends: mypackage-dev Description: Main package Package: mypackage-dev Architecture: any Description: Development files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "mypackage-dev" in Package: field (line 9), no declaration let refs = find_references(&parsed, src, Position::new(9, 10), &uri, false); // Should find: Build-Depends ref, Depends ref (no declaration) assert_eq!(refs.len(), 2); assert_eq!(refs[0].range.start.line, 2); assert_eq!(refs[1].range.start.line, 6); } #[test] fn test_references_from_relationship_field() { let text = "\ Source: foo Maintainer: Test Build-Depends: foo-dev Package: foo Architecture: any Depends: foo-dev Description: Main Package: foo-dev Architecture: any Description: Dev files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "foo-dev" in Depends field (line 6) let refs = find_references(&parsed, src, Position::new(6, 10), &uri, true); // Should find: Package: declaration, Build-Depends ref, Depends ref assert_eq!(refs.len(), 3); assert_eq!(refs[0].range.start.line, 9); assert_eq!(refs[1].range.start.line, 2); assert_eq!(refs[2].range.start.line, 6); } #[test] fn test_references_external_package() { let text = "\ Source: foo Maintainer: Test Build-Depends: debhelper Package: foo Architecture: any Description: Main "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "debhelper" - not defined in this file let refs = find_references(&parsed, src, Position::new(2, 16), &uri, true); assert!(refs.is_empty()); } #[test] fn test_references_not_on_package_name() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on Architecture value let refs = find_references(&parsed, src, Position::new(4, 16), &uri, true); assert!(refs.is_empty()); } #[test] fn test_references_package_not_referenced() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main Package: foo-dev Architecture: any Description: Dev files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // Cursor on "foo-dev" Package: field - not referenced anywhere let refs = find_references(&parsed, src, Position::new(7, 10), &uri, true); // Should find only the declaration itself assert_eq!(refs.len(), 1); assert_eq!(refs[0].range.start.line, 7); } #[test] fn test_references_package_not_referenced_no_declaration() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main Package: foo-dev Architecture: any Description: Dev files "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let uri = test_uri(); // No declaration included, and no references let refs = find_references(&parsed, src, Position::new(7, 10), &uri, false); assert!(refs.is_empty()); } } debian-lsp-0.1.8/src/control/relation_completion.rs000064400000000000000000001312731046102023000205420ustar 00000000000000use debian_control::lossless::relations::Relations; use debian_control::relations::SyntaxKind as RelSyntaxKind; use rowan::NodeOrToken; use tower_lsp_server::ls_types::{ CompletionItem, CompletionItemKind, CompletionTextEdit, Documentation, Position, Range, TextEdit, }; use crate::architecture::SharedArchitectureList; use crate::package_cache::SharedPackageCache; /// Relationship field names in debian/control. const RELATIONSHIP_FIELDS: &[&str] = &[ "Depends", "Pre-Depends", "Recommends", "Suggests", "Enhances", "Conflicts", "Breaks", "Provides", "Replaces", "Build-Depends", "Build-Depends-Indep", "Build-Depends-Arch", "Build-Conflicts", "Build-Conflicts-Indep", "Build-Conflicts-Arch", ]; /// Returns true if the field name is a relationship field. pub(crate) fn is_relationship_field(field_name: &str) -> bool { RELATIONSHIP_FIELDS .iter() .any(|f| f.eq_ignore_ascii_case(field_name)) } /// Version constraint operators in Debian relationships. const VERSION_OPERATORS: &[(&str, &str)] = &[ (">=", "Greater than or equal"), ("<=", "Less than or equal"), ("=", "Exactly equal"), (">>", "Strictly greater than"), ("<<", "Strictly less than"), ]; /// Where the cursor is within a relationship field value. #[derive(Debug, Clone, PartialEq, Eq)] enum RelationCompletionPosition { /// At a package name position (start, after comma, or after pipe). PackageName(String), /// After `(` — expecting a version operator. VersionOperator(String), /// After a version operator — expecting a version string. Version(String), /// Inside `[` — expecting an architecture name. Architecture(String), /// After `:` on a package name — expecting an architecture qualifier. ArchQualifier(String), /// Inside `<` — expecting a build profile name. BuildProfile(String), /// Inside `${` — expecting a substvar name. /// The `usize` is the length of the substvar opening (`$` or `${`). Substvar(String, usize), } /// Determine the completion position within a relationship field value prefix. /// /// Parses the prefix using the lossless relations parser and walks the /// concrete syntax tree to determine context at the end of the input. fn determine_relation_position(prefix: &str) -> RelationCompletionPosition { let (relations, _errors) = Relations::parse_relaxed(prefix, true); let syntax = relations.syntax(); // Find the last non-whitespace token in the tree. let last_token = last_significant_token(syntax); let Some(token) = last_token else { return RelationCompletionPosition::PackageName(String::new()); }; // Walk up to find what node context we're in. let parent_kind = token .parent() .map(|p| p.kind()) .unwrap_or(RelSyntaxKind::ROOT); // Check if we're inside a VERSION node (i.e. inside parentheses of a version constraint). let in_version = token .parent_ancestors() .any(|n| n.kind() == RelSyntaxKind::VERSION); if in_version { // Inside a version constraint like "libc6 (>= 2.1" or "libc6 (>" or "libc6 (" let version_node = token .parent_ancestors() .find(|n| n.kind() == RelSyntaxKind::VERSION) .unwrap(); // Check if we have a CONSTRAINT child node with any operator tokens. let constraint_text: String = version_node .children() .find(|n| n.kind() == RelSyntaxKind::CONSTRAINT) .map(|c| c.text().to_string()) .unwrap_or_default(); if constraint_text.is_empty() { // After "(" with no operator yet return RelationCompletionPosition::VersionOperator(String::new()); } // Is the last significant token part of the constraint operator? // If so, check whether the operator is followed by whitespace in the // original input — if yes, the operator is complete and we're in // version position; otherwise we're still typing the operator. if parent_kind == RelSyntaxKind::CONSTRAINT { let has_trailing_ws = token.next_token().is_some_and(|t| { matches!(t.kind(), RelSyntaxKind::WHITESPACE | RelSyntaxKind::NEWLINE) }); if has_trailing_ws { // Operator is complete, now in version position return RelationCompletionPosition::Version(String::new()); } return RelationCompletionPosition::VersionOperator(constraint_text); } // We have a constraint; we're in version position. // Collect IDENT tokens after the constraint as the version prefix. let version_prefix: String = version_node .children_with_tokens() .filter_map(|it| match it { NodeOrToken::Token(t) if t.kind() == RelSyntaxKind::IDENT || t.kind() == RelSyntaxKind::COLON => { Some(t.text().to_string()) } _ => None, }) .collect(); return RelationCompletionPosition::Version(version_prefix); } // Check if we're inside a SUBSTVAR node (i.e. inside `${...}`). let in_substvar = token .parent_ancestors() .any(|n| n.kind() == RelSyntaxKind::SUBSTVAR); if in_substvar { let substvar_node = token .parent_ancestors() .find(|n| n.kind() == RelSyntaxKind::SUBSTVAR) .unwrap(); // Collect the text of IDENT and COLON tokens inside the substvar. let partial: String = substvar_node .children_with_tokens() .filter_map(|it| match it { NodeOrToken::Token(t) if t.kind() == RelSyntaxKind::IDENT || t.kind() == RelSyntaxKind::COLON => { Some(t.text().to_string()) } _ => None, }) .collect(); // The opening is everything in the node text before the partial // (e.g. "$" or "${"). let node_text_len: usize = substvar_node.text().len().into(); let opening_len = node_text_len - partial.len(); return RelationCompletionPosition::Substvar(partial, opening_len); } // Check if we're inside an ARCHQUAL node (i.e. after ":" on a package name). let in_archqual = token .parent_ancestors() .any(|n| n.kind() == RelSyntaxKind::ARCHQUAL); if in_archqual { match token.kind() { RelSyntaxKind::COLON => { return RelationCompletionPosition::ArchQualifier(String::new()); } RelSyntaxKind::IDENT => { // If followed by whitespace, the qualifier is complete — // we're past the archqual, back to package name position. let has_trailing_ws = token.next_token().is_some_and(|t| { matches!(t.kind(), RelSyntaxKind::WHITESPACE | RelSyntaxKind::NEWLINE) }); if has_trailing_ws { return RelationCompletionPosition::PackageName(String::new()); } return RelationCompletionPosition::ArchQualifier(token.text().to_string()); } _ => { return RelationCompletionPosition::ArchQualifier(String::new()); } } } // Check if we're inside an ARCHITECTURES node (i.e. inside brackets). let in_architectures = token .parent_ancestors() .any(|n| n.kind() == RelSyntaxKind::ARCHITECTURES); if in_architectures { match token.kind() { RelSyntaxKind::L_BRACKET => { return RelationCompletionPosition::Architecture(String::new()); } RelSyntaxKind::IDENT => { // If followed by whitespace, the arch name is complete — // we're in position for a new architecture. let has_trailing_ws = token.next_token().is_some_and(|t| { matches!(t.kind(), RelSyntaxKind::WHITESPACE | RelSyntaxKind::NEWLINE) }); if has_trailing_ws { return RelationCompletionPosition::Architecture(String::new()); } // Check if preceded by "!" (negated arch). let negated = token .prev_token() .is_some_and(|t| t.kind() == RelSyntaxKind::NOT); let prefix = if negated { format!("!{}", token.text()) } else { token.text().to_string() }; return RelationCompletionPosition::Architecture(prefix); } RelSyntaxKind::NOT => { // "!" with no arch name yet return RelationCompletionPosition::Architecture("!".to_string()); } _ => { return RelationCompletionPosition::Architecture(String::new()); } } } // Check if we're inside a PROFILES node (i.e. inside angle brackets). let in_profiles = token .parent_ancestors() .any(|n| n.kind() == RelSyntaxKind::PROFILES); if in_profiles { match token.kind() { RelSyntaxKind::L_ANGLE => { return RelationCompletionPosition::BuildProfile(String::new()); } RelSyntaxKind::IDENT => { let has_trailing_ws = token.next_token().is_some_and(|t| { matches!(t.kind(), RelSyntaxKind::WHITESPACE | RelSyntaxKind::NEWLINE) }); if has_trailing_ws { return RelationCompletionPosition::BuildProfile(String::new()); } let negated = token .prev_token() .is_some_and(|t| t.kind() == RelSyntaxKind::NOT); let prefix = if negated { format!("!{}", token.text()) } else { token.text().to_string() }; return RelationCompletionPosition::BuildProfile(prefix); } RelSyntaxKind::NOT => { return RelationCompletionPosition::BuildProfile("!".to_string()); } _ => { return RelationCompletionPosition::BuildProfile(String::new()); } } } match token.kind() { RelSyntaxKind::COMMA | RelSyntaxKind::PIPE => { RelationCompletionPosition::PackageName(String::new()) } RelSyntaxKind::IDENT if parent_kind == RelSyntaxKind::RELATION => { // Could be a package name being typed RelationCompletionPosition::PackageName(token.text().to_string()) } _ => RelationCompletionPosition::PackageName(String::new()), } } /// Find the last non-whitespace token in a syntax tree. /// /// We walk forward from `first_token()` rather than using `last_token()` /// because rowan's `last_token()` returns `None` when the tree ends with /// an empty node (e.g. an ERROR node from incomplete input). fn last_significant_token( node: &rowan::SyntaxNode, ) -> Option> { let mut result = None; let mut tok = node.first_token(); while let Some(t) = tok { if !matches!(t.kind(), RelSyntaxKind::WHITESPACE | RelSyntaxKind::NEWLINE) { result = Some(t.clone()); } tok = t.next_token(); } result } /// Get completions for a relationship field value. pub(crate) async fn get_relationship_completions( prefix: &str, position: Position, package_cache: &SharedPackageCache, architecture_list: &SharedArchitectureList, ) -> Vec { match determine_relation_position(prefix) { RelationCompletionPosition::PackageName(partial) => { let cache = package_cache.read().await; let packages = cache.get_packages_with_prefix(&partial); packages .iter() .map(|pkg| { let description = cache.get_description(pkg).map(|s| s.to_string()); let documentation = description .as_deref() .map(|d| Documentation::String(d.to_string())); CompletionItem { label: pkg.clone(), kind: Some(CompletionItemKind::VALUE), detail: description.or_else(|| Some("Package".to_string())), documentation, ..Default::default() } }) .collect() } RelationCompletionPosition::VersionOperator(partial) => VERSION_OPERATORS .iter() .filter(|(op, _)| op.starts_with(&partial)) .map(|&(op, desc)| CompletionItem { label: op.to_string(), kind: Some(CompletionItemKind::OPERATOR), detail: Some(desc.to_string()), insert_text: Some(format!("{} ", op)), ..Default::default() }) .collect(), RelationCompletionPosition::Version(partial) => { get_version_completions(prefix, &partial, package_cache).await } RelationCompletionPosition::Architecture(partial) => { get_architecture_completions(&partial, architecture_list).await } RelationCompletionPosition::ArchQualifier(partial) => { get_arch_qualifier_completions(&partial, architecture_list).await } RelationCompletionPosition::BuildProfile(partial) => { get_build_profile_completions(&partial) } RelationCompletionPosition::Substvar(partial, opening_len) => { get_substvar_completions(&partial, opening_len, position) } } } /// Get completion items for architecture names. async fn get_architecture_completions( partial: &str, architecture_list: &SharedArchitectureList, ) -> Vec { let negated = partial.starts_with('!'); let prefix = if negated { &partial[1..] } else { partial }; let arches = architecture_list.read().await; arches .iter() .filter(|arch| arch.starts_with(prefix)) .map(|arch| { let label = if negated { format!("!{}", arch) } else { arch.clone() }; CompletionItem { label, kind: Some(CompletionItemKind::VALUE), ..Default::default() } }) .collect() } /// Special architecture qualifier values. const ARCH_QUALIFIER_SPECIALS: &[(&str, &str)] = &[ ("any", "Satisfied by any architecture"), ("native", "Host architecture only"), ]; /// Get completion items for architecture qualifiers (after `:` on a package name). /// /// Offers the special qualifiers `any` and `native`, plus all known architecture names. async fn get_arch_qualifier_completions( partial: &str, architecture_list: &SharedArchitectureList, ) -> Vec { let mut completions: Vec = ARCH_QUALIFIER_SPECIALS .iter() .filter(|(name, _)| name.starts_with(partial)) .map(|&(name, desc)| CompletionItem { label: name.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(desc.to_string()), ..Default::default() }) .collect(); let arches = architecture_list.read().await; completions.extend( arches .iter() .filter(|arch| arch.starts_with(partial)) .map(|arch| CompletionItem { label: arch.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some("Specific architecture".to_string()), ..Default::default() }), ); completions } /// Known build profile names. /// /// See and dpkg's /// `vendor/default/tupletable`. const BUILD_PROFILES: &[(&str, &str)] = &[ ("cross", "Cross-compilation mode"), ("nobiarch", "Disable multiarch/biarch support"), ("nocheck", "Skip test suites"), ("nodoc", "Skip documentation generation"), ("nogolang", "Skip Go-related build steps"), ("noinsttest", "Skip installed tests"), ("noperl", "Skip Perl-related build steps"), ("nopython", "Skip Python-related build steps"), ("noruby", "Skip Ruby-related build steps"), ("notriggered", "Do not activate triggers"), ("stage1", "Bootstrap stage 1"), ("stage2", "Bootstrap stage 2"), ]; /// Get completion items for build profiles (inside `<...>`). fn get_build_profile_completions(partial: &str) -> Vec { let negated = partial.starts_with('!'); let prefix = if negated { &partial[1..] } else { partial }; BUILD_PROFILES .iter() .filter(|(name, _)| name.starts_with(prefix)) .map(|&(name, desc)| { let label = if negated { format!("!{}", name) } else { name.to_string() }; CompletionItem { label, kind: Some(CompletionItemKind::VALUE), detail: Some(desc.to_string()), ..Default::default() } }) .collect() } /// Known substitution variables used in relationship fields. /// /// See deb-substvars(5). const KNOWN_SUBSTVARS: &[(&str, &str)] = &[ ("shlibs:Depends", "Shared library dependencies"), ("shlibs:Pre-Depends", "Shared library pre-dependencies"), ("shlibs:Suggests", "Shared library suggestions"), ("shlibs:Recommends", "Shared library recommendations"), ("misc:Depends", "Miscellaneous dependencies (debhelper)"), ( "misc:Pre-Depends", "Miscellaneous pre-dependencies (debhelper)", ), ( "misc:Recommends", "Miscellaneous recommendations (debhelper)", ), ("misc:Suggests", "Miscellaneous suggestions (debhelper)"), ("misc:Breaks", "Miscellaneous breaks (debhelper)"), ("misc:Enhances", "Miscellaneous enhances (debhelper)"), ("misc:Provides", "Miscellaneous provides (debhelper)"), ("misc:Conflicts", "Miscellaneous conflicts (debhelper)"), ("misc:Replaces", "Miscellaneous replaces (debhelper)"), ("perl:Depends", "Perl dependencies (dh_perl)"), ("python3:Depends", "Python 3 dependencies (dh_python3)"), ("python3:Provides", "Python 3 provides (dh_python3)"), ("python3:Breaks", "Python 3 breaks (dh_python3)"), ( "sphinxdoc:Depends", "Sphinx documentation dependencies (dh_sphinxdoc)", ), ("binary:Version", "Current binary package version"), ("source:Version", "Current source package version"), ( "source:Upstream-Version", "Upstream version (without Debian revision)", ), ]; /// Get completion items for substitution variables (inside `${...}`). /// /// Uses a `text_edit` with an explicit range covering the entire `${…` /// prefix so that the client replaces the whole substvar token rather /// than relying on word-boundary heuristics (which break on `$`, `{`, /// and `:`). fn get_substvar_completions( partial: &str, opening_len: usize, position: Position, ) -> Vec { // The cursor is at `position` and `partial` is the text after the // substvar opening (e.g. "$" or "${"). `opening_len` is the length // of that opening. The substvar token starts `opening_len + // partial.len()` characters before the cursor on the same line. let substvar_start_col = position.character - (partial.len() as u32) - (opening_len as u32); let edit_start = Position { line: position.line, character: substvar_start_col, }; let edit_range = Range { start: edit_start, end: position, }; KNOWN_SUBSTVARS .iter() .filter(|(name, _)| name.starts_with(partial)) .map(|&(name, desc)| { let full_text = format!("${{{}}}", name); CompletionItem { label: full_text.clone(), kind: Some(CompletionItemKind::VARIABLE), detail: Some(desc.to_string()), filter_text: Some(full_text.clone()), text_edit: Some(CompletionTextEdit::Edit(TextEdit { range: edit_range, new_text: full_text, })), ..Default::default() } }) .collect() } /// Extract the package name from a relationship prefix when in version position. /// /// Parses the prefix using the lossless relations parser and returns the /// name of the last relation (which is the one whose version is being typed). fn extract_package_for_version(prefix: &str) -> Option { let (relations, _errors) = Relations::parse_relaxed(prefix, true); // The last entry's last relation is the one with the version being typed. let last_entry = relations.entries().last()?; let last_relation = last_entry.relations().last()?; last_relation.try_name() } /// Get version completions for a package. async fn get_version_completions( prefix: &str, partial: &str, package_cache: &SharedPackageCache, ) -> Vec { let Some(package_name) = extract_package_for_version(prefix) else { return Vec::new(); }; let mut cache = package_cache.write().await; let Some(versions) = cache.load_versions(&package_name).await else { return Vec::new(); }; versions .iter() .filter(|v| v.version.starts_with(partial)) .map(|v| { let suites = v.suites.join(", "); CompletionItem { label: v.version.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some(suites), ..Default::default() } }) .collect() } #[cfg(test)] mod tests { use super::*; use crate::package_cache::TestPackageCache; use std::sync::Arc; fn test_cache() -> SharedPackageCache { TestPackageCache::new_shared(&[ ("cmake", Some("cross-platform make")), ("debhelper-compat", None), ( "dh-python", Some("Debian helper tools for packaging Python"), ), ("libssl-dev", None), ("pkg-config", None), ]) } fn test_arch_list() -> SharedArchitectureList { Arc::new(tokio::sync::RwLock::new(vec![ "amd64".to_string(), "arm64".to_string(), "armhf".to_string(), "i386".to_string(), ])) } /// Compute a cursor position at the end of a value prefix string. fn end_position(prefix: &str) -> Position { let line = prefix.matches('\n').count() as u32; let last_line = prefix.rsplit('\n').next().unwrap_or(prefix); let character = last_line.len() as u32; Position { line, character } } #[tokio::test] async fn test_relationship_completions_package_name_empty() { let cache = test_cache(); let completions = get_relationship_completions("", end_position(""), &cache, &test_arch_list()).await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"debhelper-compat")); assert!(labels.contains(&"cmake")); } #[tokio::test] async fn test_relationship_completions_package_name_prefix() { let cache = test_cache(); let completions = get_relationship_completions("deb", end_position("deb"), &cache, &test_arch_list()) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["debhelper-compat"]); } #[tokio::test] async fn test_relationship_completions_after_comma() { let cache = test_cache(); let completions = get_relationship_completions( "libc6 (>= 2.17), cm", end_position("libc6 (>= 2.17), cm"), &cache, &test_arch_list(), ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["cmake"]); } #[tokio::test] async fn test_relationship_completions_after_pipe() { let cache = test_cache(); let completions = get_relationship_completions( "libfoo | cm", end_position("libfoo | cm"), &cache, &test_arch_list(), ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["cmake"]); } #[tokio::test] async fn test_relationship_completions_version_operator() { let cache = test_cache(); let completions = get_relationship_completions( "libc6 (", end_position("libc6 ("), &cache, &test_arch_list(), ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec![">=", "<=", "=", ">>", "<<"]); } #[tokio::test] async fn test_relationship_completions_version_operator_partial() { let cache = test_cache(); let completions = get_relationship_completions( "libc6 (>", end_position("libc6 (>"), &cache, &test_arch_list(), ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec![">=", ">>"]); } #[tokio::test] async fn test_relationship_completions_version_position() { let cache = test_cache(); let completions = get_relationship_completions( "libc6 (>= ", end_position("libc6 (>= "), &cache, &test_arch_list(), ) .await; assert!(completions.is_empty()); } #[test] fn test_relationship_field_detection() { assert!(is_relationship_field("Depends")); assert!(is_relationship_field("depends")); assert!(is_relationship_field("Build-Depends")); assert!(is_relationship_field("Pre-Depends")); assert!(is_relationship_field("Recommends")); assert!(!is_relationship_field("Section")); assert!(!is_relationship_field("Priority")); assert!(!is_relationship_field("Homepage")); } #[tokio::test] async fn test_relationship_completions_multiline_value() { let cache = test_cache(); let completions = get_relationship_completions( "libc6 (>= 2.17),\n dh-py", end_position("libc6 (>= 2.17),\n dh-py"), &cache, &test_arch_list(), ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["dh-python"]); } #[tokio::test] async fn test_relationship_completions_with_description() { let cache = test_cache(); let completions = get_relationship_completions("cm", end_position("cm"), &cache, &test_arch_list()).await; assert_eq!(completions.len(), 1); assert_eq!(completions[0].label, "cmake"); assert_eq!( completions[0].detail, Some("cross-platform make".to_string()) ); assert_eq!( completions[0].documentation, Some(Documentation::String("cross-platform make".to_string())) ); } #[tokio::test] async fn test_relationship_completions_without_description() { let cache = test_cache(); let completions = get_relationship_completions( "debhelper", end_position("debhelper"), &cache, &test_arch_list(), ) .await; assert_eq!(completions.len(), 1); assert_eq!(completions[0].label, "debhelper-compat"); assert_eq!(completions[0].detail, Some("Package".to_string())); assert_eq!(completions[0].documentation, None); } #[test] fn test_determine_relation_position_empty() { assert_eq!( determine_relation_position(""), RelationCompletionPosition::PackageName(String::new()) ); } #[test] fn test_determine_relation_position_leading_space() { assert_eq!( determine_relation_position(" dh"), RelationCompletionPosition::PackageName("dh".to_string()) ); } #[test] fn test_determine_relation_position_leading_space_operator() { assert_eq!( determine_relation_position(" libc6 ("), RelationCompletionPosition::VersionOperator(String::new()) ); } #[test] fn test_determine_relation_position_partial_name() { assert_eq!( determine_relation_position("deb"), RelationCompletionPosition::PackageName("deb".to_string()) ); } #[test] fn test_determine_relation_position_after_open_paren() { assert_eq!( determine_relation_position("libc6 ("), RelationCompletionPosition::VersionOperator(String::new()) ); } #[test] fn test_determine_relation_position_partial_operator() { assert_eq!( determine_relation_position("libc6 (>"), RelationCompletionPosition::VersionOperator(">".to_string()) ); } #[test] fn test_determine_relation_position_after_operator() { assert_eq!( determine_relation_position("libc6 (>= "), RelationCompletionPosition::Version(String::new()) ); } #[test] fn test_determine_relation_position_partial_version() { assert_eq!( determine_relation_position("libc6 (>= 2.1"), RelationCompletionPosition::Version("2.1".to_string()) ); } #[test] fn test_determine_relation_position_after_comma() { assert_eq!( determine_relation_position("libc6, "), RelationCompletionPosition::PackageName(String::new()) ); } #[test] fn test_determine_relation_position_after_complete_relation() { assert_eq!( determine_relation_position("libc6 (>= 2.17), lib"), RelationCompletionPosition::PackageName("lib".to_string()) ); } #[test] fn test_determine_relation_position_after_open_bracket() { assert_eq!( determine_relation_position("libc6 ["), RelationCompletionPosition::Architecture(String::new()) ); } #[test] fn test_determine_relation_position_partial_arch() { assert_eq!( determine_relation_position("libc6 [amd"), RelationCompletionPosition::Architecture("amd".to_string()) ); } #[test] fn test_determine_relation_position_second_arch() { assert_eq!( determine_relation_position("libc6 [amd64 "), RelationCompletionPosition::Architecture(String::new()) ); } #[test] fn test_determine_relation_position_negated_arch() { assert_eq!( determine_relation_position("libc6 [amd64 !arm"), RelationCompletionPosition::Architecture("!arm".to_string()) ); } #[test] fn test_determine_relation_position_after_colon() { assert_eq!( determine_relation_position("libc6:"), RelationCompletionPosition::ArchQualifier(String::new()) ); } #[test] fn test_determine_relation_position_partial_archqual() { assert_eq!( determine_relation_position("libc6:an"), RelationCompletionPosition::ArchQualifier("an".to_string()) ); } #[test] fn test_determine_relation_position_complete_archqual() { assert_eq!( determine_relation_position("libc6:any "), RelationCompletionPosition::PackageName(String::new()) ); } #[tokio::test] async fn test_arch_qualifier_completions_empty() { let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions("libc6:", end_position("libc6:"), &cache, &arch_list) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"any")); assert!(labels.contains(&"native")); assert!(labels.contains(&"amd64")); } #[tokio::test] async fn test_arch_qualifier_completions_partial() { let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions("libc6:a", end_position("libc6:a"), &cache, &arch_list) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"any")); assert!(labels.contains(&"amd64")); assert!(labels.contains(&"arm64")); assert!(labels.contains(&"armhf")); assert!(!labels.contains(&"native")); assert!(!labels.contains(&"i386")); } #[test] fn test_determine_relation_position_after_angle_bracket() { assert_eq!( determine_relation_position("libc6 <"), RelationCompletionPosition::BuildProfile(String::new()) ); } #[test] fn test_determine_relation_position_partial_profile() { assert_eq!( determine_relation_position("libc6 = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"cross")); assert!(labels.contains(&"nocheck")); assert!(labels.contains(&"stage1")); } #[test] fn test_build_profile_completions_partial() { let completions = get_build_profile_completions("no"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"nocheck")); assert!(labels.contains(&"nodoc")); assert!(!labels.contains(&"cross")); assert!(!labels.contains(&"stage1")); } #[test] fn test_build_profile_completions_negated() { let completions = get_build_profile_completions("!no"); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"!nocheck")); assert!(labels.contains(&"!nodoc")); assert!(!labels.contains(&"!cross")); } #[tokio::test] async fn test_relationship_completions_build_profile() { let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions( "libc6 = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"nocheck")); assert!(!labels.contains(&"cross")); } #[test] fn test_determine_relation_position_after_dollar_brace() { assert_eq!( determine_relation_position("${"), RelationCompletionPosition::Substvar(String::new(), 2) ); } #[test] fn test_determine_relation_position_partial_substvar() { assert_eq!( determine_relation_position("${shlibs"), RelationCompletionPosition::Substvar("shlibs".to_string(), 2) ); } #[test] fn test_determine_relation_position_substvar_with_colon() { assert_eq!( determine_relation_position("${shlibs:Dep"), RelationCompletionPosition::Substvar("shlibs:Dep".to_string(), 2) ); } #[test] fn test_determine_relation_position_substvar_after_comma() { assert_eq!( determine_relation_position("gpg, ${"), RelationCompletionPosition::Substvar(String::new(), 2) ); } #[test] fn test_determine_relation_position_substvar_after_comma_partial() { assert_eq!( determine_relation_position("gpg, ${misc:"), RelationCompletionPosition::Substvar("misc:".to_string(), 2) ); } #[tokio::test] async fn test_substvar_text_edit_range_after_comma() { // Simulates: "Depends: gpg, ${misc:" with cursor at col 21 // value_prefix = "gpg, ${misc:" (12 chars), starts at col 9 // The "$" is at col 14 in the document let value_prefix = "gpg, ${misc:"; // Cursor position: col 9 (value start) + 12 (value_prefix len) = 21 let position = Position { line: 0, character: 21, }; let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions(value_prefix, position, &cache, &arch_list).await; let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .unwrap(); let range = text_edit_range(misc_depends).unwrap(); // Range should start at col 14 (the "$") not col 13 (the space) assert_eq!( range.start, Position { line: 0, character: 14 } ); assert_eq!(range.end, position); assert_eq!(text_edit_new_text(misc_depends), Some("${misc:Depends}")); } #[tokio::test] async fn test_substvar_text_edit_range_after_comma_no_space() { // Simulates: "Depends: gpg,${misc:" with cursor at col 20 // value_prefix = "gpg,${misc:" (11 chars), starts at col 9 // The "$" is at col 13 let value_prefix = "gpg,${misc:"; let position = Position { line: 0, character: 20, }; let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions(value_prefix, position, &cache, &arch_list).await; let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .unwrap(); let range = text_edit_range(misc_depends).unwrap(); // Range should start at col 13 (the "$") not col 12 (the comma) assert_eq!( range.start, Position { line: 0, character: 13 } ); assert_eq!(range.end, position); } #[test] fn test_determine_relation_position_dollar_only() { // When "$" is typed (trigger char), "{" hasn't been typed yet. // The parser still treats this as a SUBSTVAR node, but with // opening_len = 1 (just "$"). assert_eq!( determine_relation_position("gpg,$"), RelationCompletionPosition::Substvar(String::new(), 1) ); } #[test] fn test_determine_relation_position_substvar_after_comma_no_space() { assert_eq!( determine_relation_position("gpg,${"), RelationCompletionPosition::Substvar(String::new(), 2) ); } /// Extract the new_text from a CompletionItem's text_edit. fn text_edit_new_text(item: &CompletionItem) -> Option<&str> { match &item.text_edit { Some(CompletionTextEdit::Edit(edit)) => Some(&edit.new_text), _ => None, } } /// Extract the range from a CompletionItem's text_edit. fn text_edit_range(item: &CompletionItem) -> Option { match &item.text_edit { Some(CompletionTextEdit::Edit(edit)) => Some(edit.range), _ => None, } } #[test] fn test_substvar_completions_empty() { let completions = get_substvar_completions("", 2, end_position("${")); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"${shlibs:Depends}")); assert!(labels.contains(&"${misc:Depends}")); } #[test] fn test_substvar_completions_partial() { let prefix = "${shlibs"; let completions = get_substvar_completions("shlibs", 2, end_position(prefix)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"${shlibs:Depends}")); assert!(!labels.contains(&"${misc:Depends}")); let shlibs_depends = completions .iter() .find(|c| c.label == "${shlibs:Depends}") .unwrap(); assert_eq!( text_edit_new_text(shlibs_depends), Some("${shlibs:Depends}") ); // Range should cover from "$" to cursor let range = text_edit_range(shlibs_depends).unwrap(); assert_eq!( range.start, Position { line: 0, character: 0 } ); assert_eq!(range.end, end_position(prefix)); } #[test] fn test_substvar_completions_with_colon() { let prefix = "${misc:D"; let completions = get_substvar_completions("misc:D", 2, end_position(prefix)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"${misc:Depends}")); assert!(!labels.contains(&"${misc:Recommends}")); let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .unwrap(); assert_eq!(text_edit_new_text(misc_depends), Some("${misc:Depends}")); } #[test] fn test_substvar_completions_prefix_not_duplicated() { // Regression: typing "${misc:" should not produce "${misc:misc:Depends}" let prefix = "${misc:"; let completions = get_substvar_completions("misc:", 2, end_position(prefix)); let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .unwrap(); assert_eq!(text_edit_new_text(misc_depends), Some("${misc:Depends}")); let range = text_edit_range(misc_depends).unwrap(); assert_eq!( range.start, Position { line: 0, character: 0 } ); assert_eq!( range.end, Position { line: 0, character: 7 } ); } #[test] fn test_substvar_completions_dollar_only() { // When "$" triggers completion before "{" is typed, opening_len = 1. // The range should start at "$", not one char before it. let completions = get_substvar_completions("", 1, Position::new(0, 5)); let misc_depends = completions .iter() .find(|c| c.label == "${misc:Depends}") .unwrap(); let range = text_edit_range(misc_depends).unwrap(); // "$" is at col 4 (position 5 - opening 1 - partial 0) assert_eq!( range.start, Position { line: 0, character: 4 } ); assert_eq!(range.end, Position::new(0, 5)); } #[tokio::test] async fn test_relationship_completions_substvar() { let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions( "${shlibs:D", end_position("${shlibs:D"), &cache, &arch_list, ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"${shlibs:Depends}")); assert!(!labels.contains(&"${misc:Depends}")); } #[tokio::test] async fn test_relationship_completions_substvar_after_comma() { let cache = test_cache(); let arch_list = test_arch_list(); let completions = get_relationship_completions( "libc6, ${misc", end_position("libc6, ${misc"), &cache, &arch_list, ) .await; let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"${misc:Depends}")); } } debian-lsp-0.1.8/src/control/rename.rs000064400000000000000000000227141046102023000157420ustar 00000000000000//! Rename support for binary package names in debian/control. //! //! When a binary package name is renamed, this module generates a workspace edit that: //! - Updates the `Package:` field value in debian/control //! - Renames `debian/.*` files to `debian/.*` //! - Updates references in `debian/tests/control` Depends lines use crate::position::Source; use debian_control::lossless::{Control, Parse}; use std::path::Path; use tower_lsp_server::ls_types::*; /// File extensions that are named after binary packages in the debian/ directory. const PACKAGE_FILE_EXTENSIONS: &[&str] = &[ "install", "docs", "dirs", "examples", "manpages", "links", "lintian-overrides", "bug-control", "bug-presubj", "bug-script", "cron.d", "cron.daily", "cron.hourly", "cron.monthly", "cron.weekly", "default", "logrotate", "postinst", "postrm", "preinst", "prerm", "triggers", "shlibs", "symbols", "templates", "config", "init", "pam", "menu", "mime", "maintscript", "service", "tmpfile", "udev", "upstart", "bash-completion", ]; /// Information about a binary package name at a cursor position. pub struct PackageNameAtPosition { /// The current package name. pub name: String, /// The LSP range of the package name value in the source text. pub range: Range, } /// Find the binary package name at the given cursor position in a control file. /// /// Returns `Some` if the cursor is on a `Package:` field value in a binary paragraph. pub fn find_package_name_at_position( parsed: &Parse, src: Source<'_>, position: &Position, ) -> Option { let control = parsed.tree(); let offset = src.try_position_to_offset(*position)?; for binary in control.binaries() { let para = binary.as_deb822(); let entry = para.get_entry("Package")?; let value_range = entry.value_range()?; if value_range.contains(offset) || value_range.end() == offset { let name = binary.name()?; let lsp_range = src.text_range_to_lsp_range(value_range); return Some(PackageNameAtPosition { name, range: lsp_range, }); } } None } /// Collect file rename operations for a binary package rename. /// /// Scans the `debian/` directory for files named `.` and generates /// `RenameFile` operations to rename them to `.`. pub fn collect_package_file_renames( debian_dir: &Path, old_name: &str, new_name: &str, ) -> Vec { let mut ops = Vec::new(); let Ok(entries) = std::fs::read_dir(debian_dir) else { return ops; }; for entry in entries.flatten() { let path = entry.path(); let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Check if this file is named . where ext is a known extension let Some(rest) = file_name.strip_prefix(old_name) else { continue; }; let Some(ext) = rest.strip_prefix('.') else { continue; }; if !PACKAGE_FILE_EXTENSIONS.contains(&ext) { continue; } let new_path = debian_dir.join(format!("{new_name}.{ext}")); let Some(old_uri) = Uri::from_file_path(&path) else { continue; }; let Some(new_uri) = Uri::from_file_path(&new_path) else { continue; }; ops.push(ResourceOp::Rename(RenameFile { old_uri, new_uri, options: None, annotation_id: None, })); } ops } /// Generate text edits for updating package name references in a deb822-format /// file (like `debian/tests/control`). /// /// Searches all paragraphs for `Depends:` fields that reference the old package name /// and generates edits to replace with the new name. pub fn collect_tests_control_edits( tests_control_src: Source<'_>, old_name: &str, new_name: &str, ) -> Vec { let parsed = deb822_lossless::Deb822::parse(tests_control_src.text); let deb822 = parsed.tree(); let mut edits = Vec::new(); for para in deb822.paragraphs() { let Some(entry) = para.get_entry("Depends") else { continue; }; let Some(value_range) = entry.value_range() else { continue; }; let value_start: usize = value_range.start().into(); let value_end: usize = value_range.end().into(); let value_text = &tests_control_src.text[value_start..value_end]; // Find occurrences of the old package name in the Depends value. // We need to match whole package names, not substrings. let mut search_offset = 0; while let Some(pos) = value_text[search_offset..].find(old_name) { let abs_pos = search_offset + pos; let match_end = abs_pos + old_name.len(); // Check that this is a whole-word match within the dependency list let before_ok = abs_pos == 0 || !value_text.as_bytes()[abs_pos - 1].is_ascii_alphanumeric() && value_text.as_bytes()[abs_pos - 1] != b'-'; let after_ok = match_end >= value_text.len() || !value_text.as_bytes()[match_end].is_ascii_alphanumeric() && value_text.as_bytes()[match_end] != b'-'; if before_ok && after_ok { let abs_start = value_start + abs_pos; let abs_end = value_start + match_end; let start_range = rowan::TextRange::new((abs_start as u32).into(), (abs_end as u32).into()); let lsp_range = tests_control_src.text_range_to_lsp_range(start_range); edits.push(TextEdit { range: lsp_range, new_text: new_name.to_string(), }); } search_offset = match_end; } } edits } #[cfg(test)] mod tests { use super::*; #[test] fn test_find_package_name_at_position() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main package Package: foo-dev Architecture: any Description: Development files "; let parsed = Control::parse(text); // Position on "foo" in "Package: foo" (line 3, character 9 = start of "foo") let pos = Position { line: 3, character: 9, }; let idx = crate::position::LineIndex::new(text); let result = find_package_name_at_position(&parsed, Source::new(text, &idx), &pos); assert!(result.is_some()); let info = result.unwrap(); assert_eq!(info.name, "foo"); // Position on "foo-dev" in "Package: foo-dev" (line 7) let pos = Position { line: 7, character: 9, }; let idx = crate::position::LineIndex::new(text); let result = find_package_name_at_position(&parsed, Source::new(text, &idx), &pos); assert!(result.is_some()); let info = result.unwrap(); assert_eq!(info.name, "foo-dev"); // Position on "Source:" line - should not match let pos = Position { line: 0, character: 8, }; let idx = crate::position::LineIndex::new(text); let result = find_package_name_at_position(&parsed, Source::new(text, &idx), &pos); assert!(result.is_none()); // Position on "Architecture:" line - should not match let pos = Position { line: 4, character: 5, }; let idx = crate::position::LineIndex::new(text); let result = find_package_name_at_position(&parsed, Source::new(text, &idx), &pos); assert!(result.is_none()); } #[test] fn test_collect_tests_control_edits() { let text = "\ Tests: test-foo Depends: foo, bar, baz Tests: test-foo-dev Depends: foo-dev, foo "; let idx = crate::position::LineIndex::new(text); let edits = collect_tests_control_edits(Source::new(text, &idx), "foo", "qux"); // Should find "foo" in first Depends (but not "foo-dev") and "foo" in second Depends assert_eq!(edits.len(), 2); assert_eq!(edits[0].new_text, "qux"); assert_eq!(edits[1].new_text, "qux"); } #[test] fn test_collect_tests_control_edits_no_match() { let text = "\ Tests: test-bar Depends: bar, baz "; let idx = crate::position::LineIndex::new(text); let edits = collect_tests_control_edits(Source::new(text, &idx), "foo", "qux"); assert!(edits.is_empty()); } #[test] fn test_collect_tests_control_edits_whole_word() { let text = "\ Tests: test Depends: libfoo-dev, foo-dev, foo "; // Renaming "foo" should match "foo" but not "libfoo-dev" or "foo-dev" let idx = crate::position::LineIndex::new(text); let edits = collect_tests_control_edits(Source::new(text, &idx), "foo", "bar"); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, "bar"); // The edit should point to the last "foo" on line 1 assert_eq!(edits[0].range.start.line, 1); } #[test] fn test_collect_package_file_renames_empty_dir() { // Test with a non-existent directory let ops = collect_package_file_renames(Path::new("/nonexistent"), "foo", "bar"); assert!(ops.is_empty()); } } debian-lsp-0.1.8/src/control/semantic.rs000064400000000000000000000121341046102023000162710ustar 00000000000000//! Semantic token generation for Debian control files. use tower_lsp_server::ls_types::SemanticToken; use super::get_standard_field_name; use crate::deb822::semantic::{generate_tokens, FieldValidator}; use crate::position::Source; /// Field validator for control files pub struct ControlFieldValidator; impl FieldValidator for ControlFieldValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { get_standard_field_name(name) } } /// Generate semantic tokens for a control file pub fn generate_semantic_tokens( control: &debian_control::lossless::Control, src: Source<'_>, ) -> Vec { let validator = ControlFieldValidator; generate_tokens(control.as_deb822(), src, &validator) } #[cfg(test)] mod tests { use super::*; use crate::deb822::semantic::TokenType; #[test] fn test_generate_semantic_tokens_known_fields() { let text = "Source: test-package\nMaintainer: Test User \n"; let parsed = debian_control::lossless::Control::parse(text); let control = parsed.to_result().expect("Should parse"); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&control, Source::new(text, &idx)); // Should have 4 tokens: Source (field), test-package (value), Maintainer (field), value assert_eq!(tokens.len(), 4, "Should have exactly 4 tokens"); // First token: "Source" field name at line 0, char 0 assert_eq!(tokens[0].delta_line, 0, "Source should be on line 0"); assert_eq!(tokens[0].delta_start, 0, "Source should start at char 0"); assert_eq!(tokens[0].length, 6, "Source length should be 6"); assert_eq!( tokens[0].token_type, TokenType::Field as u32, "Source should be classified as Field (known field)" ); // Second token: "test-package" value at line 0, char 8 assert_eq!(tokens[1].delta_line, 0, "Value should be on same line"); assert!( tokens[1].delta_start > 0, "Value should be after field name" ); assert_eq!( tokens[1].token_type, TokenType::Value as u32, "Value should be classified as Value" ); // Third token: "Maintainer" field name at line 1, char 0 assert_eq!(tokens[2].delta_line, 1, "Maintainer should be on line 1"); assert_eq!( tokens[2].delta_start, 0, "Maintainer should start at char 0" ); assert_eq!(tokens[2].length, 10, "Maintainer length should be 10"); assert_eq!( tokens[2].token_type, TokenType::Field as u32, "Maintainer should be classified as Field (known field)" ); // Fourth token: maintainer value assert_eq!(tokens[3].delta_line, 0, "Value should be on same line"); assert_eq!( tokens[3].token_type, TokenType::Value as u32, "Value should be classified as Value" ); } #[test] fn test_generate_semantic_tokens_unknown_field() { let text = "Source: test\nX-Custom-Field: value\n"; let parsed = debian_control::lossless::Control::parse(text); let control = parsed.to_result().expect("Should parse"); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&control, Source::new(text, &idx)); assert_eq!(tokens.len(), 4, "Should have 4 tokens"); // First token: "Source" - known field assert_eq!( tokens[0].token_type, TokenType::Field as u32, "Source should be Field (known)" ); // Third token: "X-Custom-Field" - unknown field assert_eq!( tokens[2].token_type, TokenType::UnknownField as u32, "Unknown field should be classified as UnknownField" ); assert_eq!(tokens[2].length, 14, "X-Custom-Field length should be 14"); } #[test] fn test_generate_semantic_tokens_case_insensitive() { let text = "source: test\n"; let parsed = debian_control::lossless::Control::parse(text); let control = parsed.to_result().expect("Should parse"); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&control, Source::new(text, &idx)); // "source" (lowercase) should still be recognized as a known field assert_eq!( tokens[0].token_type, TokenType::Field as u32, "Lowercase 'source' should still be classified as Field" ); } #[test] fn test_field_validator() { let validator = ControlFieldValidator; // Known fields assert_eq!(validator.get_standard_field_name("Source"), Some("Source")); assert_eq!(validator.get_standard_field_name("source"), Some("Source")); assert_eq!( validator.get_standard_field_name("Package"), Some("Package") ); // Unknown field assert_eq!(validator.get_standard_field_name("UnknownField"), None); } } debian-lsp-0.1.8/src/control/symbols.rs000064400000000000000000000103431046102023000161560ustar 00000000000000//! Document symbol generation for Debian control files. use crate::position::Source; use debian_control::lossless::{Control, Parse}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{DocumentSymbol, SymbolKind}; /// Generate document symbols for a control file. /// /// The source paragraph becomes a NAMESPACE symbol and each binary /// paragraph becomes a PACKAGE symbol, enabling breadcrumb and outline /// navigation. #[allow(deprecated)] // DocumentSymbol::deprecated field pub fn generate_document_symbols(parse: &Parse, src: Source<'_>) -> Vec { let control = parse.tree(); let mut symbols = Vec::new(); if let Some(source) = control.source() { let para = source.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); let name = match source.name() { Some(n) => format!("Source: {n}"), None => "Source".to_string(), }; symbols.push(DocumentSymbol { name, detail: None, kind: SymbolKind::NAMESPACE, tags: None, deprecated: None, range, selection_range: range, children: None, }); } for binary in control.binaries() { let para = binary.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); let name = match binary.name() { Some(n) => format!("Package: {n}"), None => "Package".to_string(), }; symbols.push(DocumentSymbol { name, detail: None, kind: SymbolKind::PACKAGE, tags: None, deprecated: None, range, selection_range: range, children: None, }); } symbols } #[cfg(test)] mod tests { use super::*; #[test] fn test_source_and_binary() { let text = "\ Source: mypackage Maintainer: Test Package: mypackage Architecture: any Description: A test package "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 2); assert_eq!(symbols[0].name, "Source: mypackage"); assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE); assert_eq!(symbols[1].name, "Package: mypackage"); assert_eq!(symbols[1].kind, SymbolKind::PACKAGE); } #[test] fn test_multiple_binaries() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main package Package: foo-dev Architecture: any Description: Development files Package: foo-doc Architecture: all Description: Documentation "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 4); assert_eq!(symbols[0].name, "Source: foo"); assert_eq!(symbols[1].name, "Package: foo"); assert_eq!(symbols[2].name, "Package: foo-dev"); assert_eq!(symbols[3].name, "Package: foo-doc"); } #[test] fn test_ranges_do_not_overlap() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: Main Package: foo-dev Architecture: any Description: Dev "; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); for i in 0..symbols.len() - 1 { assert!( symbols[i].range.end.line <= symbols[i + 1].range.start.line, "Symbol {} ({}) overlaps with {} ({})", i, symbols[i].name, i + 1, symbols[i + 1].name ); } } #[test] fn test_empty_file() { let text = ""; let parsed = Control::parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 0); } } debian-lsp-0.1.8/src/copyright/actions.rs000064400000000000000000000266161046102023000164700ustar 00000000000000use crate::position::Source; use crate::workspace::FieldCasingIssue; use text_size::TextRange; use tower_lsp_server::ls_types::*; /// Format an entire copyright file using wrap-and-sort /// /// # Arguments /// * `src.text` - The source text of the file /// * `parsed` - The parsed copyright file /// /// # Returns /// A list of text edits to apply, or None if the file is already formatted pub fn format_copyright( src: Source<'_>, parsed: &debian_copyright::lossless::Parse, ) -> Option> { let mut copyright = parsed.clone().to_result().ok()?; copyright.wrap_and_sort(deb822_lossless::Indentation::Spaces(1), false, Some(79)); let formatted = copyright.to_string(); if formatted == src.text { return None; } let full_range = src.text_range_to_lsp_range(text_size::TextRange::new( 0.into(), (src.text.len() as u32).into(), )); Some(vec![TextEdit { range: full_range, new_text: formatted, }]) } /// Generate a wrap-and-sort code action for a copyright file /// /// This function creates a code action that wraps and sorts fields in paragraphs /// that overlap with the requested text range. /// /// # Arguments /// * `uri` - The URI of the copyright file /// * `src.text` - The source text of the file /// * `parsed` - The parsed copyright file /// * `text_range` - The text range to operate on /// /// # Returns /// A code action if applicable paragraphs are found, None otherwise pub fn get_wrap_and_sort_action( uri: &Uri, src: Source<'_>, parsed: &debian_copyright::lossless::Parse, text_range: TextRange, ) -> Option { let copyright = parsed.clone().to_result().ok()?; let mut edits = Vec::new(); // Check if header paragraph is in range if let Some(header) = copyright.header_in_range(text_range) { let para_range = header.as_deb822().text_range(); let formatted_para = header.as_deb822().wrap_and_sort( deb822_lossless::Indentation::Spaces(1), false, Some(79), None, None, ); let lsp_range = src.text_range_to_lsp_range(para_range); edits.push(TextEdit { range: lsp_range, new_text: formatted_para.to_string(), }); } // Check each files paragraph in range for files in copyright.iter_files_in_range(text_range) { let para_range = files.as_deb822().text_range(); let formatted_para = files.as_deb822().wrap_and_sort( deb822_lossless::Indentation::Spaces(1), false, Some(79), None, None, ); let lsp_range = src.text_range_to_lsp_range(para_range); edits.push(TextEdit { range: lsp_range, new_text: formatted_para.to_string(), }); } // Check each license paragraph in range for license_para in copyright.iter_licenses_in_range(text_range) { let para_range = license_para.as_deb822().text_range(); let formatted_para = license_para.as_deb822().wrap_and_sort( deb822_lossless::Indentation::Spaces(1), false, Some(79), None, None, ); let lsp_range = src.text_range_to_lsp_range(para_range); edits.push(TextEdit { range: lsp_range, new_text: formatted_para.to_string(), }); } if edits.is_empty() { return None; } let workspace_edit = WorkspaceEdit { changes: Some(vec![(uri.clone(), edits)].into_iter().collect()), ..Default::default() }; let action = CodeAction { title: "Wrap and sort".to_string(), kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), edit: Some(workspace_edit), ..Default::default() }; Some(CodeActionOrCommand::CodeAction(action)) } /// Generate field casing fix actions for a copyright file /// /// # Arguments /// * `uri` - The URI of the copyright file /// * `src.text` - The source text /// * `issues` - The field casing issues found /// * `diagnostics` - The diagnostics from the context /// /// # Returns /// A vector of code actions for fixing field casing pub fn get_field_casing_actions( uri: &Uri, src: Source<'_>, issues: Vec, diagnostics: &[Diagnostic], ) -> Vec { let mut actions = Vec::new(); for issue in issues { let lsp_range = src.text_range_to_lsp_range(issue.field_range); // Check if there's a matching diagnostic in the context let matching_diagnostics = diagnostics .iter() .filter(|d| { d.range == lsp_range && d.code == Some(NumberOrString::String("field-casing".to_string())) }) .cloned() .collect::>(); // Create a code action to fix the casing let edit = TextEdit { range: lsp_range, new_text: issue.standard_name.clone(), }; let workspace_edit = WorkspaceEdit { changes: Some(vec![(uri.clone(), vec![edit])].into_iter().collect()), ..Default::default() }; let action = CodeAction { title: format!( "Fix field casing: {} -> {}", issue.field_name, issue.standard_name ), kind: Some(CodeActionKind::QUICKFIX), edit: Some(workspace_edit), diagnostics: if !matching_diagnostics.is_empty() { Some(matching_diagnostics) } else { None }, ..Default::default() }; actions.push(CodeActionOrCommand::CodeAction(action)); } actions } #[cfg(test)] mod tests { use super::*; #[test] fn test_wrap_and_sort_action() { let input = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: test-package Source: https://example.com/test Files: * Copyright: 2024 Test User License: GPL-3+ License: GPL-3+ This program is free software. "#; let parsed = debian_copyright::lossless::Parse::parse(input); let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let uri: Uri = "file:///debian/copyright".parse().unwrap(); let text_range = TextRange::new(0.into(), (input.len() as u32).into()); let action = get_wrap_and_sort_action(&uri, src, &parsed, text_range); // Should return a code action assert!(action.is_some()); let CodeActionOrCommand::CodeAction(action) = action.unwrap() else { panic!("Expected CodeAction"); }; assert_eq!(action.title, "Wrap and sort"); assert_eq!(action.kind, Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS)); // Extract the edits let workspace_edit = action.edit.expect("Should have an edit"); let changes = workspace_edit.changes.expect("Should have changes"); let edits = changes.get(&uri).expect("Should have edits for the URI"); // Should have edits for header, files, and license paragraphs assert_eq!(edits.len(), 3); // Verify the actual formatted output let formatted_header = &edits[0].new_text; let formatted_files = &edits[1].new_text; let formatted_license = &edits[2].new_text; println!("Formatted header:\n{}", formatted_header); println!("Formatted files:\n{}", formatted_files); println!("Formatted license:\n{}", formatted_license); // Check that header is properly formatted (fields sorted alphabetically) assert_eq!( formatted_header, "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: test-package\nSource: https://example.com/test\n" ); // Check that files paragraph is properly formatted assert_eq!( formatted_files, "Files: *\nCopyright: 2024 Test User \nLicense: GPL-3+\n" ); // Check that license paragraph is properly formatted assert_eq!( formatted_license, "License: GPL-3+\n This program is free software.\n" ); } #[test] fn test_field_casing_actions() { let input = r#"format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ upstream-name: test "#; let idx = crate::position::LineIndex::new(input); let src = Source::new(input, &idx); let uri: Uri = "file:///debian/copyright".parse().unwrap(); let issues = vec![ FieldCasingIssue { field_name: "format".to_string(), standard_name: "Format".to_string(), field_range: TextRange::new(0.into(), 6.into()), }, FieldCasingIssue { field_name: "upstream-name".to_string(), standard_name: "Upstream-Name".to_string(), field_range: TextRange::new(76.into(), 89.into()), }, ]; let actions = get_field_casing_actions(&uri, src, issues, &[]); assert_eq!(actions.len(), 2); let CodeActionOrCommand::CodeAction(ref action) = actions[0] else { panic!("Expected CodeAction"); }; assert_eq!(action.title, "Fix field casing: format -> Format"); let CodeActionOrCommand::CodeAction(ref action) = actions[1] else { panic!("Expected CodeAction"); }; assert_eq!( action.title, "Fix field casing: upstream-name -> Upstream-Name" ); } #[test] fn test_format_copyright() { let input = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: test-package Source: https://example.com/test Files: * Copyright: 2024 Test User License: GPL-3+ License: GPL-3+ This program is free software. "#; let parsed = debian_copyright::lossless::Parse::parse(input); let idx = crate::position::LineIndex::new(input); let edits = format_copyright(Source::new(input, &idx), &parsed); // May or may not produce edits depending on whether input is already formatted if let Some(edits) = edits { assert_eq!(edits.len(), 1); assert_eq!(edits[0].range.start.line, 0); assert_eq!(edits[0].range.start.character, 0); } } #[test] fn test_format_copyright_already_formatted() { let input = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: test-package Source: https://example.com/test Files: * Copyright: 2024 Test User License: GPL-3+ License: GPL-3+ This program is free software. "#; let parsed = debian_copyright::lossless::Parse::parse(input); let idx = crate::position::LineIndex::new(input); let first_format = format_copyright(Source::new(input, &idx), &parsed); let formatted = match first_format { Some(edits) => edits[0].new_text.clone(), None => input.to_string(), }; // Format again - should return None since already formatted let parsed2 = debian_copyright::lossless::Parse::parse(&formatted); let idx2 = crate::position::LineIndex::new(&formatted); let second_format = format_copyright(Source::new(&formatted, &idx2), &parsed2); assert!(second_format.is_none()); } } debian-lsp-0.1.8/src/copyright/code_lens.rs000064400000000000000000000514361046102023000167610ustar 00000000000000//! Code lenses for debian/copyright files. //! //! Shows the number of Files paragraphs that reference each standalone //! License paragraph: //! - `License: MIT` ->"used by 3 Files paragraphs" //! //! When a source root is provided, also shows how many actual files in //! the source tree match each `Files:` paragraph's glob patterns: //! - `Files: src/*` ->"12 files" //! - `Files: docs/legacy/*` ->"0 files" use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::position::Source; use debian_copyright::GlobPattern; use rowan::ast::AstNode; use tokio::sync::Mutex; use tower_lsp_server::ls_types::{CodeLens, Command}; /// How long cached git file lists remain valid. const GIT_FILE_LIST_TTL: Duration = Duration::from_secs(300); /// Cached state for file-count code lenses. pub(crate) struct CachedFileCounts { /// The git-tracked files at the time of caching. git_files: Vec, /// When the git file list was fetched. git_fetched_at: Instant, /// The patterns (Files: + Files-Excluded:) that produced the cached counts. /// Used to detect when the copyright file changes and we need to recompute. pattern_key: Vec, /// The computed file-count lenses. lenses: Vec, } /// Shared, per-root cache of file-count code lenses. pub type SharedGitFileCache = Arc>>; /// Create a new shared git file cache. pub fn new_shared_git_file_cache() -> SharedGitFileCache { Arc::new(Mutex::new(HashMap::new())) } /// Build a cache key from the file patterns and excluded patterns. fn build_pattern_key(file_lens_data: &FileLensData) -> Vec { let mut key: Vec = file_lens_data .excluded_patterns_raw .iter() .map(|p| format!("X:{p}")) .collect(); key.extend( file_lens_data .included_patterns_raw .iter() .map(|p| format!("I:{p}")), ); for para in &file_lens_data.paragraphs { for p in ¶.patterns_raw { key.push(format!("F:{p}")); } // Separator between paragraphs key.push("|".to_string()); } key } /// Get file-count lenses, using cached results when the patterns and /// git file list haven't changed. async fn get_file_count_lenses( cache: &SharedGitFileCache, root: &Path, file_lens_data: &FileLensData, ) -> Option> { let pattern_key = build_pattern_key(file_lens_data); // Check cache: reuse if patterns match and git file list is fresh. { let map = cache.lock().await; if let Some(entry) = map.get(root) { if entry.pattern_key == pattern_key && entry.git_fetched_at.elapsed() < GIT_FILE_LIST_TTL { return Some(entry.lenses.clone()); } } } // Fetch git file list (possibly reusing a still-fresh cached list). let git_files = { let map = cache.lock().await; map.get(root) .filter(|e| e.git_fetched_at.elapsed() < GIT_FILE_LIST_TTL) .map(|e| e.git_files.clone()) }; let git_files = match git_files { Some(files) => files, None => { let root_buf = root.to_path_buf(); tokio::task::spawn_blocking(move || git_ls_files(&root_buf)) .await .ok() .flatten()? } }; // Apply Files-Excluded then Files-Included (re-include from excluded set). let included_files: Vec<&str> = git_files .iter() .map(|s| s.as_str()) .filter(|f| { let excluded = file_lens_data .excluded_patterns .iter() .any(|p| p.is_match(f)); if excluded { file_lens_data .included_patterns .iter() .any(|p| p.is_match(f)) } else { true } }) .collect(); let paragraphs = &file_lens_data.paragraphs; let mut counts = vec![0usize; paragraphs.len()]; for f in &included_files { let winning_idx = paragraphs .iter() .rposition(|para| para.patterns.iter().any(|p| p.is_match(f))); if let Some(idx) = winning_idx { counts[idx] += 1; } } let lenses: Vec = paragraphs .iter() .zip(&counts) .map(|(para_data, &count)| { let title = match count { 1 => "1 file".to_string(), n => format!("{n} files"), }; CodeLens { range: para_data.range, command: Some(Command { title, // Some editors silently drop lenses with an empty command command: "debian-lsp.noop".to_string(), arguments: None, }), data: None, } }) .collect(); // Store in cache. { let mut map = cache.lock().await; map.insert( root.to_path_buf(), CachedFileCounts { git_files, git_fetched_at: Instant::now(), pattern_key, lenses: lenses.clone(), }, ); } Some(lenses) } /// List git-tracked files relative to the given working directory. fn git_ls_files(root: &Path) -> Option> { let output = match std::process::Command::new("git") .arg("ls-files") .arg("-z") .current_dir(root) .output() { Ok(o) => o, Err(e) => { tracing::warn!("failed to run git ls-files in {}: {e}", root.display()); return None; } }; if !output.status.success() { tracing::info!( "git ls-files failed in {} (not a git repo?)", root.display() ); return None; } Some( output .stdout .split(|&b| b == 0) .filter(|s| !s.is_empty()) .filter_map(|s| std::str::from_utf8(s).ok().map(|s| s.to_string())) .collect(), ) } /// Generate code lenses for copyright license and files paragraphs. /// /// For each standalone License paragraph, counts how many Files paragraphs /// reference that license name and displays the count as a code lens. /// /// When `source_root` is provided, also adds a code lens to each Files /// paragraph showing how many actual files in the source tree match /// the paragraph's glob patterns. Files listed in the header's /// `Files-Excluded` field are excluded from counts. The file listing /// is obtained from `git ls-files` and offloaded to a blocking thread. pub async fn generate_code_lenses( parsed: &debian_copyright::lossless::Parse, src: Source<'_>, source_root: Option<&Path>, git_file_cache: &SharedGitFileCache, ) -> Vec { // Extract everything we need from the non-Send parsed tree up front, // before any .await points. let mut lenses = generate_license_lenses(parsed, src); let file_lens_data = extract_file_lens_data(parsed, src); if file_lens_data.is_empty() { tracing::debug!("no file paragraphs found in copyright file"); return lenses; } let Some(root) = source_root else { tracing::debug!("no source root provided, skipping file count lenses"); return lenses; }; // Get file-count lenses (cached when patterns and git file list unchanged). if let Some(mut file_lenses) = get_file_count_lenses(git_file_cache, root, &file_lens_data).await { file_lenses.append(&mut lenses); file_lenses } else { tracing::warn!("failed to get file count lenses for {}", root.display()); lenses } } /// Pre-extracted data for a single Files paragraph. struct FilesParagraphData { patterns_raw: Vec, patterns: Vec, range: tower_lsp_server::ls_types::Range, } /// Pre-extracted data needed for file-count lenses. struct FileLensData { excluded_patterns_raw: Vec, excluded_patterns: Vec, included_patterns_raw: Vec, included_patterns: Vec, paragraphs: Vec, } impl FileLensData { fn is_empty(&self) -> bool { self.paragraphs.is_empty() } } /// Extract file lens data from the parsed copyright tree (synchronous, /// no Send requirement). fn extract_file_lens_data( parsed: &debian_copyright::lossless::Parse, src: Source<'_>, ) -> FileLensData { let copyright = parsed.tree(); let header = copyright.header(); let excluded_patterns_raw: Vec = header .as_ref() .and_then(|h| h.files_excluded()) .unwrap_or_default(); let excluded_patterns: Vec = excluded_patterns_raw .iter() .map(|p| GlobPattern::new(p)) .collect(); let included_patterns_raw: Vec = header .as_ref() .and_then(|h| h.files_included()) .unwrap_or_default(); let included_patterns: Vec = included_patterns_raw .iter() .map(|p| GlobPattern::new(p)) .collect(); let mut paragraphs = Vec::new(); for files_para in copyright.iter_files() { let para = files_para.as_deb822(); let para_range = para.syntax().text_range(); let patterns_raw = files_para.files(); let patterns: Vec = patterns_raw.iter().map(|p| GlobPattern::new(p)).collect(); let range = if let Some(entry) = para .entries() .find(|e| e.key().is_some_and(|k| k.eq_ignore_ascii_case("Files"))) { src.text_range_to_lsp_range(entry.text_range()) } else { src.text_range_to_lsp_range(para_range) }; paragraphs.push(FilesParagraphData { patterns_raw, patterns, range, }); } FileLensData { excluded_patterns_raw, excluded_patterns, included_patterns_raw, included_patterns, paragraphs, } } /// Generate license-usage code lenses only (no source root needed). pub fn generate_license_lenses( parsed: &debian_copyright::lossless::Parse, src: Source<'_>, ) -> Vec { let copyright = parsed.tree(); let mut lenses = Vec::new(); let mut license_usage: HashMap = HashMap::new(); for files_para in copyright.iter_files() { if let Some(license) = files_para.license() { if let Some(expr) = license.expr() { for name in expr.license_names() { *license_usage.entry(name.to_lowercase()).or_insert(0) += 1; } } } } for license_para in copyright.iter_licenses() { let para = license_para.as_deb822(); let para_range = para.syntax().text_range(); let Some(name) = license_para.name() else { continue; }; let key = name.to_lowercase(); let count = license_usage.get(&key).copied().unwrap_or(0); let title = match count { 0 => "unused".to_string(), 1 => "used by 1 Files paragraph".to_string(), n => format!("used by {n} Files paragraphs"), }; let entry_range = if let Some(entry) = para .entries() .find(|e| e.key().is_some_and(|k| k.eq_ignore_ascii_case("License"))) { src.text_range_to_lsp_range(entry.text_range()) } else { src.text_range_to_lsp_range(para_range) }; lenses.push(CodeLens { range: entry_range, command: Some(Command { title, command: "debian-lsp.noop".to_string(), arguments: None, }), data: None, }); } lenses } #[cfg(test)] mod tests { use super::*; use debian_copyright::lossless::Parse; fn parse(text: &str) -> Parse { Parse::parse_relaxed(text) } #[test] fn test_license_used_by_multiple_files() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: MIT Files: lib/* Copyright: 2024 Bob License: MIT Files: debian/* Copyright: 2024 Carol License: GPL-2+ License: MIT Permission is hereby granted... License: GPL-2+ This program is free software... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 2); assert_eq!( lenses[0].command.as_ref().unwrap().title, "used by 2 Files paragraphs" ); assert_eq!( lenses[1].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); } #[test] fn test_unused_license() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT License: MIT Permission is hereby granted... License: Apache-2.0 Licensed under the Apache License... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 2); assert_eq!( lenses[0].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); assert_eq!(lenses[1].command.as_ref().unwrap().title, "unused"); } #[test] fn test_no_standalone_licenses() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT Permission is hereby granted... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 0); } #[test] fn test_case_insensitive_matching() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: mit License: MIT Permission is hereby granted... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 1); assert_eq!( lenses[0].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); } #[test] fn test_empty_copyright() { let text = ""; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 0); } #[test] fn test_or_expression_counts_individual_licenses() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: GPL-2+ or MIT License: GPL-2+ This program is free software... License: MIT Permission is hereby granted... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_license_lenses(&parsed, Source::new(text, &idx)); assert_eq!(lenses.len(), 2); assert_eq!( lenses[0].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); assert_eq!( lenses[1].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); } #[tokio::test] async fn test_file_count_with_source_root() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); // Initialize a git repo with some files std::process::Command::new("git") .args(["init"]) .current_dir(root) .output() .unwrap(); std::fs::create_dir_all(root.join("src")).unwrap(); std::fs::write(root.join("src/main.rs"), "").unwrap(); std::fs::write(root.join("src/lib.rs"), "").unwrap(); std::fs::create_dir_all(root.join("debian")).unwrap(); std::fs::write(root.join("debian/rules"), "").unwrap(); std::fs::write(root.join("README"), "").unwrap(); std::process::Command::new("git") .args(["add", "."]) .current_dir(root) .output() .unwrap(); let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Carol License: MIT Files: src/* Copyright: 2024 Alice License: MIT Files: debian/* Copyright: 2024 Bob License: GPL-2+ "; let parsed = parse(text); let lenses = { let idx = crate::position::LineIndex::new(text); generate_code_lenses( &parsed, Source::new(text, &idx), Some(root), &new_shared_git_file_cache(), ) .await }; // 3 file-count lenses + 0 license lenses (no standalone License paragraphs) // Last matching stanza wins: src/* claims src/{main,lib}.rs, // debian/* claims debian/rules, * only counts README. assert_eq!(lenses.len(), 3); assert_eq!(lenses[0].command.as_ref().unwrap().title, "1 file"); assert_eq!(lenses[1].command.as_ref().unwrap().title, "2 files"); assert_eq!(lenses[2].command.as_ref().unwrap().title, "1 file"); } #[tokio::test] async fn test_file_count_excludes_files_excluded() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(root) .output() .unwrap(); std::fs::write(root.join("foo.c"), "").unwrap(); std::fs::write(root.join("vendor.js"), "").unwrap(); std::process::Command::new("git") .args(["add", "."]) .current_dir(root) .output() .unwrap(); let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files-Excluded: vendor.js Files: * Copyright: 2024 Test License: MIT "; let parsed = parse(text); let lenses = { let idx = crate::position::LineIndex::new(text); generate_code_lenses( &parsed, Source::new(text, &idx), Some(root), &new_shared_git_file_cache(), ) .await }; assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "1 file"); } #[tokio::test] async fn test_file_count_files_included_overrides_excluded() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(root) .output() .unwrap(); std::fs::create_dir_all(root.join("vendor")).unwrap(); std::fs::write(root.join("vendor/lib.js"), "").unwrap(); std::fs::write(root.join("vendor/keep.js"), "").unwrap(); std::fs::write(root.join("foo.c"), "").unwrap(); std::process::Command::new("git") .args(["add", "."]) .current_dir(root) .output() .unwrap(); let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files-Excluded: vendor/* Files-Included: vendor/keep.js Files: * Copyright: 2024 Test License: MIT "; let parsed = parse(text); let lenses = { let idx = crate::position::LineIndex::new(text); generate_code_lenses( &parsed, Source::new(text, &idx), Some(root), &new_shared_git_file_cache(), ) .await }; // vendor/lib.js excluded, vendor/keep.js re-included, foo.c included assert_eq!(lenses.len(), 1); assert_eq!(lenses[0].command.as_ref().unwrap().title, "2 files"); } #[tokio::test] async fn test_file_count_no_source_root() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT License: MIT text "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let lenses = generate_code_lenses( &parsed, Source::new(text, &idx), None, &new_shared_git_file_cache(), ) .await; // Only license lenses, no file-count lenses assert_eq!(lenses.len(), 1); assert_eq!( lenses[0].command.as_ref().unwrap().title, "used by 1 Files paragraph" ); } } debian-lsp-0.1.8/src/copyright/completion.rs000064400000000000000000000614011046102023000171700ustar 00000000000000use std::collections::HashSet; use debian_copyright::LicenseExpr; use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, InsertTextFormat, Position}; use super::fields::{get_common_licenses, COPYRIGHT_FIELDS}; use crate::position::Source; /// Get completions for a copyright file at the given cursor position. /// /// Uses position-aware completions: if on a field value, returns value /// completions for the current field; otherwise returns field name completions. pub fn get_completions( parsed: &debian_copyright::lossless::Parse, src: Source<'_>, position: Position, ) -> Vec { let copyright = parsed.tree(); let deb822 = copyright.as_deb822(); // Collect all license names used in the file for completion let mut file_licenses: HashSet = HashSet::new(); for files_para in copyright.iter_files() { if let Some(license) = files_para.license() { if let Some(expr) = license.expr() { for name in expr.license_names() { file_licenses.insert(name.to_string()); } } } } for license_para in copyright.iter_licenses() { if let Some(name) = license_para.name() { for n in LicenseExpr::parse(&name).license_names() { file_licenses.insert(n.to_string()); } } } let mut completions = crate::deb822::completion::get_completions( deb822, src, position, COPYRIGHT_FIELDS, |field_name, prefix| get_field_value_completions(field_name, prefix, &file_licenses), ); // Offer snippet completions at positions where new paragraphs can be started let context = crate::deb822::completion::get_cursor_context(deb822, src, position); match context { Some(crate::deb822::completion::CursorContext::StartOfLine) => { if src.text.trim().is_empty() { completions.extend(get_snippet_completions()); } else { completions.extend(get_paragraph_snippet_completions()); } } Some(crate::deb822::completion::CursorContext::FieldKey) if src.text.trim().is_empty() => { completions.extend(get_snippet_completions()); } _ => {} } completions } /// The standard DEP-5 format URL. const DEP5_FORMAT_URL: &str = "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/"; /// Get snippet completions for scaffolding a new copyright file from scratch. fn get_snippet_completions() -> Vec { let mut snippets = vec![ CompletionItem { label: "DEP-5 copyright file".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold a complete DEP-5 copyright file".to_string()), insert_text: Some(format!( "Format: {}\n\ Upstream-Name: ${{1:package}}\n\ Upstream-Contact: ${{2:name }}\n\ Source: ${{3:url}}\n\ \n\ Files: *\n\ Copyright: ${{4:year}} ${{5:author}}\n\ License: ${{6:license}}\n\ \n\ Files: debian/*\n\ Copyright: ${{7:year}} ${{8:maintainer}}\n\ License: ${{9:license}}\n\ \n\ License: ${{6:license}}\n\ ${{10:License text.}}\n", DEP5_FORMAT_URL, )), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("0".to_string()), ..Default::default() }, CompletionItem { label: "DEP-5 header".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold the DEP-5 header paragraph".to_string()), insert_text: Some(format!( "Format: {}\n\ Upstream-Name: ${{1:package}}\n\ Upstream-Contact: ${{2:name }}\n\ Source: ${{3:url}}\n", DEP5_FORMAT_URL, )), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("1".to_string()), ..Default::default() }, ]; snippets.extend(get_paragraph_snippet_completions()); snippets } /// Get snippet completions for adding new paragraphs to an existing copyright file. fn get_paragraph_snippet_completions() -> Vec { vec![ CompletionItem { label: "Files paragraph".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold a Files paragraph".to_string()), insert_text: Some( "Files: ${1:*}\n\ Copyright: ${2:year} ${3:author}\n\ License: ${4:license}\n" .to_string(), ), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("2".to_string()), ..Default::default() }, CompletionItem { label: "License paragraph".to_string(), kind: Some(CompletionItemKind::SNIPPET), detail: Some("Scaffold a standalone License paragraph".to_string()), insert_text: Some( "License: ${1:license}\n\ ${2:License text.}\n" .to_string(), ), insert_text_format: Some(InsertTextFormat::SNIPPET), sort_text: Some("3".to_string()), ..Default::default() }, ] } /// Get value completions for a specific copyright field. fn get_field_value_completions( field_name: &str, prefix: &str, file_licenses: &HashSet, ) -> Vec { let prefix = prefix.trim_start(); match field_name.to_lowercase().as_str() { "format" => get_format_completions(prefix.trim_end()), "license" => get_license_completions(prefix, file_licenses), _ => vec![], } } /// Get completion items for the Format field. fn get_format_completions(prefix: &str) -> Vec { if DEP5_FORMAT_URL.starts_with(prefix) { vec![CompletionItem { label: DEP5_FORMAT_URL.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some("DEP-5 copyright format".to_string()), ..Default::default() }] } else { vec![] } } /// Extract the last token from a license expression for prefix matching. /// /// License expressions look like `GPL-2+ or MIT or A`. The last /// whitespace-separated token is what the user is currently typing. /// Returns `(last_token, expects_license)` where `expects_license` is true /// when the last complete token was "or"/"and" (or the expression is empty), /// meaning the user should see license name completions. fn last_expression_token(prefix: &str) -> (&str, bool) { let trimmed = prefix.trim_end(); // If prefix ends with whitespace, the user finished a token and is starting a new one let ends_with_space = prefix.len() > trimmed.len(); if trimmed.is_empty() { return ("", true); } let last_token = trimmed .rsplit_once(char::is_whitespace) .map_or(trimmed, |(_, t)| t); if ends_with_space { // The last complete token tells us what to expect next let lower = last_token.to_lowercase(); if lower == "or" || lower == "and" { // After "or"/"and", expect a license name ("", true) } else { // After a license name, expect "or"/"and" ("", false) } } else { // User is mid-token; check what came before to know if this is a license or keyword if let Some((before, _)) = trimmed.rsplit_once(char::is_whitespace) { let prev_token = before .rsplit_once(char::is_whitespace) .map_or(before, |(_, t)| t); let prev_lower = prev_token.to_lowercase(); if prev_lower == "or" || prev_lower == "and" { (last_token, true) } else { (last_token, false) } } else { // First token — always a license name (last_token, true) } } } /// Get completion items for license expressions, filtered by the current typing context. fn get_license_completions(prefix: &str, file_licenses: &HashSet) -> Vec { let (current_token, expects_license) = last_expression_token(prefix); if expects_license { let lower_token = current_token.to_lowercase(); // Merge common licenses and file-local licenses, deduplicating let mut seen = HashSet::new(); let mut items = Vec::new(); // File-local licenses first (more relevant) let mut local: Vec<_> = file_licenses.iter().collect(); local.sort(); for name in local { if name.to_lowercase().starts_with(&lower_token) && seen.insert(name.to_lowercase()) { items.push(CompletionItem { label: name.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some("License name (from file)".to_string()), sort_text: Some(format!("0{}", name)), ..Default::default() }); } } // Common system licenses for name in get_common_licenses() { if name.to_lowercase().starts_with(&lower_token) && seen.insert(name.to_lowercase()) { items.push(CompletionItem { label: name.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some("License name".to_string()), sort_text: Some(format!("1{}", name)), ..Default::default() }); } } items } else { // After a license name, offer "or" and "and" keywords let lower_token = current_token.to_lowercase(); let mut items = Vec::new(); for keyword in &["or", "and"] { if keyword.starts_with(&lower_token) { items.push(CompletionItem { label: keyword.to_string(), kind: Some(CompletionItemKind::KEYWORD), detail: Some("License expression operator".to_string()), ..Default::default() }); } } items } } #[cfg(test)] mod tests { use super::*; use debian_copyright::lossless::Parse; fn parse(text: &str) -> Parse { Parse::parse_relaxed(text) } #[test] fn test_get_completions_returns_fields() { let text = "Format: https://example.com\n"; let parsed = parse(text); // Cursor on field key -> field completions only (no license names mixed in) let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 3)); assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::FIELD))); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"Format")); assert!(labels.contains(&"Files")); assert!(labels.contains(&"License")); assert!(labels.contains(&"Copyright")); } #[test] fn test_field_completions_have_correct_properties() { let text = ""; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 0)); let field_completions: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::FIELD)) .collect(); assert!(!field_completions.is_empty()); for completion in &field_completions { assert!(!completion.label.is_empty()); assert!(completion.detail.is_some()); assert!(completion.documentation.is_some()); assert!(completion.insert_text.as_ref().unwrap().ends_with(": ")); } let snippet_completions: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .collect(); assert!(!snippet_completions.is_empty()); for completion in &snippet_completions { assert!(!completion.label.is_empty()); assert!(completion.detail.is_some()); assert_eq!( completion.insert_text_format, Some(InsertTextFormat::SNIPPET) ); } } #[test] fn test_license_value_completions() { // Only test if /usr/share/common-licenses exists if !std::path::Path::new("/usr/share/common-licenses").exists() { return; } let text = "License: \n"; let parsed = parse(text); // Cursor on License field value -> license name completions let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 9)); assert!(!completions.is_empty()); for completion in &completions { assert_eq!(completion.kind, Some(CompletionItemKind::VALUE)); } let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels .iter() .any(|l| l.contains("GPL") || l.contains("Apache")), "Should contain common licenses, got: {:?}", labels ); } #[test] fn test_license_value_completions_with_prefix() { if !std::path::Path::new("/usr/share/common-licenses").exists() { return; } let text = "License: GPL\n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 12)); // All results should match the GPL prefix for completion in &completions { assert!( completion.label.to_lowercase().starts_with("gpl"), "Expected GPL prefix, got: {}", completion.label ); } } #[test] fn test_format_value_completions() { let text = "Format: \n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 8)); assert_eq!(completions.len(), 1); assert_eq!(completions[0].label, DEP5_FORMAT_URL); assert_eq!(completions[0].kind, Some(CompletionItemKind::VALUE)); } #[test] fn test_format_value_completions_with_non_matching_prefix() { let text = "Format: something-else\n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 22)); assert!(completions.is_empty()); } #[test] fn test_unknown_field_value_returns_empty() { let text = "Comment: \n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 9)); assert!(completions.is_empty()); } #[test] fn test_license_completions_from_file() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: CustomLicense-1.0 Files: lib/* Copyright: 2024 Bob License: \n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(8, 9)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.contains(&"CustomLicense-1.0"), "Should include license from file, got: {:?}", labels ); } #[test] fn test_license_or_and_completion() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT \n"; let parsed = parse(text); // Cursor after "MIT " — should offer "or" and "and" let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(4, 13)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.contains(&"or"), "Should offer 'or' after license name, got: {:?}", labels ); assert!( labels.contains(&"and"), "Should offer 'and' after license name, got: {:?}", labels ); } #[test] fn test_license_completion_after_or() { if !std::path::Path::new("/usr/share/common-licenses").exists() { return; } let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT or \n"; let parsed = parse(text); // Cursor after "MIT or " — should offer license names let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(4, 16)); assert!(!completions.is_empty()); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.iter().any(|l| *l != "or" && *l != "and"), "Should offer license names after 'or', got: {:?}", labels ); } #[test] fn test_license_completion_after_or_with_prefix() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: MIT Files: * Copyright: 2024 Test License: GPL-2+ or MI\n"; let parsed = parse(text); // Cursor after "GPL-2+ or MI" — should offer MIT from file let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(8, 21)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.contains(&"MIT"), "Should offer 'MIT' matching prefix 'MI', got: {:?}", labels ); } #[test] fn test_license_expression_with_or_parses_names() { // The file_licenses should pick up both GPL-2+ and MIT // from the expression "GPL-2+ or MIT" // Verify by checking completions on an empty License field let text_with_empty = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: GPL-2+ or MIT Files: lib/* Copyright: 2024 Bob License: \n"; let parsed2 = parse(text_with_empty); let idx = crate::position::LineIndex::new(text_with_empty); let completions = get_completions( &parsed2, Source::new(text_with_empty, &idx), Position::new(8, 9), ); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!( labels.contains(&"GPL-2+"), "Should include GPL-2+ from expression, got: {:?}", labels ); assert!( labels.contains(&"MIT"), "Should include MIT from expression, got: {:?}", labels ); } #[test] fn test_last_expression_token() { // Empty assert_eq!(last_expression_token(""), ("", true)); assert_eq!(last_expression_token(" "), ("", true)); // Single license being typed assert_eq!(last_expression_token("MI"), ("MI", true)); assert_eq!(last_expression_token("GPL-2+"), ("GPL-2+", true)); // After a complete license name (space after) assert_eq!(last_expression_token("MIT "), ("", false)); // Typing "or"/"and" after a license assert_eq!(last_expression_token("MIT o"), ("o", false)); assert_eq!(last_expression_token("MIT or"), ("or", false)); // After "or " — expecting a license assert_eq!(last_expression_token("MIT or "), ("", true)); // Typing a license after "or" assert_eq!(last_expression_token("MIT or G"), ("G", true)); assert_eq!(last_expression_token("MIT or GPL-2+"), ("GPL-2+", true)); // After "and " assert_eq!(last_expression_token("MIT and "), ("", true)); assert_eq!(last_expression_token("MIT and A"), ("A", true)); // After second license assert_eq!(last_expression_token("MIT or GPL-2+ "), ("", false)); } #[test] fn test_snippet_completions_on_empty_file() { let text = ""; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 0)); let mut snippet_labels: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .map(|c| c.label.as_str()) .collect(); snippet_labels.sort(); assert_eq!( snippet_labels, vec![ "DEP-5 copyright file", "DEP-5 header", "Files paragraph", "License paragraph", ] ); for snippet in completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) { assert_eq!(snippet.insert_text_format, Some(InsertTextFormat::SNIPPET)); assert!(snippet.insert_text.is_some()); } } #[test] fn test_snippet_completions_on_whitespace_only_file() { let text = " \n\n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(2, 0)); let mut snippet_labels: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .map(|c| c.label.as_str()) .collect(); snippet_labels.sort(); assert_eq!( snippet_labels, vec![ "DEP-5 copyright file", "DEP-5 header", "Files paragraph", "License paragraph", ] ); } #[test] fn test_paragraph_snippets_on_non_empty_file() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n\n"; let parsed = parse(text); // Cursor at start of blank line after header paragraph let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(1, 0)); let mut snippet_labels: Vec<_> = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .map(|c| c.label.as_str()) .collect(); snippet_labels.sort(); assert_eq!(snippet_labels, vec!["Files paragraph", "License paragraph"]); } #[test] fn test_no_snippets_on_field_value() { let text = "Format: \n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let completions = get_completions(&parsed, Source::new(text, &idx), Position::new(0, 8)); let snippet_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::SNIPPET)) .count(); assert_eq!(snippet_count, 0); } #[test] fn test_full_file_snippet_content() { let snippets = get_snippet_completions(); let full = snippets .iter() .find(|c| c.label == "DEP-5 copyright file") .expect("Should have full file snippet"); let text = full.insert_text.as_ref().unwrap(); assert_eq!( text, &format!( "Format: {}\n\ Upstream-Name: ${{1:package}}\n\ Upstream-Contact: ${{2:name }}\n\ Source: ${{3:url}}\n\ \n\ Files: *\n\ Copyright: ${{4:year}} ${{5:author}}\n\ License: ${{6:license}}\n\ \n\ Files: debian/*\n\ Copyright: ${{7:year}} ${{8:maintainer}}\n\ License: ${{9:license}}\n\ \n\ License: ${{6:license}}\n\ ${{10:License text.}}\n", DEP5_FORMAT_URL, ) ); } } debian-lsp-0.1.8/src/copyright/detection.rs000064400000000000000000000025211046102023000167730ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian copyright file pub fn is_copyright_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/copyright") || path.ends_with("/debian/copyright") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_copyright_file() { let copyright_paths = vec![ "file:///path/to/debian/copyright", "file:///project/debian/copyright", "file:///copyright", "file:///some/path/copyright", ]; let non_copyright_paths = vec![ "file:///path/to/other.txt", "file:///path/to/copyright.txt", "file:///path/to/mycopyright", "file:///path/to/debian/copyright.backup", "file:///path/to/debian/control", ]; for path in copyright_paths { let uri = path.parse::().unwrap(); assert!( is_copyright_file(&uri), "Should detect copyright file: {}", path ); } for path in non_copyright_paths { let uri = path.parse::().unwrap(); assert!( !is_copyright_file(&uri), "Should not detect as copyright file: {}", path ); } } } debian-lsp-0.1.8/src/copyright/fields.rs000064400000000000000000000155531046102023000162740ustar 00000000000000use std::fs; use std::sync::OnceLock; use crate::deb822::completion::FieldInfo; /// All available Debian copyright file fields (DEP-5 format) /// /// Descriptions are sourced from the DEP-5 specification: /// pub const COPYRIGHT_FIELDS: &[FieldInfo] = &[ // Header paragraph fields FieldInfo::new( "Format", "URI of the format specification, e.g. `https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/`. Required in the header paragraph.", ), FieldInfo::new( "Upstream-Name", "The name upstream uses for the software. Optional, only used in the header paragraph.", ), FieldInfo::new( "Upstream-Contact", "The preferred address(es) to reach the upstream project. Typically an email address or URL. May be a line-based list. Optional, only used in the header paragraph.", ), FieldInfo::new( "Source", "An explanation of where the upstream source came from. Typically a URL. This field may be used to point at the upstream source code repository. Optional, only used in the header paragraph.", ), FieldInfo::new( "Disclaimer", "Free-form text field for non-free or contrib packages to state that they are not part of Debian and to explain why. Optional, only used in the header paragraph.", ), FieldInfo::new( "Comment", "Free-form text field for additional information. For example, it might quote the software's copyright notice if that triggers a specific requirement in the license.", ), FieldInfo::new( "License", "Short name of the license on the first line (the synopsis), followed by the full license text on subsequent lines. In a Files paragraph, the synopsis is required but the full text may be omitted if a stand-alone License paragraph with the same synopsis exists.", ), FieldInfo::new( "Copyright", "One or more free-form copyright statements, one per line. Required in Files paragraphs. In the header paragraph, it applies to files not matched by any Files paragraph.", ), FieldInfo::new( "Files-Excluded", "Whitespace-separated list of filename patterns indicating files to be excluded from the upstream source when repacking. Used by `uscan` when repacking upstream tarballs. Only used in the header paragraph.", ), // Files paragraph fields FieldInfo::new( "Files", "Whitespace-separated list of filename patterns indicating the files covered by this paragraph. Patterns use `fnmatch(3)` syntax (e.g. `*`, `?`, `[...]`). An asterisk (`*`) matches all files in the directory. Required in Files paragraphs.", ), // License paragraph has License and Comment which are already listed above ]; /// Get the standard casing for a field name pub fn get_standard_field_name(field_name: &str) -> Option<&'static str> { crate::deb822::completion::get_standard_field_name(COPYRIGHT_FIELDS, field_name) } /// Cache for common license names loaded from the system static COMMON_LICENSES_CACHE: OnceLock> = OnceLock::new(); /// Load common license names from /usr/share/common-licenses fn load_common_licenses() -> Vec { const COMMON_LICENSES_DIR: &str = "/usr/share/common-licenses"; let mut licenses = Vec::new(); if let Ok(entries) = fs::read_dir(COMMON_LICENSES_DIR) { for entry in entries.flatten() { if let Ok(file_name) = entry.file_name().into_string() { // Skip symlinks that are just shortcuts (GPL, LGPL, GFDL without version) if entry.path().is_symlink() { continue; } licenses.push(file_name); } } } licenses.sort(); licenses } /// Get common license names (cached) pub fn get_common_licenses() -> &'static [String] { COMMON_LICENSES_CACHE.get_or_init(load_common_licenses) } #[cfg(test)] mod tests { use super::*; #[test] fn test_copyright_fields() { assert!(!COPYRIGHT_FIELDS.is_empty()); assert!(COPYRIGHT_FIELDS.len() >= 10); // Test specific fields exist let field_names: Vec<_> = COPYRIGHT_FIELDS.iter().map(|f| f.name).collect(); assert!(field_names.contains(&"Format")); assert!(field_names.contains(&"Files")); assert!(field_names.contains(&"License")); assert!(field_names.contains(&"Copyright")); } #[test] fn test_copyright_field_validity() { for field in COPYRIGHT_FIELDS { assert!(!field.name.is_empty()); assert!(!field.description.is_empty()); assert!( field .name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-'), "Field {} contains invalid characters", field.name ); } } #[test] fn test_get_standard_field_name() { // Test correct casing - should return the same assert_eq!(get_standard_field_name("Format"), Some("Format")); assert_eq!(get_standard_field_name("Files"), Some("Files")); assert_eq!(get_standard_field_name("License"), Some("License")); // Test incorrect casing - should return the standard form assert_eq!(get_standard_field_name("format"), Some("Format")); assert_eq!(get_standard_field_name("files"), Some("Files")); assert_eq!(get_standard_field_name("license"), Some("License")); assert_eq!(get_standard_field_name("COPYRIGHT"), Some("Copyright")); // Test unknown fields - should return None assert_eq!(get_standard_field_name("UnknownField"), None); assert_eq!(get_standard_field_name("random"), None); } #[test] fn test_get_common_licenses() { let licenses = get_common_licenses(); // Only check for licenses if /usr/share/common-licenses exists // On macOS/Windows this directory won't exist if std::path::Path::new("/usr/share/common-licenses").exists() { assert!(!licenses.is_empty()); // Should have common licenses from /usr/share/common-licenses let license_strs: Vec<&str> = licenses.iter().map(|s| s.as_str()).collect(); assert!( license_strs.contains(&"GPL-2") || license_strs.contains(&"Apache-2.0"), "Should contain common licenses" ); } } #[test] fn test_load_common_licenses() { let licenses = load_common_licenses(); // Only check for licenses if /usr/share/common-licenses exists // On macOS/Windows this directory won't exist if std::path::Path::new("/usr/share/common-licenses").exists() { assert!(!licenses.is_empty()); for license in &licenses { assert!(!license.is_empty()); } } } } debian-lsp-0.1.8/src/copyright/hover.rs000064400000000000000000000072411046102023000161440ustar 00000000000000use tower_lsp_server::ls_types::{Hover, Position}; use super::fields::COPYRIGHT_FIELDS; use crate::position::Source; /// Get hover information for a debian/copyright file at the given cursor position. pub fn get_hover( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, ) -> Option { crate::deb822::hover::get_hover(deb822, src, position, COPYRIGHT_FIELDS) } #[cfg(test)] mod tests { use super::*; use crate::position::LineIndex; #[test] fn test_hover_on_format() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(0, 3)); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { tower_lsp_server::ls_types::HoverContents::Markup(m) => { assert!(m.value.contains("**Format**")); assert!(m.value.contains("format specification")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_files() { let text = "Format: https://example.com\n\nFiles: *\nCopyright: 2024 Test\nLicense: MIT\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(2, 2)); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { tower_lsp_server::ls_types::HoverContents::Markup(m) => { assert!(m.value.contains("**Files**")); assert!(m.value.contains("fnmatch")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_license() { let text = "Format: https://example.com\n\nFiles: *\nCopyright: 2024 Test\nLicense: MIT\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(4, 3)); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { tower_lsp_server::ls_types::HoverContents::Markup(m) => { assert!(m.value.contains("**License**")); assert!(m.value.contains("synopsis")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_copyright() { let text = "Format: https://example.com\n\nFiles: *\nCopyright: 2024 Test\nLicense: MIT\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(3, 3)); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { tower_lsp_server::ls_types::HoverContents::Markup(m) => { assert!(m.value.contains("**Copyright**")); assert!(m.value.contains("copyright statement")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_unknown_field() { let text = "Format: https://example.com\nUnknown: value\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(1, 3)); assert!(hover.is_none()); } } debian-lsp-0.1.8/src/copyright/mod.rs000064400000000000000000000006431046102023000155770ustar 00000000000000pub mod actions; pub mod code_lens; pub mod completion; pub mod detection; pub mod fields; pub mod hover; pub mod semantic; pub mod symbols; pub use actions::*; pub use code_lens::generate_code_lenses; pub use completion::*; pub use detection::is_copyright_file; pub use fields::get_standard_field_name; pub use hover::get_hover; pub use semantic::generate_semantic_tokens; pub use symbols::generate_document_symbols; debian-lsp-0.1.8/src/copyright/semantic.rs000064400000000000000000000075321046102023000166270ustar 00000000000000//! Semantic token generation for Debian copyright files. use tower_lsp_server::ls_types::SemanticToken; use super::get_standard_field_name; use crate::deb822::semantic::{generate_tokens, FieldValidator}; use crate::position::Source; /// Field validator for copyright files pub struct CopyrightFieldValidator; impl FieldValidator for CopyrightFieldValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { get_standard_field_name(name) } } /// Generate semantic tokens for a copyright file pub fn generate_semantic_tokens( copyright: &debian_copyright::lossless::Copyright, src: Source<'_>, ) -> Vec { let validator = CopyrightFieldValidator; generate_tokens(copyright.as_deb822(), src, &validator) } #[cfg(test)] mod tests { use super::*; use crate::deb822::semantic::TokenType; #[test] fn test_generate_semantic_tokens_known_fields() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: test\n"; let parsed = debian_copyright::lossless::Parse::parse(text); let copyright = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(©right, Source::new(text, &idx)); assert_eq!(tokens.len(), 4, "Should have exactly 4 tokens"); // First token: "Format" field name assert_eq!(tokens[0].delta_line, 0); assert_eq!(tokens[0].delta_start, 0); assert_eq!(tokens[0].length, 6); assert_eq!(tokens[0].token_type, TokenType::Field as u32); // Second token: format value assert_eq!(tokens[1].delta_line, 0); assert_eq!(tokens[1].token_type, TokenType::Value as u32); // Third token: "Upstream-Name" field name assert_eq!(tokens[2].delta_line, 1); assert_eq!(tokens[2].delta_start, 0); assert_eq!(tokens[2].length, 13); assert_eq!(tokens[2].token_type, TokenType::Field as u32); // Fourth token: upstream-name value assert_eq!(tokens[3].delta_line, 0); assert_eq!(tokens[3].token_type, TokenType::Value as u32); } #[test] fn test_generate_semantic_tokens_unknown_field() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nX-Custom: value\n"; let parsed = debian_copyright::lossless::Parse::parse(text); let copyright = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(©right, Source::new(text, &idx)); assert_eq!(tokens.len(), 4); // First token: "Format" - known field assert_eq!(tokens[0].token_type, TokenType::Field as u32); // Third token: "X-Custom" - unknown field assert_eq!(tokens[2].token_type, TokenType::UnknownField as u32); assert_eq!(tokens[2].length, 8); } #[test] fn test_generate_semantic_tokens_case_insensitive() { let text = "format: https://example.com\n"; let parsed = debian_copyright::lossless::Parse::parse(text); let copyright = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(©right, Source::new(text, &idx)); assert_eq!(tokens[0].token_type, TokenType::Field as u32); } #[test] fn test_field_validator() { let validator = CopyrightFieldValidator; assert_eq!(validator.get_standard_field_name("Format"), Some("Format")); assert_eq!(validator.get_standard_field_name("format"), Some("Format")); assert_eq!(validator.get_standard_field_name("Files"), Some("Files")); assert_eq!( validator.get_standard_field_name("License"), Some("License") ); assert_eq!(validator.get_standard_field_name("UnknownField"), None); } } debian-lsp-0.1.8/src/copyright/symbols.rs000064400000000000000000000133421046102023000165100ustar 00000000000000//! Document symbol generation for Debian copyright files. use crate::position::Source; use debian_copyright::lossless::Parse; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{DocumentSymbol, SymbolKind}; /// Generate document symbols for a copyright file. /// /// The header paragraph becomes a top-level symbol, and each Files and /// standalone License paragraph becomes a symbol, giving breadcrumb and /// outline navigation. #[allow(deprecated)] // DocumentSymbol::deprecated field pub fn generate_document_symbols(parse: &Parse, src: Source<'_>) -> Vec { let copyright = parse.tree(); let mut symbols = Vec::new(); if let Some(header) = copyright.header() { let para = header.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); let name = "Header".to_string(); symbols.push(DocumentSymbol { name, detail: header.format_string(), kind: SymbolKind::NAMESPACE, tags: None, deprecated: None, range, selection_range: range, children: None, }); } for files_para in copyright.iter_files() { let para = files_para.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); let files = files_para.files(); let name = format!("Files: {}", files.join(", ")); let detail = files_para .license() .and_then(|l| l.name().map(|s| s.to_string())); symbols.push(DocumentSymbol { name, detail, kind: SymbolKind::FILE, tags: None, deprecated: None, range, selection_range: range, children: None, }); } for license_para in copyright.iter_licenses() { let para = license_para.as_deb822(); let range = src.text_range_to_lsp_range(para.syntax().text_range()); let name = match license_para.name() { Some(n) => format!("License: {n}"), None => "License".to_string(), }; symbols.push(DocumentSymbol { name, detail: None, kind: SymbolKind::KEY, tags: None, deprecated: None, range, selection_range: range, children: None, }); } symbols } #[cfg(test)] mod tests { use super::*; fn parse(text: &str) -> Parse { Parse::parse_relaxed(text) } #[test] fn test_header_only() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: foo\n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 1); assert_eq!(symbols[0].name, "Header"); assert_eq!( symbols[0].detail.as_deref(), Some("https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/") ); assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE); } #[test] fn test_header_without_upstream_name() { let text = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\n"; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 1); assert_eq!(symbols[0].name, "Header"); } #[test] fn test_files_paragraphs() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* Copyright: 2024 Alice License: MIT Files: debian/* Copyright: 2024 Bob License: GPL-2+ "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 3); assert_eq!(symbols[0].name, "Header"); assert_eq!(symbols[0].kind, SymbolKind::NAMESPACE); assert_eq!(symbols[1].name, "Files: src/*"); assert_eq!(symbols[1].detail.as_deref(), Some("MIT")); assert_eq!(symbols[1].kind, SymbolKind::FILE); assert_eq!(symbols[2].name, "Files: debian/*"); assert_eq!(symbols[2].detail.as_deref(), Some("GPL-2+")); assert_eq!(symbols[2].kind, SymbolKind::FILE); } #[test] fn test_license_paragraphs() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: * Copyright: 2024 Test License: MIT License: MIT Permission is hereby granted... "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 3); assert_eq!(symbols[2].name, "License: MIT"); assert_eq!(symbols[2].kind, SymbolKind::KEY); } #[test] fn test_multiple_file_patterns() { let text = "\ Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Files: src/* lib/* include/* Copyright: 2024 Test License: Apache-2.0 "; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols[1].name, "Files: src/*, lib/*, include/*"); } #[test] fn test_empty_file() { let text = ""; let parsed = parse(text); let idx = crate::position::LineIndex::new(text); let symbols = generate_document_symbols(&parsed, Source::new(text, &idx)); assert_eq!(symbols.len(), 0); } } debian-lsp-0.1.8/src/deb822/completion.rs000064400000000000000000000377571046102023000161670ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Documentation, Position}; use crate::position::Source; /// A field definition for a deb822-based file format. pub struct FieldInfo { pub name: &'static str, pub description: &'static str, } impl FieldInfo { pub const fn new(name: &'static str, description: &'static str) -> Self { Self { name, description } } } /// What kind of position the cursor is at in a deb822 document. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CursorContext { /// Cursor is on a field key (the part before the colon). FieldKey, /// Cursor is on a field value (after the colon). FieldValue { field_name: String, value_prefix: String, }, /// Cursor is at the start of a line where a new field could be added. StartOfLine, } /// Look up the standard (canonical) casing for a field name. pub fn get_standard_field_name(fields: &[FieldInfo], field_name: &str) -> Option<&'static str> { let lowercase = field_name.to_lowercase(); fields .iter() .find(|f| f.name.to_lowercase() == lowercase) .map(|f| f.name) } /// Generate field name completions from a list of field definitions. pub fn get_field_completions(fields: &[FieldInfo]) -> Vec { fields .iter() .map(|field| CompletionItem { label: field.name.to_string(), kind: Some(CompletionItemKind::FIELD), detail: Some(field.description.to_string()), documentation: Some(Documentation::String(field.description.to_string())), insert_text: Some(format!("{}: ", field.name)), ..Default::default() }) .collect() } /// Determine what kind of completion context the cursor is in. /// /// Returns `None` for positions where no completions make sense /// (e.g. continuation lines, comments, blank lines between paragraphs). pub fn get_cursor_context( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, ) -> Option { if src.text.is_empty() { return Some(CursorContext::StartOfLine); } let offset = src.try_position_to_offset(position)?; // If cursor is at column 0 of a new line, it's a position where a new // field can be started, not a continuation of the previous entry. if position.character == 0 { return Some(CursorContext::StartOfLine); } // Find the entry that contains the cursor offset. For incomplete entries // (no colon — the user is still typing a field name), the CST range // excludes the trailing newline, so we use an inclusive end check to // match when the cursor is right at the boundary. let entry = deb822 .paragraphs() .flat_map(|p| p.entries().collect::>()) .find(|entry| { let r = entry.text_range(); if entry.colon_range().is_none() { r.start() <= offset && offset <= r.end() } else { r.start() <= offset && offset < r.end() } }); if let Some(entry) = entry { let field_name = entry.key()?; let colon_range = match entry.colon_range() { Some(r) => r, None => { // Entry has a key but no colon — the user is still typing // a field name. Treat as a field key. return Some(CursorContext::FieldKey); } }; if offset < colon_range.start() { // Before the colon → on the field key return Some(CursorContext::FieldKey); } if offset < colon_range.end() { // On the colon itself → treat as field key return Some(CursorContext::FieldKey); } // After the colon → on the field value // Use the raw source text (not entry.value()) to extract the prefix, // because value_range() spans the raw source including newlines and // continuation-line indentation, while entry.value() strips those. let value_prefix = if let Some(value_range) = entry.value_range() { if offset <= value_range.start() { String::new() } else { let prefix_end = if offset < value_range.end() { offset } else { value_range.end() }; let start: usize = value_range.start().into(); let end: usize = prefix_end.into(); let mut prefix_bytes = end.min(src.text.len()); while !src.text.is_char_boundary(prefix_bytes) { prefix_bytes -= 1; } src.text[start..prefix_bytes].to_string() } } else { String::new() }; return Some(CursorContext::FieldValue { field_name, value_prefix, }); } // Not on any entry — only offer field completions at column 0 // (start of a line where a new field could be written). if position.character == 0 { Some(CursorContext::StartOfLine) } else { None } } /// Get completions for a deb822 document at the given cursor position. /// /// If the cursor is on a field value, calls `value_completer` to get /// value-specific completions (or returns empty if none are defined for /// that field). If the cursor is not on a field value, returns field /// name completions. pub fn get_completions( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, fields: &[FieldInfo], value_completer: impl Fn(&str, &str) -> Vec, ) -> Vec { match get_cursor_context(deb822, src, position) { Some(CursorContext::FieldValue { field_name, value_prefix, }) => value_completer(&field_name, &value_prefix), Some(CursorContext::FieldKey | CursorContext::StartOfLine) => get_field_completions(fields), None => vec![], } } #[cfg(test)] mod tests { use super::*; const TEST_FIELDS: &[FieldInfo] = &[ FieldInfo::new("Source", "Name of the source package"), FieldInfo::new("Package", "Binary package name"), ]; #[test] fn test_get_field_completions() { let completions = get_field_completions(TEST_FIELDS); assert_eq!(completions.len(), 2); for completion in &completions { assert_eq!(completion.kind, Some(CompletionItemKind::FIELD)); assert!(completion.detail.is_some()); assert!(completion.documentation.is_some()); assert!(completion.insert_text.as_ref().unwrap().ends_with(": ")); } assert_eq!(completions[0].label, "Source"); assert_eq!(completions[1].label, "Package"); } #[test] fn test_get_cursor_context_on_value() { let text = "Source: test\nSection: py\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 11)) .expect("Should have context"); assert_eq!( ctx, CursorContext::FieldValue { field_name: "Section".to_string(), value_prefix: "py".to_string(), } ); } #[test] fn test_get_cursor_context_immediately_after_colon() { let text = "Section: py\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(0, 8)) .expect("Should have context"); assert_eq!( ctx, CursorContext::FieldValue { field_name: "Section".to_string(), value_prefix: "".to_string(), } ); } #[test] fn test_get_cursor_context_on_field_key() { let text = "Source: test\nSection: py\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 3)) .expect("Should have context"); assert_eq!(ctx, CursorContext::FieldKey); } #[test] fn test_get_cursor_context_empty_text() { let text = ""; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(0, 0)) .expect("Should have context"); assert_eq!(ctx, CursorContext::StartOfLine); } #[test] fn test_get_completions_on_value_with_completer() { let text = "Source: te\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let completions = get_completions( &deb822, Source::new(text, &idx), Position::new(0, 10), TEST_FIELDS, |field, _prefix| { if field == "Source" { vec![CompletionItem { label: "test-value".to_string(), ..Default::default() }] } else { vec![] } }, ); assert_eq!(completions.len(), 1); assert_eq!(completions[0].label, "test-value"); } #[test] fn test_get_completions_falls_back_to_fields() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); // Cursor on field key area → no value context → field completions let idx = crate::position::LineIndex::new(text); let completions = get_completions( &deb822, Source::new(text, &idx), Position::new(0, 2), TEST_FIELDS, |_, _| vec![], ); assert_eq!(completions.len(), 2); assert_eq!(completions[0].label, "Source"); assert_eq!(completions[1].label, "Package"); } #[test] fn test_get_completions_no_value_completions_returns_empty() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); // Cursor on value, completer returns empty → empty (not field completions) let idx = crate::position::LineIndex::new(text); let completions = get_completions( &deb822, Source::new(text, &idx), Position::new(0, 10), TEST_FIELDS, |_, _| vec![], ); assert!(completions.is_empty()); } #[test] fn test_get_cursor_context_multiline_value() { let text = "Build-Depends:\n debhelper-compat (= 13),\n pkg-co\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); // Cursor at end of "pkg-co" on line 2, column 7 let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(2, 7)) .expect("Should have context"); match ctx { CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Build-Depends"); // Should include the full raw value text up to cursor assert!( value_prefix.contains("debhelper-compat"), "prefix should contain prior relations: {:?}", value_prefix ); assert!( value_prefix.ends_with("pkg-co"), "prefix should end with partial name: {:?}", value_prefix ); } other => panic!("Expected FieldValue, got {:?}", other), } } #[test] fn test_get_cursor_context_partial_field_name_no_colon() { let text = "Source: test\nMai"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 3)) .expect("Should have context"); assert_eq!(ctx, CursorContext::FieldKey); } #[test] fn test_get_cursor_context_empty_new_line_after_entry() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 0)) .expect("Should have context"); assert_eq!(ctx, CursorContext::StartOfLine); } #[test] fn test_get_cursor_context_typing_single_char_on_new_line() { let text = "Source: test\nM"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 1)) .expect("Should have context"); assert_eq!(ctx, CursorContext::FieldKey); } #[test] fn test_get_cursor_context_substvar_after_comma() { let text = "Depends: gpg,${misc:\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(0, 20)) .expect("Should have context"); match ctx { CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Depends"); assert_eq!(value_prefix, "gpg,${misc:"); } other => panic!("Expected FieldValue, got {:?}", other), } } #[test] fn test_get_cursor_context_substvar_multiline() { let text = "Depends:\n gpg,${misc:\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); // Line 1: " gpg,${misc:\n", cursor at col 12 (after last ':') let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(1, 12)) .expect("Should have context"); match ctx { CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Depends"); // value_prefix should NOT include the continuation-line indent assert_eq!(value_prefix, "gpg,${misc:"); } other => panic!("Expected FieldValue, got {:?}", other), } } #[test] fn test_get_cursor_context_substvar_after_comma_space() { let text = "Depends: gpg, ${misc:\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(0, 21)) .expect("Should have context"); match ctx { CursorContext::FieldValue { field_name, value_prefix, } => { assert_eq!(field_name, "Depends"); assert_eq!(value_prefix, "gpg, ${misc:"); } other => panic!("Expected FieldValue, got {:?}", other), } } #[test] fn test_partial_field_between_existing_fields() { let text = "Source: debian-codemods\nSection: devel\nHomepa\nPriority: optional\n"; let deb822 = deb822_lossless::Deb822::parse(text).tree(); let idx = crate::position::LineIndex::new(text); let ctx = get_cursor_context(&deb822, Source::new(text, &idx), Position::new(2, 6)); assert!(ctx.is_some(), "Should have context"); assert_eq!(ctx.unwrap(), CursorContext::FieldKey); } } debian-lsp-0.1.8/src/deb822/folding.rs000064400000000000000000000071231046102023000154200ustar 00000000000000//! Generic folding range generation for deb822 files. //! //! Each paragraph in a deb822 file becomes a foldable region. use deb822_lossless::Deb822; use tower_lsp_server::ls_types::{FoldingRange, FoldingRangeKind}; use crate::position::Source; /// Generate folding ranges for a deb822 document. /// /// Each paragraph that spans more than one line produces a `Region` folding /// range. Single-line paragraphs are omitted because there is nothing to fold. pub fn generate_folding_ranges(deb822: &Deb822, src: Source<'_>) -> Vec { deb822 .paragraphs() .filter_map(|para| { let range = src.text_range_to_lsp_range(para.text_range()); // When the range ends at column 0, the actual content ends on the // previous line (the trailing newline pushed us to the next line). let end_line = if range.end.character == 0 && range.end.line > range.start.line { range.end.line - 1 } else { range.end.line }; if range.start.line == end_line { return None; } Some(FoldingRange { start_line: range.start.line, start_character: None, end_line, end_character: None, kind: Some(FoldingRangeKind::Region), collapsed_text: None, }) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_single_paragraph() { let text = "Source: foo\nMaintainer: Test \n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&deb822, Source::new(text, &idx)); assert_eq!(ranges.len(), 1); assert_eq!(ranges[0].start_line, 0); assert_eq!(ranges[0].end_line, 1); } #[test] fn test_multiple_paragraphs() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any Description: A test package "; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&deb822, Source::new(text, &idx)); assert_eq!(ranges.len(), 2); assert_eq!(ranges[0].start_line, 0); assert_eq!(ranges[0].end_line, 1); assert_eq!(ranges[1].start_line, 3); assert_eq!(ranges[1].end_line, 5); } #[test] fn test_empty_file() { let text = ""; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&deb822, Source::new(text, &idx)); assert_eq!(ranges.len(), 0); } #[test] fn test_single_line_paragraph_excluded() { let text = "Source: foo\n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&deb822, Source::new(text, &idx)); assert_eq!(ranges.len(), 0); } #[test] fn test_folding_kind_is_region() { let text = "Source: foo\nMaintainer: Test \n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&deb822, Source::new(text, &idx)); assert_eq!(ranges[0].kind, Some(FoldingRangeKind::Region)); } } debian-lsp-0.1.8/src/deb822/hover.rs000064400000000000000000000134151046102023000151220ustar 00000000000000use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position}; use super::completion::{get_cursor_context, CursorContext, FieldInfo}; use crate::position::Source; /// Look up the description for a field name (case-insensitive). fn get_field_description( fields: &[FieldInfo], field_name: &str, ) -> Option<(&'static str, &'static str)> { let lowercase = field_name.to_lowercase(); fields .iter() .find(|f| f.name.to_lowercase() == lowercase) .map(|f| (f.name, f.description)) } /// Get hover information for a deb822 document at the given cursor position. /// /// Returns a hover with the field name and description when the cursor /// is on a known field name. pub fn get_hover( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, fields: &[FieldInfo], ) -> Option { let ctx = get_cursor_context(deb822, src, position)?; match ctx { CursorContext::FieldKey => get_field_hover_at(deb822, src, position, fields), CursorContext::FieldValue { field_name, .. } => get_field_description(fields, &field_name) .map(|(canonical, description)| make_hover(canonical, description)), CursorContext::StartOfLine => None, } } /// Find the field name at the cursor position and return hover info. fn get_field_hover_at( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, fields: &[FieldInfo], ) -> Option { let offset = src.try_position_to_offset(position)?; let entry = deb822 .paragraphs() .flat_map(|p| p.entries().collect::>()) .find(|entry| { let r = entry.text_range(); r.start() <= offset && offset <= r.end() })?; let field_name = entry.key()?; get_field_description(fields, &field_name) .map(|(canonical, description)| make_hover(canonical, description)) } fn make_hover(field_name: &str, description: &str) -> Hover { Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: format!("**{}**\n\n{}", field_name, description), }), range: None, } } #[cfg(test)] mod tests { use super::*; const TEST_FIELDS: &[FieldInfo] = &[ FieldInfo::new("Source", "Name of the source package"), FieldInfo::new("Package", "Binary package name"), FieldInfo::new("Build-Depends", "Build dependencies"), ]; #[test] fn test_hover_on_field_key() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(0, 2), TEST_FIELDS, ); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { HoverContents::Markup(m) => { assert!(m.value.contains("**Source**")); assert!(m.value.contains("Name of the source package")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_field_value() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(0, 10), TEST_FIELDS, ); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { HoverContents::Markup(m) => { assert!(m.value.contains("**Source**")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_unknown_field() { let text = "Unknown: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(0, 2), TEST_FIELDS, ); assert!(hover.is_none()); } #[test] fn test_hover_case_insensitive() { let text = "source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(0, 2), TEST_FIELDS, ); assert!(hover.is_some()); let hover = hover.unwrap(); match hover.contents { HoverContents::Markup(m) => { // Should show canonical casing assert!(m.value.contains("**Source**")); } _ => panic!("Expected markup content"), } } #[test] fn test_hover_on_empty_line() { let text = "Source: test\n\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(1, 0), TEST_FIELDS, ); assert!(hover.is_none()); } #[test] fn test_hover_on_start_of_line() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover( &deb822, Source::new(text, &idx), Position::new(1, 0), TEST_FIELDS, ); assert!(hover.is_none()); } } debian-lsp-0.1.8/src/deb822/mod.rs000064400000000000000000000002631046102023000145530ustar 00000000000000//! Generic utilities for deb822-lossless based files. pub mod completion; pub mod folding; pub mod hover; pub mod on_type_formatting; pub mod selection_range; pub mod semantic; debian-lsp-0.1.8/src/deb822/on_type_formatting.rs000064400000000000000000000155071046102023000177120ustar 00000000000000use tower_lsp_server::ls_types::{Position, TextEdit}; /// Generate on-type formatting edits for deb822 files. /// /// Handles two cases: /// - After typing `:` at the end of a field name, insert a trailing space /// - After typing a newline inside a multi-line field value, insert a leading space for /// continuation pub fn on_type_formatting( deb822: &deb822_lossless::Deb822, source_text: &str, position: Position, ch: &str, ) -> Option> { match ch { ":" => on_type_colon(deb822, source_text, position), "\n" => on_type_newline(deb822, source_text, position), _ => None, } } /// After typing `:`, check if the CST shows we just completed a field separator and insert /// a space. fn on_type_colon( deb822: &deb822_lossless::Deb822, source_text: &str, position: Position, ) -> Option> { // The position is after the colon was typed. Look up the entry at the colon position. let colon_col = position.character.checked_sub(1)?; let entry = deb822.entry_at_line_col(position.line as usize, colon_col as usize)?; // Verify this entry has a colon (i.e. the colon we typed is the field separator). let colon_range = entry.colon_range()?; // Convert the colon's byte offset to a line/column to confirm it matches the typed position. let colon_offset: usize = colon_range.start().into(); let colon_line = source_text[..colon_offset].matches('\n').count(); if colon_line != position.line as usize { return None; } // Check that there isn't already a space after the colon. let after_colon_offset: usize = colon_range.end().into(); if source_text[after_colon_offset..].starts_with(' ') { return None; } Some(vec![TextEdit { range: tower_lsp_server::ls_types::Range { start: position, end: position, }, new_text: " ".to_string(), }]) } /// After typing a newline, if the previous line is part of a field entry, /// insert a leading space for continuation. fn on_type_newline( deb822: &deb822_lossless::Deb822, source_text: &str, position: Position, ) -> Option> { if position.line == 0 { return None; } let prev_line_idx = (position.line - 1) as usize; let prev_line = source_text.lines().nth(prev_line_idx)?; // Use the CST to check if the previous line is inside a field entry. // Use column 0 for field lines and column 1 for continuation lines (which start // with whitespace, so column 0 is indent, not inside the entry key). let col = if prev_line.starts_with(' ') || prev_line.starts_with('\t') { 1 } else { 0 }; let entry = deb822.entry_at_line_col(prev_line_idx, col)?; // If the entry has no colon yet, the user is still typing a field name — don't insert. entry.colon_range()?; // Check if the current line already starts with whitespace. let current_line = source_text .lines() .nth(position.line as usize) .unwrap_or(""); if current_line.starts_with(' ') || current_line.starts_with('\t') { return None; } // Don't add indentation if the current line has non-whitespace content. if !current_line.is_empty() { return None; } Some(vec![TextEdit { range: tower_lsp_server::ls_types::Range { start: position, end: position, }, new_text: " ".to_string(), }]) } #[cfg(test)] mod tests { use super::*; fn parse(text: &str) -> deb822_lossless::Deb822 { deb822_lossless::Deb822::parse(text).tree() } #[test] fn test_colon_after_field_name_inserts_space() { // Position is after the colon (cursor position after typing) let text = "Source:\n"; let deb822 = parse(text); let edits = on_type_formatting(&deb822, text, Position::new(0, 7), ":").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); assert_eq!(edits[0].range.start, Position::new(0, 7)); } #[test] fn test_colon_after_hyphenated_field_name_inserts_space() { let text = "Vcs-Bzr:\n"; let deb822 = parse(text); let edits = on_type_formatting(&deb822, text, Position::new(0, 8), ":").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); assert_eq!(edits[0].range.start, Position::new(0, 8)); } #[test] fn test_colon_with_existing_space_no_edit() { let text = "Source: foo\n"; let deb822 = parse(text); let result = on_type_formatting(&deb822, text, Position::new(0, 7), ":"); assert!(result.is_none()); } #[test] fn test_colon_in_value_no_edit() { let text = "Source: foo:bar\n"; let deb822 = parse(text); // Typing a colon inside a value (after the field colon) should not trigger let result = on_type_formatting(&deb822, text, Position::new(0, 11), ":"); assert!(result.is_none()); } #[test] fn test_colon_on_continuation_line_no_edit() { let text = "Description: foo\n bar:\n"; let deb822 = parse(text); let result = on_type_formatting(&deb822, text, Position::new(1, 5), ":"); assert!(result.is_none()); } #[test] fn test_newline_after_field_value_inserts_space() { let text = "Description: foo\n\n"; let deb822 = parse(text); let edits = on_type_formatting(&deb822, text, Position::new(1, 0), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); assert_eq!(edits[0].range.start, Position::new(1, 0)); } #[test] fn test_newline_after_continuation_line_inserts_space() { let text = "Description: foo\n bar\n\n"; let deb822 = parse(text); let edits = on_type_formatting(&deb822, text, Position::new(2, 0), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_at_start_of_file_no_edit() { let text = "\n"; let deb822 = parse(text); let result = on_type_formatting(&deb822, text, Position::new(0, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_after_empty_line_no_edit() { let text = "Source: foo\n\n\n"; let deb822 = parse(text); // Line 1 is empty (paragraph separator), line 2 is the new line let result = on_type_formatting(&deb822, text, Position::new(2, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_already_indented_no_edit() { let text = "Description: foo\n bar\n"; let deb822 = parse(text); // The current line already starts with a space let result = on_type_formatting(&deb822, text, Position::new(1, 0), "\n"); assert!(result.is_none()); } } debian-lsp-0.1.8/src/deb822/selection_range.rs000064400000000000000000000150471046102023000171430ustar 00000000000000//! Selection range generation for deb822 files. //! //! Provides hierarchical selection expansion: //! 1. Field value only //! 2. Field name + value (entire entry) //! 3. Entire paragraph //! 4. Complete file use crate::position::Source; use deb822_lossless::Deb822; use text_size::TextSize; use tower_lsp_server::ls_types::{Position, Range, SelectionRange}; /// Generate selection ranges for the given positions in a deb822 document. pub fn generate_selection_ranges( deb822: &Deb822, src: Source<'_>, positions: &[Position], ) -> Vec { let file_range = Range::new( Position::new(0, 0), src.offset_to_position(TextSize::try_from(src.text.len()).unwrap()), ); positions .iter() .map(|pos| { let file_sel = SelectionRange { range: file_range, parent: None, }; let Some(offset) = src.try_position_to_offset(*pos) else { return file_sel; }; let offset = TextSize::from(u32::from(offset)); let Some(para) = deb822.paragraph_at_position(offset) else { return file_sel; }; let para_range = src.text_range_to_lsp_range(para.text_range()); let para_sel = SelectionRange { range: para_range, parent: Some(Box::new(file_sel)), }; let Some(entry) = para.entry_at_position(offset) else { return para_sel; }; let entry_range = src.text_range_to_lsp_range(entry.text_range()); let entry_sel = SelectionRange { range: entry_range, parent: Some(Box::new(para_sel)), }; if let Some(value_range) = entry.value_range() { let value_lsp_range = src.text_range_to_lsp_range(value_range); if value_range.contains(offset) { return SelectionRange { range: value_lsp_range, parent: Some(Box::new(entry_sel)), }; } } if let Some(key_range) = entry.key_range() { let key_lsp_range = src.text_range_to_lsp_range(key_range); if key_range.contains(offset) { return SelectionRange { range: key_lsp_range, parent: Some(Box::new(entry_sel)), }; } } entry_sel }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_selection_range_in_value() { let text = "Source: foo\nMaintainer: Test \n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position in the value "foo" on line 0, col 8 let ranges = generate_selection_ranges(&deb822, src, &[Position::new(0, 8)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Innermost: value range assert_eq!(sel.range.start.line, 0); assert_eq!(sel.range.start.character, 8); // Parent: entry range let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 0); assert_eq!(entry_sel.range.start.character, 0); // Grandparent: paragraph range let para_sel = entry_sel.parent.as_ref().unwrap(); assert_eq!(para_sel.range.start.line, 0); // Great-grandparent: file range let file_sel = para_sel.parent.as_ref().unwrap(); assert_eq!(file_sel.range.start.line, 0); assert_eq!(file_sel.range.start.character, 0); assert!(file_sel.parent.is_none()); } #[test] fn test_selection_range_in_key() { let text = "Source: foo\n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position in "Source" at col 2 let ranges = generate_selection_ranges(&deb822, src, &[Position::new(0, 2)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Innermost: key range ("Source") assert_eq!(sel.range.start.character, 0); assert_eq!(sel.range.end.character, 6); // Parent: entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 0); // Grandparent: paragraph assert!(entry_sel.parent.is_some()); } #[test] fn test_selection_range_multiple_paragraphs() { let text = "\ Source: foo Maintainer: Test Package: foo Architecture: any "; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position in second paragraph, "Architecture" value "any" let ranges = generate_selection_ranges(&deb822, src, &[Position::new(4, 15)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Value range assert_eq!(sel.range.start.line, 4); // Entry let entry_sel = sel.parent.as_ref().unwrap(); assert_eq!(entry_sel.range.start.line, 4); // Paragraph starts at line 3 let para_sel = entry_sel.parent.as_ref().unwrap(); assert_eq!(para_sel.range.start.line, 3); // File let file_sel = para_sel.parent.as_ref().unwrap(); assert_eq!(file_sel.range.start.line, 0); } #[test] fn test_selection_range_multiple_positions() { let text = "Source: foo\nMaintainer: Test \n"; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let ranges = generate_selection_ranges(&deb822, src, &[Position::new(0, 8), Position::new(1, 13)]); assert_eq!(ranges.len(), 2); } #[test] fn test_selection_range_empty_file() { let text = ""; let parsed = Deb822::parse(text); let deb822 = parsed.tree(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let ranges = generate_selection_ranges(&deb822, src, &[Position::new(0, 0)]); assert_eq!(ranges.len(), 1); // Should return file range assert!(ranges[0].parent.is_none()); } } debian-lsp-0.1.8/src/deb822/semantic.rs000064400000000000000000000226461046102023000156100ustar 00000000000000//! Generic semantic token generation for deb822 files. //! //! This module provides the core logic for generating semantic tokens from //! deb822-lossless parse trees. File-type-specific modules (control, copyright) //! use this by providing field validation callbacks. use deb822_lossless::{Deb822, SyntaxKind}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::SemanticToken; use crate::position::Source; /// Semantic token types reported by the server. /// /// The discriminant values must match the order in the `token_types` legend /// registered in `main.rs` `initialize()`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u32)] pub enum TokenType { // deb822 types Field = 0, UnknownField = 1, Value = 2, Comment = 3, // Changelog-specific types ChangelogPackage = 4, ChangelogVersion = 5, ChangelogDistribution = 6, ChangelogUrgency = 7, ChangelogMaintainer = 8, ChangelogTimestamp = 9, ChangelogMetadataValue = 10, ChangelogBugReference = 11, } /// Token modifier bit flags pub mod token_modifier { pub const DECLARATION: u32 = 1 << 0; } /// Field validation callback pub trait FieldValidator { /// Check if a field name is valid and get its standard casing fn get_standard_field_name(&self, name: &str) -> Option<&'static str>; } /// Helper for building semantic token arrays pub struct SemanticTokensBuilder { tokens: Vec, prev_line: u32, prev_char: u32, } impl SemanticTokensBuilder { pub fn new() -> Self { Self { tokens: Vec::new(), prev_line: 0, prev_char: 0, } } /// Add a token at the given position pub fn push( &mut self, line: u32, start_char: u32, length: u32, token_type: TokenType, token_modifiers: u32, ) { let delta_line = line - self.prev_line; let delta_start = if delta_line == 0 { start_char - self.prev_char } else { start_char }; self.tokens.push(SemanticToken { delta_line, delta_start, length, token_type: token_type as u32, token_modifiers_bitset: token_modifiers, }); self.prev_line = line; self.prev_char = start_char; } pub fn build(self) -> Vec { self.tokens } } impl Default for SemanticTokensBuilder { fn default() -> Self { Self::new() } } /// Generate semantic tokens for a deb822 file. /// /// `src` carries the buffer plus its salsa-cached line index so the /// per-token offset → position conversions inside the loop are /// O(log N) rather than O(N). pub fn generate_tokens( deb822: &Deb822, src: Source<'_>, validator: &V, ) -> Vec { let mut builder = SemanticTokensBuilder::new(); // Single pass through the syntax tree for element in deb822.syntax().descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = element { match token.kind() { SyntaxKind::COMMENT => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); builder.push( start_pos.line, start_pos.character, length, TokenType::Comment, 0, ); } SyntaxKind::KEY => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let key = token.text(); let length = crate::position::utf16_len(key); // Check if field is known let token_type = if validator.get_standard_field_name(key).is_some() { TokenType::Field } else { TokenType::UnknownField }; builder.push( start_pos.line, start_pos.character, length, token_type, token_modifier::DECLARATION, ); } SyntaxKind::VALUE => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); if length > 0 { builder.push( start_pos.line, start_pos.character, length, TokenType::Value, 0, ); } } _ => {} } } } builder.build() } #[cfg(test)] mod tests { use super::*; struct TestValidator; impl FieldValidator for TestValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { if name.eq_ignore_ascii_case("Source") { Some("Source") } else if name.eq_ignore_ascii_case("Package") { Some("Package") } else { None } } } #[test] fn test_generate_tokens_known_and_unknown_fields() { let text = "Source: foo\nX-Custom: bar\n"; let parsed = deb822_lossless::Deb822::parse(text); let deb822 = parsed.tree(); let validator = TestValidator; let idx = crate::position::LineIndex::new(text); let tokens = generate_tokens(&deb822, Source::new(text, &idx), &validator); // Source (known field), value, X-Custom (unknown field), value assert_eq!(tokens.len(), 4); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 6); // "Source" assert_eq!(tokens[1].token_type, TokenType::Value as u32); assert_eq!(tokens[2].token_type, TokenType::UnknownField as u32); assert_eq!(tokens[2].length, 8); // "X-Custom" assert_eq!(tokens[3].token_type, TokenType::Value as u32); } #[test] fn test_generate_tokens_comments() { let text = "# This is a comment\nSource: foo\n"; let parsed = deb822_lossless::Deb822::parse(text); let deb822 = parsed.tree(); let validator = TestValidator; let idx = crate::position::LineIndex::new(text); let tokens = generate_tokens(&deb822, Source::new(text, &idx), &validator); assert_eq!(tokens[0].token_type, TokenType::Comment as u32); assert_eq!(tokens[1].token_type, TokenType::Field as u32); } #[test] fn test_generate_tokens_multi_paragraph() { let text = "Source: foo\n\nPackage: bar\n"; let parsed = deb822_lossless::Deb822::parse(text); let deb822 = parsed.tree(); let validator = TestValidator; let idx = crate::position::LineIndex::new(text); let tokens = generate_tokens(&deb822, Source::new(text, &idx), &validator); // Source, value, Package, value assert_eq!(tokens.len(), 4); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 6); // "Source" assert_eq!(tokens[2].token_type, TokenType::Field as u32); assert_eq!(tokens[2].length, 7); // "Package" } #[test] fn test_generate_tokens_empty() { let text = ""; let parsed = deb822_lossless::Deb822::parse(text); let deb822 = parsed.tree(); let validator = TestValidator; let idx = crate::position::LineIndex::new(text); let tokens = generate_tokens(&deb822, Source::new(text, &idx), &validator); assert_eq!(tokens.len(), 0); } #[test] fn test_generate_tokens_field_modifiers() { let text = "Source: foo\n"; let parsed = deb822_lossless::Deb822::parse(text); let deb822 = parsed.tree(); let validator = TestValidator; let idx = crate::position::LineIndex::new(text); let tokens = generate_tokens(&deb822, Source::new(text, &idx), &validator); // Field tokens should have DECLARATION modifier assert_eq!( tokens[0].token_modifiers_bitset, token_modifier::DECLARATION ); // Value tokens should have no modifiers assert_eq!(tokens[1].token_modifiers_bitset, 0); } #[test] fn test_semantic_tokens_builder() { let mut builder = SemanticTokensBuilder::new(); // Add a token on line 0 builder.push(0, 0, 6, TokenType::Field, 0); // Add another token on the same line builder.push(0, 8, 4, TokenType::Value, 0); // Add a token on line 1 builder.push(1, 0, 7, TokenType::Field, 0); let tokens = builder.build(); assert_eq!(tokens.len(), 3); // First token assert_eq!(tokens[0].delta_line, 0); assert_eq!(tokens[0].delta_start, 0); assert_eq!(tokens[0].length, 6); // Second token (same line) assert_eq!(tokens[1].delta_line, 0); assert_eq!(tokens[1].delta_start, 8); // Third token (new line) assert_eq!(tokens[2].delta_line, 1); assert_eq!(tokens[2].delta_start, 0); } } debian-lsp-0.1.8/src/dep3/completion.rs000064400000000000000000000112251046102023000160120ustar 00000000000000//! Completions for DEP-3 patch headers. //! //! Active only when the cursor is in the header portion of the file. //! Completions in the unified-diff body are left to diff-lsp. use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position}; use super::fields::DEP3_FIELDS; use crate::position::{LineIndex, Source}; /// Get completion items for a DEP-3 header at `position`. `header` /// is the parsed deb822 of the header portion only; `header_end` is /// the byte offset where the diff body begins. Returns `Vec::new()` /// if the cursor is in the diff body. pub fn get_completions( header: &deb822_lossless::Deb822, header_end: usize, src: Source<'_>, position: Position, ) -> Vec { if !super::is_in_dep3_header(src, header_end, position) { return Vec::new(); } let header_text = &src.text[..header_end]; // The header substring shares its 0-offset with the buffer, so a // fresh LineIndex over it is correct for the deb822 layer. let header_idx = LineIndex::new(header_text); let header_src = Source::new(header_text, &header_idx); crate::deb822::completion::get_completions( header, header_src, position, DEP3_FIELDS, value_completions, ) } /// Field-value completions for fields with a small enumerated value /// space. `Forwarded:` and `Origin:` (the category prefix) are the /// useful ones; everything else returns no value completions. fn value_completions(field_name: &str, value_prefix: &str) -> Vec { let candidates: &[(&str, &str)] = match field_name { "Forwarded" => &[ ( "yes", "The patch has been forwarded upstream (followed by a URL or reference).", ), ("no", "The patch has not been forwarded upstream."), ( "not-needed", "The patch is Debian-specific and doesn't need forwarding.", ), ], // Origin's first comma-separated component is the category; // suggest those when the user is at the start of the value. "Origin" if !value_prefix.contains(',') => &[ ("upstream", "Cherry-picked from the upstream VCS."), ( "backport", "An upstream patch that had to be modified to apply to this version.", ), ( "vendor", "Created by Debian or another distribution vendor.", ), ("other", "Doesn't fit any of the above categories."), ], _ => return Vec::new(), }; candidates .iter() .filter(|(label, _)| label.starts_with(value_prefix)) .map(|(label, doc)| CompletionItem { label: (*label).to_string(), kind: Some(CompletionItemKind::ENUM_MEMBER), detail: Some((*doc).to_string()), insert_text: Some((*label).to_string()), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; fn run(text: &str, position: Position) -> Vec { let idx = LineIndex::new(text); let src = Source::new(text, &idx); let header_end = dep3::lossless::header_end(text); let parsed = deb822_lossless::Deb822::parse(&text[..header_end]); get_completions(&parsed.tree(), header_end, src, position) } #[test] fn field_name_completions_at_start_of_line_in_header() { let completions = run("Author: alice\n\n", Position::new(1, 0)); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"Description")); assert!(labels.contains(&"Forwarded")); assert!(labels.contains(&"Last-Update")); } #[test] fn no_completions_in_diff_body() { assert!(run("Author: alice\n---\n@@ -1 +1 @@\n", Position::new(2, 0)).is_empty()); } #[test] fn forwarded_value_enum_completions() { // Cursor is right after "Forwarded: " on line 0. let completions = run("Forwarded: \n", Position::new(0, 11)); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"yes")); assert!(labels.contains(&"no")); assert!(labels.contains(&"not-needed")); } #[test] fn origin_category_enum_completions() { let completions = run("Origin: \n", Position::new(0, 8)); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"upstream")); assert!(labels.contains(&"backport")); assert!(labels.contains(&"vendor")); } } debian-lsp-0.1.8/src/dep3/detection.rs000064400000000000000000000041611046102023000156200ustar 00000000000000//! Position helpers for DEP-3 header regions. use tower_lsp_server::ls_types::Position; use crate::position::Source; /// Return true if `position` falls inside the DEP-3 header portion of /// the buffer — i.e. before the first `---` / `diff ` / `Index:` line. /// Positions on or after the diff body return `false` so LSP features /// don't reach into the unified-diff territory that diff-lsp owns. /// /// `header_end` is the byte offset where the diff body starts; supply /// the salsa-cached value (from `Workspace::get_parsed_dep3_header`) /// when the file is open so we don't recompute it on every call. pub fn is_in_dep3_header(src: Source<'_>, header_end: usize, position: Position) -> bool { let Some(offset) = src.try_position_to_offset(position) else { return false; }; let offset: usize = offset.into(); offset < header_end } #[cfg(test)] mod tests { use super::*; use crate::position::LineIndex; fn check(text: &str, position: Position) -> bool { let header_end = dep3::lossless::header_end(text); let idx = LineIndex::new(text); is_in_dep3_header(Source::new(text, &idx), header_end, position) } #[test] fn cursor_in_header_returns_true() { let text = "Author: alice\nDescription: bla\n---\n@@ -1 +1 @@\n"; assert!(check(text, Position::new(0, 0))); assert!(check(text, Position::new(1, 5))); } #[test] fn cursor_in_diff_body_returns_false() { let text = "Author: alice\n---\n@@ -1 +1 @@\n-x\n+y\n"; // Line 2 is `---`, line 3 is the hunk header. assert!(!check(text, Position::new(2, 0))); assert!(!check(text, Position::new(3, 1))); } #[test] fn cursor_at_diff_marker_line_returns_false() { let text = "Author: alice\n---\n"; // Line 1 is `---` (the boundary); positions on it are body. assert!(!check(text, Position::new(1, 0))); } #[test] fn header_only_file_is_all_header() { let text = "Author: alice\nDescription: bla\n"; assert!(check(text, Position::new(0, 0))); assert!(check(text, Position::new(1, 5))); } } debian-lsp-0.1.8/src/dep3/diagnostics.rs000064400000000000000000000076531046102023000161620ustar 00000000000000//! Diagnostics for DEP-3 patch headers. //! //! Operates only on the header portion of a patch — the unified diff //! body is left to diff-lsp. use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString}; use crate::position::Source; /// Generate diagnostics for a DEP-3 header. `header` is the parsed /// deb822 tree of the header portion only (everything before the /// first `---` / `diff ` / `Index:` line); `source_text` is the /// whole patch buffer, needed to map rowan byte ranges back to LSP /// `Position`s. /// /// Currently surfaces field-name casing issues (e.g. `description` → /// `Description`). Returns an empty vector if the header is empty. pub fn get_diagnostics(header: &deb822_lossless::Deb822, src: Source<'_>) -> Vec { let mut diags = Vec::new(); for paragraph in header.paragraphs() { for entry in paragraph.entries() { let Some(key) = entry.key() else { continue; }; // `Bug-` is valid DEP-3 (vendor-specific extension); // skip casing checks against the canonical-name table for // these. Only flag `Bug-` if the vendor part itself looks // mis-cased — out of scope for now. if key.starts_with("Bug-") { continue; } let Some(canonical) = super::get_standard_field_name(&key) else { continue; // unknown field; not a casing issue }; if key == canonical { continue; } let Some(field_range) = entry.key_range() else { continue; }; let lsp_range = src.text_range_to_lsp_range(field_range); diags.push(Diagnostic { range: lsp_range, severity: Some(DiagnosticSeverity::WARNING), code: Some(NumberOrString::String("field-casing".to_string())), source: Some("debian-lsp".to_string()), message: format!("Field name '{}' should be '{}'", key, canonical), ..Default::default() }); } } diags } #[cfg(test)] mod tests { use super::*; /// Test helper: parse the DEP-3 header out of `text` and call /// `get_diagnostics` with it. fn run(text: &str) -> Vec { let header_end = dep3::lossless::header_end(text); let parsed = deb822_lossless::Deb822::parse(&text[..header_end]); let idx = crate::position::LineIndex::new(text); get_diagnostics(&parsed.tree(), Source::new(text, &idx)) } #[test] fn lowercase_field_flagged() { let text = "author: alice\ndescription: bla\n"; let diags = run(text); assert_eq!(diags.len(), 2); assert_eq!(diags[0].message, "Field name 'author' should be 'Author'"); assert_eq!( diags[1].message, "Field name 'description' should be 'Description'" ); } #[test] fn canonical_field_not_flagged() { assert_eq!(run("Author: alice\nDescription: bla\n").len(), 0); } #[test] fn unknown_field_not_flagged() { assert_eq!(run("Author: alice\nX-Custom: y\n").len(), 0); } #[test] fn bug_vendor_field_not_flagged() { assert_eq!( run("Author: alice\nBug-Debian: https://bugs.debian.org/1\n").len(), 0 ); } #[test] fn diff_body_not_inspected() { // First field is in header — well, "wrong-case" is unknown so // not flagged. The point is: the diff line below is never // looked at. assert!(run("wrong-case: alice\n---\nthis-would-also-be-wrong: x\n").is_empty()); } #[test] fn diff_body_after_known_field_not_inspected() { // `author` should be flagged once; `foo:` in the diff body is // not a field at all and must not appear. assert_eq!(run("author: alice\n---\nfoo: bar\n").len(), 1); } } debian-lsp-0.1.8/src/dep3/fields.rs000064400000000000000000000055111046102023000151100ustar 00000000000000//! Known DEP-3 field names and descriptions. //! //! Sourced from the DEP-3 specification: //! use crate::deb822::completion::FieldInfo; /// Canonical DEP-3 patch-header field names with one-line descriptions. pub const DEP3_FIELDS: &[FieldInfo] = &[ FieldInfo::new( "Description", "Synopsis on the first line followed by a longer indented body. Required (or `Subject` as an alias).", ), FieldInfo::new( "Subject", "Alias for `Description` carried over from email/git-format-patch headers.", ), FieldInfo::new( "Origin", "Where the patch came from. Optional category prefix (`upstream`, `backport`, `vendor`, `other`) followed by a comma and a URL or commit reference.", ), FieldInfo::new( "Bug", "URL of the upstream bug entry that this patch fixes.", ), FieldInfo::new( "Bug-Debian", "URL of the matching Debian bug entry. Other vendors use `Bug-` (e.g. `Bug-Ubuntu`).", ), FieldInfo::new( "Forwarded", "Whether the patch has been forwarded upstream. One of `yes`, `no`, `not-needed`, or a URL pointing at the forwarded patch.", ), FieldInfo::new( "Author", "Author of the patch. Equivalent to `From` (kept verbatim from `git format-patch` headers); both are accepted but `Author` is the DEP-3 canonical name.", ), FieldInfo::new( "From", "Alias for `Author` carried over from email/git-format-patch headers.", ), FieldInfo::new( "Reviewed-By", "Reviewer who has examined the patch. May appear multiple times.", ), FieldInfo::new( "Last-Update", "ISO date (`YYYY-MM-DD`) of the last edit to the patch metadata.", ), FieldInfo::new( "Applied-Upstream", "Set when the patch has landed upstream. Format: a version, a commit identifier, or a `commit:` reference.", ), ]; /// Look up the canonical casing for a DEP-3 field name. Returns /// `None` for fields not in the spec (typically vendor-specific /// `Bug-` headers, or `X-`-prefixed extensions). pub fn get_standard_field_name(field_name: &str) -> Option<&'static str> { crate::deb822::completion::get_standard_field_name(DEP3_FIELDS, field_name) } #[cfg(test)] mod tests { use super::*; #[test] fn standard_casing_canonicalises_lowercase() { assert_eq!(get_standard_field_name("description"), Some("Description")); assert_eq!(get_standard_field_name("LAST-UPDATE"), Some("Last-Update")); assert_eq!(get_standard_field_name("Author"), Some("Author")); } #[test] fn standard_casing_returns_none_for_unknown() { assert_eq!(get_standard_field_name("Bug-Ubuntu"), None); assert_eq!(get_standard_field_name("X-Custom"), None); } } debian-lsp-0.1.8/src/dep3/hover.rs000064400000000000000000000042051046102023000147640ustar 00000000000000//! Hover docs for DEP-3 patch headers. use tower_lsp_server::ls_types::{Hover, Position}; use super::fields::DEP3_FIELDS; use crate::position::{LineIndex, Source}; /// Hover info for the DEP-3 header at `position`. `header` is the /// parsed deb822 of the header portion only; `header_end` is where /// the diff body starts. Returns `None` if the cursor is in the /// diff body, or on a field name we don't have docs for. pub fn get_hover( header: &deb822_lossless::Deb822, header_end: usize, src: Source<'_>, position: Position, ) -> Option { if !super::is_in_dep3_header(src, header_end, position) { return None; } let header_text = &src.text[..header_end]; let header_idx = LineIndex::new(header_text); let header_src = Source::new(header_text, &header_idx); crate::deb822::hover::get_hover(header, header_src, position, DEP3_FIELDS) } #[cfg(test)] mod tests { use super::*; fn run(text: &str, position: Position) -> Option { let header_end = dep3::lossless::header_end(text); let parsed = deb822_lossless::Deb822::parse(&text[..header_end]); let idx = LineIndex::new(text); get_hover( &parsed.tree(), header_end, Source::new(text, &idx), position, ) } #[test] fn hover_on_known_field_in_header() { let hover = run( "Author: alice\nForwarded: not-needed\n", Position::new(1, 3), ) .expect("hover available"); match hover.contents { tower_lsp_server::ls_types::HoverContents::Markup(m) => { assert!(m.value.contains("**Forwarded**")); assert!(m.value.contains("not-needed")); } _ => panic!("Expected markup content"), } } #[test] fn hover_in_diff_body_returns_none() { // Position on the `---` line. assert!(run("Author: alice\n---\n@@ -1 +1 @@\n", Position::new(1, 1)).is_none()); } #[test] fn hover_on_unknown_field_returns_none() { assert!(run("Author: alice\nX-Custom: y\n", Position::new(1, 3)).is_none()); } } debian-lsp-0.1.8/src/dep3/mod.rs000064400000000000000000000013541046102023000144220ustar 00000000000000//! Module for DEP-3 patch headers. //! //! DEP-3 specifies a deb822-shaped header at the top of a quilt patch //! file under `debian/patches/`. The header runs until the first //! `---` / `diff ` / `Index:` line — everything after that is the //! unified diff itself, which we leave to diff-lsp. pub mod completion; pub mod detection; pub mod diagnostics; pub mod fields; pub mod hover; pub mod parsing; pub mod semantic; pub mod symbols; pub use completion::get_completions; pub use detection::is_in_dep3_header; pub use diagnostics::get_diagnostics; pub use fields::get_standard_field_name; pub use hover::get_hover; pub use parsing::parse_dep3_header; pub use semantic::generate_semantic_tokens; pub use symbols::generate_document_symbols; debian-lsp-0.1.8/src/dep3/parsing.rs000064400000000000000000000032721046102023000153070ustar 00000000000000//! Parsing helpers for DEP-3 patch headers. //! //! Thin wrappers over the [`dep3`] crate's relaxed parser: a quilt //! patch under `debian/patches/` is a DEP-3 header (deb822) followed by //! an unspecified body (usually a unified diff, sometimes an `Index:` //! line, sometimes a bare `---`). [`dep3::lossless::header_end`] finds //! that boundary; [`dep3::lossless::PatchHeader::parse_relaxed`] parses //! the header portion and returns the offset where the body starts so //! callers can map source ranges back into the original file. pub use dep3::lossless::PatchHeader; /// Parse the DEP-3 header portion of `content`. Returns the parsed /// header and the byte offset where the diff body starts (equal to /// `content.len()` if there is no body). Returns `None` if the header /// portion can't be parsed as deb822 — e.g. a malformed continuation /// line in the header. pub fn parse_dep3_header(content: &str) -> Option<(PatchHeader, usize)> { PatchHeader::parse_relaxed(content).ok() } #[cfg(test)] mod tests { use super::*; #[test] fn parse_header_reads_first_paragraph() { let s = "Author: alice\nDescription: bla\n---\n@@ -1 +1 @@\n"; let (header, end) = parse_dep3_header(s).expect("header parses"); assert_eq!(end, "Author: alice\nDescription: bla\n".len()); assert_eq!(header.author(), Some("alice".to_string())); assert_eq!(header.description(), Some("bla".to_string())); } #[test] fn parse_returns_header_end() { let s = "Author: alice\nDescription: bla\n---\n@@ -1 +1 @@\n-x\n+y\n"; let (_, end) = parse_dep3_header(s).expect("parses"); assert_eq!(end, "Author: alice\nDescription: bla\n".len()); } } debian-lsp-0.1.8/src/dep3/semantic.rs000064400000000000000000000074361046102023000154550ustar 00000000000000//! Semantic token generation for DEP-3 patch headers. //! //! Tokens cover only the header portion of the file — anything past //! the first `---` / `diff ` / `Index:` line is the unified diff and //! gets left to diff-lsp. use tower_lsp_server::ls_types::SemanticToken; use super::get_standard_field_name; use crate::deb822::semantic::{generate_tokens, FieldValidator}; struct Dep3FieldValidator; impl FieldValidator for Dep3FieldValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { // `Bug-` headers are valid DEP-3 (the spec lists // `Bug-Debian` as the canonical example) but vendor-specific so // we don't enumerate them. Treat any non-empty `Bug-…` as known. if let Some(stripped) = name.strip_prefix("Bug-") { if !stripped.is_empty() { return Some(intern(name)); } } get_standard_field_name(name) } } /// Intern a string with `'static` lifetime in a process-wide cache so /// `FieldValidator` can return it. Vendor names (`Debian`, `Ubuntu`, …) /// recur across an editing session, so the leak is bounded by the /// distinct set of vendors the user touches. fn intern(name: &str) -> &'static str { use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; static CACHE: OnceLock>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut guard = cache.lock().expect("intern cache poisoned"); if let Some(s) = guard.get(name) { return s; } let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); guard.insert(name.to_string(), leaked); leaked } /// Generate semantic tokens for a DEP-3 header. `header` is the /// parsed deb822 of the header portion only; `src` carries the /// whole patch buffer + line index. Tokens are emitted only for /// the header — the diff body is left for diff-lsp. pub fn generate_semantic_tokens( header: &deb822_lossless::Deb822, src: crate::position::Source<'_>, ) -> Vec { let validator = Dep3FieldValidator; generate_tokens(header, src, &validator) } #[cfg(test)] mod tests { use super::*; use crate::deb822::semantic::TokenType; fn run(text: &str) -> Vec { let header_end = dep3::lossless::header_end(text); let parsed = deb822_lossless::Deb822::parse(&text[..header_end]); let idx = crate::position::LineIndex::new(text); generate_semantic_tokens(&parsed.tree(), crate::position::Source::new(text, &idx)) } #[test] fn known_field_emits_field_token() { let tokens = run("Author: alice\nDescription: bla\n"); assert!(!tokens.is_empty()); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 6); } #[test] fn unknown_field_emits_unknown_token() { let tokens = run("Author: alice\nX-Custom: x\n"); let kinds: Vec = tokens.iter().map(|t| t.token_type).collect(); assert!(kinds.contains(&(TokenType::UnknownField as u32))); } #[test] fn vendor_bug_field_treated_as_known() { let tokens = run("Author: alice\nBug-Debian: https://bugs.debian.org/123\n"); let field_tokens: Vec<&SemanticToken> = tokens .iter() .filter(|t| t.token_type == TokenType::Field as u32) .collect(); assert_eq!(field_tokens.len(), 2); } #[test] fn diff_body_does_not_emit_tokens() { let tokens = run("Author: alice\n---\n+++ b/foo\n+@@ -1 +1 @@\n"); let field_tokens: Vec<&SemanticToken> = tokens .iter() .filter(|t| t.token_type == TokenType::Field as u32) .collect(); assert_eq!(field_tokens.len(), 1); } } debian-lsp-0.1.8/src/dep3/symbols.rs000064400000000000000000000065371046102023000153430ustar 00000000000000//! Document symbols for DEP-3 patch headers. //! //! Each header field becomes a symbol so the outline view shows the //! patch's metadata at a glance. The diff body is left untouched. use rowan::ast::AstNode; use tower_lsp_server::ls_types::{DocumentSymbol, SymbolKind}; use crate::position::Source; /// Generate document symbols for a DEP-3 header. `header` is the /// parsed deb822 of the header portion only; `src` carries the whole /// patch buffer plus its line index, used to map rowan byte ranges /// back to LSP `Range`s. One symbol per field; the field name is /// the symbol name and the field value is its `detail` (truncated /// for multi-line values). #[allow(deprecated)] // DocumentSymbol::deprecated is required by the LSP type pub fn generate_document_symbols( header: &deb822_lossless::Deb822, src: Source<'_>, ) -> Vec { let mut symbols = Vec::new(); let Some(paragraph) = header.paragraphs().next() else { return symbols; }; for entry in paragraph.entries() { let Some(name) = entry.key() else { continue; }; let entry_range = entry.syntax().text_range(); let range = src.text_range_to_lsp_range(entry_range); let detail = entry.value().as_str().to_string(); let detail = first_line_truncated(&detail, 80); symbols.push(DocumentSymbol { name: name.to_string(), detail: Some(detail), kind: SymbolKind::FIELD, tags: None, deprecated: None, range, selection_range: range, children: None, }); } symbols } /// Return the first line of `s`, trimmed and clipped to `max_chars`. /// Used so multi-line `Description:` values don't blow up the symbol /// detail line. fn first_line_truncated(s: &str, max_chars: usize) -> String { let line = s.split('\n').next().unwrap_or(s).trim(); if line.chars().count() <= max_chars { return line.to_string(); } let mut out: String = line.chars().take(max_chars).collect(); out.push('…'); out } #[cfg(test)] mod tests { use super::*; fn run(text: &str) -> Vec { let header_end = dep3::lossless::header_end(text); let parsed = deb822_lossless::Deb822::parse(&text[..header_end]); let idx = crate::position::LineIndex::new(text); generate_document_symbols(&parsed.tree(), Source::new(text, &idx)) } #[test] fn one_symbol_per_field() { let symbols = run("Author: alice\nDescription: bla\nForwarded: not-needed\n"); let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["Author", "Description", "Forwarded"]); } #[test] fn detail_carries_first_line_of_value() { let symbols = run("Description: short synopsis\n long body line\n more\n"); assert_eq!(symbols[0].detail.as_deref(), Some("short synopsis")); } #[test] fn diff_body_not_inspected() { let symbols = run("Author: alice\n---\n@@ -1 +1 @@\n-x\n+y\n"); let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["Author"]); } #[test] fn empty_header_returns_no_symbols() { // File starts with a diff marker — no header at all. assert!(run("---\n@@ -1 +1 @@\n").is_empty()); } } debian-lsp-0.1.8/src/distros.rs000064400000000000000000000316611046102023000145030ustar 00000000000000//! Cached Debian distribution information from distro-info-data. //! //! Loads `DebianDistroInfo` once and provides distribution names, //! suite-to-codename mappings (date-aware), and per-release metadata. use std::sync::OnceLock; use chrono::NaiveDate; use distro_info::{DebianDistroInfo, DistroInfo}; /// Cached data derived from distro-info-data. struct DistroData { /// All known distribution names (aliases + codenames), for completions. distributions: Vec, /// The parsed distro-info data, if available. debian_info: Option, } fn load_distro_data() -> DistroData { let mut distributions = vec![ "unstable".to_string(), "stable".to_string(), "testing".to_string(), "oldstable".to_string(), "experimental".to_string(), "sid".to_string(), "UNRELEASED".to_string(), ]; let Ok(debian_info) = DebianDistroInfo::new() else { return DistroData { distributions, debian_info: None, }; }; for release in debian_info.iter() { let series = release.series().to_string(); if !distributions.contains(&series) { distributions.push(series); } } DistroData { distributions, debian_info: Some(debian_info), } } static DISTRO_DATA: OnceLock = OnceLock::new(); fn cached() -> &'static DistroData { DISTRO_DATA.get_or_init(load_distro_data) } /// Resolved suite codenames for a given date. struct SuiteResolution { testing: Option, stable: Option, oldstable: Option, } /// Resolve testing/stable/oldstable codenames for the given date. fn resolve_suites(debian_info: &DebianDistroInfo, date: NaiveDate) -> SuiteResolution { let supported = debian_info.supported(date); let mut released_supported: Vec<_> = supported .iter() .filter(|r| r.version().is_some() && r.release().is_some_and(|released| released <= date)) .collect(); released_supported.sort_by_key(|r| r.release()); let stable = released_supported.last().map(|r| r.series().to_string()); let oldstable = if released_supported.len() >= 2 { Some( released_supported[released_supported.len() - 2] .series() .to_string(), ) } else { None }; // testing = the next release that has a version number but hasn't been released // at this date (either no release date or release date is in the future) let testing = debian_info .iter() .find(|r| r.version().is_some() && r.release().is_none_or(|released| released > date)) .map(|r| r.series().to_string()); SuiteResolution { testing, stable, oldstable, } } /// Whether distro-info-data is available on this system. #[cfg(test)] pub fn has_distro_info() -> bool { cached().debian_info.is_some() } /// Get all known Debian distribution names (aliases + codenames), for completions. pub fn get_all_distributions() -> &'static [String] { &cached().distributions } /// Map a distribution alias to its codename or vice versa, using today's date. /// /// Convenience wrapper around [`get_distribution_mapping_at`]. pub fn get_distribution_mapping(distribution: &str) -> Option { let today = chrono::Local::now().date_naive(); get_distribution_mapping_at(distribution, today) } /// Map a distribution alias to its codename or vice versa at the given date. /// /// Returns `None` if there is no mapping (e.g. the distribution is /// unambiguous, or distro-info data is unavailable). /// /// Examples (with today's date): /// - `"unstable"` → `Some("sid")` /// - `"sid"` → `Some("unstable")` /// - `"testing"` → `Some("forky")` (current testing codename) /// - `"trixie"` → `Some("stable")` (current stable codename) /// - `"experimental"` → `None` pub fn get_distribution_mapping_at(distribution: &str, date: NaiveDate) -> Option { let data = cached(); match distribution { "unstable" => Some("sid".to_string()), "sid" => Some("unstable".to_string()), "experimental" => None, "testing" | "stable" | "oldstable" => { let Some(debian_info) = &data.debian_info else { return None; }; let suites = resolve_suites(debian_info, date); match distribution { "testing" => suites.testing, "stable" => suites.stable, "oldstable" => suites.oldstable, _ => unreachable!(), } } codename => { let Some(debian_info) = &data.debian_info else { return None; }; let suites = resolve_suites(debian_info, date); if suites.testing.as_deref() == Some(codename) { Some("testing".to_string()) } else if suites.stable.as_deref() == Some(codename) { Some("stable".to_string()) } else if suites.oldstable.as_deref() == Some(codename) { Some("oldstable".to_string()) } else { None } } } } /// Get a short detail string for a distribution name, suitable for the /// `detail` field of a completion item. /// /// For aliases like "testing", returns the codename (e.g. "forky"). /// For codenames like "trixie", returns the alias and version (e.g. "stable, Debian 13"). pub fn get_distribution_detail(distribution: &str) -> Option { let data = cached(); let today = chrono::Local::now().date_naive(); // Resolve the codename: for aliases use the mapping, for codenames use as-is. let debian_info = data.debian_info.as_ref(); let suites = debian_info.map(|di| resolve_suites(di, today)); let codename = match distribution { "UNRELEASED" => return None, "unstable" | "sid" => Some("sid"), "testing" => suites.as_ref().and_then(|s| s.testing.as_deref()), "stable" => suites.as_ref().and_then(|s| s.stable.as_deref()), "oldstable" => suites.as_ref().and_then(|s| s.oldstable.as_deref()), other => Some(other), }; let mut parts = Vec::new(); // Show the suite mapping if there is one if let Some(mapped) = get_distribution_mapping(distribution) { parts.push(mapped); } // Show version and dates from release info if let Some(release) = codename.and_then(|c| debian_info.and_then(|di| di.iter().find(|r| r.series() == c))) { if let Some(version) = release.version() { parts.push(format!("Debian {}", version)); } if let Some(released) = release.release() { parts.push(format!("released {}", released)); } if let Some(eol) = release.eol() { if *eol < today { parts.push(format!("end of life since {}", eol)); } else { parts.push(format!("EOL {}", eol)); } } } if parts.is_empty() { None } else { Some(parts.join(", ")) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_unstable_maps_to_sid() { assert_eq!( get_distribution_mapping("unstable"), Some("sid".to_string()) ); } #[test] fn test_sid_maps_to_unstable() { assert_eq!( get_distribution_mapping("sid"), Some("unstable".to_string()) ); } #[test] fn test_experimental_has_no_mapping() { assert_eq!(get_distribution_mapping("experimental"), None); } #[test] fn test_testing_maps_to_codename() { let result = get_distribution_mapping("testing"); if let Some(ref codename) = result { assert_ne!(codename, "sid"); assert_ne!(codename, "experimental"); } } #[test] fn test_stable_maps_to_codename() { let result = get_distribution_mapping("stable"); if let Some(ref codename) = result { assert_ne!(codename, "sid"); assert_ne!(codename, "experimental"); assert_ne!(codename, "unstable"); } } #[test] fn test_suite_codenames_are_distinct() { let today = chrono::Local::now().date_naive(); let Some(debian_info) = &cached().debian_info else { return; }; let suites = resolve_suites(debian_info, today); let mut seen = std::collections::HashSet::new(); for name in [&suites.testing, &suites.stable, &suites.oldstable] .into_iter() .flatten() { assert!(seen.insert(name.as_str()), "Duplicate codename: {}", name); } } #[test] fn test_get_all_distributions_includes_aliases() { let dists = get_all_distributions(); assert!(dists.contains(&"unstable".to_string())); assert!(dists.contains(&"stable".to_string())); assert!(dists.contains(&"testing".to_string())); assert!(dists.contains(&"UNRELEASED".to_string())); assert!(dists.contains(&"sid".to_string())); } #[test] fn test_get_all_distributions_includes_codenames() { if cached().debian_info.is_none() { return; // distro-info-data not available (e.g. Windows) } let dists = get_all_distributions(); // Should have more than just the aliases (if distro-info is available) assert!(dists.len() > 7); } #[test] fn test_codename_reverse_mapping() { if let Some(codename) = get_distribution_mapping("testing") { assert_eq!( get_distribution_mapping(&codename), Some("testing".to_string()) ); } if let Some(codename) = get_distribution_mapping("stable") { assert_eq!( get_distribution_mapping(&codename), Some("stable".to_string()) ); } if let Some(codename) = get_distribution_mapping("oldstable") { assert_eq!( get_distribution_mapping(&codename), Some("oldstable".to_string()) ); } } #[test] fn test_stable_in_2020_was_buster() { if cached().debian_info.is_none() { return; // distro-info-data not available (e.g. Windows) } let date = NaiveDate::from_ymd_opt(2020, 6, 1).unwrap(); assert_eq!( get_distribution_mapping_at("stable", date), Some("buster".to_string()) ); } #[test] fn test_stable_in_2024_was_bookworm() { if cached().debian_info.is_none() { return; // distro-info-data not available (e.g. Windows) } let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); assert_eq!( get_distribution_mapping_at("stable", date), Some("bookworm".to_string()) ); } #[test] fn test_oldstable_in_2024_was_bullseye() { if cached().debian_info.is_none() { return; // distro-info-data not available (e.g. Windows) } let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); assert_eq!( get_distribution_mapping_at("oldstable", date), Some("bullseye".to_string()) ); } #[test] fn test_buster_was_stable_in_2020() { if cached().debian_info.is_none() { return; // distro-info-data not available (e.g. Windows) } let date = NaiveDate::from_ymd_opt(2020, 6, 1).unwrap(); assert_eq!( get_distribution_mapping_at("buster", date), Some("stable".to_string()) ); } #[test] fn test_unstable_detail_contains_sid() { let detail = get_distribution_detail("unstable").unwrap(); assert!(detail.contains("sid"), "Expected 'sid' in: {}", detail); } #[test] fn test_sid_detail_contains_unstable() { let detail = get_distribution_detail("sid").unwrap(); assert!( detail.contains("unstable"), "Expected 'unstable' in: {}", detail ); } #[test] fn test_stable_detail_contains_version() { if let Some(detail) = get_distribution_detail("stable") { assert!( detail.contains("Debian"), "Expected 'Debian' in: {}", detail ); } } #[test] fn test_codename_detail_contains_alias() { if let Some(codename) = get_distribution_mapping("stable") { let detail = get_distribution_detail(&codename).unwrap(); assert!( detail.contains("stable"), "Expected 'stable' in: {}", detail ); } } #[test] fn test_unreleased_has_no_detail() { assert_eq!(get_distribution_detail("UNRELEASED"), None); } #[test] fn test_experimental_has_no_detail() { // experimental has no version or dates in distro-info assert_eq!(get_distribution_detail("experimental"), None); } } debian-lsp-0.1.8/src/lintian_overrides/completion.rs000064400000000000000000000053061046102023000207020ustar 00000000000000use crate::position::Source; use lintian_overrides::{LintianOverrides, Parse, SyntaxKind}; use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position}; /// Get completion items for a lintian overrides file. `parsed` is /// the salsa-cached parse — its tree is always usable even on /// malformed input, so we don't bail on parse errors. pub fn get_completions( parsed: &Parse, src: Source<'_>, position: Position, tags: &[(String, String)], ) -> Vec { let offset = match src.try_position_to_offset(position) { Some(o) => o, None => return Vec::new(), }; let root = parsed.syntax(); // Find the token at the cursor position let token = root.token_at_offset(offset).right_biased(); // Determine context: are we on a tag, package spec, or starting a new line? let on_tag = token.as_ref().is_some_and(|t| t.kind() == SyntaxKind::TAG); let on_package_name = token .as_ref() .is_some_and(|t| t.kind() == SyntaxKind::PACKAGE_NAME); // If on a tag or at the start of a line (where a tag would go), suggest known tags if on_tag || on_package_name { return tags .iter() .map(|(tag, description)| CompletionItem { label: tag.clone(), kind: Some(CompletionItemKind::VALUE), detail: if description.is_empty() { None } else { Some(description.clone()) }, ..Default::default() }) .collect(); } Vec::new() } #[cfg(test)] mod tests { use super::*; fn run(text: &str, position: Position, tags: &[(String, String)]) -> Vec { let parsed = LintianOverrides::parse(text); let idx = crate::position::LineIndex::new(text); get_completions(&parsed, Source::new(text, &idx), position, tags) } #[test] fn test_completions_empty_file() { assert!(run("", Position::new(0, 0), &[]).is_empty()); } #[test] fn test_completions_on_tag() { let tags = vec![ ("some-tag".to_string(), "A test tag".to_string()), ("other-tag".to_string(), "Another tag".to_string()), ]; // Position at start of line, on the tag token let completions = run("some-tag\n", Position::new(0, 0), &tags); assert_eq!(completions.len(), 2); assert!(completions.iter().any(|c| c.label == "some-tag")); assert!(completions.iter().any(|c| c.label == "other-tag")); } #[test] fn test_completions_no_tags_available() { assert!(run("some-tag\n", Position::new(0, 0), &[]).is_empty()); } } debian-lsp-0.1.8/src/lintian_overrides/detection.rs000064400000000000000000000027671046102023000205170ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if the given URI points to a lintian overrides file. /// /// Matches: /// - `debian/source/lintian-overrides` /// - `debian/.lintian-overrides` pub fn is_lintian_overrides_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/source/lintian-overrides") || path.ends_with(".lintian-overrides") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_lintian_overrides_file() { assert!(is_lintian_overrides_file( &str::parse("file:///tmp/debian/source/lintian-overrides").unwrap() )); assert!(is_lintian_overrides_file( &str::parse("file:///tmp/debian/mypackage.lintian-overrides").unwrap() )); assert!(!is_lintian_overrides_file( &str::parse("file:///tmp/debian/control").unwrap() )); assert!(!is_lintian_overrides_file( &str::parse("file:///tmp/debian/copyright").unwrap() )); assert!(!is_lintian_overrides_file( &str::parse("file:///tmp/lintian-overrides").unwrap() )); } #[test] fn test_source_lintian_overrides() { assert!(is_lintian_overrides_file( &str::parse("file:///home/user/pkg/debian/source/lintian-overrides").unwrap() )); } #[test] fn test_binary_lintian_overrides() { assert!(is_lintian_overrides_file( &str::parse("file:///home/user/pkg/debian/libfoo1.lintian-overrides").unwrap() )); } } debian-lsp-0.1.8/src/lintian_overrides/mod.rs000064400000000000000000000004361046102023000173070ustar 00000000000000pub mod completion; pub mod detection; pub mod semantic; pub mod tags; pub use completion::*; pub use detection::is_lintian_overrides_file; pub use lintian_overrides::LintianOverrides; pub use semantic::generate_semantic_tokens; pub use tags::{LintianTagCache, SharedLintianTagCache}; debian-lsp-0.1.8/src/lintian_overrides/semantic.rs000064400000000000000000000062441046102023000203360ustar 00000000000000use crate::position::Source; use lintian_overrides::{AstNode as _, LintianOverrides, SyntaxKind}; use text_size::TextSize; use tower_lsp_server::ls_types::SemanticToken; /// Semantic token type indices matching the legend in main.rs: /// 0 = debianField, 1 = debianUnknownField, 2 = debianValue, /// 3 = debianComment const TOKEN_TYPE_COMMENT: u32 = 3; const TOKEN_TYPE_FIELD: u32 = 0; const TOKEN_TYPE_VALUE: u32 = 2; /// Generate semantic tokens for a lintian overrides file. pub fn generate_semantic_tokens( overrides: &LintianOverrides, src: Source<'_>, ) -> Vec { let mut tokens = Vec::new(); let mut prev_line = 0u32; let mut prev_start = 0u32; for element in overrides.syntax().descendants_with_tokens() { let token = match element.into_token() { Some(t) => t, None => continue, }; let token_type = match token.kind() { SyntaxKind::COMMENT => TOKEN_TYPE_COMMENT, SyntaxKind::TAG => TOKEN_TYPE_FIELD, SyntaxKind::PACKAGE_NAME | SyntaxKind::PACKAGE_TYPE => TOKEN_TYPE_VALUE, SyntaxKind::INFO => TOKEN_TYPE_VALUE, _ => continue, }; let start_offset: TextSize = token.text_range().start(); let length = token.text_range().len(); let pos = src.offset_to_position(start_offset); let delta_line = pos.line - prev_line; let delta_start = if delta_line == 0 { pos.character - prev_start } else { pos.character }; tokens.push(SemanticToken { delta_line, delta_start, length: u32::from(length), token_type, token_modifiers_bitset: 0, }); prev_line = pos.line; prev_start = pos.character; } tokens } #[cfg(test)] mod tests { use super::*; #[test] fn test_semantic_tokens_comment() { let text = "# This is a comment\n"; let parsed = LintianOverrides::parse(text); let overrides = parsed.ok().unwrap(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&overrides, Source::new(text, &idx)); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TOKEN_TYPE_COMMENT); } #[test] fn test_semantic_tokens_simple_override() { let text = "some-tag\n"; let parsed = LintianOverrides::parse(text); let overrides = parsed.ok().unwrap(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&overrides, Source::new(text, &idx)); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TOKEN_TYPE_FIELD); } #[test] fn test_semantic_tokens_override_with_package_and_info() { let text = "mypackage: some-tag extra info\n"; let parsed = LintianOverrides::parse(text); let overrides = parsed.ok().unwrap(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&overrides, Source::new(text, &idx)); // Should have tokens for: package name, tag, info assert!(tokens.len() >= 3); } } debian-lsp-0.1.8/src/lintian_overrides/tags.rs000064400000000000000000000023731046102023000174700ustar 00000000000000use std::sync::Arc; use tokio::sync::RwLock; /// Shared lintian tag cache, loaded from `lintian-explain-tags --list`. pub type SharedLintianTagCache = Arc>; /// Cache of known lintian tags with their one-line descriptions. pub struct LintianTagCache { /// (tag_name, visibility) pairs, populated lazily. tags: Option>, } impl LintianTagCache { pub fn new() -> Self { Self { tags: None } } /// Return the cached tags, loading them on first call. pub async fn get_tags(&mut self) -> &[(String, String)] { if self.tags.is_none() { self.tags = Some(load_tags().await); } self.tags.as_deref().unwrap_or(&[]) } } /// Load all known lintian tags by running `lintian-explain-tags --list`. async fn load_tags() -> Vec<(String, String)> { let output = match tokio::process::Command::new("lintian-explain-tags") .arg("--list") .output() .await { Ok(o) if o.status.success() => o, _ => return Vec::new(), }; let stdout = String::from_utf8_lossy(&output.stdout); stdout .lines() .filter(|line| !line.is_empty()) .map(|line| (line.to_string(), String::new())) .collect() } debian-lsp-0.1.8/src/main.rs000064400000000000000000002752461046102023000137510ustar 00000000000000//! Debian Language Server Protocol implementation. #![deny(missing_docs)] #![deny(unsafe_code)] use std::sync::Arc; use tokio::sync::Mutex; use tower_lsp_server::jsonrpc::Result; use tower_lsp_server::ls_types::*; use tower_lsp_server::{Client, LanguageServer, LspService, Server}; use clap::{Parser, Subcommand}; /// Server settings received from the client via initializationOptions. #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] #[serde(default)] struct Settings { /// Allow the upstream-ontologist to make network requests when guessing /// upstream metadata values. Defaults to `false`. upstream_ontologist_net_access: bool, } mod architecture; mod bugs; mod changelog; mod control; mod copyright; mod deb822; mod dep3; mod distros; mod lintian_overrides; mod maintainers; mod package_cache; mod patches_series; mod popcon; mod position; mod rdeps; mod rules; mod source_format; mod source_options; mod tests; mod udd; mod upstream_metadata; mod vcswatch; mod watch; mod workspace; use position::{LineIndex, Source}; use std::collections::HashMap; use tower_lsp_server::ls_types::notification::Notification; use workspace::Workspace; /// Custom notification for package status, displayed in the editor status bar. enum PackageStatusNotification {} /// Parameters for the package status notification. #[derive(Debug, serde::Serialize, serde::Deserialize)] struct PackageStatusParams { /// Source package name (from debian/changelog) name: String, /// Package version (from debian/changelog) version: String, } impl Notification for PackageStatusNotification { type Params = PackageStatusParams; const METHOD: &'static str = "debian/packageStatus"; } /// Debian file type #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FileType { /// debian/control file Control, /// debian/copyright file Copyright, /// debian/watch file Watch, /// debian/tests/control file TestsControl, /// debian/changelog file Changelog, /// debian/source/format file SourceFormat, /// debian/source/options or debian/source/local-options file SourceOptions, /// debian/upstream/metadata file UpstreamMetadata, /// debian/rules file Rules, /// lintian overrides file (debian/source/lintian-overrides or debian/*.lintian-overrides) LintianOverrides, /// debian/patches/series file PatchesSeries, /// A quilt patch file under debian/patches/ (e.g. *.patch / *.diff / /// no-extension entries listed in series). Patch, } impl FileType { /// Detect the file type from a URI fn detect(uri: &Uri) -> Option { if control::is_control_file(uri) { Some(Self::Control) } else if copyright::is_copyright_file(uri) { Some(Self::Copyright) } else if watch::is_watch_file(uri) { Some(Self::Watch) } else if tests::is_tests_control_file(uri) { Some(Self::TestsControl) } else if changelog::is_changelog_file(uri) { Some(Self::Changelog) } else if source_format::is_source_format_file(uri) { Some(Self::SourceFormat) } else if source_options::is_source_options_or_local_options_file(uri) { Some(Self::SourceOptions) } else if upstream_metadata::is_upstream_metadata_file(uri) { Some(Self::UpstreamMetadata) } else if rules::is_rules_file(uri) { Some(Self::Rules) } else if lintian_overrides::is_lintian_overrides_file(uri) { Some(Self::LintianOverrides) } else if patches_series::is_patches_series_file(uri) { Some(Self::PatchesSeries) } else if patches_series::is_patch_file(uri) { Some(Self::Patch) } else { None } } } /// Information about an open file #[derive(Clone, Copy)] struct FileInfo { /// The workspace's source file ID source_file: workspace::SourceFile, /// The detected file type file_type: FileType, } struct Backend { client: Client, workspace: Arc>, files: Arc>>, package_cache: package_cache::SharedPackageCache, architecture_list: architecture::SharedArchitectureList, bug_cache: bugs::SharedBugCache, maintainer_cache: maintainers::SharedMaintainerCache, vcswatch_cache: vcswatch::SharedVcsWatchCache, popcon_cache: popcon::SharedPopconCache, rdeps_cache: rdeps::SharedRdepsCache, git_file_cache: copyright::code_lens::SharedGitFileCache, lintian_tag_cache: lintian_overrides::SharedLintianTagCache, upstream_cache: upstream_metadata::SharedUpstreamCache, settings: Arc>, } impl Backend { /// Acquire a clone of the workspace under a brief Mutex lock. /// /// Salsa's `Storage` clones cheaply (Arc bump on the shared /// ingredient cache); the only non-trivial part is the /// `files: HashMap` clone. Holding the lock /// only long enough to clone lets other handlers (including /// writers) acquire it for their own brief locks while this /// handler runs the heavy work — parsing, diagnostics, code /// actions — on its own clone. Salsa storage is shared, so the /// clone always sees the latest set inputs. async fn workspace_clone(&self) -> Workspace { self.workspace.lock().await.clone() } fn collect_diagnostics( source_file: workspace::SourceFile, file_type: FileType, workspace: &Workspace, ) -> Option> { match file_type { FileType::Control => { let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(source_file); Some(control::diagnostics::get_diagnostics(src, &parsed)) } FileType::Copyright => Some(workspace.get_copyright_diagnostics(source_file)), FileType::Patch => { let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let (parsed, _) = workspace.get_parsed_dep3_header(source_file); Some(dep3::get_diagnostics(&parsed.tree(), src)) } FileType::Watch | FileType::TestsControl | FileType::Changelog | FileType::SourceFormat | FileType::SourceOptions | FileType::UpstreamMetadata | FileType::Rules | FileType::LintianOverrides | FileType::PatchesSeries => None, } } /// Find the `debian/` directory by walking up from the given URI. fn find_debian_dir(uri: &Uri) -> Option { let path = uri.to_file_path()?; path.ancestors() .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("debian")) .map(|p| p.to_path_buf()) } /// Get or load the changelog source file for the debian directory /// containing the given URI. If the changelog is already open, reuses the /// existing workspace entry; otherwise reads it from disk and inserts it /// into the workspace so the Salsa cache is populated. fn get_changelog_source_file( uri: &Uri, files: &HashMap, workspace: &mut Workspace, ) -> Option { let debian_dir = Self::find_debian_dir(uri)?; let changelog_path = debian_dir.join("changelog"); let changelog_uri = Uri::from_file_path(&changelog_path)?; if let Some(info) = files.get(&changelog_uri) { return Some(info.source_file); } // Not open — read from disk and insert into the workspace let text = std::fs::read_to_string(&changelog_path).ok()?; Some(workspace.update_file(changelog_uri, text)) } /// Look up the version from `debian/changelog` for the same project as the /// given control file URI. Checks open files first, falls back to reading /// from disk. fn get_changelog_version( control_uri: &Uri, files: &Arc>>, workspace: &Workspace, ) -> Option { let control_path = control_uri.to_file_path()?; let changelog_path = control_path.parent()?.join("changelog"); let changelog_uri = Uri::from_file_path(&changelog_path)?; // Check if the changelog is open in the workspace let files_guard = files.try_lock().ok()?; let changelog_file = files_guard.get(&changelog_uri)?; let parsed = workspace.get_parsed_changelog(changelog_file.source_file); let changelog = parsed.tree(); let entry = changelog.iter().next()?; Some(entry.version()?.to_string()) } /// Read `*.substvars` files from the same directory as the control file /// and populate the map with their key=value pairs. fn read_substvars_files( control_uri: &Uri, map: &mut std::collections::HashMap, ) { let control_path = control_uri.to_file_path(); let Some(control_path) = control_path.as_deref() else { return; }; let Some(debian_dir) = control_path.parent() else { return; }; let Ok(entries) = std::fs::read_dir(debian_dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("substvars") { continue; } let Ok(content) = std::fs::read_to_string(&path) else { continue; }; for line in content.lines() { if let Some((key, value)) = line.split_once('=') { map.entry(key.to_string()) .or_insert_with(|| value.to_string()); } } } } /// Spawn a background task to prefetch bug data for the source package /// in the given changelog, so completions are fast when the user needs them. fn prefetch_changelog_bugs(&self, source_file: workspace::SourceFile, workspace: &Workspace) { let parsed = workspace.get_parsed_changelog(source_file); let changelog = parsed.tree(); let package_name = changelog.iter().next().and_then(|entry| entry.package()); if let Some(package_name) = package_name { let bug_cache = self.bug_cache.clone(); let client = self.client.clone(); tokio::spawn(async move { { let mut cache = bug_cache.write().await; cache.prefetch_bugs_for_package(&package_name).await; if let Some(err) = cache.last_udd_error.take() { client .show_message( MessageType::WARNING, format!("UDD connection failed: {err}"), ) .await; } } bug_cache .write() .await .prefetch_launchpad_bugs_for_package(&package_name) .await; }); } } /// Spawn a background task to populate the upstream metadata guess cache. fn prefetch_upstream_guesses(&self, uri: &Uri) { let project_root = Self::find_debian_dir(uri).and_then(|d| d.parent().map(|p| p.to_path_buf())); if let Some(project_root) = project_root { let cache = self.upstream_cache.clone(); let settings = self.settings.clone(); tokio::spawn(async move { let needs_populate = !cache.read().await.is_cached(&project_root); if needs_populate { let net_access = settings.lock().await.upstream_ontologist_net_access; cache .write() .await .populate(&project_root, net_access) .await; } }); } } /// Send a `debian/packageStatus` notification with the source package name /// and version extracted from `debian/changelog`. async fn send_package_status(&self, uri: &Uri) { let params = { let files = self.files.lock().await; let mut workspace = self.workspace.lock().await; let source_file = Self::get_changelog_source_file(uri, &files, &mut workspace); source_file.and_then(|sf| { let parsed = workspace.get_parsed_changelog(sf); let changelog = parsed.tree(); let entry = changelog.iter().next()?; let name = entry.package()?; let version = entry.version()?; Some(PackageStatusParams { name, version: version.to_string(), }) }) }; if let Some(params) = params { self.client .send_notification::(params) .await; } } } impl LanguageServer for Backend { async fn initialize(&self, params: InitializeParams) -> Result { if let Some(options) = params.initialization_options { match serde_json::from_value::(options) { Ok(settings) => { *self.settings.lock().await = settings; } Err(e) => { tracing::warn!("Failed to parse initializationOptions: {e}"); } } } Ok(InitializeResult { capabilities: ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Options( TextDocumentSyncOptions { open_close: Some(true), change: Some(TextDocumentSyncKind::INCREMENTAL), will_save_wait_until: Some(true), ..Default::default() }, )), completion_provider: Some(CompletionOptions { resolve_provider: None, trigger_characters: Some(vec![ ":".to_string(), " ".to_string(), "(".to_string(), "[".to_string(), "<".to_string(), "$".to_string(), "=".to_string(), ",".to_string(), "#".to_string(), ]), work_done_progress_options: Default::default(), all_commit_characters: None, completion_item: None, }), folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), document_symbol_provider: Some(OneOf::Left(true)), code_action_provider: Some(CodeActionProviderCapability::Simple(true)), semantic_tokens_provider: Some( SemanticTokensServerCapabilities::SemanticTokensOptions( SemanticTokensOptions { work_done_progress_options: WorkDoneProgressOptions::default(), legend: SemanticTokensLegend { token_types: vec![ SemanticTokenType::new("debianField"), SemanticTokenType::new("debianUnknownField"), SemanticTokenType::new("debianValue"), SemanticTokenType::new("debianComment"), SemanticTokenType::new("changelogPackage"), SemanticTokenType::new("changelogVersion"), SemanticTokenType::new("changelogDistribution"), SemanticTokenType::new("changelogUrgency"), SemanticTokenType::new("changelogMaintainer"), SemanticTokenType::new("changelogTimestamp"), SemanticTokenType::new("changelogMetadataValue"), SemanticTokenType::new("changelogBugReference"), ], token_modifiers: vec![SemanticTokenModifier::DECLARATION], }, range: Some(false), full: Some(SemanticTokensFullOptions::Bool(true)), }, ), ), code_lens_provider: Some(CodeLensOptions { resolve_provider: Some(false), }), execute_command_provider: Some(ExecuteCommandOptions { commands: vec![ control::code_lens::OPEN_URL_COMMAND.to_string(), changelog::ADD_CHANGELOG_ENTRY_COMMAND.to_string(), control::ADD_BINARY_PACKAGE_COMMAND.to_string(), ], ..Default::default() }), inlay_hint_provider: Some(OneOf::Left(true)), document_on_type_formatting_provider: Some(DocumentOnTypeFormattingOptions { first_trigger_character: ":".to_string(), more_trigger_character: Some(vec!["\n".to_string(), "-".to_string()]), }), document_formatting_provider: Some(OneOf::Left(true)), rename_provider: Some(OneOf::Right(RenameOptions { prepare_provider: Some(true), work_done_progress_options: Default::default(), })), selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)), document_link_provider: Some(DocumentLinkOptions { resolve_provider: Some(false), work_done_progress_options: Default::default(), }), definition_provider: Some(OneOf::Left(true)), references_provider: Some(OneOf::Left(true)), hover_provider: Some(HoverProviderCapability::Simple(true)), ..Default::default() }, ..Default::default() }) } async fn initialized(&self, _: InitializedParams) { self.client .log_message(MessageType::INFO, "Debian LSP initialized!") .await; } async fn shutdown(&self) -> Result<()> { Ok(()) } async fn did_open(&self, params: DidOpenTextDocumentParams) { self.client .log_message( MessageType::INFO, format!("file opened: {:?}", params.text_document.uri), ) .await; // Detect file type once let Some(file_type) = FileType::detect(¶ms.text_document.uri) else { return; }; // Hold the workspace lock only for the input update; clone // the salsa Storage and run diagnostics on the clone so other // requests can take the lock concurrently. let (workspace, source_file) = { let mut workspace = self.workspace.lock().await; let source_file = workspace.update_file( params.text_document.uri.clone(), params.text_document.text.clone(), ); (workspace.clone(), source_file) }; let mut files = self.files.lock().await; files.insert( params.text_document.uri.clone(), FileInfo { source_file, file_type, }, ); if file_type == FileType::Changelog { self.prefetch_changelog_bugs(source_file, &workspace); } if file_type == FileType::UpstreamMetadata { self.prefetch_upstream_guesses(¶ms.text_document.uri); } let diagnostics = Self::collect_diagnostics(source_file, file_type, &workspace); drop(files); if let Some(diagnostics) = diagnostics { self.client .publish_diagnostics(params.text_document.uri.clone(), diagnostics, None) .await; } self.send_package_status(¶ms.text_document.uri).await; } async fn did_change(&self, params: DidChangeTextDocumentParams) { self.client .log_message( MessageType::INFO, format!("file changed: {:?}", params.text_document.uri), ) .await; // Get or detect the file type let mut files = self.files.lock().await; let file_info = files.get(¶ms.text_document.uri).copied(); let file_type = file_info .map(|info| info.file_type) .or_else(|| FileType::detect(¶ms.text_document.uri)); let Some(file_type) = file_type else { return; }; if params.content_changes.is_empty() { return; } // Apply incremental content changes to the current text. // // The Mutex is held only for the splice + `update_file`. The // diagnostics phase below runs on a Workspace clone so other // requests can take the lock concurrently — a salsa `Storage` // clone is a cheap Arc bump and shares the cache with the // original. let (workspace, source_file) = { let mut workspace = self.workspace.lock().await; // Owned String here because we splice content_changes into it // before handing back to salsa via update_file. let mut text: String = file_info .map(|info| workspace.source_text(info.source_file).to_string()) .unwrap_or_default(); for change in ¶ms.content_changes { if let Some(range) = &change.range { // The text mutates as we apply each change, so we // build a fresh LineIndex per change. Avoiding this // would mean tracking newline insertions/deletions // through the splices — not worth the complexity for // the rare case of multiple content changes per // did_change. let idx = LineIndex::new(&text); if let Some(text_range) = Source::new(&text, &idx).try_lsp_range_to_text_range(range) { let start: usize = text_range.start().into(); let end: usize = text_range.end().into(); text.replace_range(start..end, &change.text); } } else { // Full replacement text = change.text.clone(); } } let source_file = workspace.update_file(params.text_document.uri.clone(), text); files.insert( params.text_document.uri.clone(), FileInfo { source_file, file_type, }, ); (workspace.clone(), source_file) }; let diagnostics = Self::collect_diagnostics(source_file, file_type, &workspace); drop(files); if let Some(diagnostics) = diagnostics { self.client .publish_diagnostics(params.text_document.uri.clone(), diagnostics, None) .await; } if file_type == FileType::Changelog { self.send_package_status(¶ms.text_document.uri).await; } } async fn will_save_wait_until( &self, params: WillSaveTextDocumentParams, ) -> Result>> { let files = self.files.lock().await; let file_info = match files.get(¶ms.text_document.uri) { Some(info) => *info, None => return Ok(None), }; if file_info.file_type != FileType::Changelog { return Ok(None); } let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file_info.source_file); let idx = workspace.get_line_index(file_info.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_changelog(file_info.source_file); let changelog = parsed.tree(); let edit = changelog::generate_timestamp_update_edit(&changelog, src); Ok(edit.map(|e| vec![e])) } async fn did_close(&self, params: DidCloseTextDocumentParams) { let mut files = self.files.lock().await; files.remove(¶ms.text_document.uri); drop(files); // Clear diagnostics so stale squiggles don't linger after the file // is closed. self.client .publish_diagnostics(params.text_document.uri, vec![], None) .await; } async fn completion(&self, params: CompletionParams) -> Result> { let uri = params.text_document_position.text_document.uri; let position = params.text_document_position.position; // Look up the file type from our cache let files = self.files.lock().await; let file_info = files .get(&uri) .map(|info| (info.file_type, info.source_file)); drop(files); // Release the lock let completions = match file_info { Some((FileType::Control, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(source_file); // Check if cursor is on a field value to try async relationship completions let cursor_context = deb822::completion::get_cursor_context( parsed.tree().as_deb822(), src, position, ); if let Some(deb822::completion::CursorContext::FieldValue { field_name, value_prefix, }) = &cursor_context { drop(workspace); // Release lock before async operations // Try async completions (relationship fields via package cache) if let Some(async_completions) = control::get_async_field_value_completions( field_name, value_prefix, position, &self.package_cache, &self.architecture_list, &self.maintainer_cache, ) .await { async_completions } else { // Fall back to sync completions (Section, Priority, etc.) control::get_field_value_completions(field_name, value_prefix) } } else { // Not on a field value — get field name completions // (workspace lock and parsed result already held) control::get_completions(parsed.tree().as_deb822(), src, position) } } Some((FileType::Copyright, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_copyright(source_file); copyright::get_completions(&parsed, src, position) } Some((FileType::Watch, source_file)) => { let workspace = self.workspace_clone().await; let parsed = workspace.get_parsed_watch(source_file); let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let wf = parsed.to_watch_file(); match &wf { debian_watch::parse::ParsedWatchFile::LineBased(wf) => { watch::get_linebased_completions(&uri, wf, src, position) } debian_watch::parse::ParsedWatchFile::Deb822(wf) => { watch::get_completions_deb822(wf.as_deb822(), src, position) } } } Some((FileType::TestsControl, _)) => tests::get_completions(&uri, position), Some((FileType::Changelog, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_changelog(source_file); drop(workspace); if let Some((items, is_incomplete)) = changelog::get_async_bug_completions(&parsed, src, position, &self.bug_cache) .await { if items.is_empty() { return Ok(None); } return Ok(Some(CompletionResponse::List(CompletionList { is_incomplete, items, }))); } changelog::get_completions(&parsed, src, position) } Some((FileType::SourceFormat, _)) => source_format::get_completions(&uri, position), Some((FileType::LintianOverrides, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_lintian_overrides(source_file); drop(workspace); let mut tag_cache = self.lintian_tag_cache.write().await; let tags = tag_cache.get_tags().await; lintian_overrides::get_completions(&parsed, src, position, tags) } Some((FileType::SourceOptions, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); source_options::get_completions(&uri, position, &source_text) } Some((FileType::UpstreamMetadata, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let project_root = Self::find_debian_dir(&uri).and_then(|d| d.parent().map(|p| p.to_path_buf())); drop(workspace); upstream_metadata::get_completions( Source::new(&source_text, &idx), position, &self.upstream_cache, project_root.as_deref(), ) .await } Some((FileType::Rules, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let parsed = workspace.get_parsed_rules(source_file); let makefile = parsed.tree(); rules::get_completions(&makefile, &source_text, position) } Some((FileType::PatchesSeries, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let parsed = workspace.get_parsed_patches_series(source_file); patches_series::get_completions(&uri, &parsed, &source_text, position) } // Patch completions cover the DEP-3 header at the top of // the file. The unified-diff body is left to diff-lsp. Some((FileType::Patch, source_file)) => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(source_file); let idx = workspace.get_line_index(source_file); let src = Source::new(&source_text, &idx); let (parsed, header_end) = workspace.get_parsed_dep3_header(source_file); dep3::get_completions(&parsed.tree(), header_end, src, position) } None => Vec::new(), }; if completions.is_empty() { Ok(None) } else { Ok(Some(CompletionResponse::Array(completions))) } } async fn completion_resolve(&self, item: CompletionItem) -> Result { Ok(item) } async fn code_action(&self, params: CodeActionParams) -> Result> { let workspace = self.workspace_clone().await; let files = self.files.lock().await; let file_info = match files.get(¶ms.text_document.uri) { Some(info) => info, None => return Ok(None), }; // Only control, copyright, and changelog files support code actions for now match file_info.file_type { FileType::Control | FileType::Copyright | FileType::Changelog => {} _ => return Ok(None), } let source_text = workspace.source_text(file_info.source_file); let idx = workspace.get_line_index(file_info.source_file); let src = Source::new(&source_text, &idx); let mut actions = Vec::new(); let text_range = src.try_lsp_range_to_text_range(¶ms.range); match file_info.file_type { FileType::Control => { let Some(text_range) = text_range else { return Ok(None); }; // Add wrap-and-sort action let parsed = workspace.get_parsed_control(file_info.source_file); if let Some(action) = control::get_wrap_and_sort_action( ¶ms.text_document.uri, src, &parsed, text_range, ) { actions.push(action); } // "Add binary package" is document-level and should only // appear via the command palette, not the lightbulb. if params.context.trigger_kind != Some(CodeActionTriggerKind::AUTOMATIC) { actions.push(control::get_add_binary_package_command( ¶ms.text_document.uri, )); } // Add field casing fixes let issues = control::diagnostics::find_field_casing_issues(&parsed, Some(text_range)); actions.extend(control::get_field_casing_actions( ¶ms.text_document.uri, src, issues, ¶ms.context.diagnostics, )); } FileType::Copyright => { let Some(text_range) = text_range else { return Ok(None); }; // Add wrap-and-sort action let parsed = workspace.get_parsed_copyright(file_info.source_file); if let Some(action) = copyright::get_wrap_and_sort_action( ¶ms.text_document.uri, src, &parsed, text_range, ) { actions.push(action); } // Add field casing fixes let issues = workspace .find_copyright_field_casing_issues(file_info.source_file, Some(text_range)); actions.extend(copyright::get_field_casing_actions( ¶ms.text_document.uri, src, issues, ¶ms.context.diagnostics, )); } FileType::Changelog => { // Add new changelog entry: document-level, palette only. if params.context.trigger_kind != Some(CodeActionTriggerKind::AUTOMATIC) { actions.push(changelog::get_add_changelog_entry_command( ¶ms.text_document.uri, )); } // Check for UNRELEASED entries in the requested range and offer "Mark for upload" if let Some(text_range) = text_range { let unreleased_entries = workspace .find_unreleased_entries_in_range(file_info.source_file, text_range); for info in unreleased_entries { let lsp_range = src.text_range_to_lsp_range(info.unreleased_range); let edit = TextEdit { range: lsp_range, new_text: info.target_distribution.clone(), }; let workspace_edit = WorkspaceEdit { changes: Some( vec![(params.text_document.uri.clone(), vec![edit])] .into_iter() .collect(), ), ..Default::default() }; let action = CodeAction { title: format!("Mark for upload to {}", info.target_distribution), kind: Some(CodeActionKind::REFACTOR), edit: Some(workspace_edit), ..Default::default() }; actions.push(CodeActionOrCommand::CodeAction(action)); } } } _ => unreachable!(), } if actions.is_empty() { Ok(None) } else { Ok(Some(actions)) } } async fn prepare_rename( &self, params: TextDocumentPositionParams, ) -> Result> { let workspace = self.workspace_clone().await; let files = self.files.lock().await; let file_info = match files.get(¶ms.text_document.uri) { Some(info) => info, None => return Ok(None), }; if file_info.file_type != FileType::Control { return Ok(None); } let source_text = workspace.source_text(file_info.source_file); let idx = workspace.get_line_index(file_info.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(file_info.source_file); let Some(pkg) = control::find_package_name_at_position(&parsed, src, ¶ms.position) else { return Ok(None); }; Ok(Some(PrepareRenameResponse::RangeWithPlaceholder { range: pkg.range, placeholder: pkg.name, })) } async fn rename(&self, params: RenameParams) -> Result> { let workspace = self.workspace_clone().await; let files = self.files.lock().await; let uri = ¶ms.text_document_position.text_document.uri; let file_info = match files.get(uri) { Some(info) => info, None => return Ok(None), }; if file_info.file_type != FileType::Control { return Ok(None); } let source_text = workspace.source_text(file_info.source_file); let idx = workspace.get_line_index(file_info.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(file_info.source_file); let Some(pkg) = control::find_package_name_at_position( &parsed, src, ¶ms.text_document_position.position, ) else { return Ok(None); }; let old_name = &pkg.name; let new_name = ¶ms.new_name; // Edit the Package: field value in debian/control let control_edit = TextEdit { range: pkg.range, new_text: new_name.clone(), }; let mut document_changes: Vec = Vec::new(); // Add the text edit for the control file document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit { text_document: OptionalVersionedTextDocumentIdentifier { uri: uri.clone(), version: None, }, edits: vec![OneOf::Left(control_edit)], })); // Determine the debian/ directory from the control file URI if let Some(control_path) = uri.to_file_path() { if let Some(debian_dir) = control_path.parent() { // Collect file renames for debian/. files let file_renames = control::collect_package_file_renames(debian_dir, old_name, new_name); for op in file_renames { document_changes.push(DocumentChangeOperation::Op(op)); } // Update references in debian/tests/control let tests_control_path = debian_dir.join("tests/control"); if tests_control_path.exists() { // Try to use the open file from the workspace first let tests_control_uri = Uri::from_file_path(&tests_control_path); let tests_text = if let Some(ref tc_uri) = tests_control_uri { files.get(tc_uri).map(|info| { ( tc_uri.clone(), workspace.source_text(info.source_file).to_string(), ) }) } else { None }; // Fall back to reading from disk let tests_text = tests_text.or_else(|| { let text = std::fs::read_to_string(&tests_control_path).ok()?; let tc_uri = Uri::from_file_path(&tests_control_path)?; Some((tc_uri, text)) }); if let Some((tc_uri, text)) = tests_text { let tests_idx = LineIndex::new(&text); let edits = control::collect_tests_control_edits( Source::new(&text, &tests_idx), old_name, new_name, ); if !edits.is_empty() { document_changes.push(DocumentChangeOperation::Edit( TextDocumentEdit { text_document: OptionalVersionedTextDocumentIdentifier { uri: tc_uri, version: None, }, edits: edits.into_iter().map(OneOf::Left).collect(), }, )); } } } } } Ok(Some(WorkspaceEdit { document_changes: Some(DocumentChanges::Operations(document_changes)), ..Default::default() })) } async fn semantic_tokens_full( &self, params: SemanticTokensParams, ) -> Result> { let uri = ¶ms.text_document.uri; let workspace = self.workspace_clone().await; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let tokens = match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); let control = parsed.tree(); control::generate_semantic_tokens(&control, src) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); let copyright = parsed.tree(); copyright::generate_semantic_tokens(©right, src) } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); changelog::generate_semantic_tokens(&parsed, src) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); watch::generate_semantic_tokens(&parsed, src) } FileType::TestsControl => { let deb822_parse = workspace.get_parsed_deb822(file.source_file); tests::generate_semantic_tokens(&deb822_parse, src) } FileType::UpstreamMetadata => { let parsed = workspace.get_parsed_upstream_metadata(file.source_file); let yaml_file = parsed.tree(); match yaml_file.document() { Some(doc) => upstream_metadata::generate_semantic_tokens(&doc, src), None => vec![], } } FileType::Rules => { let parsed = workspace.get_parsed_rules(file.source_file); let makefile = parsed.tree(); rules::generate_semantic_tokens(&makefile, src) } FileType::SourceFormat => vec![], FileType::SourceOptions => source_options::generate_semantic_tokens(&source_text), FileType::LintianOverrides => { let parsed = workspace.get_parsed_lintian_overrides(file.source_file); // Walk the resilient tree even if there were parse // errors — lintian-overrides always produces a usable // green tree, and dropping it would mean an editor // sees no token highlights while the user is typing // a malformed line. let overrides = parsed.tree(); lintian_overrides::generate_semantic_tokens(&overrides, src) } FileType::PatchesSeries => { let parsed = workspace.get_parsed_patches_series(file.source_file); let patches_series = parsed.tree(); patches_series::generate_semantic_tokens(&patches_series, src) } // Semantic tokens cover the DEP-3 header at the top of // the patch only — the unified-diff body is left to // diff-lsp. FileType::Patch => { let (parsed, _) = workspace.get_parsed_dep3_header(file.source_file); dep3::generate_semantic_tokens(&parsed.tree(), src) } }; if tokens.is_empty() { Ok(None) } else { Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { result_id: None, data: tokens, }))) } } async fn document_symbol( &self, params: DocumentSymbolParams, ) -> Result> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let symbols = match file.file_type { FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); changelog::generate_document_symbols(&parsed, src) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); copyright::generate_document_symbols(&parsed, src) } FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); control::generate_document_symbols(&parsed, src) } FileType::Patch => { let (parsed, _) = workspace.get_parsed_dep3_header(file.source_file); dep3::generate_document_symbols(&parsed.tree(), src) } _ => return Ok(None), }; if symbols.is_empty() { Ok(None) } else { Ok(Some(DocumentSymbolResponse::Nested(symbols))) } } async fn folding_range(&self, params: FoldingRangeParams) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let ranges = match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); deb822::folding::generate_folding_ranges(parsed.tree().as_deb822(), src) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); deb822::folding::generate_folding_ranges(parsed.tree().as_deb822(), src) } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); changelog::generate_folding_ranges(&parsed, src) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); watch::generate_folding_ranges(&parsed, src) } FileType::TestsControl => { let deb822_parse = workspace.get_parsed_deb822(file.source_file); match deb822_parse.to_result() { Ok(deb822) => deb822::folding::generate_folding_ranges(&deb822, src), Err(_) => return Ok(None), } } _ => return Ok(None), }; if ranges.is_empty() { Ok(None) } else { Ok(Some(ranges)) } } async fn selection_range( &self, params: SelectionRangeParams, ) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let ranges = match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); deb822::selection_range::generate_selection_ranges( parsed.tree().as_deb822(), src, ¶ms.positions, ) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); deb822::selection_range::generate_selection_ranges( parsed.tree().as_deb822(), src, ¶ms.positions, ) } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); changelog::generate_selection_ranges(&parsed, src, ¶ms.positions) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); watch::generate_selection_ranges(&parsed, src, ¶ms.positions) } FileType::TestsControl => { let deb822_parse = workspace.get_parsed_deb822(file.source_file); match deb822_parse.to_result() { Ok(deb822) => deb822::selection_range::generate_selection_ranges( &deb822, src, ¶ms.positions, ), Err(_) => return Ok(None), } } FileType::SourceOptions => { source_options::generate_selection_ranges(src, ¶ms.positions) } _ => return Ok(None), }; if ranges.is_empty() { Ok(None) } else { Ok(Some(ranges)) } } async fn on_type_formatting( &self, params: DocumentOnTypeFormattingParams, ) -> Result>> { let uri = ¶ms.text_document_position.text_document.uri; let position = params.text_document_position.position; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); Ok(deb822::on_type_formatting::on_type_formatting( parsed.tree().as_deb822(), &source_text, position, ¶ms.ch, )) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); Ok(deb822::on_type_formatting::on_type_formatting( parsed.tree().as_deb822(), &source_text, position, ¶ms.ch, )) } FileType::TestsControl => { let deb822 = workspace.get_parsed_deb822(file.source_file).tree(); Ok(deb822::on_type_formatting::on_type_formatting( &deb822, &source_text, position, ¶ms.ch, )) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); let wf = parsed.to_watch_file(); match &wf { debian_watch::parse::ParsedWatchFile::Deb822(_) => { let deb822 = workspace.get_parsed_deb822(file.source_file).tree(); Ok(deb822::on_type_formatting::on_type_formatting( &deb822, &source_text, position, ¶ms.ch, )) } _ => Ok(None), } } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); Ok(changelog::on_type_formatting::on_type_formatting( &parsed, &source_text, position, ¶ms.ch, )) } FileType::UpstreamMetadata => { Ok(upstream_metadata::on_type_formatting::on_type_formatting( &source_text, position, ¶ms.ch, )) } _ => Ok(None), } } async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); Ok(control::format_control(src, &parsed)) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); Ok(copyright::format_copyright(src, &parsed)) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); let wf = parsed.to_watch_file(); match &wf { debian_watch::parse::ParsedWatchFile::Deb822(_) => { let deb822 = workspace.get_parsed_deb822(file.source_file).tree(); let wrap_paragraph = |p: &deb822_lossless::Paragraph| -> deb822_lossless::Paragraph { p.wrap_and_sort( deb822_lossless::Indentation::Spaces(1), false, Some(79), None, None, ) }; let formatted = deb822 .wrap_and_sort(None, Some(&wrap_paragraph)) .to_string(); if formatted.as_str() == &*source_text { return Ok(None); } let full_range = src.text_range_to_lsp_range(text_size::TextRange::new( 0.into(), (source_text.len() as u32).into(), )); Ok(Some(vec![TextEdit { range: full_range, new_text: formatted, }])) } _ => Ok(None), } } FileType::TestsControl => { let deb822 = workspace.get_parsed_deb822(file.source_file).tree(); let wrap_paragraph = |p: &deb822_lossless::Paragraph| -> deb822_lossless::Paragraph { p.wrap_and_sort( deb822_lossless::Indentation::Spaces(1), false, Some(79), None, None, ) }; let formatted = deb822 .wrap_and_sort(None, Some(&wrap_paragraph)) .to_string(); if formatted.as_str() == &*source_text { return Ok(None); } let full_range = src.text_range_to_lsp_range(text_size::TextRange::new( 0.into(), (source_text.len() as u32).into(), )); Ok(Some(vec![TextEdit { range: full_range, new_text: formatted, }])) } _ => Ok(None), } } async fn code_lens(&self, params: CodeLensParams) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); match file.file_type { FileType::Control => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let parsed = workspace.get_parsed_control(file.source_file); drop(workspace); let src = Source::new(&source_text, &idx); let ctx = control::code_lens::LensContext { package_cache: &self.package_cache, vcswatch_cache: &self.vcswatch_cache, bug_cache: &self.bug_cache, popcon_cache: &self.popcon_cache, rdeps_cache: &self.rdeps_cache, }; let (lenses, uncached) = control::generate_code_lenses(&parsed, src, &ctx).await; if !uncached.is_empty() { let client = self.client.clone(); let package_cache = self.package_cache.clone(); let vcswatch_cache = self.vcswatch_cache.clone(); let bug_cache = self.bug_cache.clone(); let popcon_cache = self.popcon_cache.clone(); let rdeps_cache = self.rdeps_cache.clone(); tokio::spawn(async move { if uncached.needs_policy_version { let mut cache = package_cache.write().await; cache.load_versions("debian-policy").await; } if let Some(url) = &uncached.vcs_git_url { let mut cache = vcswatch_cache.write().await; cache.get_version_for_url(url).await; } if let Some(source) = &uncached.source_package { let mut cache = bug_cache.write().await; cache.prefetch_bugs_for_package(source).await; } for pkg in &uncached.binary_packages { { let mut cache = bug_cache.write().await; cache.prefetch_bugs_for_binary_package(pkg).await; } { let mut cache = popcon_cache.write().await; cache.get_inst_count(pkg).await; } { let mut cache = rdeps_cache.write().await; cache.get_rdeps_count(pkg).await; } } let _ = client.code_lens_refresh().await; }); } if lenses.is_empty() { Ok(None) } else { Ok(Some(lenses)) } } FileType::Copyright => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let parsed = workspace.get_parsed_copyright(file.source_file); drop(workspace); let src = Source::new(&source_text, &idx); // Derive the source root from the copyright file URI // (debian/copyright -> parent is debian/ -> parent is source root) let source_root = uri.to_file_path().and_then(|p| { p.parent() .and_then(|debian| debian.parent()) .map(|root| root.to_path_buf()) }); let lenses = copyright::generate_code_lenses( &parsed, src, source_root.as_deref(), &self.git_file_cache, ) .await; if lenses.is_empty() { Ok(None) } else { Ok(Some(lenses)) } } _ => Ok(None), } } async fn inlay_hint(&self, params: InlayHintParams) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); match file.file_type { FileType::Changelog => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_changelog(file.source_file); let hints = changelog::generate_inlay_hints(&parsed, src, ¶ms.range); if hints.is_empty() { Ok(None) } else { Ok(Some(hints)) } } FileType::Control => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let parsed = workspace.get_parsed_control(file.source_file); // Resolve substvars from changelog and .substvars files let resolved_substvars = { let mut map = std::collections::HashMap::new(); if let Some(version) = Self::get_changelog_version(uri, &self.files, &workspace) { map.insert("binary:Version".to_string(), version.clone()); map.insert("source:Version".to_string(), version); } Self::read_substvars_files(uri, &mut map); map }; drop(workspace); // Release lock before async package cache access let src = Source::new(&source_text, &idx); let ctx = control::inlay_hints::HintContext { package_cache: &self.package_cache, resolved_substvars: &resolved_substvars, }; let (hints, uncached_packages) = control::generate_inlay_hints(&parsed, src, ¶ms.range, &ctx).await; // Load uncached packages in the background (two batch // subprocess calls), then ask the editor to re-request hints. if !uncached_packages.is_empty() { let cache = self.package_cache.clone(); let client = self.client.clone(); tokio::spawn(async move { let mut c = cache.write().await; c.load_versions_batch(&uncached_packages).await; c.load_providers_batch(&uncached_packages).await; drop(c); let _ = client.inlay_hint_refresh().await; }); } if hints.is_empty() { Ok(None) } else { Ok(Some(hints)) } } _ => Ok(None), } } async fn hover(&self, params: HoverParams) -> Result> { let uri = ¶ms.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); match file.file_type { FileType::Control => { let parsed = workspace.get_parsed_control(file.source_file); Ok(control::get_hover(parsed.tree().as_deb822(), src, position)) } FileType::Copyright => { let parsed = workspace.get_parsed_copyright(file.source_file); let copyright = parsed.tree(); Ok(copyright::get_hover(copyright.as_deb822(), src, position)) } FileType::Watch => { let parsed = workspace.get_parsed_watch(file.source_file); let wf = parsed.to_watch_file(); match &wf { debian_watch::parse::ParsedWatchFile::Deb822(wf) => { Ok(watch::get_hover(wf.as_deb822(), src, position)) } _ => Ok(None), } } FileType::Changelog => { let parsed = workspace.get_parsed_changelog(file.source_file); drop(workspace); Ok(changelog::get_hover(&parsed, src, position, &self.bug_cache).await) } FileType::UpstreamMetadata => { let parsed = workspace.get_parsed_upstream_metadata(file.source_file); let yaml_file = parsed.tree(); match yaml_file.document() { Some(doc) => Ok(upstream_metadata::get_hover(&doc, src, position)), None => Ok(None), } } FileType::Patch => { let (parsed, header_end) = workspace.get_parsed_dep3_header(file.source_file); Ok(dep3::get_hover(&parsed.tree(), header_end, src, position)) } _ => Ok(None), } } async fn document_link(&self, params: DocumentLinkParams) -> Result>> { let uri = ¶ms.text_document.uri; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); match file.file_type { FileType::UpstreamMetadata => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let parsed = workspace.get_parsed_upstream_metadata(file.source_file); let yaml_file = parsed.tree(); match yaml_file.document() { Some(doc) => Ok(Some(upstream_metadata::get_document_links( &doc, &source_text, ))), None => Ok(None), } } _ => Ok(None), } } async fn goto_definition( &self, params: GotoDefinitionParams, ) -> Result> { let uri = ¶ms.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); match file.file_type { FileType::Control => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(file.source_file); let location = control::goto_definition(&parsed, src, position, uri); Ok(location.map(GotoDefinitionResponse::Scalar)) } _ => Ok(None), } } async fn references(&self, params: ReferenceParams) -> Result>> { let uri = ¶ms.text_document_position.text_document.uri; let position = params.text_document_position.position; let include_declaration = params.context.include_declaration; let files = self.files.lock().await; let file = match files.get(uri) { Some(f) => *f, None => return Ok(None), }; drop(files); match file.file_type { FileType::Control => { let workspace = self.workspace_clone().await; let source_text = workspace.source_text(file.source_file); let idx = workspace.get_line_index(file.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(file.source_file); let refs = control::find_references(&parsed, src, position, uri, include_declaration); if refs.is_empty() { Ok(None) } else { Ok(Some(refs)) } } _ => Ok(None), } } async fn execute_command( &self, params: ExecuteCommandParams, ) -> Result> { if params.command == control::code_lens::OPEN_URL_COMMAND { if let Some(url) = params.arguments.first().and_then(|v| v.as_str()) { if let Ok(uri) = url.parse::() { let _ = self .client .show_document(ShowDocumentParams { uri, external: Some(true), take_focus: Some(true), selection: None, }) .await; } } } else if params.command == changelog::ADD_CHANGELOG_ENTRY_COMMAND { if let Some(uri_str) = params.arguments.first().and_then(|v| v.as_str()) { if let Ok(uri) = uri_str.parse::() { let workspace = self.workspace_clone().await; let workspace_edit = { let files = self.files.lock().await; files.get(&uri).and_then(|file_info| { let parsed = workspace.get_parsed_changelog(file_info.source_file); let changelog = parsed.tree(); changelog::generate_new_changelog_entry(&changelog) .ok() .map(|new_entry| WorkspaceEdit { changes: Some( vec![( uri.clone(), vec![TextEdit { range: Range { start: Position { line: 0, character: 0, }, end: Position { line: 0, character: 0, }, }, new_text: new_entry, }], )] .into_iter() .collect(), ), ..Default::default() }) }) }; if let Some(edit) = workspace_edit { let _ = self.client.apply_edit(edit).await; } } } } else if params.command == control::ADD_BINARY_PACKAGE_COMMAND { if let Some(uri_str) = params.arguments.first().and_then(|v| v.as_str()) { if let Ok(uri) = uri_str.parse::() { let workspace = self.workspace_clone().await; let files = self.files.lock().await; if let Some(file_info) = files.get(&uri) { let source_text = workspace.source_text(file_info.source_file); let idx = workspace.get_line_index(file_info.source_file); let src = Source::new(&source_text, &idx); let parsed = workspace.get_parsed_control(file_info.source_file); drop(files); if let Some(edit) = control::build_add_binary_package_edit(&uri, src, &parsed) { let _ = self.client.apply_edit(edit).await; } } } } } Ok(None) } } /// Command-line interface for debian-lsp. #[derive(Parser, Debug)] #[command(name = "debian-lsp", version, about)] struct Cli { #[command(subcommand)] command: Option, } /// Subcommands for debian-lsp. #[derive(Subcommand, Debug)] enum Command { /// Check Debian files and report diagnostics to stdout. /// /// Output is in gcc-style format: filename:line: severity: message. /// Paths can be files or directories; directories are walked recursively /// and any recognized Debian files are checked. Check { /// Files or directories to check. paths: Vec, }, } /// Severity level for a diagnostic, used in gcc-style output. fn severity_label(severity: Option) -> &'static str { match severity { Some(DiagnosticSeverity::ERROR) => "error", Some(DiagnosticSeverity::WARNING) => "warning", Some(DiagnosticSeverity::INFORMATION) => "note", Some(DiagnosticSeverity::HINT) => "note", _ => "error", } } /// Directory names to skip when walking recursively. const SKIP_DIRS: &[&str] = &[".git", ".hg", ".svn", ".bzr", "__pycache__", ".tox"]; /// Collect all regular files under `dir` recursively, skipping VCS directories. fn collect_files_recursive(dir: &std::path::Path) -> Vec { let mut files = Vec::new(); let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { eprintln!("{}: error: {}", dir.display(), e); return files; } }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if SKIP_DIRS.contains(&name) { continue; } } files.extend(collect_files_recursive(&path)); } else if path.is_file() { files.push(path); } } files.sort(); files } /// Run one-off diagnostics on the given paths, printing gcc-style output. /// /// Paths can be files or directories. Directories are walked recursively /// and any recognized Debian files found within are checked. /// /// Returns the number of errors found (for the exit code). fn run_check(paths: &[std::path::PathBuf]) -> i32 { let mut workspace = Workspace::new(); let mut error_count: i32 = 0; // Expand directories into individual files, tracking which were explicit. let explicit_paths: std::collections::HashSet = paths.iter().filter(|p| !p.is_dir()).cloned().collect(); let mut files = Vec::new(); for path in paths { if path.is_dir() { files.extend(collect_files_recursive(path)); } else { files.push(path.clone()); } } for path in &files { // Determine the file type before reading, so we skip non-Debian files // without trying to read them (they may be binary, etc.). let abs_path = match std::fs::canonicalize(path) { Ok(p) => p, Err(_) => path.to_path_buf(), }; let uri = match Uri::from_file_path(&abs_path) { Some(u) => u, None => { if explicit_paths.contains(path) { eprintln!("{}: error: could not convert path to URI", path.display()); error_count += 1; } continue; } }; let file_type = match FileType::detect(&uri) { Some(ft) => ft, None => { // Only warn for paths the user specified explicitly, not files // discovered by walking a directory. if explicit_paths.contains(path) { eprintln!( "{}: warning: unrecognized Debian file type, skipping", path.display() ); } continue; } }; let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(e) => { eprintln!("{}: error: {}", path.display(), e); error_count += 1; continue; } }; let source_file = workspace.update_file(uri, content.clone()); let diagnostics = match Backend::collect_diagnostics(source_file, file_type, &workspace) { Some(d) => d, None => continue, }; let display_path = path.display(); for diag in &diagnostics { let line = diag.range.start.line + 1; // LSP lines are 0-based let col = diag.range.start.character + 1; let severity = severity_label(diag.severity); println!( "{}:{}:{}: {}: {}", display_path, line, col, severity, diag.message ); if severity == "error" { error_count += 1; } } } error_count } #[tokio::main] async fn main() { let cli = Cli::parse(); match cli.command { Some(Command::Check { paths }) => { let errors = run_check(&paths); std::process::exit(if errors > 0 { 1 } else { 0 }); } None => { // Default: run as LSP server tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_ansi(false) .init(); let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); // Load package cache in background let package_cache = package_cache::new_shared_cache(); let cache_for_loading = package_cache.clone(); tokio::spawn(async move { package_cache::stream_packages_into(&cache_for_loading).await; }); // Load architecture list in background let architecture_list = architecture::new_shared_list(); let arch_for_loading = architecture_list.clone(); tokio::spawn(async move { architecture::stream_into(&arch_for_loading).await; }); let udd_pool = udd::shared_pool(); let bug_cache = bugs::new_shared_bug_cache(udd_pool.clone()); let vcswatch_cache = vcswatch::new_shared_vcswatch_cache(udd_pool.clone()); let popcon_cache = popcon::new_shared_popcon_cache(udd_pool.clone()); let maintainer_cache = maintainers::new_shared_maintainer_cache(udd_pool.clone()); let rdeps_cache = rdeps::new_shared_rdeps_cache(udd_pool); let (service, socket) = LspService::new(|client| Backend { client, workspace: Arc::new(Mutex::new(Workspace::new())), files: Arc::new(Mutex::new(HashMap::new())), package_cache: package_cache.clone(), architecture_list: architecture_list.clone(), bug_cache: bug_cache.clone(), maintainer_cache: maintainer_cache.clone(), vcswatch_cache: vcswatch_cache.clone(), popcon_cache: popcon_cache.clone(), rdeps_cache: rdeps_cache.clone(), git_file_cache: copyright::code_lens::new_shared_git_file_cache(), lintian_tag_cache: Arc::new(tokio::sync::RwLock::new( lintian_overrides::LintianTagCache::new(), )), upstream_cache: upstream_metadata::upstream_cache::new_shared(), settings: Arc::new(Mutex::new(Settings::default())), }); Server::new(stdin, stdout, socket).serve(service).await; } } } #[cfg(test)] mod main_tests { use super::*; #[test] fn test_completion_returns_control_completions() { let text = "Source: test\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = LineIndex::new(text); let src = Source::new(text, &idx); let completions = control::get_completions(&deb822, src, Position::new(0, 3)); assert!(!completions.is_empty()); assert!(completions.iter().any(|c| c.label == "Source")); } #[test] fn test_workspace_integration() { // Test that the workspace can parse control files let mut workspace = workspace::Workspace::new(); let url = str::parse("file:///debian/control").unwrap(); let content = "source: test-package\nMaintainer: Test \n"; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_control(file); // Should parse correctly assert!(parsed.errors().is_empty()); if let Ok(control) = parsed.to_result() { let mut field_names = Vec::new(); for paragraph in control.as_deb822().paragraphs() { for entry in paragraph.entries() { if let Some(name) = entry.key() { field_names.push(name); } } } assert!(field_names.contains(&"source".to_string())); assert!(field_names.contains(&"Maintainer".to_string())); } } #[test] fn test_field_casing_detection() { // Test that we can detect incorrect field casing use control::get_standard_field_name; // Test correct casing - should return the same assert_eq!(get_standard_field_name("Source"), Some("Source")); assert_eq!(get_standard_field_name("Package"), Some("Package")); assert_eq!(get_standard_field_name("Maintainer"), Some("Maintainer")); // Test incorrect casing - should return the standard form assert_eq!(get_standard_field_name("source"), Some("Source")); assert_eq!(get_standard_field_name("package"), Some("Package")); assert_eq!(get_standard_field_name("maintainer"), Some("Maintainer")); assert_eq!(get_standard_field_name("MAINTAINER"), Some("Maintainer")); // Test unknown fields - should return None assert_eq!(get_standard_field_name("UnknownField"), None); assert_eq!(get_standard_field_name("random"), None); } #[test] fn test_changelog_action_generation() { // Test that we can generate a new changelog entry let changelog_content = r#"test-package (1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_content); let changelog = parsed.tree(); let result = changelog::generate_new_changelog_entry(&changelog); assert!(result.is_ok(), "Should successfully generate entry"); let new_entry = result.unwrap(); // Parse the lines to verify exact structure let lines: Vec<&str> = new_entry.lines().collect(); assert!(lines.len() >= 5, "Should have at least 5 lines"); // Check the header line exactly (version is incremented, uses UNRELEASED) assert_eq!( lines[0], "test-package (1.0-2) UNRELEASED; urgency=medium", "First line should be header with incremented version and UNRELEASED" ); // Check empty line after header assert_eq!(lines[1], "", "Second line should be empty"); // Check bullet point line assert_eq!(lines[2], " * ", "Third line should be bullet point"); // Check empty line before signature assert_eq!(lines[3], "", "Fourth line should be empty"); // Check signature line starts with proper format assert!( lines[4].starts_with(" -- "), "Fifth line should start with signature marker, got: {}", lines[4] ); } #[test] fn test_changelog_version_increment_multiple_revisions() { // Test the version increment logic with different versions let changelog_text = r#"mypackage (2.5-3) unstable; urgency=low * Some changes. -- Jane Smith Tue, 15 Feb 2025 10:30:00 +0000 "#; let parsed = debian_changelog::ChangeLog::parse(changelog_text); let changelog = parsed.tree(); let result = changelog::generate_new_changelog_entry(&changelog); assert!(result.is_ok(), "Should successfully generate entry"); let new_entry = result.unwrap(); let lines: Vec<&str> = new_entry.lines().collect(); // Check exact version increment (3 -> 4) with UNRELEASED assert_eq!( lines[0], "mypackage (2.5-4) UNRELEASED; urgency=medium", "Should increment debian revision from 3 to 4 with UNRELEASED" ); } #[test] fn test_changelog_file_type_detection() { // Test that we correctly detect changelog files let changelog_uri: Uri = str::parse("file:///path/to/debian/changelog").unwrap(); let control_uri: Uri = str::parse("file:///path/to/debian/control").unwrap(); assert_eq!(FileType::detect(&changelog_uri), Some(FileType::Changelog)); assert_eq!(FileType::detect(&control_uri), Some(FileType::Control)); } #[test] fn test_incremental_edit_apply() { // Simulate applying an incremental edit like did_change does let mut text = "Source: test\nMaintainer: Alice\n".to_string(); let range = Range::new(Position::new(0, 8), Position::new(0, 12)); let idx = LineIndex::new(&text); let text_range = Source::new(&text, &idx) .try_lsp_range_to_text_range(&range) .unwrap(); let start: usize = text_range.start().into(); let end: usize = text_range.end().into(); text.replace_range(start..end, "hello"); assert_eq!(text, "Source: hello\nMaintainer: Alice\n"); } #[test] fn test_incremental_edit_insert() { let mut text = "Source: test\n".to_string(); // Insert at end of line 0 let range = Range::new(Position::new(0, 12), Position::new(0, 12)); let idx = LineIndex::new(&text); let text_range = Source::new(&text, &idx) .try_lsp_range_to_text_range(&range) .unwrap(); let start: usize = text_range.start().into(); let end: usize = text_range.end().into(); text.replace_range(start..end, "-pkg"); assert_eq!(text, "Source: test-pkg\n"); } #[test] fn test_incremental_edit_delete() { let mut text = "Source: test-pkg\n".to_string(); let range = Range::new(Position::new(0, 8), Position::new(0, 16)); let idx = LineIndex::new(&text); let text_range = Source::new(&text, &idx) .try_lsp_range_to_text_range(&range) .unwrap(); let start: usize = text_range.start().into(); let end: usize = text_range.end().into(); text.replace_range(start..end, ""); assert_eq!(text, "Source: \n"); } #[test] fn test_incremental_edit_multiline() { let mut text = "Source: test\nMaintainer: Alice\nPriority: optional\n".to_string(); // Replace entire second line let range = Range::new(Position::new(1, 0), Position::new(2, 0)); let idx = LineIndex::new(&text); let text_range = Source::new(&text, &idx) .try_lsp_range_to_text_range(&range) .unwrap(); let start: usize = text_range.start().into(); let end: usize = text_range.end().into(); text.replace_range(start..end, "Maintainer: Bob\n"); assert_eq!(text, "Source: test\nMaintainer: Bob\nPriority: optional\n"); } #[test] fn test_workspace_update_file_reuses_input() { let mut workspace = workspace::Workspace::new(); let url: Uri = str::parse("file:///debian/control").unwrap(); let file1 = workspace.update_file(url.clone(), "Source: a\n".to_string()); let file2 = workspace.update_file(url.clone(), "Source: b\n".to_string()); // Should reuse the same SourceFile input assert_eq!(file1, file2); // Text should be updated assert_eq!(&*workspace.source_text(file2), "Source: b\n"); } #[test] fn test_upstream_metadata_file_type_detection() { let metadata_uri: Uri = str::parse("file:///path/to/debian/upstream/metadata").unwrap(); let non_metadata_uri: Uri = str::parse("file:///path/to/upstream/metadata").unwrap(); assert_eq!( FileType::detect(&metadata_uri), Some(FileType::UpstreamMetadata) ); assert_eq!(FileType::detect(&non_metadata_uri), None); } #[test] fn test_source_options_file_type_detection() { let options_uri: Uri = str::parse("file:///path/to/debian/source/options").unwrap(); let local_options_uri: Uri = str::parse("file:///path/to/debian/source/local-options").unwrap(); let non_options_uri: Uri = str::parse("file:///path/to/debian/options").unwrap(); assert_eq!( FileType::detect(&options_uri), Some(FileType::SourceOptions) ); assert_eq!( FileType::detect(&local_options_uri), Some(FileType::SourceOptions) ); assert_eq!(FileType::detect(&non_options_uri), None); } } debian-lsp-0.1.8/src/maintainers.rs000064400000000000000000000071721046102023000153260ustar 00000000000000//! Maintainer identity suggestions from environment and UDD. //! //! Provides completions for Maintainer and Uploaders fields by combining: //! 1. The user's identity from `$DEBEMAIL`/`$DEBFULLNAME` (matching `dch` behavior) //! 2. Common maintainer identities from the UDD `sources` table use std::sync::Arc; use tokio::sync::RwLock; /// Thread-safe shared cache for maintainer identity lookups. pub type SharedMaintainerCache = Arc>; /// Cached maintainer identities from UDD. pub struct MaintainerCache { pool: crate::udd::SharedPool, /// Distinct maintainer identities fetched from UDD, or `None` if not yet fetched. maintainers: Option>, } #[derive(sqlx::FromRow)] struct MaintainerRow { maintainer: String, } impl MaintainerCache { /// Create a new maintainer cache using the given UDD connection pool. pub fn new(pool: crate::udd::SharedPool) -> Self { Self { pool, maintainers: None, } } /// Get the list of known maintainer identities, fetching from UDD if needed. pub async fn get_maintainers(&mut self) -> &[String] { if self.maintainers.is_none() { self.fetch_maintainers().await; } self.maintainers.as_deref().unwrap_or(&[]) } async fn fetch_maintainers(&mut self) { let rows: Vec = match sqlx::query_as( "SELECT DISTINCT maintainer FROM sources \ WHERE release = 'sid' \ ORDER BY maintainer \ LIMIT 5000", ) .fetch_all(&*self.pool) .await { Ok(rows) => rows, Err(e) => { tracing::warn!(error = %e, "UDD maintainer query failed"); self.maintainers = Some(Vec::new()); return; } }; self.maintainers = Some(rows.into_iter().map(|r| r.maintainer).collect()); } /// Insert cached entries for testing purposes. #[cfg(test)] pub(crate) fn insert_cached(&mut self, maintainers: Vec) { self.maintainers = Some(maintainers); } } /// Create a new shared maintainer cache. pub fn new_shared_maintainer_cache(pool: crate::udd::SharedPool) -> SharedMaintainerCache { Arc::new(RwLock::new(MaintainerCache::new(pool))) } /// Get the user's identity from Debian environment variables. /// /// Checks `$DEBFULLNAME` and `$DEBEMAIL` first (matching `dch` behavior), /// then falls back to `$EMAIL` for the email part. pub fn get_user_identity() -> Option { let name = std::env::var("DEBFULLNAME").ok().filter(|s| !s.is_empty()); let email = std::env::var("DEBEMAIL") .ok() .or_else(|| std::env::var("EMAIL").ok()) .filter(|s| !s.is_empty()); match (name, email) { (Some(n), Some(e)) => Some(format!("{} <{}>", n, e)), _ => None, } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_maintainers_from_cache() { let mut cache = MaintainerCache::new(crate::udd::shared_pool()); cache.insert_cached(vec![ "Alice ".to_string(), "Bob ".to_string(), ]); let maintainers = cache.get_maintainers().await; assert_eq!(maintainers.len(), 2); assert_eq!(maintainers[0], "Alice "); } #[tokio::test] async fn test_get_maintainers_empty_before_fetch() { let mut cache = MaintainerCache::new(crate::udd::shared_pool()); cache.insert_cached(vec![]); let maintainers = cache.get_maintainers().await; assert!(maintainers.is_empty()); } } debian-lsp-0.1.8/src/package_cache.rs000064400000000000000000000434761046102023000155410ustar 00000000000000use std::collections::HashMap; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::RwLock; /// Version information for a package. #[derive(Debug, Clone)] pub struct VersionInfo { /// The version string. pub version: String, /// The suites this version is available in. pub suites: Vec, } /// Access to the package cache. #[async_trait::async_trait] pub trait PackageCache: Send + Sync { /// Get packages whose names start with a given prefix, sorted. fn get_packages_with_prefix(&self, prefix: &str) -> Vec; /// Get the cached short description for a package. fn get_description(&self, package: &str) -> Option<&str>; /// Load and cache versions for a package. async fn load_versions(&mut self, package: &str) -> Option<&[VersionInfo]>; /// Load and cache versions for multiple packages in a single batch call. async fn load_versions_batch(&mut self, packages: &[String]); /// Load and cache providers for multiple packages in a single batch call. async fn load_providers_batch(&mut self, packages: &[String]); /// Get already-cached versions for a package, without triggering a lookup. fn get_cached_versions(&self, package: &str) -> Option<&[VersionInfo]>; /// Get already-cached providers for a package, without triggering a lookup. fn get_cached_providers(&self, package: &str) -> Option<&[String]>; /// Insert a package name with its short description into the cache. fn insert_package(&mut self, name: String, description: String); } /// Thread-safe shared package cache. pub type SharedPackageCache = Arc>; /// Package cache backed by `apt-cache`. pub struct AptPackageCache { /// All available package names (sorted). packages: Vec, /// Package descriptions (package name -> short description). descriptions: HashMap, /// Cached versions for specific packages. versions: HashMap>, /// Cached providers for virtual packages. providers: HashMap>, } impl AptPackageCache { /// Create a new empty cache. pub fn new() -> Self { Self { packages: Vec::new(), descriptions: HashMap::new(), versions: HashMap::new(), providers: HashMap::new(), } } } /// Extract the suite name from an apt source line like /// `500 http://deb.debian.org/debian unstable/main amd64 Packages`. /// Returns the suite component (e.g. "unstable", "bullseye"). fn extract_suite_from_apt_line(line: &str) -> Option<&str> { // Format: "PRIORITY URL SUITE/COMPONENT ARCH TYPE" // or: "PRIORITY /var/lib/dpkg/status" let trimmed = line.trim(); let mut parts = trimmed.split_whitespace(); let _priority = parts.next()?; let url_or_path = parts.next()?; if url_or_path.starts_with('/') { return None; // local dpkg status, not an archive } let suite_component = parts.next()?; suite_component.split('/').next() } /// Parse a single line of `apt-cache policy` output, updating the /// current version list. Call this for every line after the package /// header (the `name:` line). /// /// Version lines look like `" *** 13.20 500"` or `" 13.11.6 500"` and /// are indented with up to 5 leading spaces. Suite lines like /// `" 500 http://... suite/component ..."` have 8+ leading spaces. fn parse_policy_line(line: &str, versions: &mut Vec) { let trimmed = line.trim(); // Count leading spaces to distinguish version lines (<=5 spaces) from // suite/source lines (8+ spaces). let leading_spaces = line.len() - line.trim_start().len(); if trimmed.starts_with("***") || (leading_spaces < 8 && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())) { // Version line: "VERSION PRIORITY" or "*** VERSION PRIORITY" let version_str = if let Some(rest) = trimmed.strip_prefix("*** ") { rest.split_whitespace().next() } else { trimmed.split_whitespace().next() }; if let Some(v) = version_str { versions.push(VersionInfo { version: v.to_string(), suites: Vec::new(), }); } } else if let Some(last) = versions.last_mut() { // Suite line under a version if let Some(suite) = extract_suite_from_apt_line(trimmed) { let suite = suite.to_string(); if !last.suites.contains(&suite) { last.suites.push(suite); } } } } /// Parse the full output of `apt-cache policy ` into /// a list of `VersionInfo` entries (newest first, following apt order). fn parse_policy_versions(text: &str) -> Vec { let mut versions = Vec::new(); let mut in_version_table = false; for line in text.lines() { if line.starts_with(" Version table:") { in_version_table = true; continue; } if in_version_table { parse_policy_line(line, &mut versions); } } versions } #[async_trait::async_trait] impl PackageCache for AptPackageCache { fn get_packages_with_prefix(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_ascii_lowercase(); self.packages .iter() .filter(|p| p.starts_with(&prefix_lower)) .cloned() .collect() } fn get_description(&self, package: &str) -> Option<&str> { self.descriptions.get(package).map(|s| s.as_str()) } async fn load_versions(&mut self, package: &str) -> Option<&[VersionInfo]> { if self.versions.contains_key(package) { return self.versions.get(package).map(|v| v.as_slice()); } match Command::new("apt-cache") .arg("policy") .arg(package) .output() .await { Ok(output) if output.status.success() => { let text = String::from_utf8_lossy(&output.stdout); let versions = parse_policy_versions(&text); self.versions.insert(package.to_string(), versions); self.versions.get(package).map(|v| v.as_slice()) } _ => None, } } async fn load_versions_batch(&mut self, packages: &[String]) { let uncached: Vec<&String> = packages .iter() .filter(|p| !self.versions.contains_key(p.as_str())) .collect(); if uncached.is_empty() { return; } let Ok(output) = Command::new("apt-cache") .arg("policy") .args(&uncached) .output() .await else { return; }; if !output.status.success() { return; } // Initialize empty entries for all requested packages so we don't // re-query them on the next call. for pkg in &uncached { self.versions.entry(pkg.to_string()).or_default(); } let text = String::from_utf8_lossy(&output.stdout); let mut current_package: Option = None; let mut current_versions: Vec = Vec::new(); let mut in_version_table = false; for line in text.lines() { if !line.starts_with(' ') && line.ends_with(':') { // New package header — save previous package's versions if let Some(pkg) = current_package.take() { if !current_versions.is_empty() { self.versions .insert(pkg, std::mem::take(&mut current_versions)); } } current_package = Some(line.trim_end_matches(':').to_string()); current_versions.clear(); in_version_table = false; } else if line.starts_with(" Version table:") { in_version_table = true; } else if in_version_table { parse_policy_line(line, &mut current_versions); } } // Save last package if let Some(pkg) = current_package { if !current_versions.is_empty() { self.versions.insert(pkg, current_versions); } } } async fn load_providers_batch(&mut self, packages: &[String]) { let uncached: Vec<&String> = packages .iter() .filter(|p| !self.providers.contains_key(p.as_str())) .collect(); if uncached.is_empty() { return; } let Ok(output) = Command::new("apt-cache") .arg("showpkg") .args(&uncached) .output() .await else { return; }; if !output.status.success() { return; } // Initialize empty entries so we don't re-query. for pkg in &uncached { self.providers.entry(pkg.to_string()).or_default(); } let text = String::from_utf8_lossy(&output.stdout); let mut current_package: Option = None; let mut in_reverse_provides = false; for line in text.lines() { if let Some(name) = line.strip_prefix("Package: ") { current_package = Some(name.to_string()); in_reverse_provides = false; } else if line.starts_with("Reverse Provides:") { in_reverse_provides = true; } else if line.starts_with("Versions:") || line.starts_with("Reverse Depends:") || line.starts_with("Dependencies:") || line.starts_with("Provides:") { in_reverse_provides = false; } else if in_reverse_provides { if let (Some(pkg), Some(name)) = (¤t_package, line.split_whitespace().next()) { let providers = self.providers.entry(pkg.clone()).or_default(); let name = name.to_string(); if !providers.contains(&name) { providers.push(name); } } } } // Sort providers for consistent display for providers in self.providers.values_mut() { providers.sort(); } } fn get_cached_versions(&self, package: &str) -> Option<&[VersionInfo]> { self.versions.get(package).map(|v| v.as_slice()) } fn get_cached_providers(&self, package: &str) -> Option<&[String]> { self.providers.get(package).map(|v| v.as_slice()) } fn insert_package(&mut self, name: String, description: String) { let pos = self.packages.binary_search(&name).unwrap_or_else(|p| p); self.packages.insert(pos, name.clone()); self.descriptions.insert(name, description); } } /// Create a new shared cache backed by apt-cache. pub fn new_shared_cache() -> SharedPackageCache { Arc::new(RwLock::new(AptPackageCache::new())) } /// Stream package names and descriptions from `apt-cache search` into the /// shared cache. /// /// Each line is `name - description`, inserted as soon as it is read so /// completions are available immediately while the full list loads. pub async fn stream_packages_into(cache: &SharedPackageCache) { let Ok(mut child) = Command::new("apt-cache") .args(["search", "--names-only", "."]) .stdout(std::process::Stdio::piped()) .spawn() else { return; }; let Some(stdout) = child.stdout.take() else { return; }; let mut lines = BufReader::new(stdout).lines(); while let Ok(Some(line)) = lines.next_line().await { if let Some((name, description)) = line.split_once(" - ") { cache .write() .await .insert_package(name.to_string(), description.to_string()); } } } #[cfg(test)] /// Simple in-memory package cache for tests. #[derive(Default)] pub struct TestPackageCache { /// Packages and their optional descriptions. pub packages: Vec<(String, Option)>, /// Cached versions. pub versions: HashMap>, /// Cached providers for virtual packages. pub providers: HashMap>, } #[cfg(test)] #[async_trait::async_trait] impl PackageCache for TestPackageCache { fn get_packages_with_prefix(&self, prefix: &str) -> Vec { let prefix_lower = prefix.to_ascii_lowercase(); let mut result: Vec = self .packages .iter() .filter(|(name, _)| name.starts_with(&prefix_lower)) .map(|(name, _)| name.clone()) .collect(); result.sort_unstable(); result } fn get_description(&self, package: &str) -> Option<&str> { self.packages .iter() .find(|(name, _)| name == package) .and_then(|(_, desc)| desc.as_deref()) } async fn load_versions(&mut self, package: &str) -> Option<&[VersionInfo]> { self.versions.get(package).map(|v| v.as_slice()) } async fn load_versions_batch(&mut self, _packages: &[String]) { // Test cache is pre-populated; nothing to load. } async fn load_providers_batch(&mut self, _packages: &[String]) { // Test cache is pre-populated; nothing to load. } fn get_cached_versions(&self, package: &str) -> Option<&[VersionInfo]> { self.versions.get(package).map(|v| v.as_slice()) } fn get_cached_providers(&self, package: &str) -> Option<&[String]> { self.providers.get(package).map(|v| v.as_slice()) } fn insert_package(&mut self, name: String, description: String) { self.packages.push((name, Some(description))); } } #[cfg(test)] impl TestPackageCache { /// Create a shared test cache. pub fn new_shared(packages: &[(&str, Option<&str>)]) -> SharedPackageCache { let mut cache = TestPackageCache::default(); for &(name, desc) in packages { cache .packages .push((name.to_string(), desc.map(|s| s.to_string()))); } Arc::new(RwLock::new(cache)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_empty_cache() { let cache = AptPackageCache::new(); assert!(cache.get_packages_with_prefix("foo").is_empty()); } #[test] fn test_insert_package_sorted() { let mut cache = AptPackageCache::new(); cache.insert_package("cmake".to_string(), "cross-platform make".to_string()); cache.insert_package("apt".to_string(), "package manager".to_string()); cache.insert_package("zsh".to_string(), "shell".to_string()); cache.insert_package("debhelper".to_string(), "helper tools".to_string()); assert_eq!(cache.packages, vec!["apt", "cmake", "debhelper", "zsh"]); assert_eq!(cache.get_description("cmake"), Some("cross-platform make")); } #[test] fn test_test_cache_prefix() { let cache_arc = TestPackageCache::new_shared(&[ ("debhelper", None), ("debhelper-compat", None), ("cmake", Some("cross-platform make")), ]); let cache = cache_arc.try_read().unwrap(); let deb = cache.get_packages_with_prefix("deb"); assert_eq!(deb, vec!["debhelper", "debhelper-compat"]); let cm = cache.get_packages_with_prefix("cm"); assert_eq!(cm, vec!["cmake"]); assert_eq!(cache.get_description("cmake"), Some("cross-platform make")); assert_eq!(cache.get_description("debhelper"), None); } #[test] fn test_extract_suite_from_apt_line_normal() { let line = " 500 http://deb.debian.org/debian unstable/main amd64 Packages"; assert_eq!(extract_suite_from_apt_line(line), Some("unstable")); } #[test] fn test_extract_suite_from_apt_line_dpkg_status() { let line = " 100 /var/lib/dpkg/status"; assert_eq!(extract_suite_from_apt_line(line), None); } #[test] fn test_extract_suite_from_apt_line_malformed() { assert_eq!(extract_suite_from_apt_line(""), None); assert_eq!(extract_suite_from_apt_line(" "), None); assert_eq!(extract_suite_from_apt_line("500"), None); } #[test] fn test_parse_policy_line_version() { let mut versions = Vec::new(); parse_policy_line(" 2.40-4 500", &mut versions); assert_eq!(versions.len(), 1); assert_eq!(versions[0].version, "2.40-4"); assert!(versions[0].suites.is_empty()); } #[test] fn test_parse_policy_line_installed_version() { let mut versions = Vec::new(); parse_policy_line(" *** 2.40-4 500", &mut versions); assert_eq!(versions.len(), 1); assert_eq!(versions[0].version, "2.40-4"); assert!(versions[0].suites.is_empty()); } #[test] fn test_parse_policy_line_suite() { let mut versions = vec![VersionInfo { version: "2.40-4".to_string(), suites: Vec::new(), }]; parse_policy_line( " 500 http://deb.debian.org/debian unstable/main amd64 Packages", &mut versions, ); assert_eq!(versions[0].suites, vec!["unstable"]); } #[test] fn test_parse_policy_versions_realistic() { let output = "\ debhelper: Installed: 13.20 Candidate: 13.20 Version table: *** 13.20 500 500 http://deb.debian.org/debian unstable/main amd64 Packages 100 /var/lib/dpkg/status 13.11.6 500 500 http://deb.debian.org/debian bookworm/main amd64 Packages "; let versions = parse_policy_versions(output); assert_eq!(versions.len(), 2); assert_eq!(versions[0].version, "13.20"); assert_eq!(versions[0].suites, vec!["unstable"]); assert_eq!(versions[1].version, "13.11.6"); assert_eq!(versions[1].suites, vec!["bookworm"]); } } debian-lsp-0.1.8/src/patches_series/completion.rs000064400000000000000000000222061046102023000201610ustar 00000000000000use super::detection::list_patch_files; use std::collections::HashSet; use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position, Uri}; /// Get completion items for a debian/patches/series file pub fn get_completions( uri: &Uri, parsed: &patchkit::edit::Parse, source_text: &str, position: Position, ) -> Vec { let series = parsed.tree(); let current_line = source_text .lines() .nth(position.line as usize) .unwrap_or(""); let before_cursor = ¤t_line[..position.character as usize]; let tokens: Vec<&str> = before_cursor.split_whitespace().collect(); if tokens.len() >= 2 { return Vec::new(); } if tokens.len() == 1 && before_cursor.ends_with(' ') { return get_strip_option_completions(tokens[0]); } let already_listed: HashSet = series.patch_entries().filter_map(|e| e.name()).collect(); let patch_files = list_patch_files(uri); get_patch_file_completions(&patch_files, &already_listed) } // Get snippet completions for each patches in the debian/patches folder fn get_patch_file_completions( patch_files: &HashSet, already_listed: &HashSet, ) -> Vec { let mut items: Vec = patch_files .iter() .filter(|name| !already_listed.contains(*name)) .map(|name| CompletionItem { label: name.clone(), kind: Some(CompletionItemKind::FILE), detail: Some("Patch file".to_string()), ..Default::default() }) .collect(); items.sort_by(|a, b| a.label.cmp(&b.label)); items } /// Get completion items for the strip option (-p0, -p1, -p2...) fn get_strip_option_completions(patch: &str) -> Vec { let count = patch.split('/').count() - 1; (0..=count) .map(|n| { let label = format!("-p{}", n); CompletionItem { label: label.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some(format!("Strip {} path segment(s)", n)), ..Default::default() } }) .collect() } #[cfg(test)] mod tests { use super::*; fn make_series( patches: &[&str], ) -> patchkit::edit::Parse { let text = patches.join("\n") + "\n"; patchkit::edit::series::parse(&text) } fn empty_series() -> patchkit::edit::Parse { patchkit::edit::series::parse("") } #[test] fn test_strip_option_completions_no_slash() { let completions = get_strip_option_completions("fix-arm.patch"); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["-p0"]); assert_eq!(completions.len(), 1); } #[test] fn test_strip_option_completions_one_slash() { let completions = get_strip_option_completions("upstream/fix-arm.patch"); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["-p0", "-p1"]); assert_eq!(completions.len(), 2); } #[test] fn test_strip_option_completions_two_slashes() { let completions = get_strip_option_completions("a/b/fix.patch"); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["-p0", "-p1", "-p2"]); assert_eq!(completions.len(), 3); } #[test] fn test_strip_option_completions_have_details() { let completions = get_strip_option_completions("fix-arm.patch"); for c in &completions { assert!(c.detail.is_some()); } } #[test] fn test_strip_option_completions_detail_format() { let completions = get_strip_option_completions("upstream/fix-arm.patch"); for (i, c) in completions.iter().enumerate() { assert_eq!( c.detail.as_deref(), Some(format!("Strip {} path segment(s)", i).as_str()) ); } } #[test] fn test_strip_option_completions_kind() { let completions = get_strip_option_completions("fix-arm.patch"); assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::VALUE))); } #[test] fn test_strip_options_after_space() { let parsed = make_series(&["fix-arm.patch"]); let source_text = "fix-arm.patch "; let position = Position::new(0, 14); let uri: Uri = "file:///debian/patches/series".parse().unwrap(); let completions = get_completions(&uri, &parsed, source_text, position); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["-p0"]); assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::VALUE))); } #[test] fn test_strip_options_after_space_subdir() { let parsed = make_series(&["upstream/fix-arm.patch"]); let source_text = "upstream/fix-arm.patch "; let position = Position::new(0, 23); let uri: Uri = "file:///debian/patches/series".parse().unwrap(); let completions = get_completions(&uri, &parsed, source_text, position); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["-p0", "-p1"]); } #[test] fn test_no_completions_when_two_tokens() { let parsed = make_series(&["fix-arm.patch"]); let source_text = "fix-arm.patch -p1"; let position = Position::new(0, 17); let uri: Uri = "file:///debian/patches/series".parse().unwrap(); let completions = get_completions(&uri, &parsed, source_text, position); assert!(completions.is_empty()); } #[test] fn test_no_completions_when_patch_and_option_present() { let parsed = make_series(&["fix-arm.patch"]); let source_text = "fix-arm.patch -p1 "; let position = Position::new(0, 18); let uri: Uri = "file:///debian/patches/series".parse().unwrap(); let completions = get_completions(&uri, &parsed, source_text, position); assert!(completions.is_empty()); } #[test] fn test_patch_file_completions_excludes_already_listed() { let patch_files: HashSet = vec![ "fix-arm.patch".to_string(), "fix-mips.patch".to_string(), "CVE-2024.patch".to_string(), ] .into_iter() .collect(); let already_listed: HashSet = vec!["fix-arm.patch".to_string()].into_iter().collect(); let completions = get_patch_file_completions(&patch_files, &already_listed); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(!labels.contains(&"fix-arm.patch")); assert!(labels.contains(&"fix-mips.patch")); assert!(labels.contains(&"CVE-2024.patch")); } #[test] fn test_patch_file_completions_sorted() { let patch_files: HashSet = vec![ "zzz.patch".to_string(), "aaa.patch".to_string(), "mmm.patch".to_string(), ] .into_iter() .collect(); let already_listed: HashSet = HashSet::new(); let completions = get_patch_file_completions(&patch_files, &already_listed); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["aaa.patch", "mmm.patch", "zzz.patch"]); } #[test] fn test_patch_file_completions_have_file_kind() { let patch_files: HashSet = vec!["fix-arm.patch".to_string()].into_iter().collect(); let already_listed: HashSet = HashSet::new(); let completions = get_patch_file_completions(&patch_files, &already_listed); assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::FILE))); } #[test] fn test_patch_file_completions_all_listed_returns_empty() { let patch_files: HashSet = vec!["fix-arm.patch".to_string(), "fix-mips.patch".to_string()] .into_iter() .collect(); let already_listed: HashSet = vec!["fix-arm.patch".to_string(), "fix-mips.patch".to_string()] .into_iter() .collect(); let completions = get_patch_file_completions(&patch_files, &already_listed); assert!(completions.is_empty()); } #[test] fn test_no_strip_options_without_space() { let parsed = empty_series(); let source_text = "fix-arm"; let position = Position::new(0, 7); let uri: Uri = "file:///debian/patches/series".parse().unwrap(); let completions = get_completions(&uri, &parsed, source_text, position); let labels: Vec<&str> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(!labels.contains(&"-p0")); assert!(!labels.contains(&"-p1")); assert!(!labels.contains(&"-p2")); } } debian-lsp-0.1.8/src/patches_series/detection.rs000064400000000000000000000155451046102023000177760ustar 00000000000000use std::collections::HashSet; use std::fs; use std::path::Path; use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian patches/series file pub fn is_patches_series_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/debian/patches/series") } /// Check if a URI points at a quilt patch file under `debian/patches/`. /// Excludes the `series` file itself (which is line-listed, not a /// patch). Patches may live in subdirectories (e.g. `upstream/`) so we /// match any path containing `/debian/patches/` with at least one /// component after it, where the final component isn't `series`. pub fn is_patch_file(uri: &Uri) -> bool { let path = uri.as_str(); let Some(after) = path.split("/debian/patches/").nth(1) else { return false; }; if after.is_empty() { return false; } // Final component must not be `series`. let last = after.rsplit('/').next().unwrap_or(""); last != "series" } // Get all files in a debian/patches folder pub fn list_patch_files(uri: &Uri) -> HashSet { let Some(path) = uri.to_file_path() else { return HashSet::new(); }; let Some(patches_dir) = path.parent() else { return HashSet::new(); }; let patches_dir = patches_dir.to_path_buf(); let mut result = HashSet::new(); collect_patches(&patches_dir, &patches_dir, &mut result); result } fn collect_patches(base: &Path, dir: &Path, result: &mut HashSet) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_patches(base, &path, result); } else { if path.file_name().and_then(|n| n.to_str()) == Some("series") { continue; } if let Ok(relative) = path.strip_prefix(base) { if let Some(s) = relative.to_str() { result.insert(s.to_string()); } } } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_patches_series_file() { let tests_patches_series_paths = vec![ "file:///path/to/debian/patches/series", "file:///project/debian/patches/series", ]; let non_tests_patches_series_paths = vec![ "file:///path/to/other.txt", "file:///path/to/debian/control", "file:///path/to/debian/copyright", "file:///path/to/debian/watch", "file:///path/to/patches/series", // Not in debian/ directory "file:///path/to/debian/tests/control.backup", ]; for path in tests_patches_series_paths { let uri = path.parse::().unwrap(); assert!( is_patches_series_file(&uri), "Should detect tests/patches/series file: {}", path ); } for path in non_tests_patches_series_paths { let uri = path.parse::().unwrap(); assert!( !is_patches_series_file(&uri), "Should not detect as tests/patches/series file: {}", path ); } } #[test] fn test_is_patch_file() { let patches = [ "file:///pkg/debian/patches/fix-arm.patch", "file:///pkg/debian/patches/fix-mips.diff", "file:///pkg/debian/patches/001-no-extension", "file:///pkg/debian/patches/upstream/fix-leak.patch", ]; let non_patches = [ "file:///pkg/debian/patches/series", "file:///pkg/debian/patches/", "file:///pkg/debian/control", "file:///pkg/debian/patches", "file:///pkg/patches/fix-arm.patch", ]; for path in patches { let uri = path.parse::().unwrap(); assert!(is_patch_file(&uri), "expected patch: {}", path); } for path in non_patches { let uri = path.parse::().unwrap(); assert!(!is_patch_file(&uri), "expected not patch: {}", path); } } #[test] fn test_collect_patches_excludes_series_file() { let dir = tempfile::tempdir().unwrap(); let base = dir.path(); std::fs::write(base.join("fix-arm.patch"), "").unwrap(); std::fs::write(base.join("fix-mips.diff"), "").unwrap(); std::fs::write(base.join("fix-no-extension"), "").unwrap(); std::fs::write(base.join("series"), "").unwrap(); // doit être exclu let mut result = std::collections::HashSet::new(); collect_patches(base, base, &mut result); assert!(result.contains("fix-arm.patch")); assert!(result.contains("fix-mips.diff")); assert!(result.contains("fix-no-extension")); assert!(!result.contains("series")); // series exclu ✓ } #[test] fn test_collect_patches_recursive() { let dir = tempfile::tempdir().unwrap(); let base = dir.path(); std::fs::create_dir(base.join("upstream")).unwrap(); std::fs::write(base.join("fix-arm.patch"), "").unwrap(); std::fs::write(base.join("upstream").join("fix-leak.patch"), "").unwrap(); std::fs::write(base.join("upstream").join("fix-mem.diff"), "").unwrap(); let mut result = std::collections::HashSet::new(); collect_patches(base, base, &mut result); assert!(result.contains("fix-arm.patch")); assert!(result.contains( &std::path::Path::new("upstream") .join("fix-leak.patch") .to_string_lossy() .to_string() )); assert!(result.contains( &std::path::Path::new("upstream") .join("fix-mem.diff") .to_string_lossy() .to_string() )); } #[test] fn test_collect_patches_accepts_diff_extension() { let dir = tempfile::tempdir().unwrap(); let base = dir.path(); std::fs::write(base.join("fix-arm.diff"), "").unwrap(); let mut result = std::collections::HashSet::new(); collect_patches(base, base, &mut result); assert!(result.contains("fix-arm.diff")); } #[test] fn test_collect_patches_accepts_no_extension() { let dir = tempfile::tempdir().unwrap(); let base = dir.path(); std::fs::write(base.join("001-fix-build"), "").unwrap(); let mut result = std::collections::HashSet::new(); collect_patches(base, base, &mut result); assert!(result.contains("001-fix-build")); } #[test] fn test_collect_patches_empty_dir() { let dir = tempfile::tempdir().unwrap(); let base = dir.path(); let mut result = std::collections::HashSet::new(); collect_patches(base, base, &mut result); assert!(result.is_empty()); } } debian-lsp-0.1.8/src/patches_series/mod.rs000064400000000000000000000003571046102023000165720ustar 00000000000000//! Module for handling debian/patches/series files pub mod completion; pub mod detection; pub use completion::*; pub use detection::{is_patch_file, is_patches_series_file}; pub mod semantic; pub use semantic::generate_semantic_tokens; debian-lsp-0.1.8/src/patches_series/semantic.rs000064400000000000000000000137271046102023000176230ustar 00000000000000//! Semantic token generation for debian/patches/series files. use crate::deb822::semantic::{token_modifier, SemanticTokensBuilder, TokenType}; use crate::position::Source; use patchkit::edit::series::lex::SyntaxKind; use patchkit::edit::series::lossless::SeriesFile; use rowan::ast::AstNode; use tower_lsp_server::ls_types::SemanticToken; /// Generate semantic tokens for a debian/patches/series file pub fn generate_semantic_tokens(series: &SeriesFile, src: Source<'_>) -> Vec { let mut builder = SemanticTokensBuilder::new(); for element in series.syntax().descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = element { match token.kind() { SyntaxKind::HASH | SyntaxKind::TEXT => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); builder.push( start_pos.line, start_pos.character, length, TokenType::Comment, 0, ); } SyntaxKind::PATCH_NAME => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); builder.push( start_pos.line, start_pos.character, length, TokenType::Value, token_modifier::DECLARATION, ); } SyntaxKind::OPTION => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); builder.push( start_pos.line, start_pos.character, length, TokenType::Field, 0, ); } _ => {} } } } builder.build() } #[cfg(test)] mod tests { use super::generate_semantic_tokens; use crate::deb822::semantic::TokenType; use crate::position::Source; #[test] fn test_generate_semantic_tokens_patch_name() { let text = "fix-arm.patch\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert!(!tokens.is_empty()); assert_eq!(tokens[0].delta_line, 0); assert_eq!(tokens[0].delta_start, 0); assert_eq!(tokens[0].length, 13); assert_eq!(tokens[0].token_type, TokenType::Value as u32); } #[test] fn test_generate_semantic_tokens_patch_with_option() { let text = "fix-arm.patch -p1\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert_eq!(tokens.len(), 2); assert_eq!(tokens[0].token_type, TokenType::Value as u32); assert_eq!(tokens[0].length, 13); assert_eq!(tokens[1].token_type, TokenType::Field as u32); assert_eq!(tokens[1].length, 3); } #[test] fn test_generate_semantic_tokens_comment() { let text = "# This is a comment\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert!(!tokens.is_empty()); for token in &tokens { assert_eq!(token.token_type, TokenType::Comment as u32); } } #[test] fn test_generate_semantic_tokens_multiple_patches() { let text = "fix-arm.patch\nfix-mips.patch -p1\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert_eq!(tokens.len(), 3); assert_eq!(tokens[0].token_type, TokenType::Value as u32); assert_eq!(tokens[1].token_type, TokenType::Value as u32); assert_eq!(tokens[2].token_type, TokenType::Field as u32); } #[test] fn test_generate_semantic_tokens_empty_file() { let text = ""; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert!(tokens.is_empty()); } #[test] fn test_generate_semantic_tokens_mixed() { let text = "# Security\nfix-arm.patch -p1\nooo.patch\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert!(!tokens.is_empty()); assert_eq!(tokens[0].token_type, TokenType::Comment as u32); } #[test] fn test_generate_semantic_tokens_subdir_patch() { let text = "upstream/fix-arm.patch\n"; let parsed = patchkit::edit::series::parse(text); let series = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&series, Source::new(text, &idx)); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TokenType::Value as u32); assert_eq!(tokens[0].length, 22); } } debian-lsp-0.1.8/src/popcon.rs000064400000000000000000000101661046102023000143070ustar 00000000000000//! Popularity contest data from UDD (Ultimate Debian Database). //! //! Queries the `popcon` table to find install counts for packages. use std::num::NonZeroUsize; use std::sync::Arc; use lru::LruCache; use tokio::sync::RwLock; /// Thread-safe shared cache for popcon lookups. pub type SharedPopconCache = Arc>; /// Maximum number of distinct packages cached. Bounded so a long /// editor session that touches many unique packages doesn't keep /// growing the cache. const POPCON_CACHE_CAPACITY: usize = 4096; /// Cached popcon data from UDD. pub struct PopconCache { pool: crate::udd::SharedPool, /// Map from package name to install count. `None` means "looked up, not found". inst_by_package: LruCache>, } #[derive(sqlx::FromRow)] struct PopconRow { insts: Option, } impl PopconCache { /// Create a new popcon cache using the given UDD connection pool. pub fn new(pool: crate::udd::SharedPool) -> Self { Self { pool, inst_by_package: LruCache::new( NonZeroUsize::new(POPCON_CACHE_CAPACITY).expect("non-zero capacity"), ), } } /// Look up the install count for a package, fetching if needed. /// /// Returns `None` if the package is not found in popcon or the query fails. pub async fn get_inst_count(&mut self, package: &str) -> Option { if !self.inst_by_package.contains(package) { self.fetch_inst_count(package).await; } self.inst_by_package.get(package).and_then(|v| *v) } /// Look up the install count from cache only, without fetching. /// Does not promote the entry in the LRU. /// /// Returns `None` if the package has not been fetched yet or was not found. pub fn get_cached_inst_count(&self, package: &str) -> Option { self.inst_by_package.peek(package).and_then(|v| *v) } /// Returns `true` if this package has been looked up (hit or miss). pub fn is_cached(&self, package: &str) -> bool { self.inst_by_package.contains(package) } async fn fetch_inst_count(&mut self, package: &str) { let row: Option = match sqlx::query_as("SELECT insts FROM popcon WHERE package = $1 LIMIT 1") .bind(package) .fetch_optional(&*self.pool) .await { Ok(row) => row, Err(e) => { tracing::warn!(package, error = %e, "UDD popcon query failed"); return; } }; match row { Some(PopconRow { insts: Some(n) }) => { self.inst_by_package .put(package.to_string(), u32::try_from(n).ok()); } _ => { self.inst_by_package.put(package.to_string(), None); } } } /// Insert a cached entry for testing purposes. #[cfg(test)] pub(crate) fn insert_cached(&mut self, package: &str, inst_count: u32) { self.inst_by_package .put(package.to_string(), Some(inst_count)); } /// Insert a cached "not found" entry for testing purposes. #[cfg(test)] pub(crate) fn insert_cached_missing(&mut self, package: &str) { self.inst_by_package.put(package.to_string(), None); } } /// Create a new shared popcon cache. pub fn new_shared_popcon_cache(pool: crate::udd::SharedPool) -> SharedPopconCache { Arc::new(RwLock::new(PopconCache::new(pool))) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_inst_count_from_cache() { let mut cache = PopconCache::new(crate::udd::shared_pool()); cache.insert_cached("hello", 42000); let count = cache.get_inst_count("hello").await; assert_eq!(count, Some(42000)); } #[tokio::test] async fn test_get_inst_count_unknown_package() { let mut cache = PopconCache::new(crate::udd::shared_pool()); cache.insert_cached_missing("nonexistent-package-xyz"); let count = cache.get_inst_count("nonexistent-package-xyz").await; assert_eq!(count, None); } } debian-lsp-0.1.8/src/position.rs000064400000000000000000000522661046102023000146640ustar 00000000000000use text_size::{TextRange, TextSize}; use tower_lsp_server::ls_types::{Position, Range}; /// Return the UTF-16 code unit length of a string. pub fn utf16_len(s: &str) -> u32 { s.chars().map(|c| c.len_utf16() as u32).sum() } /// Pre-computed byte offsets where each line starts. /// /// Building it walks the buffer once (O(N)). After that, mapping a /// byte offset to its `(line, byte_in_line)` is an O(log N) binary /// search; computing the UTF-16 column from there is O(line length). /// Without the index every position conversion is a linear scan from /// the start of the buffer — for a 100KB changelog that's tens of /// MB of byte-walking per LSP request when many ranges need /// conversion. /// /// `Arc` is salsa-cached per buffer (via /// [`crate::workspace::Workspace::get_line_index`]) so all consumers /// in a single LSP request share one index. #[derive(Debug, PartialEq, Eq)] pub struct LineIndex { /// Byte offset where each line starts. `line_starts[0]` is always /// 0; `line_starts[N]` for N > 0 is the byte after the Nth `\n`. line_starts: Vec, /// Total byte length of the source. Stored so out-of-range /// positions can be detected without re-querying the source. text_len: TextSize, } impl LineIndex { /// Build a line index from `text` in a single linear scan. pub fn new(text: &str) -> Self { let mut line_starts = Vec::with_capacity(text.bytes().filter(|&b| b == b'\n').count() + 1); line_starts.push(TextSize::from(0)); for (i, b) in text.bytes().enumerate() { if b == b'\n' { line_starts.push(TextSize::try_from(i + 1).unwrap()); } } Self { line_starts, text_len: TextSize::try_from(text.len()).unwrap(), } } /// Convert a byte offset to an LSP `Position`. `text` must be /// the same buffer the index was built from. pub fn offset_to_position(&self, text: &str, offset: TextSize) -> Position { let offset = offset.min(self.text_len); let line = match self.line_starts.binary_search(&offset) { Ok(idx) => idx, Err(idx) => idx.saturating_sub(1), }; let line_start: usize = self.line_starts[line].into(); // Walk only the part of the line up to the offset to count // UTF-16 code units. Lines are typically short, so this is // effectively O(1). let line_text = &text[line_start..usize::from(offset)]; let utf16_col: u32 = line_text.chars().map(|c| c.len_utf16() as u32).sum(); Position { line: line as u32, character: utf16_col, } } /// Convert an LSP `Position` to a byte offset. Returns `None` /// when the position is past the end of the buffer. pub fn try_position_to_offset(&self, text: &str, position: Position) -> Option { let line = position.line as usize; if line >= self.line_starts.len() { return None; } let line_start: usize = self.line_starts[line].into(); let line_end: usize = self .line_starts .get(line + 1) .map(|&s| usize::from(s)) .unwrap_or_else(|| usize::from(self.text_len)); // Strip the trailing newline (if any) so columns past the // last visible character map to end-of-line content, not the // newline byte. let content_end = if line_end > line_start && text.as_bytes().get(line_end - 1) == Some(&b'\n') { line_end - 1 } else { line_end }; let line_text = &text[line_start..content_end]; let mut utf16_col: u32 = 0; for (rel_byte, ch) in line_text.char_indices() { if utf16_col >= position.character { return TextSize::try_from(line_start + rel_byte).ok(); } utf16_col += ch.len_utf16() as u32; } if utf16_col >= position.character || position.character == utf16_col { return TextSize::try_from(content_end).ok(); } None } /// Convert a `TextRange` to an LSP `Range`. pub fn text_range_to_lsp_range(&self, text: &str, range: TextRange) -> Range { Range { start: self.offset_to_position(text, range.start()), end: self.offset_to_position(text, range.end()), } } /// Convert an LSP `Range` to a `TextRange`. Returns `None` when /// either endpoint is past the end of the buffer. pub fn try_lsp_range_to_text_range(&self, text: &str, range: &Range) -> Option { let start = self.try_position_to_offset(text, range.start)?; let end = self.try_position_to_offset(text, range.end)?; Some(TextRange::new(start, end)) } /// Apply an in-place splice to the index without re-walking the /// whole buffer. /// /// `byte_range` is the range in the *old* text being replaced; the /// caller has already verified it falls on UTF-8 boundaries (e.g. /// because it came from `try_lsp_range_to_text_range`). /// `new_text` is the replacement string. /// /// Cost is O(L + N - i) where L is the number of newlines in /// `new_text` (typically 0 for a single-character edit), N is the /// total number of lines, and i is the line index of the edit. /// For an edit near the start of a 100KB changelog this beats a /// full rebuild (which has to scan all 100KB) substantially; for /// an edit at the end the two are similar. pub fn splice(&mut self, byte_range: std::ops::Range, new_text: &str) { let start = TextSize::try_from(byte_range.start).unwrap(); let end = TextSize::try_from(byte_range.end).unwrap(); debug_assert!(start <= end, "splice range start past end"); debug_assert!(end <= self.text_len, "splice range past end of buffer"); // Lines fully before the edit are untouched. The first // potentially-affected line is the one containing `start`, // i.e. the largest `i` with `line_starts[i] <= start`. let first_affected = match self.line_starts.binary_search(&start) { Ok(idx) => idx, Err(idx) => idx.saturating_sub(1), }; // Line starts that are strictly inside (start, end] are gone. // A line that starts at exactly `start` is preserved (it's the // line containing the edit). After the edit completes we // re-check whether `start` itself is still a line start. // // We collect the surviving line_starts >= end, shifted by the // delta, into a new vector built in place. let after = self .line_starts .iter() .position(|&s| s > end) .unwrap_or(self.line_starts.len()); // delta = (new_text.len() - (end - start)), as a signed shift // applied to every line_start at or after `after`. let removed = u32::from(end - start) as i64; let added = new_text.len() as i64; let delta = added - removed; // Compute the shifted tail first so we can reuse the existing // allocation below. let mut tail: Vec = self.line_starts[after..] .iter() .map(|&s| { let v = u32::from(s) as i64 + delta; debug_assert!(v >= 0, "shifted line_start underflowed"); TextSize::from(v as u32) }) .collect(); // Truncate to the unaffected prefix, then re-add line starts // that appear inside the splice region. self.line_starts.truncate(first_affected + 1); // Re-emit the line start at `start` if and only if it was // originally a line start. Specifically: if `start` equals // `line_starts[first_affected]`, the line still begins there; // otherwise the edit happens mid-line and no new line start // is introduced at `start`. // (No action needed — the prefix already includes that entry // when `line_starts[first_affected] == start`.) // Walk new_text for newlines; each `\n` at byte j inside // new_text introduces a line start at `start + j + 1`. for (j, b) in new_text.bytes().enumerate() { if b == b'\n' { let s = u32::from(start) + j as u32 + 1; self.line_starts.push(TextSize::from(s)); } } // Append the shifted tail. The last entry of the prefix and // the first entry of the tail can in principle coincide if // both sides describe the same line start; dedupe to keep the // invariant that line_starts is strictly increasing. if let (Some(&last), Some(&first)) = (self.line_starts.last(), tail.first()) { if last == first { tail.remove(0); } } self.line_starts.extend(tail); // Update text_len. let new_len = u32::from(self.text_len) as i64 + delta; debug_assert!(new_len >= 0, "text_len underflowed"); self.text_len = TextSize::from(new_len as u32); } } /// Read-only view over a buffer plus its line index. /// /// Bundles `&str` (the buffer text) with `&LineIndex` (the /// pre-computed line-start offsets) so call chains that need to /// convert byte offsets to LSP positions don't have to thread two /// parameters everywhere. Construct via /// [`crate::workspace::Workspace::source`] or directly with /// [`Source::new`]. #[derive(Clone, Copy)] pub struct Source<'a> { /// The buffer text. pub text: &'a str, /// Pre-computed line index for `text`. pub idx: &'a LineIndex, } impl<'a> Source<'a> { /// Bundle `text` with its line index. pub fn new(text: &'a str, idx: &'a LineIndex) -> Self { Self { text, idx } } /// Convert a byte offset to an LSP `Position`. pub fn offset_to_position(&self, offset: TextSize) -> Position { self.idx.offset_to_position(self.text, offset) } /// Convert an LSP `Position` to a byte offset, or `None` when /// out of range. pub fn try_position_to_offset(&self, position: Position) -> Option { self.idx.try_position_to_offset(self.text, position) } /// Convert a `TextRange` to an LSP `Range`. pub fn text_range_to_lsp_range(&self, range: TextRange) -> Range { self.idx.text_range_to_lsp_range(self.text, range) } /// Convert an LSP `Range` to a `TextRange`, or `None` when out /// of range. pub fn try_lsp_range_to_text_range(&self, range: &Range) -> Option { self.idx.try_lsp_range_to_text_range(self.text, range) } } #[cfg(test)] mod tests { use super::*; fn idx(text: &str) -> LineIndex { LineIndex::new(text) } #[test] fn test_try_position_to_offset_multiline_value_end() { let text = "Source: test\nSection: py\n"; let offset = idx(text) .try_position_to_offset(text, Position::new(1, 11)) .unwrap(); assert_eq!(offset, TextSize::from(24u32)); } #[test] fn test_try_position_to_offset_returns_none_for_out_of_range_character() { let text = "Source: test\nSection: py\n"; let offset = idx(text).try_position_to_offset(text, Position::new(1, 99)); assert!(offset.is_none()); } #[test] fn test_try_position_to_offset_returns_none_for_out_of_range_line() { let text = "Source: test\n"; let offset = idx(text).try_position_to_offset(text, Position::new(5, 0)); assert!(offset.is_none()); } #[test] fn test_try_lsp_range_to_text_range_valid() { let text = "Source: test\nSection: py\n"; let range = Range::new(Position::new(1, 0), Position::new(1, 7)); let text_range = idx(text).try_lsp_range_to_text_range(text, &range).unwrap(); assert_eq!(text_range.start(), TextSize::from(13u32)); assert_eq!(text_range.end(), TextSize::from(20u32)); } #[test] fn test_try_lsp_range_to_text_range_invalid_returns_none() { let text = "Source: test\n"; let range = Range::new(Position::new(10, 0), Position::new(10, 1)); assert!(idx(text) .try_lsp_range_to_text_range(text, &range) .is_none()); } #[test] fn test_offset_to_position_with_multibyte_chars() { // 'ij' is U+0133: 2 bytes in UTF-8, 1 code unit in UTF-16 let text = "Vernooij rest"; let i = idx(text); // 'V' at byte 0 -> col 0 assert_eq!( i.offset_to_position(text, TextSize::from(0u32)), Position::new(0, 0) ); // 'ij' starts at byte 6, col 6 assert_eq!( i.offset_to_position(text, TextSize::from(6u32)), Position::new(0, 6) ); // ' ' after 'ij' is at byte 8, but UTF-16 col 7 assert_eq!( i.offset_to_position(text, TextSize::from(8u32)), Position::new(0, 7) ); // 'r' of "rest" at byte 9, UTF-16 col 8 assert_eq!( i.offset_to_position(text, TextSize::from(9u32)), Position::new(0, 8) ); } #[test] fn test_try_position_to_offset_with_multibyte_chars() { let text = "Vernooij rest"; let i = idx(text); // col 7 in UTF-16 -> byte 8 (the space after 'ij') let offset = i.try_position_to_offset(text, Position::new(0, 7)).unwrap(); assert_eq!(offset, TextSize::from(8u32)); // col 8 in UTF-16 -> byte 9 ('r') let offset = i.try_position_to_offset(text, Position::new(0, 8)).unwrap(); assert_eq!(offset, TextSize::from(9u32)); } #[test] fn test_utf16_len() { assert_eq!(utf16_len("hello"), 5); assert_eq!(utf16_len("Vernooij"), 7); // ij is 1 UTF-16 code unit assert_eq!(utf16_len(""), 0); // Emoji 😀 (U+1F600) is 2 UTF-16 code units (surrogate pair) assert_eq!(utf16_len("😀"), 2); } #[test] fn line_index_handles_empty_buffer() { let i = LineIndex::new(""); assert_eq!( i.offset_to_position("", TextSize::from(0u32)), Position::new(0, 0) ); assert!(i.try_position_to_offset("", Position::new(1, 0)).is_none()); } #[test] fn line_index_round_trips_offsets() { // For every byte boundary in a few representative buffers, // `offset → position → offset` should round-trip. for text in [ "", "no newline", "one\nline", "trailing newline\n", "Source: test\nSection: py\n", "Vernooij\n middle\n", ] { let i = LineIndex::new(text); for byte_off in 0..=text.len() { if !text.is_char_boundary(byte_off) { continue; } let off = TextSize::try_from(byte_off).unwrap(); let pos = i.offset_to_position(text, off); let back = i .try_position_to_offset(text, pos) .expect("position from offset_to_position must round-trip"); // If the offset lands on a newline, the position is // really the end-of-line-content for the previous // line, so try_position_to_offset returns the start // of the newline, not after it. Allow that one case. if back != off { let was_at_newline = text.as_bytes().get(byte_off.saturating_sub(1)) == Some(&b'\n'); assert!( was_at_newline, "round-trip mismatch in {:?}: off={} pos={:?} back={}", text, byte_off, pos, usize::from(back) ); } } } } /// Splice helper used by the tests below: applies `byte_range` /// →`new_text` to both the LineIndex (incrementally) and a /// freshly-rebuilt LineIndex, returning both for comparison. fn splice_and_rebuild( original: &str, byte_range: std::ops::Range, new_text: &str, ) -> (String, LineIndex, LineIndex) { let mut spliced = LineIndex::new(original); spliced.splice(byte_range.clone(), new_text); let mut text = String::from(original); text.replace_range(byte_range, new_text); let rebuilt = LineIndex::new(&text); (text, spliced, rebuilt) } #[test] fn splice_single_char_insert_no_newline() { // "Source: foo" → "Source: foox" let (_, spliced, rebuilt) = splice_and_rebuild("Source: foo", 11..11, "x"); assert_eq!(spliced, rebuilt); } #[test] fn splice_single_char_insert_into_middle_of_line() { // Edit on line 2; lines after the edit should shift but // line_starts_count stays the same. let (_, spliced, rebuilt) = splice_and_rebuild("aa\nbb\ncc\n", 4..4, "X"); assert_eq!(spliced, rebuilt); } #[test] fn splice_insert_newline() { // Insert a newline mid-line. The line count should grow by 1. let (text, spliced, rebuilt) = splice_and_rebuild("aaaa", 2..2, "\n"); assert_eq!(text, "aa\naa"); assert_eq!(spliced, rebuilt); } #[test] fn splice_insert_multiple_newlines() { let (_, spliced, rebuilt) = splice_and_rebuild("aaaa", 2..2, "\n\n\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_delete_inside_line() { let (text, spliced, rebuilt) = splice_and_rebuild("Source: foobar\n", 8..11, ""); assert_eq!(text, "Source: bar\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_delete_across_newline() { // Delete "b\nc", merging two lines into one. let (text, spliced, rebuilt) = splice_and_rebuild("aab\ncdd\n", 2..5, ""); assert_eq!(text, "aadd\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_replace_spanning_multiple_lines() { // Replace "b\ncc\n" → "X" — collapse 3 lines into 2. let (text, spliced, rebuilt) = splice_and_rebuild("aab\ncc\nde\n", 2..7, "X"); assert_eq!(text, "aaXde\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_replace_with_newline_growth() { let (text, spliced, rebuilt) = splice_and_rebuild("aa", 1..1, "X\nY"); assert_eq!(text, "aX\nYa"); assert_eq!(spliced, rebuilt); } #[test] fn splice_at_start_of_buffer() { let (text, spliced, rebuilt) = splice_and_rebuild("aaa\nbbb\n", 0..0, "Z\n"); assert_eq!(text, "Z\naaa\nbbb\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_at_end_of_buffer() { let (text, spliced, rebuilt) = splice_and_rebuild("aaa\nbbb\n", 8..8, "Z"); assert_eq!(text, "aaa\nbbb\nZ"); assert_eq!(spliced, rebuilt); } #[test] fn splice_at_exact_line_boundary_keeps_line() { // Insert at byte 4 (start of "bbb" line). The new content // pushes "bbb" forward but shouldn't drop the line break // before it. let (text, spliced, rebuilt) = splice_and_rebuild("aaa\nbbb\n", 4..4, "X"); assert_eq!(text, "aaa\nXbbb\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_replacing_just_a_newline() { // Replace "\n" with " " — joins two lines. let (text, spliced, rebuilt) = splice_and_rebuild("aaa\nbbb\n", 3..4, " "); assert_eq!(text, "aaa bbb\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_full_replacement_with_no_newlines() { let (text, spliced, rebuilt) = splice_and_rebuild("aa\nbb\ncc", 0..8, "x"); assert_eq!(text, "x"); assert_eq!(spliced, rebuilt); } #[test] fn splice_into_empty_buffer() { let (text, spliced, rebuilt) = splice_and_rebuild("", 0..0, "hello\nworld\n"); assert_eq!(text, "hello\nworld\n"); assert_eq!(spliced, rebuilt); } #[test] fn splice_chained_edits_match_full_rebuild() { // Apply a sequence of edits to the same index and to a // running rebuilt index. Every step the two should agree. let mut text = String::from("Source: foo\nMaintainer: A B \n"); let mut spliced = LineIndex::new(&text); let edits: &[(std::ops::Range, &str)] = &[ (8..11, "bar"), // insert mid-line, no newline (12..12, "Section: misc\n"), // insert a whole new line (0..7, "Package"), // edit start of buffer (text.len()..text.len(), "X"), // append at end (placeholder, fixed below) ]; for (range, replacement) in edits { // Re-evaluate the "append at end" range against the // current text length. let range = if range.start == range.end && range.start >= text.len() { text.len()..text.len() } else { range.clone() }; spliced.splice(range.clone(), replacement); text.replace_range(range, replacement); let rebuilt = LineIndex::new(&text); assert_eq!( spliced, rebuilt, "spliced and rebuilt diverged after edits, text={:?}", text ); } } } debian-lsp-0.1.8/src/rdeps.rs000064400000000000000000000104231046102023000141220ustar 00000000000000//! Reverse dependency counts from UDD (Ultimate Debian Database). //! //! Queries the `depends` table to count how many packages depend on a given package. use std::num::NonZeroUsize; use std::sync::Arc; use lru::LruCache; use tokio::sync::RwLock; /// Thread-safe shared cache for reverse dependency lookups. pub type SharedRdepsCache = Arc>; /// Maximum number of distinct packages cached. Each rdeps lookup is /// expensive (LIKE scan over `all_packages`), so the cap is generous; /// the LRU exists to bound memory over very long sessions. const RDEPS_CACHE_CAPACITY: usize = 4096; /// Cached reverse dependency counts from UDD. pub struct RdepsCache { pool: crate::udd::SharedPool, /// Map from package name to reverse dependency count. `None` means "looked up, not found". count_by_package: LruCache>, } #[derive(sqlx::FromRow)] struct RdepsRow { count: Option, } impl RdepsCache { /// Create a new reverse dependencies cache using the given UDD connection pool. pub fn new(pool: crate::udd::SharedPool) -> Self { Self { pool, count_by_package: LruCache::new( NonZeroUsize::new(RDEPS_CACHE_CAPACITY).expect("non-zero capacity"), ), } } /// Look up the reverse dependency count for a package, fetching if needed. /// /// Returns `None` if the package is not found or the query fails. pub async fn get_rdeps_count(&mut self, package: &str) -> Option { if !self.count_by_package.contains(package) { self.fetch_rdeps_count(package).await; } self.count_by_package.get(package).and_then(|v| *v) } /// Look up the reverse dependency count from cache only, without fetching. /// Does not promote the entry in the LRU. /// /// Returns `None` if the package has not been fetched yet or was not found. pub fn get_cached_rdeps_count(&self, package: &str) -> Option { self.count_by_package.peek(package).and_then(|v| *v) } /// Returns `true` if this package has been looked up (hit or miss). pub fn is_cached(&self, package: &str) -> bool { self.count_by_package.contains(package) } async fn fetch_rdeps_count(&mut self, package: &str) { let row: Option = match sqlx::query_as( "SELECT COUNT(DISTINCT source) AS count FROM all_packages \ WHERE depends LIKE '%' || $1 || '%' AND release = 'sid'", ) .bind(package) .fetch_optional(&*self.pool) .await { Ok(row) => row, Err(e) => { tracing::warn!(package, error = %e, "UDD rdeps query failed"); return; } }; match row { Some(RdepsRow { count: Some(n) }) => { self.count_by_package .put(package.to_string(), u32::try_from(n).ok()); } _ => { self.count_by_package.put(package.to_string(), None); } } } /// Insert a cached entry for testing purposes. #[cfg(test)] pub(crate) fn insert_cached(&mut self, package: &str, count: u32) { self.count_by_package.put(package.to_string(), Some(count)); } /// Insert a cached "not found" entry for testing purposes. #[cfg(test)] pub(crate) fn insert_cached_missing(&mut self, package: &str) { self.count_by_package.put(package.to_string(), None); } } /// Create a new shared reverse dependencies cache. pub fn new_shared_rdeps_cache(pool: crate::udd::SharedPool) -> SharedRdepsCache { Arc::new(RwLock::new(RdepsCache::new(pool))) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_rdeps_count_from_cache() { let mut cache = RdepsCache::new(crate::udd::shared_pool()); cache.insert_cached("libc6", 15000); let count = cache.get_rdeps_count("libc6").await; assert_eq!(count, Some(15000)); } #[tokio::test] async fn test_get_rdeps_count_unknown_package() { let mut cache = RdepsCache::new(crate::udd::shared_pool()); cache.insert_cached_missing("nonexistent-package-xyz"); let count = cache.get_rdeps_count("nonexistent-package-xyz").await; assert_eq!(count, None); } } debian-lsp-0.1.8/src/rules/completion.rs000064400000000000000000000101421046102023000163060ustar 00000000000000use makefile_lossless::Makefile; use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position}; use super::fields::{RULES_TARGETS, RULES_VARIABLES}; /// Get completions for a debian/rules file at the given position. pub fn get_completions( makefile: &Makefile, source_text: &str, position: Position, ) -> Vec { let lines: Vec<&str> = source_text.lines().collect(); let line = lines.get(position.line as usize).copied().unwrap_or(""); // At column 0 on an empty line, offer target completions if position.character == 0 && line.trim().is_empty() { return get_target_completions(makefile); } // If the line starts with a tab, we're in a recipe — no completions for now if line.starts_with('\t') { return vec![]; } // If the line looks like a variable assignment prefix, offer variable completions if position.character > 0 && !line.contains('=') && !line.contains(':') { return get_variable_completions(); } vec![] } /// Generate target name completions, excluding targets already defined. fn get_target_completions(makefile: &Makefile) -> Vec { let existing_targets: Vec = makefile .rules() .flat_map(|r| r.targets().collect::>()) .collect(); RULES_TARGETS .iter() .filter(|target| !existing_targets.iter().any(|t| t == target.name)) .map(|target| { let detail = if target.required { format!("{} (required)", target.description) } else { target.description.to_string() }; CompletionItem { label: target.name.to_string(), kind: Some(CompletionItemKind::FUNCTION), detail: Some(detail), insert_text: Some(format!("{}:\n\t", target.name)), ..Default::default() } }) .collect() } /// Generate variable name completions. fn get_variable_completions() -> Vec { RULES_VARIABLES .iter() .map(|var| CompletionItem { label: var.name.to_string(), kind: Some(CompletionItemKind::VARIABLE), detail: Some(var.description.to_string()), insert_text: Some(format!("{} = ", var.name)), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_completions_empty_line() { let text = "#!/usr/bin/make -f\n\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let completions = get_completions(&makefile, text, Position::new(1, 0)); assert!(!completions.is_empty()); assert!(completions.iter().any(|c| c.label == "clean")); assert!(completions.iter().any(|c| c.label == "build")); } #[test] fn test_completions_exclude_existing_targets() { let text = "clean:\n\trm -rf build\n\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let completions = get_completions(&makefile, text, Position::new(2, 0)); // "clean" should not be offered since it already exists assert!(!completions.iter().any(|c| c.label == "clean")); // But "build" should still be offered assert!(completions.iter().any(|c| c.label == "build")); } #[test] fn test_completions_in_recipe() { let text = "clean:\n\t"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let completions = get_completions(&makefile, text, Position::new(1, 1)); assert!(completions.is_empty()); } #[test] fn test_completions_target_insert_text() { let text = "\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let completions = get_completions(&makefile, text, Position::new(0, 0)); let clean = completions.iter().find(|c| c.label == "clean").unwrap(); assert_eq!(clean.insert_text.as_deref(), Some("clean:\n\t")); assert_eq!(clean.kind, Some(CompletionItemKind::FUNCTION)); } } debian-lsp-0.1.8/src/rules/detection.rs000064400000000000000000000021421046102023000161140ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a debian/rules file. pub fn is_rules_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/debian/rules") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_rules_file() { let valid_paths = vec![ "file:///path/to/debian/rules", "file:///project/debian/rules", ]; let invalid_paths = vec![ "file:///path/to/other.txt", "file:///path/to/debian/control", "file:///path/to/debian/copyright", "file:///path/to/rules", "file:///path/to/debian/rules.bak", ]; for path in valid_paths { let uri = path.parse::().unwrap(); assert!(is_rules_file(&uri), "Should detect rules file: {}", path); } for path in invalid_paths { let uri = path.parse::().unwrap(); assert!( !is_rules_file(&uri), "Should not detect as rules file: {}", path ); } } } debian-lsp-0.1.8/src/rules/fields.rs000064400000000000000000000142421046102023000154100ustar 00000000000000/// A known debian/rules target. pub struct RulesTarget { pub name: &'static str, pub description: &'static str, pub required: bool, } impl RulesTarget { pub const fn new(name: &'static str, description: &'static str, required: bool) -> Self { Self { name, description, required, } } } /// A known debian/rules variable. pub struct RulesVariable { pub name: &'static str, pub description: &'static str, } impl RulesVariable { pub const fn new(name: &'static str, description: &'static str) -> Self { Self { name, description } } } /// Standard debian/rules targets as defined by Debian Policy. pub const RULES_TARGETS: &[RulesTarget] = &[ RulesTarget::new("clean", "Clean up the build tree", true), RulesTarget::new("build", "Build the package", true), RulesTarget::new("build-arch", "Build architecture-dependent files", true), RulesTarget::new("build-indep", "Build architecture-independent files", true), RulesTarget::new("binary", "Build all binary packages", true), RulesTarget::new( "binary-arch", "Build architecture-dependent binary packages", true, ), RulesTarget::new( "binary-indep", "Build architecture-independent binary packages", true, ), RulesTarget::new( "install", "Install files into the package build directory", false, ), RulesTarget::new( "get-orig-source", "Get the original upstream source tarball", false, ), RulesTarget::new( "override_dh_auto_configure", "Override dh_auto_configure step", false, ), RulesTarget::new( "override_dh_auto_build", "Override dh_auto_build step", false, ), RulesTarget::new("override_dh_auto_test", "Override dh_auto_test step", false), RulesTarget::new( "override_dh_auto_install", "Override dh_auto_install step", false, ), RulesTarget::new( "override_dh_auto_clean", "Override dh_auto_clean step", false, ), RulesTarget::new("override_dh_install", "Override dh_install step", false), RulesTarget::new( "override_dh_gencontrol", "Override dh_gencontrol step", false, ), RulesTarget::new("override_dh_shlibdeps", "Override dh_shlibdeps step", false), RulesTarget::new("override_dh_strip", "Override dh_strip step", false), RulesTarget::new( "execute_before_dh_auto_configure", "Execute before dh_auto_configure step", false, ), RulesTarget::new( "execute_after_dh_auto_configure", "Execute after dh_auto_configure step", false, ), RulesTarget::new( "execute_before_dh_auto_build", "Execute before dh_auto_build step", false, ), RulesTarget::new( "execute_after_dh_auto_build", "Execute after dh_auto_build step", false, ), RulesTarget::new( "execute_before_dh_auto_test", "Execute before dh_auto_test step", false, ), RulesTarget::new( "execute_after_dh_auto_test", "Execute after dh_auto_test step", false, ), RulesTarget::new( "execute_before_dh_auto_install", "Execute before dh_auto_install step", false, ), RulesTarget::new( "execute_after_dh_auto_install", "Execute after dh_auto_install step", false, ), RulesTarget::new( "execute_before_dh_auto_clean", "Execute before dh_auto_clean step", false, ), RulesTarget::new( "execute_after_dh_auto_clean", "Execute after dh_auto_clean step", false, ), ]; /// Common debian/rules variables. pub const RULES_VARIABLES: &[RulesVariable] = &[ RulesVariable::new( "DEB_BUILD_OPTIONS", "Build options (nocheck, nostrip, parallel, etc.)", ), RulesVariable::new( "DEB_BUILD_MAINT_OPTIONS", "Maintainer build options (hardening, reproducible, etc.)", ), RulesVariable::new( "DEB_HOST_GNU_TYPE", "GNU system type for the host architecture", ), RulesVariable::new( "DEB_BUILD_GNU_TYPE", "GNU system type for the build architecture", ), RulesVariable::new( "DEB_HOST_MULTIARCH", "Multiarch triplet for the host architecture", ), RulesVariable::new( "DPKG_EXPORT_BUILDFLAGS", "Export dpkg build flags to the environment", ), RulesVariable::new("DEB_BUILD_HARDENING", "Enable hardening build flags"), ]; /// Check if a target name is a known standard target. pub fn is_known_target(name: &str) -> bool { if RULES_TARGETS.iter().any(|t| t.name == name) { return true; } // Also recognize override_ and execute_{before,after}_ prefixed targets name.starts_with("override_dh_") || name.starts_with("execute_before_dh_") || name.starts_with("execute_after_dh_") } #[cfg(test)] mod tests { use super::*; #[test] fn test_known_targets() { assert!(is_known_target("clean")); assert!(is_known_target("build")); assert!(is_known_target("binary")); assert!(is_known_target("binary-arch")); assert!(is_known_target("binary-indep")); assert!(is_known_target("override_dh_auto_build")); assert!(is_known_target("override_dh_fixperms")); assert!(is_known_target("execute_before_dh_auto_test")); assert!(is_known_target("execute_after_dh_auto_install")); } #[test] fn test_unknown_targets() { assert!(!is_known_target("my-custom-target")); assert!(!is_known_target("foo")); } #[test] fn test_rules_targets_not_empty() { assert!(!RULES_TARGETS.is_empty()); for target in RULES_TARGETS { assert!(!target.name.is_empty()); assert!(!target.description.is_empty()); } } #[test] fn test_rules_variables_not_empty() { assert!(!RULES_VARIABLES.is_empty()); for var in RULES_VARIABLES { assert!(!var.name.is_empty()); assert!(!var.description.is_empty()); } } } debian-lsp-0.1.8/src/rules/mod.rs000064400000000000000000000004671046102023000147250ustar 00000000000000//! Module for handling debian/rules files. //! //! These files are Makefiles that define how to build a Debian package. pub mod completion; pub mod detection; pub mod fields; pub mod semantic; pub use completion::get_completions; pub use detection::is_rules_file; pub use semantic::generate_semantic_tokens; debian-lsp-0.1.8/src/rules/semantic.rs000064400000000000000000000133211046102023000157420ustar 00000000000000//! Semantic token generation for debian/rules files. use makefile_lossless::{Makefile, SyntaxKind}; use rowan::ast::AstNode; use tower_lsp_server::ls_types::SemanticToken; use super::fields::is_known_target; use crate::deb822::semantic::{SemanticTokensBuilder, TokenType}; use crate::position::utf16_len; use crate::position::Source; /// Generate semantic tokens for a debian/rules file. pub fn generate_semantic_tokens(makefile: &Makefile, src: Source<'_>) -> Vec { let mut builder = SemanticTokensBuilder::new(); for element in makefile.syntax().descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = element { match token.kind() { SyntaxKind::COMMENT => { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = utf16_len(token.text()); builder.push( start_pos.line, start_pos.character, length, TokenType::Comment, 0, ); } SyntaxKind::IDENTIFIER => { // Check parent node to determine context if let Some(parent) = token.parent() { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = utf16_len(token.text()); let text = token.text(); match parent.kind() { SyntaxKind::TARGETS => { let token_type = if is_known_target(text) { TokenType::Field } else { TokenType::UnknownField }; builder.push( start_pos.line, start_pos.character, length, token_type, crate::deb822::semantic::token_modifier::DECLARATION, ); } SyntaxKind::VARIABLE => { builder.push( start_pos.line, start_pos.character, length, TokenType::Field, crate::deb822::semantic::token_modifier::DECLARATION, ); } _ => {} } } } _ => {} } } } builder.build() } #[cfg(test)] mod tests { use super::*; #[test] fn test_known_target() { let text = "clean:\n\trm -rf build\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(!tokens.is_empty()); // "clean" is a known target assert_eq!(tokens[0].token_type, TokenType::Field as u32); } #[test] fn test_unknown_target() { let text = "my-custom-target:\n\techo hello\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(!tokens.is_empty()); // "my-custom-target" is not a known target assert_eq!(tokens[0].token_type, TokenType::UnknownField as u32); } #[test] fn test_variable_definition() { let text = "PYTHON = python3\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(!tokens.is_empty()); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!( tokens[0].token_modifiers_bitset, crate::deb822::semantic::token_modifier::DECLARATION ); } #[test] fn test_comment() { let text = "# This is a comment\nclean:\n\trm -rf build\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(!tokens.is_empty()); assert_eq!(tokens[0].token_type, TokenType::Comment as u32); } #[test] fn test_empty_file() { let text = ""; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(tokens.is_empty()); } #[test] fn test_override_target() { let text = "override_dh_auto_build:\n\tdh_auto_build -- --verbose\n"; let parsed = Makefile::parse(text); let makefile = parsed.tree(); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&makefile, Source::new(text, &idx)); assert!(!tokens.is_empty()); // override_dh_auto_build is a known target assert_eq!(tokens[0].token_type, TokenType::Field as u32); } } debian-lsp-0.1.8/src/source_format/completion.rs000064400000000000000000000033251046102023000200310ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position, Uri}; use super::detection::is_source_format_file; use super::fields::SOURCE_FORMATS; /// Get completion items for debian/source/format file pub fn get_completions(_uri: &Uri, _position: Position) -> Vec { if !is_source_format_file(_uri) { return Vec::new(); } SOURCE_FORMATS .iter() .map(|(format, description)| CompletionItem { label: (*format).to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some((*description).to_string()), insert_text: Some((*format).to_string()), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_completions() { let uri = str::parse("file:///tmp/debian/source/format").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position); assert_eq!(completions.len(), 5); assert!(completions .iter() .any(|c| c.label == "3.0 (quilt)" && c.kind == Some(CompletionItemKind::VALUE))); assert!(completions .iter() .any(|c| c.label == "3.0 (native)" && c.kind == Some(CompletionItemKind::VALUE))); assert!(completions .iter() .any(|c| c.label == "1.0" && c.kind == Some(CompletionItemKind::VALUE))); } #[test] fn test_get_completions_non_format_file() { let uri = str::parse("file:///tmp/debian/control").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position); assert_eq!(completions.len(), 0); } } debian-lsp-0.1.8/src/source_format/detection.rs000064400000000000000000000014661046102023000176420ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if the given URI points to a debian/source/format file pub fn is_source_format_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/source/format") || path.ends_with("/debian/source/format") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_source_format_file() { assert!(is_source_format_file( &str::parse("file:///tmp/debian/source/format").unwrap() )); assert!(is_source_format_file( &str::parse("file:///tmp/source/format").unwrap() )); assert!(!is_source_format_file( &str::parse("file:///tmp/debian/control").unwrap() )); assert!(!is_source_format_file( &str::parse("file:///tmp/format").unwrap() )); } } debian-lsp-0.1.8/src/source_format/fields.rs000064400000000000000000000007011046102023000171210ustar 00000000000000/// Valid source format values for debian/source/format pub const SOURCE_FORMATS: &[(&str, &str)] = &[ ( "3.0 (quilt)", "Source format with quilt-based patches (recommended)", ), ( "3.0 (native)", "Source format for native Debian packages (no upstream)", ), ("1.0", "Legacy source format"), ("3.0 (git)", "Source format using git repository"), ("3.0 (custom)", "Custom source format"), ]; debian-lsp-0.1.8/src/source_format/mod.rs000064400000000000000000000001711046102023000164330ustar 00000000000000pub mod completion; pub mod detection; pub mod fields; pub use completion::*; pub use detection::is_source_format_file; debian-lsp-0.1.8/src/source_options/completion.rs000064400000000000000000000140771046102023000202420ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, Position, Uri}; use super::detection::{is_source_local_options_file, is_source_options_or_local_options_file}; use super::fields::{COMPRESSION_LEVEL_VALUES, COMPRESSION_VALUES, SOURCE_OPTIONS}; /// Get completion items for debian/source/options or debian/source/local-options file pub fn get_completions(uri: &Uri, position: Position, source_text: &str) -> Vec { if !is_source_options_or_local_options_file(uri) { return Vec::new(); } let is_local = is_source_local_options_file(uri); // Find the current line text let line_text = source_text .lines() .nth(position.line as usize) .unwrap_or(""); // Skip comment lines if line_text.trim_start().starts_with('#') { return Vec::new(); } // Check if we're completing a value (after '=') if let Some(eq_pos) = line_text.find('=') { let option_name = line_text[..eq_pos].trim(); let value_part = line_text[eq_pos + 1..].trim().trim_matches('"'); // Only provide value completions if cursor is after the '=' if (position.character as usize) > eq_pos { return get_value_completions(option_name, value_part); } } // Complete option names SOURCE_OPTIONS .iter() .filter(|opt| is_local || opt.allowed_in_options) .map(|opt| { let insert_text = if opt.takes_value { format!("{} = ", opt.name) } else { opt.name.to_string() }; CompletionItem { label: opt.name.to_string(), kind: Some(CompletionItemKind::PROPERTY), detail: Some(opt.description.to_string()), insert_text: Some(insert_text), ..Default::default() } }) .collect() } /// Get value completions for a specific option fn get_value_completions(option_name: &str, _prefix: &str) -> Vec { let values: &[(&str, &str)] = match option_name { "compression" => COMPRESSION_VALUES, "compression-level" => COMPRESSION_LEVEL_VALUES, _ => return Vec::new(), }; values .iter() .map(|(value, description)| CompletionItem { label: (*value).to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some((*description).to_string()), insert_text: Some((*value).to_string()), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_completions_option_names() { let uri = str::parse("file:///tmp/debian/source/options").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position, ""); assert!(!completions.is_empty()); assert!(completions .iter() .any(|c| c.label == "compression" && c.kind == Some(CompletionItemKind::PROPERTY))); assert!(completions.iter().any(|c| c.label == "single-debian-patch")); // abort-on-upstream-changes is local-options only assert!(!completions .iter() .any(|c| c.label == "abort-on-upstream-changes")); } #[test] fn test_get_completions_local_options_includes_all() { let uri = str::parse("file:///tmp/debian/source/local-options").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position, ""); assert!(completions .iter() .any(|c| c.label == "abort-on-upstream-changes")); assert!(completions.iter().any(|c| c.label == "compression")); } #[test] fn test_get_completions_compression_values() { let uri = str::parse("file:///tmp/debian/source/options").unwrap(); let position = Position::new(0, 15); let source_text = "compression = "; let completions = get_completions(&uri, position, source_text); assert_eq!(completions.len(), COMPRESSION_VALUES.len()); assert!(completions .iter() .any(|c| c.label == "xz" && c.kind == Some(CompletionItemKind::VALUE))); assert!(completions.iter().any(|c| c.label == "gzip")); } #[test] fn test_get_completions_compression_level_values() { let uri = str::parse("file:///tmp/debian/source/options").unwrap(); let position = Position::new(0, 22); let source_text = "compression-level = "; let completions = get_completions(&uri, position, source_text); assert_eq!(completions.len(), COMPRESSION_LEVEL_VALUES.len()); assert!(completions.iter().any(|c| c.label == "9")); assert!(completions.iter().any(|c| c.label == "best")); } #[test] fn test_get_completions_comment_line() { let uri = str::parse("file:///tmp/debian/source/options").unwrap(); let position = Position::new(0, 5); let source_text = "# comment"; let completions = get_completions(&uri, position, source_text); assert!(completions.is_empty()); } #[test] fn test_get_completions_non_options_file() { let uri = str::parse("file:///tmp/debian/control").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position, ""); assert!(completions.is_empty()); } #[test] fn test_value_options_have_equals_in_insert_text() { let uri = str::parse("file:///tmp/debian/source/options").unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position, ""); let compression = completions .iter() .find(|c| c.label == "compression") .unwrap(); assert_eq!(compression.insert_text.as_deref(), Some("compression = ")); let single_patch = completions .iter() .find(|c| c.label == "single-debian-patch") .unwrap(); assert_eq!( single_patch.insert_text.as_deref(), Some("single-debian-patch") ); } } debian-lsp-0.1.8/src/source_options/detection.rs000064400000000000000000000045231046102023000200420ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if the given URI points to a debian/source/options file pub fn is_source_options_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/source/options") || path.ends_with("/debian/source/options") } /// Check if the given URI points to a debian/source/local-options file pub fn is_source_local_options_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/source/local-options") || path.ends_with("/debian/source/local-options") } /// Check if the given URI points to a debian/source/options or local-options file pub fn is_source_options_or_local_options_file(uri: &Uri) -> bool { is_source_options_file(uri) || is_source_local_options_file(uri) } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_source_options_file() { assert!(is_source_options_file( &str::parse("file:///tmp/debian/source/options").unwrap() )); assert!(is_source_options_file( &str::parse("file:///tmp/source/options").unwrap() )); assert!(!is_source_options_file( &str::parse("file:///tmp/debian/control").unwrap() )); assert!(!is_source_options_file( &str::parse("file:///tmp/options").unwrap() )); assert!(!is_source_options_file( &str::parse("file:///tmp/debian/source/local-options").unwrap() )); } #[test] fn test_is_source_local_options_file() { assert!(is_source_local_options_file( &str::parse("file:///tmp/debian/source/local-options").unwrap() )); assert!(is_source_local_options_file( &str::parse("file:///tmp/source/local-options").unwrap() )); assert!(!is_source_local_options_file( &str::parse("file:///tmp/debian/source/options").unwrap() )); } #[test] fn test_is_source_options_or_local_options_file() { assert!(is_source_options_or_local_options_file( &str::parse("file:///tmp/debian/source/options").unwrap() )); assert!(is_source_options_or_local_options_file( &str::parse("file:///tmp/debian/source/local-options").unwrap() )); assert!(!is_source_options_or_local_options_file( &str::parse("file:///tmp/debian/control").unwrap() )); } } debian-lsp-0.1.8/src/source_options/fields.rs000064400000000000000000000141721046102023000173330ustar 00000000000000/// A dpkg-source option definition. pub struct SourceOption { /// The long option name (without leading --) pub name: &'static str, /// Description of the option pub description: &'static str, /// Whether the option takes a value pub takes_value: bool, /// Whether the option is allowed in debian/source/options (some are local-options only) pub allowed_in_options: bool, } /// Valid long options for debian/source/options and debian/source/local-options. /// /// These are the options that can be specified in the options files. /// The leading `--` should be stripped in the file. pub const SOURCE_OPTIONS: &[SourceOption] = &[ SourceOption { name: "compression", description: "Select compression to use (supported: bzip2, gzip, lzma, xz)", takes_value: true, allowed_in_options: true, }, SourceOption { name: "compression-level", description: "Compression level to use (1-9, best, fast)", takes_value: true, allowed_in_options: true, }, SourceOption { name: "threads-max", description: "Use at most this many threads with the compressor", takes_value: true, allowed_in_options: true, }, SourceOption { name: "diff-ignore", description: "Perl regex to filter out files from diff generation", takes_value: true, allowed_in_options: true, }, SourceOption { name: "extend-diff-ignore", description: "Extend the default diff-ignore regex with additional pattern", takes_value: true, allowed_in_options: true, }, SourceOption { name: "tar-ignore", description: "Pattern passed to tar's --exclude when generating tarballs", takes_value: true, allowed_in_options: true, }, // Format 3.0 (quilt) build options SourceOption { name: "single-debian-patch", description: "Use debian/patches/debian-changes as automatic patch", takes_value: false, allowed_in_options: true, }, SourceOption { name: "create-empty-orig", description: "Create an empty original tarball if missing and format permits", takes_value: false, allowed_in_options: true, }, SourceOption { name: "no-unapply-patches", description: "Do not unapply patches after build", takes_value: false, allowed_in_options: true, }, SourceOption { name: "unapply-patches", description: "Unapply patches after build (default)", takes_value: false, allowed_in_options: true, }, // Format 3.0 (quilt) options only in local-options SourceOption { name: "abort-on-upstream-changes", description: "Fail if an automatic patch has been generated", takes_value: false, allowed_in_options: false, }, SourceOption { name: "auto-commit", description: "Automatically record generated patches in the quilt series", takes_value: false, allowed_in_options: true, }, // Generic build options SourceOption { name: "include-removal", description: "Include removed files in the diff (format 1.0)", takes_value: false, allowed_in_options: true, }, SourceOption { name: "include-timestamp", description: "Include file timestamps in the diff (format 1.0)", takes_value: false, allowed_in_options: true, }, SourceOption { name: "include-binaries", description: "Include binary files in the debian tarball", takes_value: false, allowed_in_options: true, }, SourceOption { name: "no-preparation", description: "Do not prepare the build tree", takes_value: false, allowed_in_options: true, }, SourceOption { name: "no-check", description: "Do not check signature and checksums before unpacking", takes_value: false, allowed_in_options: true, }, SourceOption { name: "no-copy", description: "Do not copy original tarballs near the source package", takes_value: false, allowed_in_options: true, }, SourceOption { name: "no-overwrite-dir", description: "Do not overwrite the extraction directory if it exists", takes_value: false, allowed_in_options: true, }, SourceOption { name: "require-valid-signature", description: "Abort if the package does not have a valid signature", takes_value: false, allowed_in_options: true, }, SourceOption { name: "require-strong-checksums", description: "Abort if the package contains no strong checksums", takes_value: false, allowed_in_options: true, }, SourceOption { name: "ignore-bad-version", description: "Allow bad source package versions", takes_value: false, allowed_in_options: true, }, SourceOption { name: "skip-debianization", description: "Do not apply debian diff to upstream sources (format 1.0/3.0 quilt)", takes_value: false, allowed_in_options: true, }, SourceOption { name: "skip-patches", description: "Do not apply patches at the end of extraction (format 3.0 quilt)", takes_value: false, allowed_in_options: true, }, ]; /// Valid values for the --compression option pub const COMPRESSION_VALUES: &[(&str, &str)] = &[ ("xz", "XZ compression (default)"), ("gzip", "Gzip compression"), ("bzip2", "Bzip2 compression"), ("lzma", "LZMA compression"), ]; /// Valid values for the --compression-level option pub const COMPRESSION_LEVEL_VALUES: &[(&str, &str)] = &[ ("1", "Fastest compression"), ("2", "Level 2 compression"), ("3", "Level 3 compression"), ("4", "Level 4 compression"), ("5", "Level 5 compression"), ("6", "Default compression level"), ("7", "Level 7 compression"), ("8", "Level 8 compression"), ("9", "Best compression"), ("best", "Best compression (alias for 9)"), ("fast", "Fastest compression (alias for 1)"), ]; debian-lsp-0.1.8/src/source_options/mod.rs000064400000000000000000000004441046102023000166410ustar 00000000000000pub mod completion; pub mod detection; pub mod fields; pub mod selection_range; pub mod semantic; pub use completion::get_completions; pub use detection::is_source_options_or_local_options_file; pub use selection_range::generate_selection_ranges; pub use semantic::generate_semantic_tokens; debian-lsp-0.1.8/src/source_options/selection_range.rs000064400000000000000000000074771046102023000212400ustar 00000000000000//! Selection range generation for debian/source/options files. //! //! Hierarchy: current line → file. use crate::position::Source; use text_size::TextSize; use tower_lsp_server::ls_types::{Position, Range, SelectionRange}; /// Generate selection ranges for a debian/source/options file. pub fn generate_selection_ranges(src: Source<'_>, positions: &[Position]) -> Vec { let file_range = Range::new( Position::new(0, 0), src.offset_to_position(TextSize::from(src.text.len() as u32)), ); positions .iter() .map(|pos| { let file_sel = SelectionRange { range: file_range, parent: None, }; let line = pos.line as usize; let line_text = match src.text.lines().nth(line) { Some(t) => t, None => return file_sel, }; if line_text.trim().is_empty() { return file_sel; } // Calculate byte offset of the line start let line_start: usize = src .text .lines() .take(line) .map(|l| l.len() + 1) // +1 for newline .sum(); let line_range = Range::new( src.offset_to_position(TextSize::from(line_start as u32)), src.offset_to_position(TextSize::from((line_start + line_text.len()) as u32)), ); SelectionRange { range: line_range, parent: Some(Box::new(file_sel)), } }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_selection_on_option_line() { let text = "compression = xz\nsingle-debian-patch\n"; let idx = crate::position::LineIndex::new(text); let ranges = generate_selection_ranges(Source::new(text, &idx), &[Position::new(0, 5)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Line range assert_eq!(sel.range.start.line, 0); assert_eq!(sel.range.end.line, 0); // Parent: file let file_sel = sel.parent.as_ref().unwrap(); assert_eq!(file_sel.range.start.line, 0); assert!(file_sel.parent.is_none()); } #[test] fn test_selection_on_second_line() { let text = "compression = xz\nsingle-debian-patch\n"; let idx = crate::position::LineIndex::new(text); let ranges = generate_selection_ranges(Source::new(text, &idx), &[Position::new(1, 3)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; assert_eq!(sel.range.start.line, 1); assert_eq!(sel.range.end.line, 1); assert!(sel.parent.is_some()); } #[test] fn test_selection_on_empty_line() { let text = "compression = xz\n\nsingle-debian-patch\n"; let idx = crate::position::LineIndex::new(text); let ranges = generate_selection_ranges(Source::new(text, &idx), &[Position::new(1, 0)]); assert_eq!(ranges.len(), 1); // Empty line falls back to file range assert!(ranges[0].parent.is_none()); } #[test] fn test_selection_on_comment() { let text = "# a comment\ncompression = xz\n"; let idx = crate::position::LineIndex::new(text); let ranges = generate_selection_ranges(Source::new(text, &idx), &[Position::new(0, 3)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; assert_eq!(sel.range.start.line, 0); assert!(sel.parent.is_some()); } #[test] fn test_empty_file() { let text = ""; let idx = crate::position::LineIndex::new(text); let ranges = generate_selection_ranges(Source::new(text, &idx), &[Position::new(0, 0)]); assert_eq!(ranges.len(), 1); assert!(ranges[0].parent.is_none()); } } debian-lsp-0.1.8/src/source_options/semantic.rs000064400000000000000000000132631046102023000176700ustar 00000000000000//! Semantic token generation for debian/source/options files. //! //! The file format is simple: one option per line, comments start with '#', //! options can have values separated by '='. use tower_lsp_server::ls_types::SemanticToken; use crate::deb822::semantic::{SemanticTokensBuilder, TokenType}; use super::fields::SOURCE_OPTIONS; /// Check if the given option name is a known dpkg-source option fn is_known_option(name: &str) -> bool { SOURCE_OPTIONS.iter().any(|opt| opt.name == name) } /// Generate semantic tokens for a debian/source/options file. pub fn generate_semantic_tokens(source_text: &str) -> Vec { let mut builder = SemanticTokensBuilder::new(); for (line_num, line) in source_text.lines().enumerate() { let line_num = line_num as u32; let trimmed = line.trim(); if trimmed.is_empty() { continue; } // Comment lines if trimmed.starts_with('#') { let start_col = line.find('#').unwrap() as u32; let length = (line.len() - start_col as usize) as u32; builder.push(line_num, start_col, length, TokenType::Comment, 0); continue; } // Option lines: "option-name" or "option-name = value" if let Some(eq_pos) = trimmed.find('=') { let option_name = trimmed[..eq_pos].trim(); let value = trimmed[eq_pos + 1..].trim(); // Find the actual position of the option name in the original line let name_start = line.find(option_name).unwrap_or(0) as u32; let name_len = option_name.len() as u32; let token_type = if is_known_option(option_name) { TokenType::Field } else { TokenType::UnknownField }; builder.push( line_num, name_start, name_len, token_type, crate::deb822::semantic::token_modifier::DECLARATION, ); // Value token if !value.is_empty() { // Find position of value in the original line (after '=') let eq_in_line = line.find('=').unwrap(); let after_eq = &line[eq_in_line + 1..]; let value_offset_in_after = after_eq.len() - after_eq.trim_start().len(); let value_start = (eq_in_line + 1 + value_offset_in_after) as u32; let value_len = value.len() as u32; builder.push(line_num, value_start, value_len, TokenType::Value, 0); } } else { // Boolean option (no value) let option_name = trimmed; let name_start = line.find(option_name).unwrap_or(0) as u32; let name_len = option_name.len() as u32; let token_type = if is_known_option(option_name) { TokenType::Field } else { TokenType::UnknownField }; builder.push( line_num, name_start, name_len, token_type, crate::deb822::semantic::token_modifier::DECLARATION, ); } } builder.build() } #[cfg(test)] mod tests { use super::*; #[test] fn test_comment_line() { let text = "# this is a comment\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TokenType::Comment as u32); assert_eq!(tokens[0].length, 19); } #[test] fn test_option_with_value() { let text = "compression = \"bzip2\"\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 2); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 11); // "compression" assert_eq!(tokens[1].token_type, TokenType::Value as u32); } #[test] fn test_boolean_option() { let text = "single-debian-patch\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 19); // "single-debian-patch" } #[test] fn test_unknown_option() { let text = "unknown-option\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 1); assert_eq!(tokens[0].token_type, TokenType::UnknownField as u32); } #[test] fn test_empty_text() { let tokens = generate_semantic_tokens(""); assert_eq!(tokens.is_empty(), true); } #[test] fn test_mixed_content() { let text = "# comment\ncompression = xz\nsingle-debian-patch\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 4); assert_eq!(tokens[0].token_type, TokenType::Comment as u32); assert_eq!(tokens[1].token_type, TokenType::Field as u32); // compression assert_eq!(tokens[2].token_type, TokenType::Value as u32); // xz assert_eq!(tokens[3].token_type, TokenType::Field as u32); // single-debian-patch } #[test] fn test_empty_lines_skipped() { let text = "compression = xz\n\nsingle-debian-patch\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens.len(), 3); } #[test] fn test_delta_positions() { let text = "compression = xz\nsingle-debian-patch\n"; let tokens = generate_semantic_tokens(text); assert_eq!(tokens[0].delta_line, 0); // compression on line 0 assert_eq!(tokens[1].delta_line, 0); // xz on same line assert_eq!(tokens[2].delta_line, 1); // single-debian-patch on line 1 } } debian-lsp-0.1.8/src/tests/completion.rs000064400000000000000000000013741046102023000163250ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, Position, Uri}; /// Get completion items for a debian/tests/control file pub fn get_completions(_uri: &Uri, _position: Position) -> Vec { // TODO: Implement completions for debian/tests/control // For now, return empty - we'll add field completions later // when we have a dedicated debian-tests crate vec![] } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_completions_returns_empty_for_now() { let uri = "file:///debian/tests/control".parse().unwrap(); let position = Position::new(0, 0); let completions = get_completions(&uri, position); // For now, should return empty assert!(completions.is_empty()); } } debian-lsp-0.1.8/src/tests/detection.rs000064400000000000000000000025561046102023000161350ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian tests/control file pub fn is_tests_control_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/debian/tests/control") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_tests_control_file() { let tests_control_paths = vec![ "file:///path/to/debian/tests/control", "file:///project/debian/tests/control", ]; let non_tests_control_paths = vec![ "file:///path/to/other.txt", "file:///path/to/debian/control", "file:///path/to/debian/copyright", "file:///path/to/debian/watch", "file:///path/to/tests/control", // Not in debian/ directory "file:///path/to/debian/tests/control.backup", ]; for path in tests_control_paths { let uri = path.parse::().unwrap(); assert!( is_tests_control_file(&uri), "Should detect tests/control file: {}", path ); } for path in non_tests_control_paths { let uri = path.parse::().unwrap(); assert!( !is_tests_control_file(&uri), "Should not detect as tests/control file: {}", path ); } } } debian-lsp-0.1.8/src/tests/mod.rs000064400000000000000000000006761046102023000147370ustar 00000000000000//! Module for handling debian/tests/control files //! //! For now, this provides basic file detection and empty completion support. //! In the future, this will be extended with a dedicated debian-tests crate //! for proper parsing and validation of autopkgtest control files. pub mod completion; pub mod detection; pub mod semantic; pub use completion::*; pub use detection::is_tests_control_file; pub use semantic::generate_semantic_tokens; debian-lsp-0.1.8/src/tests/semantic.rs000064400000000000000000000064671046102023000157670ustar 00000000000000//! Semantic token generation for debian/tests/control files. use tower_lsp_server::ls_types::SemanticToken; use crate::deb822::semantic::{generate_tokens, FieldValidator}; use crate::position::Source; /// Known field names for debian/tests/control (autopkgtest) const TESTS_CONTROL_FIELDS: &[&str] = &[ "Tests", "Test-Command", "Restrictions", "Features", "Depends", "Tests-Directory", "Classes", "Architecture", ]; /// Field validator for debian/tests/control files struct TestsControlFieldValidator; impl FieldValidator for TestsControlFieldValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { let lower = name.to_lowercase(); TESTS_CONTROL_FIELDS .iter() .find(|f| f.to_lowercase() == lower) .copied() } } /// Generate semantic tokens for a debian/tests/control file pub fn generate_semantic_tokens( deb822_parse: &deb822_lossless::Parse, src: Source<'_>, ) -> Vec { let deb822 = deb822_parse.tree(); let validator = TestsControlFieldValidator; generate_tokens(&deb822, src, &validator) } #[cfg(test)] mod tests { use super::*; use crate::deb822::semantic::TokenType; #[test] fn test_known_fields() { let text = "Tests: my-test\nDepends: @\nRestrictions: needs-root\n"; let parsed = deb822_lossless::Deb822::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); assert_eq!(tokens.len(), 6); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 5); // "Tests" assert_eq!(tokens[2].token_type, TokenType::Field as u32); assert_eq!(tokens[2].length, 7); // "Depends" assert_eq!(tokens[4].token_type, TokenType::Field as u32); assert_eq!(tokens[4].length, 12); // "Restrictions" } #[test] fn test_unknown_field() { let text = "Tests: my-test\nX-Custom: value\n"; let parsed = deb822_lossless::Deb822::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); assert_eq!(tokens[2].token_type, TokenType::UnknownField as u32); assert_eq!(tokens[2].length, 8); // "X-Custom" } #[test] fn test_case_insensitive() { let text = "tests: my-test\n"; let parsed = deb822_lossless::Deb822::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); assert_eq!(tokens[0].token_type, TokenType::Field as u32); } #[test] fn test_field_validator() { let validator = TestsControlFieldValidator; assert_eq!(validator.get_standard_field_name("Tests"), Some("Tests")); assert_eq!(validator.get_standard_field_name("tests"), Some("Tests")); assert_eq!( validator.get_standard_field_name("Test-Command"), Some("Test-Command") ); assert_eq!( validator.get_standard_field_name("Restrictions"), Some("Restrictions") ); assert_eq!(validator.get_standard_field_name("UnknownField"), None); } } debian-lsp-0.1.8/src/udd.rs000064400000000000000000000010761046102023000135650ustar 00000000000000//! Shared connection pool for the Ultimate Debian Database (UDD). use std::sync::Arc; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; const UDD_URL: &str = "postgres://udd-mirror:udd-mirror@udd-mirror.debian.net/udd"; /// A shared UDD connection pool that can be cloned cheaply. pub type SharedPool = Arc; /// Create a shared lazy connection pool to UDD. pub fn shared_pool() -> SharedPool { let pool = PgPoolOptions::new() .max_connections(2) .connect_lazy(UDD_URL) .expect("invalid UDD connection URL"); Arc::new(pool) } debian-lsp-0.1.8/src/upstream_metadata/completion.rs000064400000000000000000001005301046102023000206550ustar 00000000000000use std::path::Path; use rowan::ast::AstNode; use tower_lsp_server::ls_types::{ CompletionItem, CompletionItemKind, CompletionTextEdit, Position, Range, TextEdit, }; use yaml_edit::{Document, Mapping, YamlFile, YamlNode}; use super::fields::{ get_subfield_name_completions, get_subfield_value_completions, FieldValueType, UPSTREAM_FIELDS, }; use super::upstream_cache::SharedUpstreamCache; use crate::position::Source; /// Cursor context within the upstream/metadata YAML document. enum CursorContext { /// Cursor is at a position where a new top-level field name can be typed. TopLevelKey, /// Cursor is on a top-level scalar value. TopLevelValue { field_name: String, prefix: String }, /// Cursor is inside a mapping-list value, on a sub-field key position. SubFieldKey { subfields: &'static [super::fields::SubField], prefix: String, /// Column (0-indexed) where sub-field keys should start. indent: u32, }, /// Cursor is inside a mapping-list value, on a sub-field value position. SubFieldValue { subfields: &'static [super::fields::SubField], subfield_name: String, prefix: String, /// Column (0-indexed) where sub-field keys should start (for newline after value). indent: u32, }, /// No completions available (e.g. inside a scalar-list value). None, } /// Check whether a byte offset falls strictly within the range of a syntax node. /// Uses exclusive end to avoid matching trailing whitespace/newlines. fn offset_in_node_exclusive(node: &impl AstNode, offset: u32) -> bool { let range = node.syntax().text_range(); let start: u32 = range.start().into(); let end: u32 = range.end().into(); offset >= start && offset < end } /// Find the top-level mapping entry whose range contains the cursor offset. fn find_entry_at_offset(mapping: &Mapping, offset: u32) -> Option { mapping .entries() .find(|entry| offset_in_node_exclusive(entry, offset)) } /// Compute the column (0-indexed) at which sub-field keys start in a mapping. fn subfield_indent(mapping: &Mapping, src: Source<'_>) -> Option { mapping .entries() .next() .and_then(|e| e.key_node()) .and_then(|key| match key { YamlNode::Scalar(s) => { // yaml_edit LineColumn is 1-indexed Some(s.start_position(src.text).column as u32 - 1) } _ => None, }) } /// Compute the default indent for a sequence item's sub-fields based on the /// top-level key that owns the sequence. The convention is key_column + 2. fn default_subfield_indent(entry: &yaml_edit::MappingEntry, src: Source<'_>) -> u32 { entry .key_node() .and_then(|key| match key { YamlNode::Scalar(s) => Some(s.start_position(src.text).column as u32 - 1 + 2), _ => None, }) .unwrap_or(2) } /// Get the text of a scalar key node. fn key_text(entry: &yaml_edit::MappingEntry) -> Option { match entry.key_node()? { YamlNode::Scalar(s) => Some(s.as_string()), _ => Option::None, } } /// Look up the field definition for a top-level key name. fn lookup_field(key: &str) -> Option<&'static super::fields::UpstreamField> { let lower = key.to_ascii_lowercase(); UPSTREAM_FIELDS .iter() .find(|f| f.name.to_ascii_lowercase() == lower) } /// Extract the text between the value start and the cursor offset. fn extract_value_prefix(entry: &yaml_edit::MappingEntry, src: Source<'_>, offset: u32) -> String { match entry.value_node() { Some(YamlNode::Scalar(s)) => { let r = s.byte_range(); if offset >= r.start { src.text .get(r.start as usize..offset as usize) .unwrap_or("") .to_string() } else { String::new() } } _ => String::new(), } } /// Determine the cursor context by walking the YAML CST. fn determine_context(doc: &Document, src: Source<'_>, offset: u32) -> CursorContext { let mapping = match doc.as_mapping() { Some(m) => m, // Document isn't a mapping (or is empty) — offer top-level field names. None => return CursorContext::TopLevelKey, }; // Check if cursor falls within an existing top-level entry (exclusive end). // If not found, check if the cursor is right at the boundary of the last // entry. This handles cases like "Reference:\n" where the entry range is // 0..11 and offset is 11. let entry = find_entry_at_offset(&mapping, offset).or_else(|| { mapping .entries() .filter(|e| { let range = e.syntax().text_range(); let end: u32 = range.end().into(); offset == end }) .last() }); let entry = match entry { Some(e) => e, None => return CursorContext::TopLevelKey, }; // Check if cursor is on the key portion of the entry. if let Some(YamlNode::Scalar(key_scalar)) = entry.key_node() { let range = key_scalar.byte_range(); if offset >= range.start && offset < range.end { return CursorContext::TopLevelKey; } } // Look up the field to determine its value type. let field_name = match key_text(&entry) { Some(name) => name, None => return CursorContext::None, }; let field = match lookup_field(&field_name) { Some(f) => f, None => return CursorContext::None, }; // For scalar fields, if the cursor is on a different line than the key, // the user is likely starting a new top-level field. let cursor_line = yaml_edit::byte_offset_to_line_column(src.text, offset as usize).line; let key_line = entry .key_node() .map(|key| match key { YamlNode::Scalar(s) => { yaml_edit::byte_offset_to_line_column(src.text, s.byte_range().start as usize).line } _ => 0, }) .unwrap_or(0); // Check if cursor is inside the value node's range. let in_value = entry.value_node().is_some_and(|v| match &v { YamlNode::Scalar(s) => { let r = s.byte_range(); offset >= r.start && offset < r.end } YamlNode::Sequence(s) => offset_in_node_exclusive(s, offset), YamlNode::Mapping(m) => offset_in_node_exclusive(m, offset), _ => false, }); match field.value_type { FieldValueType::Scalar => { if cursor_line == key_line || in_value { let prefix = extract_value_prefix(&entry, src, offset); CursorContext::TopLevelValue { field_name, prefix } } else { CursorContext::TopLevelKey } } FieldValueType::ScalarList => { if cursor_line == key_line || in_value { CursorContext::None } else { CursorContext::TopLevelKey } } FieldValueType::MappingList(subfields) => { determine_mapping_list_context(entry, src, offset, subfields) } } } /// Determine context within a mapping-list value (e.g. Registry, Reference). /// /// The value is a sequence of mappings. We need to find which mapping the /// cursor is in, and whether it's on a sub-field key or value. fn determine_mapping_list_context( entry: yaml_edit::MappingEntry, src: Source<'_>, offset: u32, subfields: &'static [super::fields::SubField], ) -> CursorContext { let default_indent = default_subfield_indent(&entry, src); // Don't offer sub-field completions on the same line as the top-level key. // E.g. "Reference:|" should not complete "Author:" right after the colon. let key_line = entry .key_node() .map(|key| match key { YamlNode::Scalar(s) => { yaml_edit::byte_offset_to_line_column(src.text, s.byte_range().start as usize).line } _ => 0, }) .unwrap_or(0); let cursor_line = yaml_edit::byte_offset_to_line_column(src.text, offset as usize).line; if cursor_line == key_line { return CursorContext::None; } let value_node = match entry.value_node() { Some(v) => v, // Value not yet typed — offer sub-field names. None => { return CursorContext::SubFieldKey { subfields, prefix: String::new(), indent: default_indent, } } }; let sequence = match value_node.as_sequence() { Some(s) => s, // Value exists but isn't a sequence — might be partially typed. None => { // Could be a mapping directly (single item without sequence notation). if let Some(inner_mapping) = value_node.as_mapping() { return determine_inner_mapping_context( inner_mapping, src, offset, subfields, default_indent, ); } return CursorContext::SubFieldKey { subfields, prefix: String::new(), indent: default_indent, }; } }; // Walk sequence items to find which one contains the cursor. for item in sequence.values() { if let YamlNode::Mapping(inner_mapping) = item { if offset_in_node_exclusive(&inner_mapping, offset) { return determine_inner_mapping_context( &inner_mapping, src, offset, subfields, default_indent, ); } } } // Cursor is in the sequence but not inside any mapping item — // likely on a new `- ` line, offer sub-field names. // Derive indent from the last sequence item's mapping if available. let indent = sequence .values() .filter_map(|item| item.as_mapping().cloned()) .last() .and_then(|m| subfield_indent(&m, src)) .unwrap_or(default_indent); CursorContext::SubFieldKey { subfields, prefix: String::new(), indent, } } /// Determine context within an inner mapping (a single item in a mapping list). fn determine_inner_mapping_context( mapping: &Mapping, src: Source<'_>, offset: u32, subfields: &'static [super::fields::SubField], default_indent: u32, ) -> CursorContext { let indent = subfield_indent(mapping, src).unwrap_or(default_indent); // Check each sub-entry. We need to find entries where the cursor is either // within the entry range, or just past the entry on the same line (for // values that are empty or where the cursor is at the end of the value). for sub_entry in mapping.entries() { if let Some(YamlNode::Scalar(key_scalar)) = sub_entry.key_node() { let key_range = key_scalar.byte_range(); if offset < key_range.end && offset >= key_range.start { // Cursor is on the sub-field key. let prefix = &src.text[key_range.start as usize..offset as usize]; return CursorContext::SubFieldKey { subfields, prefix: prefix.to_string(), indent, }; } // Check if cursor is past the key (in the value area). // The value area starts at or after the colon (key_range.end). // We consider the cursor to be on this entry's value if // offset >= key_end and they are on the same line. if offset >= key_range.end { let key_end_line = yaml_edit::byte_offset_to_line_column(src.text, key_range.end as usize).line; let cursor_line = yaml_edit::byte_offset_to_line_column(src.text, offset as usize).line; if cursor_line == key_end_line { let subfield_name = key_scalar.as_string(); let prefix = match sub_entry.value_node() { Some(YamlNode::Scalar(val_scalar)) => { let val_range = val_scalar.byte_range(); if offset >= val_range.start { src.text .get(val_range.start as usize..offset as usize) .unwrap_or("") .to_string() } else { String::new() } } _ => String::new(), }; return CursorContext::SubFieldValue { subfields, subfield_name, prefix, indent, }; } } } } // Cursor is in the mapping but not inside any entry — new sub-field. CursorContext::SubFieldKey { subfields, prefix: String::new(), indent, } } /// Determine context and produce completions synchronously where possible. /// /// Returns `Ok(completions)` for contexts that don't need the cache, or /// `Err((field_name, prefix))` when the cache must be consulted. fn get_sync_completions( src: Source<'_>, position: Position, ) -> Result, (String, String)> { let offset = match src.try_position_to_offset(position) { Some(o) => u32::from(o), None => return Ok(get_field_completions()), }; let parse = YamlFile::parse(src.text); let yaml_file = parse.tree(); let doc = match yaml_file.document() { Some(d) => d, None => return Ok(get_field_completions()), }; match determine_context(&doc, src, offset) { CursorContext::TopLevelKey => Ok(get_field_completions()), CursorContext::TopLevelValue { field_name, prefix } => Err((field_name, prefix)), CursorContext::SubFieldKey { subfields, ref prefix, indent, } => Ok(get_subfield_name_completions(subfields, prefix, indent)), CursorContext::SubFieldValue { subfields, ref subfield_name, ref prefix, indent, } => Ok(get_subfield_value_completions( subfields, subfield_name, prefix, indent, )), CursorContext::None => Ok(vec![]), } } /// Get completions for a debian/upstream/metadata file at the given position. pub async fn get_completions( src: Source<'_>, position: Position, upstream_cache: &SharedUpstreamCache, project_root: Option<&Path>, ) -> Vec { // Do the YAML parsing and context determination synchronously first, // since YamlFile contains non-Send CST nodes that cannot be held // across an await point. match get_sync_completions(src, position) { Ok(completions) => completions, Err((field_name, prefix)) => { get_guessed_value_completions( upstream_cache, project_root, &field_name, &prefix, position, ) .await } } } /// Get completions from guessed upstream metadata values. async fn get_guessed_value_completions( upstream_cache: &SharedUpstreamCache, project_root: Option<&Path>, field_name: &str, prefix: &str, position: Position, ) -> Vec { let project_root = match project_root { Some(p) => p, None => return vec![], }; let cache = upstream_cache.read().await; let values = match cache.get_values(project_root, field_name) { Some(v) => v, None => return vec![], }; // The prefix may include leading whitespace (e.g. the space after the // colon). Trim it for matching, and compute the range that covers // the typed prefix so that the client replaces it correctly. let trimmed = prefix.trim_start(); let normalized = trimmed.to_ascii_lowercase(); let prefix_start_col = position.character - trimmed.len() as u32; let replace_range = Range::new(Position::new(position.line, prefix_start_col), position); values .iter() .filter(|v| v.to_ascii_lowercase().starts_with(&normalized)) .map(|v| CompletionItem { label: v.clone(), kind: Some(CompletionItemKind::VALUE), detail: Some(format!("Guessed value for {field_name}")), text_edit: Some(CompletionTextEdit::Edit(TextEdit::new( replace_range, v.clone(), ))), filter_text: Some(trimmed.to_string()), ..Default::default() }) .collect() } /// Generate field name completions for all known DEP-12 fields. fn get_field_completions() -> Vec { UPSTREAM_FIELDS .iter() .map(|field| CompletionItem { label: field.name.to_string(), kind: Some(CompletionItemKind::FIELD), detail: Some(field.description.to_string()), insert_text: Some(format!("{}: ", field.name)), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; use tower_lsp_server::ls_types::InsertTextFormat; /// Test helper: calls the sync completion path, returning empty for /// contexts that would need the upstream cache. fn test_completions(text: &str, position: Position) -> Vec { let idx = crate::position::LineIndex::new(text); get_sync_completions(Source::new(text, &idx), position).unwrap_or_default() } #[test] fn test_get_completions_at_start_of_empty_line() { let text = "Repository: https://example.com\n\n"; let completions = test_completions(text, Position::new(1, 0)); assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); assert_eq!(completions[0].label, "Repository"); assert_eq!( completions[0].detail.as_deref(), Some("URL of the upstream source repository") ); } #[test] fn test_get_completions_on_value() { let text = "Repository: https://example.com\n"; let completions = test_completions(text, Position::new(0, 12)); assert_eq!(completions.len(), 0); } #[test] fn test_get_completions_on_existing_field_key() { let text = "Repository: https://example.com\n"; let completions = test_completions(text, Position::new(0, 0)); // Cursor is on the key of an existing entry. assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); } #[test] fn test_get_completions_typing_field_name() { let text = "Repository: https://example.com\nBug"; let completions = test_completions(text, Position::new(1, 3)); assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); } #[test] fn test_get_completions_partial_field_name_at_col_zero() { let text = "Repository: https://example.com\nBug"; let completions = test_completions(text, Position::new(1, 0)); assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); } #[test] fn test_get_completions_on_indented_scalar_list() { // Other-References is a ScalarList — no sub-field completions. let text = "Other-References:\n - https://example.com\n"; let completions = test_completions(text, Position::new(1, 4)); assert_eq!(completions.len(), 0); } #[test] fn test_get_completions_indented_line() { // Indented lines are continuation/value lines — no field completions let text = "Repository: https://example.com\n indented\n"; let completions = test_completions(text, Position::new(1, 2)); assert_eq!(completions.len(), 0); } #[test] fn test_get_completions_empty_file() { let completions = test_completions("", Position::new(0, 0)); assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); } #[test] fn test_get_completions_line_beyond_file() { let text = "Repository: https://example.com\n"; let completions = test_completions(text, Position::new(5, 0)); // Line 5 doesn't exist — falls back to field completions. assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); } #[test] fn test_field_completions_have_insert_text() { let completions = get_field_completions(); assert_eq!(completions.len(), UPSTREAM_FIELDS.len()); for c in &completions { assert_eq!(c.kind, Some(CompletionItemKind::FIELD)); assert!(c.insert_text.as_ref().unwrap().ends_with(": ")); assert!(c.detail.is_some()); } } #[test] fn test_registry_subfield_name_completions() { let text = "Registry:\n - Name: PyPI\n Entry: example\n"; let completions = test_completions(text, Position::new(1, 4)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Name", "Entry"]); } #[test] fn test_registry_name_value_completions() { let text = "Registry:\n - Name: \n"; let completions = test_completions(text, Position::new(1, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!( labels, vec![ "ASCL", "BitBucket", "CPAN", "Codeberg", "SourceForge", "GitHub", "GitLab", "Go", "Hackage", "Heptapod", "Launchpad", "Maven", "PyPI", "Savannah", "SourceHut", "crates.io", "npm", ] ); } #[test] fn test_registry_name_value_completions_with_prefix() { let text = "Registry:\n - Name: Py\n"; let completions = test_completions(text, Position::new(1, 12)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["PyPI"]); } #[test] fn test_registry_entry_no_value_completions() { let text = "Registry:\n - Name: PyPI\n Entry: example\n"; let completions = test_completions(text, Position::new(2, 11)); assert_eq!(completions.len(), 0); } #[test] fn test_registry_second_subfield_key() { let text = "Registry:\n - Name: PyPI\n Entry: example\n"; let completions = test_completions(text, Position::new(2, 4)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Name", "Entry"]); } #[test] fn test_reference_subfield_name_completions() { let text = "Reference:\n - Type: Article\n Title: A paper\n"; let completions = test_completions(text, Position::new(1, 4)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!( labels, vec![ "Type", "Title", "Author", "Year", "DOI", "URL", "Journal", "Volume", "EPRINT", "ISSN", "Comment", ] ); } #[test] fn test_reference_type_value_completions() { let text = "Reference:\n - Type: \n"; let completions = test_completions(text, Position::new(1, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!( labels, vec![ "Article", "Book", "Conference", "InProceedings", "Manual", "PhdThesis", "TechReport", "Unpublished", ] ); } #[test] fn test_reference_type_value_with_prefix() { let text = "Reference:\n - Type: Art\n"; let completions = test_completions(text, Position::new(1, 13)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Article"]); } #[test] fn test_funding_subfield_completions() { let text = "Funding:\n - Type: grant\n Funder: NSF\n"; let completions = test_completions(text, Position::new(1, 4)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["Type", "Funder", "Grant", "URL"]); } #[test] fn test_non_mapping_list_indented() { let text = "Screenshots:\n - https://example.com\n"; let completions = test_completions(text, Position::new(1, 4)); assert_eq!(completions.len(), 0); } #[test] fn test_registry_name_value_right_after_colon() { let text = "Registry:\n - Name:\n"; let completions = test_completions(text, Position::new(1, 8)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 17); // all known registries assert_eq!(labels[0], "ASCL"); } #[test] fn test_registry_name_value_after_colon_space() { let text = "Registry:\n - Name: \n"; let completions = test_completions(text, Position::new(1, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 17); assert_eq!(labels[0], "ASCL"); } #[test] fn test_top_level_value_right_after_colon() { let text = "Repository:\n"; // Cursor right after the colon — should be in value context. let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(0, 11)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_after_colon_space() { let text = "Repository: \n"; // Cursor after "Repository: " — should be in value context. let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(0, 12)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_after_colon_space_no_newline() { let text = "Repository: "; let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(0, 12)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_after_colon_space_with_other_fields() { let text = "Name: foo\nRepository: \n"; let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(1, 12)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_on_colon() { // Position on the colon itself — still value context (same line as key). let text = "Repository: \n"; let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(0, 10)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_between_colon_and_space() { // Position between colon and space. let text = "Repository: \n"; let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(0, 11)); assert!(result.is_err(), "expected TopLevelValue context"); } #[test] fn test_top_level_value_prefix_extraction() { // Check what prefix we extract for various cursor positions. for (text, line, col, expected_prefix) in [ ("Repository:\n", 0u32, 11u32, ""), ("Repository: \n", 0, 12, ""), ("Repository: https\n", 0, 17, "https"), ("Repository: ", 0, 12, ""), ] { let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(line, col)); match result { Err((field, prefix)) => { assert_eq!(field, "Repository"); assert_eq!(prefix, expected_prefix, "text={text:?} col={col}"); } Ok(_) => panic!("expected TopLevelValue for text={text:?} col={col}"), } } } #[test] fn test_top_level_value_colon_space_with_existing_content() { // Simulates typing "Repository: " when there's already other content. let text = "Name: foo\nRepository: "; let idx = crate::position::LineIndex::new(text); let result = get_sync_completions(Source::new(text, &idx), Position::new(1, 12)); assert!(result.is_err(), "expected TopLevelValue context, got Ok"); } #[test] fn test_mapping_list_no_completions_on_key_line() { let text = "Reference:\n"; let completions = test_completions(text, Position::new(0, 10)); assert_eq!(completions.len(), 0); } #[test] fn test_mapping_list_no_completions_on_key_line_no_newline() { let text = "Reference:"; let completions = test_completions(text, Position::new(0, 10)); assert_eq!(completions.len(), 0); } #[test] fn test_unknown_parent_field_indented() { let text = "X-Custom:\n - foo\n"; let completions = test_completions(text, Position::new(1, 4)); assert_eq!(completions.len(), 0); } #[test] fn test_subfield_name_after_first_entry_with_indent() { let text = "Reference:\n - Type: Book\n \n"; let completions = test_completions(text, Position::new(2, 4)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 11); // all Reference sub-fields assert_eq!(labels[0], "Type"); assert_eq!(labels[1], "Title"); } #[test] fn test_subfield_name_after_first_entry_less_indent() { let text = "Reference:\n - Type: Book\n \n"; let completions = test_completions(text, Position::new(2, 3)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 11); assert_eq!(labels[0], "Type"); } #[test] fn test_subfield_value_after_colon_space_with_existing_value() { let text = "Registry:\n - Name: PyPI\n"; let completions = test_completions(text, Position::new(1, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 17); assert_eq!(labels[0], "ASCL"); } #[test] fn test_reference_type_value_after_colon_no_space() { let text = "Reference:\n - Type:\n"; let completions = test_completions(text, Position::new(1, 8)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 8); // all reference types assert_eq!(labels[0], "Article"); } #[test] fn test_reference_type_value_after_colon_space() { let text = "Reference:\n - Type: \n"; let completions = test_completions(text, Position::new(1, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels.len(), 8); assert_eq!(labels[0], "Article"); } #[test] fn test_subfield_insert_text_has_newline_and_indent() { let text = "Reference:\n - Type: Book\n \n"; let completions = test_completions(text, Position::new(2, 4)); let title = completions.iter().find(|c| c.label == "Title").unwrap(); assert_eq!(title.insert_text_format, Some(InsertTextFormat::SNIPPET)); assert_eq!(title.insert_text.as_deref(), Some("Title: $1\n $0")); } #[test] fn test_value_insert_text_has_newline_and_indent() { let text = "Reference:\n - Type: \n"; let completions = test_completions(text, Position::new(1, 10)); let article = completions.iter().find(|c| c.label == "Article").unwrap(); assert_eq!(article.insert_text_format, Some(InsertTextFormat::SNIPPET)); assert_eq!(article.insert_text.as_deref(), Some("Article\n $0")); } } debian-lsp-0.1.8/src/upstream_metadata/detection.rs000064400000000000000000000024601046102023000204650ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a debian/upstream/metadata file. pub fn is_upstream_metadata_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/debian/upstream/metadata") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_upstream_metadata_file() { let valid_paths = vec![ "file:///path/to/debian/upstream/metadata", "file:///project/debian/upstream/metadata", ]; let invalid_paths = vec![ "file:///path/to/other.txt", "file:///path/to/debian/control", "file:///path/to/debian/copyright", "file:///path/to/upstream/metadata", "file:///path/to/debian/upstream/metadata.bak", ]; for path in valid_paths { let uri = path.parse::().unwrap(); assert!( is_upstream_metadata_file(&uri), "Should detect upstream/metadata file: {}", path ); } for path in invalid_paths { let uri = path.parse::().unwrap(); assert!( !is_upstream_metadata_file(&uri), "Should not detect as upstream/metadata file: {}", path ); } } } debian-lsp-0.1.8/src/upstream_metadata/document_link.rs000064400000000000000000000107321046102023000213430ustar 00000000000000//! Document link support for debian/upstream/metadata files. //! //! Provides clickable links for URL-valued fields like Repository, //! Bug-Database, Documentation, etc. use tower_lsp_server::ls_types::{DocumentLink, Position, Range, Uri}; use yaml_edit::{Document, YamlNode}; /// Field names whose values are URLs that should be clickable links. const URL_FIELD_NAMES: &[&str] = &[ "Repository", "Repository-Browse", "Bug-Database", "Bug-Submit", "Changelog", "Documentation", "FAQ", "Donation", "Gallery", "Webservice", ]; fn is_url_field(name: &str) -> bool { let lower = name.to_ascii_lowercase(); URL_FIELD_NAMES .iter() .any(|f| f.to_ascii_lowercase() == lower) } /// Get document links for URL-valued fields in an upstream/metadata file. pub fn get_document_links(doc: &Document, source_text: &str) -> Vec { let mapping = match doc.as_mapping() { Some(m) => m, None => return vec![], }; let mut links = vec![]; for entry in mapping.entries() { let key_scalar = match entry.key_node() { Some(YamlNode::Scalar(s)) => s, _ => continue, }; let key_text = key_scalar.as_string(); if !is_url_field(&key_text) { continue; } let val_scalar = match entry.value_node() { Some(YamlNode::Scalar(s)) => s, _ => continue, }; let val_text = val_scalar.as_string(); let uri = match val_text.parse::() { Ok(uri) => uri, Err(_) => continue, }; // yaml_edit positions are 1-indexed; LSP positions are 0-indexed let start = val_scalar.start_position(source_text); let end = val_scalar.end_position(source_text); let range = Range::new( Position::new(start.line as u32 - 1, start.column as u32 - 1), Position::new(end.line as u32 - 1, end.column as u32 - 1), ); links.push(DocumentLink { range, target: Some(uri), tooltip: Some(key_text), data: None, }); } links } #[cfg(test)] mod tests { use super::*; fn parse_doc(text: &str) -> Document { text.parse::().unwrap() } #[test] fn test_url_field_produces_link() { let text = "Repository: https://github.com/example/project\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 1); assert_eq!( links[0].target.as_ref().unwrap().as_str(), "https://github.com/example/project" ); assert_eq!(links[0].tooltip.as_deref(), Some("Repository")); // Value starts after "Repository: " (column 12) assert_eq!(links[0].range.start, Position::new(0, 12)); } #[test] fn test_non_url_field_no_link() { let text = "Name: my-project\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 0); } #[test] fn test_multiple_url_fields() { let text = "Repository: https://github.com/example/project\nBug-Database: https://bugs.example.com\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 2); } #[test] fn test_invalid_url_skipped() { let text = "Repository: not a valid url\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 0); } #[test] fn test_unknown_field_no_link() { let text = "X-Custom: https://example.com\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 0); } #[test] fn test_mixed_url_and_text_fields() { let text = "Name: my-project\nRepository: https://github.com/example\nContact: maintainer@example.com\nBug-Submit: https://bugs.example.com/new\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 2); assert_eq!(links[0].tooltip.as_deref(), Some("Repository")); assert_eq!(links[1].tooltip.as_deref(), Some("Bug-Submit")); } #[test] fn test_non_mapping_document() { let text = "- item1\n- item2\n"; let doc = parse_doc(text); let links = get_document_links(&doc, text); assert_eq!(links.len(), 0); } } debian-lsp-0.1.8/src/upstream_metadata/fields.rs000064400000000000000000000235621046102023000177630ustar 00000000000000use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind}; /// The type of value a DEP-12 field accepts. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FieldValueType { /// A plain scalar value (URL, string, etc.) Scalar, /// A sequence of scalar values (e.g. list of URLs) ScalarList, /// A sequence of mappings with known sub-fields MappingList(&'static [SubField]), } /// A sub-field within a mapping list value (e.g. Registry → Name, Entry). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SubField { pub name: &'static str, pub description: &'static str, pub known_values: &'static [&'static str], } /// A field definition for the upstream/metadata file (DEP-12). pub struct UpstreamField { pub name: &'static str, pub description: &'static str, pub value_type: FieldValueType, } impl UpstreamField { pub const fn new(name: &'static str, description: &'static str) -> Self { Self { name, description, value_type: FieldValueType::Scalar, } } pub const fn with_value_type( name: &'static str, description: &'static str, value_type: FieldValueType, ) -> Self { Self { name, description, value_type, } } } /// Known registry names for the Registry field. pub const KNOWN_REGISTRIES: &[&str] = &[ "ASCL", "BitBucket", "CPAN", "Codeberg", "SourceForge", "GitHub", "GitLab", "Go", "Hackage", "Heptapod", "Launchpad", "Maven", "PyPI", "Savannah", "SourceHut", "crates.io", "npm", ]; /// Sub-fields for Registry entries. pub const REGISTRY_SUBFIELDS: &[SubField] = &[ SubField { name: "Name", description: "Name of the software registry", known_values: KNOWN_REGISTRIES, }, SubField { name: "Entry", description: "Identifier or URL of the entry in the registry", known_values: &[], }, ]; /// Known reference types for the Reference field. pub const KNOWN_REFERENCE_TYPES: &[&str] = &[ "Article", "Book", "Conference", "InProceedings", "Manual", "PhdThesis", "TechReport", "Unpublished", ]; /// Sub-fields for Reference entries. pub const REFERENCE_SUBFIELDS: &[SubField] = &[ SubField { name: "Type", description: "Type of bibliographic reference", known_values: KNOWN_REFERENCE_TYPES, }, SubField { name: "Title", description: "Title of the referenced work", known_values: &[], }, SubField { name: "Author", description: "Author(s) of the referenced work", known_values: &[], }, SubField { name: "Year", description: "Publication year", known_values: &[], }, SubField { name: "DOI", description: "Digital Object Identifier", known_values: &[], }, SubField { name: "URL", description: "URL of the referenced work", known_values: &[], }, SubField { name: "Journal", description: "Journal name", known_values: &[], }, SubField { name: "Volume", description: "Volume number", known_values: &[], }, SubField { name: "EPRINT", description: "arXiv or other e-print identifier", known_values: &[], }, SubField { name: "ISSN", description: "International Standard Serial Number", known_values: &[], }, SubField { name: "Comment", description: "Additional comments about the reference", known_values: &[], }, ]; /// Sub-fields for Funding entries. pub const FUNDING_SUBFIELDS: &[SubField] = &[ SubField { name: "Type", description: "Type of funding source", known_values: &[], }, SubField { name: "Funder", description: "Name of the funding organization", known_values: &[], }, SubField { name: "Grant", description: "Grant identifier or number", known_values: &[], }, SubField { name: "URL", description: "URL with more information about the funding", known_values: &[], }, ]; use tower_lsp_server::ls_types::InsertTextFormat; fn make_indent(indent: u32) -> String { " ".repeat(indent as usize) } fn enum_completions(values: &[&str], prefix: &str, indent: u32) -> Vec { let normalized = prefix.trim().to_ascii_lowercase(); let indent_str = make_indent(indent); values .iter() .filter(|v| v.to_ascii_lowercase().starts_with(&normalized)) .map(|&v| CompletionItem { label: v.to_string(), kind: Some(CompletionItemKind::VALUE), insert_text: Some(format!("{v}\n{indent_str}$0")), insert_text_format: Some(InsertTextFormat::SNIPPET), ..Default::default() }) .collect() } /// Get value completions for a sub-field within a mapping list. pub fn get_subfield_value_completions( subfields: &[SubField], subfield_name: &str, prefix: &str, indent: u32, ) -> Vec { let lower = subfield_name.to_ascii_lowercase(); subfields .iter() .find(|sf| sf.name.to_ascii_lowercase() == lower) .map(|sf| enum_completions(sf.known_values, prefix, indent)) .unwrap_or_default() } /// Get sub-field name completions for a mapping list field. pub fn get_subfield_name_completions( subfields: &[SubField], prefix: &str, indent: u32, ) -> Vec { let normalized = prefix.trim().to_ascii_lowercase(); let indent_str = make_indent(indent); subfields .iter() .filter(|sf| sf.name.to_ascii_lowercase().starts_with(&normalized)) .map(|sf| CompletionItem { label: sf.name.to_string(), kind: Some(CompletionItemKind::FIELD), detail: Some(sf.description.to_string()), insert_text: Some(format!("{}: $1\n{indent_str}$0", sf.name)), insert_text_format: Some(InsertTextFormat::SNIPPET), ..Default::default() }) .collect() } /// DEP-12 upstream metadata fields. pub const UPSTREAM_FIELDS: &[UpstreamField] = &[ UpstreamField::new("Repository", "URL of the upstream source repository"), UpstreamField::new( "Repository-Browse", "Web interface for the upstream repository", ), UpstreamField::new("Bug-Database", "URL of the upstream bug tracking system"), UpstreamField::new("Bug-Submit", "URL for submitting new upstream bugs"), UpstreamField::new("Name", "Human-readable name of the upstream project"), UpstreamField::new("Contact", "Contact information for the upstream authors"), UpstreamField::new("Changelog", "URL of the upstream changelog"), UpstreamField::new("Documentation", "URL of the upstream documentation"), UpstreamField::new("FAQ", "URL of the upstream FAQ"), UpstreamField::new("Donation", "URL for donating to the upstream project"), UpstreamField::with_value_type( "Screenshots", "URL of upstream screenshots", FieldValueType::ScalarList, ), UpstreamField::new("Gallery", "URL of an upstream image gallery"), UpstreamField::new("Webservice", "URL of the upstream web service"), UpstreamField::new("Security-Contact", "Contact for reporting security issues"), UpstreamField::new("CPE", "Common Platform Enumeration identifier"), UpstreamField::new("ASCL-Id", "Astrophysics Source Code Library identifier"), UpstreamField::new("Cite-As", "Preferred citation for the software"), UpstreamField::with_value_type( "Funding", "Funding information for the project", FieldValueType::MappingList(FUNDING_SUBFIELDS), ), UpstreamField::with_value_type( "Reference", "Bibliographic references for the software", FieldValueType::MappingList(REFERENCE_SUBFIELDS), ), UpstreamField::with_value_type( "Registry", "External software registry entries", FieldValueType::MappingList(REGISTRY_SUBFIELDS), ), UpstreamField::with_value_type( "Other-References", "Additional references not covered by Reference", FieldValueType::ScalarList, ), ]; /// Look up the standard (canonical) casing for a field name. pub fn get_standard_field_name(field_name: &str) -> Option<&'static str> { let lowercase = field_name.to_lowercase(); UPSTREAM_FIELDS .iter() .find(|f| f.name.to_lowercase() == lowercase) .map(|f| f.name) } #[cfg(test)] mod tests { use super::*; #[test] fn test_upstream_fields() { assert_eq!(UPSTREAM_FIELDS.len(), 21); let names: Vec<_> = UPSTREAM_FIELDS.iter().map(|f| f.name).collect(); assert_eq!(names[0], "Repository"); assert_eq!(names[1], "Repository-Browse"); assert_eq!(names[2], "Bug-Database"); assert_eq!(names[3], "Bug-Submit"); assert_eq!(names[4], "Name"); } #[test] fn test_upstream_field_validity() { for field in UPSTREAM_FIELDS { assert!(!field.name.is_empty(), "Field name must not be empty"); assert!( !field.description.is_empty(), "Description for {} must not be empty", field.name ); } } #[test] fn test_get_standard_field_name() { assert_eq!(get_standard_field_name("Repository"), Some("Repository")); assert_eq!(get_standard_field_name("repository"), Some("Repository")); assert_eq!( get_standard_field_name("bug-database"), Some("Bug-Database") ); assert_eq!(get_standard_field_name("CPE"), Some("CPE")); assert_eq!(get_standard_field_name("cpe"), Some("CPE")); assert_eq!(get_standard_field_name("UnknownField"), None); } } debian-lsp-0.1.8/src/upstream_metadata/hover.rs000064400000000000000000000130701046102023000176310ustar 00000000000000//! Hover support for debian/upstream/metadata files. use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position}; use yaml_edit::{Document, YamlNode}; use super::fields::{get_standard_field_name, UPSTREAM_FIELDS}; use crate::position::Source; fn get_field_description(field_name: &str) -> Option<(&'static str, &'static str)> { let lowercase = field_name.to_lowercase(); UPSTREAM_FIELDS .iter() .find(|f| f.name.to_lowercase() == lowercase) .map(|f| (f.name, f.description)) } fn make_hover(field_name: &str, description: &str) -> Hover { Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: format!("**{}**\n\n{}", field_name, description), }), range: None, } } /// Get hover information for a debian/upstream/metadata file at the given cursor position. pub fn get_hover(doc: &Document, src: Source<'_>, position: Position) -> Option { let source_text = src.text; let mapping = doc.as_mapping()?; // Convert LSP 0-indexed position to yaml_edit 1-indexed LineColumn let target_line = position.line as usize + 1; let target_col = position.character as usize + 1; for entry in mapping.entries() { // Check if cursor is on the key if let Some(YamlNode::Scalar(key_scalar)) = entry.key_node() { let key_text = key_scalar.as_string(); let start = key_scalar.start_position(source_text); let end = key_scalar.end_position(source_text); if target_line >= start.line && target_line <= end.line && (target_line != start.line || target_col >= start.column) && (target_line != end.line || target_col <= end.column) { return get_field_description(&key_text) .map(|(canonical, desc)| make_hover(canonical, desc)); } // Check if cursor is on the value if let Some(YamlNode::Scalar(val_scalar)) = entry.value_node() { let val_start = val_scalar.start_position(source_text); let val_end = val_scalar.end_position(source_text); if target_line >= val_start.line && target_line <= val_end.line && (target_line != val_start.line || target_col >= val_start.column) && (target_line != val_end.line || target_col <= val_end.column) { let canonical = get_standard_field_name(&key_text).unwrap_or(&key_text); return get_field_description(canonical).map(|(c, desc)| make_hover(c, desc)); } } } } None } #[cfg(test)] mod tests { use super::*; fn hover_markdown(hover: &Hover) -> &str { match &hover.contents { HoverContents::Markup(markup) => &markup.value, _ => panic!("Expected markup content"), } } fn parse_doc(text: &str) -> Document { text.parse::().unwrap() } #[test] fn test_hover_on_field_key() { let text = "Repository: https://github.com/example/project\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let hover = get_hover(&doc, src, Position::new(0, 3)).unwrap(); assert_eq!( hover_markdown(&hover), "**Repository**\n\nURL of the upstream source repository" ); } #[test] fn test_hover_on_field_value() { let text = "Repository: https://github.com/example/project\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let hover = get_hover(&doc, src, Position::new(0, 15)).unwrap(); assert_eq!( hover_markdown(&hover), "**Repository**\n\nURL of the upstream source repository" ); } #[test] fn test_hover_on_unknown_field() { let text = "X-Custom: value\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); assert_eq!(get_hover(&doc, src, Position::new(0, 3)), None); } #[test] fn test_hover_on_empty_line() { let text = "Repository: https://example.com\n\nBug-Database: https://bugs.example.com\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); assert_eq!(get_hover(&doc, src, Position::new(1, 0)), None); } #[test] fn test_hover_case_insensitive_key() { let text = "repository: https://example.com\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let hover = get_hover(&doc, src, Position::new(0, 3)).unwrap(); assert_eq!( hover_markdown(&hover), "**Repository**\n\nURL of the upstream source repository" ); } #[test] fn test_hover_second_field() { let text = "Repository: https://example.com\nBug-Database: https://bugs.example.com\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let hover = get_hover(&doc, src, Position::new(1, 5)).unwrap(); assert_eq!( hover_markdown(&hover), "**Bug-Database**\n\nURL of the upstream bug tracking system" ); } } debian-lsp-0.1.8/src/upstream_metadata/mod.rs000064400000000000000000000011011046102023000172550ustar 00000000000000//! Module for handling debian/upstream/metadata files (DEP-12). //! //! These files use YAML format and contain machine-readable metadata //! about the upstream project. pub mod completion; pub mod detection; pub mod document_link; pub mod fields; pub mod hover; pub mod on_type_formatting; pub mod semantic; pub mod upstream_cache; pub use completion::get_completions; pub use detection::is_upstream_metadata_file; pub use document_link::get_document_links; pub use hover::get_hover; pub use semantic::generate_semantic_tokens; pub use upstream_cache::SharedUpstreamCache; debian-lsp-0.1.8/src/upstream_metadata/on_type_formatting.rs000064400000000000000000000256531046102023000224270ustar 00000000000000//! On-type formatting for debian/upstream/metadata files. //! //! Handles two triggers: //! - `\n`: insert indentation matching the sub-field key column when inside a //! mapping-list entry (e.g. Registry, Reference, Funding). //! - `:`: insert a trailing space after a field-name colon. use rowan::ast::AstNode; use tower_lsp_server::ls_types::{Position, Range, TextEdit}; use yaml_edit::{Mapping, YamlFile, YamlNode}; use super::fields::{FieldValueType, UPSTREAM_FIELDS}; /// Generate on-type formatting edits for upstream/metadata files. pub fn on_type_formatting( source_text: &str, position: Position, ch: &str, ) -> Option> { match ch { "\n" => on_type_newline(source_text, position), ":" => on_type_colon(source_text, position), _ => None, } } /// After typing a newline, if the previous line is inside a YAML mapping that /// belongs to a mapping-list field, insert indentation so the cursor lands at /// the correct column for the next sub-field key. fn on_type_newline(source_text: &str, position: Position) -> Option> { if position.line == 0 { return None; } // Don't add indentation if the current line has non-whitespace content. let current_line = source_text .lines() .nth(position.line as usize) .unwrap_or(""); if current_line.contains(|c: char| !c.is_whitespace()) { return None; } let indent = indent_for_position(source_text, position)?; let indent_str = " ".repeat(indent); // If the line already has the right indentation, don't emit an edit. if current_line == indent_str { return None; } // Replace any existing whitespace on the line with the correct indent. let line_start = Position::new(position.line, 0); let line_end = Position::new(position.line, current_line.len() as u32); Some(vec![TextEdit { range: Range { start: line_start, end: line_end, }, new_text: indent_str, }]) } /// Compute the indentation (as a number of spaces) that should be inserted /// on a new line at `position`, or `None` if no auto-indent applies. fn indent_for_position(source_text: &str, position: Position) -> Option { let parse = YamlFile::parse(source_text); let yaml_file = parse.tree(); let doc = yaml_file.document()?; let mapping = doc.as_mapping()?; let prev_line_idx = (position.line - 1) as usize; let prev_line = source_text.lines().nth(prev_line_idx)?; // Find which top-level entry the previous line belongs to. // Use a byte offset in the middle of the previous line. let prev_line_offset = source_text .lines() .take(prev_line_idx) .map(|l| l.len() + 1) .sum::(); // Use offset at the start of non-whitespace content on the previous line. let content_start = prev_line.len() - prev_line.trim_start().len(); let probe_offset = (prev_line_offset + content_start) as u32; // Find the top-level entry containing this offset. let entry = mapping.entries().find(|e| { let range = e.syntax().text_range(); let start: u32 = range.start().into(); let end: u32 = range.end().into(); probe_offset >= start && probe_offset < end })?; // Get the field name and check if it's a mapping-list field. let field_name = match entry.key_node()? { YamlNode::Scalar(s) => s.as_string(), _ => return None, }; let lower = field_name.to_ascii_lowercase(); let field = UPSTREAM_FIELDS .iter() .find(|f| f.name.to_ascii_lowercase() == lower)?; match field.value_type { FieldValueType::MappingList(_) => {} _ => return None, } // Compute the default indent from the top-level key position. let default_indent = entry .key_node() .and_then(|key| match key { YamlNode::Scalar(s) => Some(s.start_position(source_text).column - 1 + 2), _ => None, }) .unwrap_or(2); // Find the inner mapping that the previous line belongs to. let value_node = match entry.value_node() { Some(v) => v, // No value yet (e.g. just "Reference:\n") — use default indent. None => return Some(default_indent), }; let sequence = match value_node.as_sequence() { Some(s) => s, None => return Some(default_indent), }; for item in sequence.values() { if let YamlNode::Mapping(inner_mapping) = item { let indent = mapping_key_column(&inner_mapping, source_text); if let Some(indent) = indent { let range = inner_mapping.syntax().text_range(); let start: u32 = range.start().into(); let end: u32 = range.end().into(); if probe_offset >= start && probe_offset < end { return Some(indent); } } } } // Previous line is in the sequence but not in any mapping — e.g. on the // `- ` line itself. The indent should match the first mapping's key column, // or derive from the entry's key column + 2. let from_seq: Vec<_> = sequence.values().collect(); let indent = from_seq .iter() .find_map(|item| { item.as_mapping() .and_then(|m| mapping_key_column(m, source_text)) }) .unwrap_or(default_indent); Some(indent) } /// Get the 0-indexed column of the first key in a mapping. fn mapping_key_column(mapping: &Mapping, source_text: &str) -> Option { mapping .entries() .next() .and_then(|e| e.key_node()) .and_then(|key| match key { YamlNode::Scalar(s) => Some(s.start_position(source_text).column - 1), _ => None, }) } /// After typing `:`, insert a space if this looks like a field separator. fn on_type_colon(source_text: &str, position: Position) -> Option> { // Check that the character after the cursor isn't already a space. let line = source_text.lines().nth(position.line as usize)?; let col = position.character as usize; if line[col..].starts_with(' ') { return None; } // Check that the text before the colon on this line looks like a field name // (i.e. no colon before this one on the same line, which would mean we're // in a value like a URL). let before_colon = &line[..col.saturating_sub(1)]; let trimmed = before_colon.trim_start_matches("- ").trim_start(); if trimmed.contains(':') { return None; } Some(vec![TextEdit { range: Range { start: position, end: position, }, new_text: " ".to_string(), }]) } #[cfg(test)] mod tests { use super::*; #[test] fn test_newline_after_subfield_inserts_indent() { let text = "Reference:\n - Type: Book\n\n"; let edits = on_type_formatting(text, Position::new(2, 0), "\n").unwrap(); assert_eq!(edits.len(), 1); // "Type" starts at column 4 (after " - "), so indent should be 4 spaces. assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_after_subfield_no_dash_prefix() { // "- Type: Book" with dash at column 0 → Type at column 2. let text = "Reference:\n- Type: Book\n\n"; let edits = on_type_formatting(text, Position::new(2, 0), "\n").unwrap(); assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_after_continuation_subfield() { let text = "Registry:\n - Name: PyPI\n Entry: example\n\n"; let edits = on_type_formatting(text, Position::new(3, 0), "\n").unwrap(); assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_after_scalar_field_no_indent() { let text = "Repository: https://example.com\n\n"; let result = on_type_formatting(text, Position::new(1, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_at_start_of_file() { let text = "\n"; let result = on_type_formatting(text, Position::new(0, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_already_has_content() { let text = "Reference:\n - Type: Book\nfoo\n"; let result = on_type_formatting(text, Position::new(2, 0), "\n"); assert!(result.is_none()); } #[test] fn test_newline_editor_auto_indented_wrong() { // Editor auto-indented with 2 spaces, but correct indent is 4. let text = "Reference:\n - Type: Book\n \n"; let edits = on_type_formatting(text, Position::new(2, 2), "\n").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); // Should replace the existing 2 spaces. assert_eq!(edits[0].range.start, Position::new(2, 0)); assert_eq!(edits[0].range.end, Position::new(2, 2)); } #[test] fn test_newline_editor_auto_indented_correct() { // Editor already indented correctly — no edit needed. let text = "Reference:\n - Type: Book\n \n"; let result = on_type_formatting(text, Position::new(2, 4), "\n"); assert!(result.is_none()); } #[test] fn test_newline_trailing_newline_no_empty_line_in_lines() { // This simulates what the editor sends: text ends with \n, and position // is on the line after the last \n. Rust's .lines() won't yield that // empty trailing line. let text = "Reference:\n - Type: Book\n"; let edits = on_type_formatting(text, Position::new(2, 0), "\n").unwrap(); assert_eq!(edits[0].new_text, " "); } #[test] fn test_newline_after_top_level_mapping_list_key() { // User typed "Reference:" and pressed Enter. Should indent for a // sequence item. let text = "Reference:\n"; let edits = on_type_formatting(text, Position::new(1, 0), "\n").unwrap(); // Default indent is key_column(0) + 2 = 2 spaces. assert_eq!(edits[0].new_text, " "); } #[test] fn test_colon_inserts_space() { let text = "Repository:\n"; let edits = on_type_formatting(text, Position::new(0, 11), ":").unwrap(); assert_eq!(edits.len(), 1); assert_eq!(edits[0].new_text, " "); } #[test] fn test_colon_with_existing_space_no_edit() { let text = "Repository: foo\n"; let result = on_type_formatting(text, Position::new(0, 11), ":"); assert!(result.is_none()); } #[test] fn test_colon_in_url_no_edit() { let text = "Repository: https:\n"; // Colon after "https" — there's already a colon earlier on the line. let result = on_type_formatting(text, Position::new(0, 18), ":"); assert!(result.is_none()); } #[test] fn test_colon_after_subfield_name() { let text = "Registry:\n - Name:\n"; let edits = on_type_formatting(text, Position::new(1, 9), ":").unwrap(); assert_eq!(edits[0].new_text, " "); } } debian-lsp-0.1.8/src/upstream_metadata/semantic.rs000064400000000000000000000175671046102023000203300ustar 00000000000000//! Semantic token generation for debian/upstream/metadata files. use tower_lsp_server::ls_types::SemanticToken; use yaml_edit::{Document, Mapping, YamlNode}; use super::fields::get_standard_field_name; use crate::deb822::semantic::{SemanticTokensBuilder, TokenType}; use crate::position::Source; /// Generate semantic tokens for a debian/upstream/metadata file. pub fn generate_semantic_tokens(doc: &Document, src: Source<'_>) -> Vec { let mapping = match doc.as_mapping() { Some(m) => m, None => return vec![], }; let mut builder = SemanticTokensBuilder::new(); emit_mapping_tokens(&mapping, src, &mut builder, true); builder.build() } /// Emit semantic tokens for all entries in a mapping. /// /// When `top_level` is true, keys are checked against DEP-12 field names to /// distinguish known fields from unknown ones. Nested mapping keys are always /// emitted as plain fields. fn emit_mapping_tokens( mapping: &Mapping, src: Source<'_>, builder: &mut SemanticTokensBuilder, top_level: bool, ) { for entry in mapping.entries() { // Emit token for the key if let Some(YamlNode::Scalar(key_scalar)) = entry.key_node() { let key_text = key_scalar.as_string(); let pos = key_scalar.start_position(src.text); let range = key_scalar.byte_range(); let len = range.end - range.start; // LineColumn is 1-indexed, LSP is 0-indexed let line = pos.line.saturating_sub(1) as u32; let col = pos.column.saturating_sub(1) as u32; let token_type = if top_level { if get_standard_field_name(&key_text).is_some() { TokenType::Field } else { TokenType::UnknownField } } else { TokenType::Field }; builder.push( line, col, len, token_type, crate::deb822::semantic::token_modifier::DECLARATION, ); } // Emit tokens for the value, recursing into nested structures if let Some(value_node) = entry.value_node() { emit_node_tokens(&value_node, src, builder); } } } /// Emit semantic tokens for a YAML node, recursing into mappings and sequences. fn emit_node_tokens(node: &YamlNode, src: Source<'_>, builder: &mut SemanticTokensBuilder) { match node { YamlNode::Scalar(scalar) => { let pos = scalar.start_position(src.text); let range = scalar.byte_range(); let len = range.end - range.start; let line = pos.line.saturating_sub(1) as u32; let col = pos.column.saturating_sub(1) as u32; if len > 0 { builder.push(line, col, len, TokenType::Value, 0); } } YamlNode::Mapping(mapping) => { emit_mapping_tokens(mapping, src, builder, false); } YamlNode::Sequence(sequence) => { for item in sequence.values() { emit_node_tokens(&item, src, builder); } } _ => {} } } #[cfg(test)] mod tests { use super::*; fn parse_doc(text: &str) -> Document { text.parse::().unwrap() } #[test] fn test_known_fields() { let text = "Repository: https://github.com/example/project\nBug-Database: https://github.com/example/project/issues\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); assert_eq!(tokens.len(), 4); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 10); // "Repository" assert_eq!(tokens[1].token_type, TokenType::Value as u32); assert_eq!(tokens[2].token_type, TokenType::Field as u32); assert_eq!(tokens[2].length, 12); // "Bug-Database" assert_eq!(tokens[3].token_type, TokenType::Value as u32); } #[test] fn test_unknown_field() { let text = "Repository: https://example.com\nX-Custom: value\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); assert_eq!(tokens.len(), 4); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[2].token_type, TokenType::UnknownField as u32); assert_eq!(tokens[2].length, 8); // "X-Custom" } #[test] fn test_non_mapping_document() { // A YAML document that is a sequence, not a mapping let text = "- item1\n- item2\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); assert_eq!(tokens.len(), 0); } #[test] fn test_sequence_with_nested_mappings() { // Registry has a sequence of mappings — all keys and values should get tokens let text = "Registry:\n - Name: PyPI\n Entry: example\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); // Registry (key) + Name (key) + PyPI (value) + Entry (key) + example (value) assert_eq!(tokens.len(), 5); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 8); // "Registry" assert_eq!(tokens[1].token_type, TokenType::Field as u32); assert_eq!(tokens[1].length, 4); // "Name" assert_eq!(tokens[2].token_type, TokenType::Value as u32); assert_eq!(tokens[2].length, 4); // "PyPI" assert_eq!(tokens[3].token_type, TokenType::Field as u32); assert_eq!(tokens[3].length, 5); // "Entry" assert_eq!(tokens[4].token_type, TokenType::Value as u32); assert_eq!(tokens[4].length, 7); // "example" } #[test] fn test_sequence_of_scalars() { let text = "Other-References:\n - https://example.com\n - https://example.org\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); // Other-References (key) + 2 scalar values assert_eq!(tokens.len(), 3); assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[1].token_type, TokenType::Value as u32); assert_eq!(tokens[2].token_type, TokenType::Value as u32); } #[test] fn test_declaration_modifier_on_keys() { let text = "Repository: https://example.com\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); assert_eq!(tokens.len(), 2); // Key should have DECLARATION modifier assert_eq!( tokens[0].token_modifiers_bitset, crate::deb822::semantic::token_modifier::DECLARATION ); // Value should have no modifiers assert_eq!(tokens[1].token_modifiers_bitset, 0); } #[test] fn test_delta_positions() { let text = "Repository: https://example.com\nBug-Database: https://bugs.example.com\n"; let doc = parse_doc(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&doc, Source::new(text, &idx)); assert_eq!(tokens.len(), 4); // First key at line 0 assert_eq!(tokens[0].delta_line, 0); assert_eq!(tokens[0].delta_start, 0); // First value on same line assert_eq!(tokens[1].delta_line, 0); // Second key on next line assert_eq!(tokens[2].delta_line, 1); assert_eq!(tokens[2].delta_start, 0); } } debian-lsp-0.1.8/src/upstream_metadata/upstream_cache.rs000064400000000000000000000102771046102023000214770ustar 00000000000000//! Cache for guessed upstream metadata values. //! //! Uses the `upstream-ontologist` crate to guess field values from the project //! source tree, and caches results per project root directory. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; use upstream_ontologist::UpstreamDatum; /// Thread-safe shared upstream metadata cache. pub type SharedUpstreamCache = Arc>; /// Cache of guessed upstream metadata values, keyed by project root. pub struct UpstreamCache { /// project_root → (field_name → guessed_values) cache: HashMap>>, } impl UpstreamCache { /// Create a new empty cache. pub fn new() -> Self { Self { cache: HashMap::new(), } } /// Check whether guessed values have been cached for a project root. pub fn is_cached(&self, project_root: &Path) -> bool { self.cache.contains_key(project_root) } /// Look up cached guessed values for a specific field in a project. pub fn get_values(&self, project_root: &Path, field: &str) -> Option<&[String]> { self.cache .get(project_root) .and_then(|fields| fields.get(field)) .map(|v| v.as_slice()) } /// Run the upstream-ontologist guessers and populate the cache for a project. /// /// If `net_access` is true, the upstream-ontologist may make HTTP requests /// to resolve repository URLs, detect forges, etc. pub async fn populate(&mut self, project_root: &Path, net_access: bool) { let metadata = match upstream_ontologist::guess_upstream_metadata( project_root, Some(false), Some(net_access), None, None, ) .await { Ok(m) => m, Err(e) => { tracing::debug!("upstream-ontologist error: {e}"); // Still mark as cached so we don't retry on every keystroke. self.cache .insert(project_root.to_path_buf(), HashMap::new()); return; } }; let mut fields: HashMap> = HashMap::new(); for item in metadata.iter() { let field_name = item.datum.field().to_string(); if let Some(value) = datum_to_string(&item.datum) { let entry = fields.entry(field_name).or_default(); if !entry.contains(&value) { entry.push(value); } } } self.cache.insert(project_root.to_path_buf(), fields); } } /// Extract a string value from an UpstreamDatum for use as a completion. fn datum_to_string(datum: &UpstreamDatum) -> Option { // Most datum types have a direct string representation. if let Some(s) = datum.as_str() { return Some(s.to_string()); } // For types without as_str, try specific conversions. match datum { UpstreamDatum::Screenshots(urls) => { // Return each URL individually — caller deduplicates. urls.first().map(|u| u.to_string()) } UpstreamDatum::Registry(entries) => { // Registry is a list of (name, entry) pairs — not a simple scalar. // Skip for now; sub-field completions handle these. let _ = entries; None } _ => None, } } /// Create a new shared upstream metadata cache. pub fn new_shared() -> SharedUpstreamCache { Arc::new(RwLock::new(UpstreamCache::new())) } #[cfg(test)] mod tests { use super::*; #[test] fn test_empty_cache() { let cache = UpstreamCache::new(); assert!(!cache.is_cached(Path::new("/nonexistent"))); assert_eq!( cache.get_values(Path::new("/nonexistent"), "Repository"), None ); } #[tokio::test] async fn test_populate_caches_result() { let mut cache = UpstreamCache::new(); let dir = tempfile::tempdir().unwrap(); cache.populate(dir.path(), false).await; // After populating, the project root should be cached (even if empty). assert!(cache.is_cached(dir.path())); } } debian-lsp-0.1.8/src/vcswatch.rs000064400000000000000000000117021046102023000146300ustar 00000000000000//! VCS watch data from UDD (Ultimate Debian Database). //! //! Queries the `vcswatch` table to find the latest packaged version //! for a given VCS repository URL. use std::num::NonZeroUsize; use std::sync::Arc; use lru::LruCache; use tokio::sync::RwLock; /// Thread-safe shared cache for VCS watch lookups. pub type SharedVcsWatchCache = Arc>; /// Maximum number of distinct VCS URLs cached. URLs referenced over a /// long editor session would otherwise grow unbounded; with the LRU /// the oldest entries fall out once the cap is reached. const VCSWATCH_CACHE_CAPACITY: usize = 1024; /// Cached VCS watch data from UDD. pub struct VcsWatchCache { pool: crate::udd::SharedPool, /// Map from VCS URL to packaged version. `None` means "looked up, not found". version_by_url: LruCache>, } #[derive(sqlx::FromRow)] struct VcsWatchRow { url: Option, version: Option, } impl VcsWatchCache { /// Create a new VCS watch cache using the given UDD connection pool. pub fn new(pool: crate::udd::SharedPool) -> Self { Self { pool, version_by_url: LruCache::new( NonZeroUsize::new(VCSWATCH_CACHE_CAPACITY).expect("non-zero capacity"), ), } } /// Look up the packaged version for a VCS URL, fetching if needed. /// /// Returns `None` if the URL is not found in vcswatch or the query fails. pub async fn get_version_for_url(&mut self, url: &str) -> Option<&str> { if !self.version_by_url.contains(url) { self.fetch_version_for_url(url).await; } self.version_by_url.get(url).and_then(|v| v.as_deref()) } /// Look up the packaged version from cache only, without fetching. /// Does not promote the entry in the LRU. pub fn get_cached_version_for_url(&self, url: &str) -> Option<&str> { self.version_by_url.peek(url).and_then(|v| v.as_deref()) } /// Returns `true` if this URL has been looked up (hit or miss). pub fn is_cached(&self, url: &str) -> bool { self.version_by_url.contains(url) } async fn fetch_version_for_url(&mut self, url: &str) { let row: Option = match sqlx::query_as("SELECT url, version::text FROM vcswatch WHERE url = $1 LIMIT 1") .bind(url) .fetch_optional(&*self.pool) .await { Ok(row) => row, Err(e) => { tracing::warn!(url, error = %e, "UDD vcswatch query failed"); return; } }; match row { Some(VcsWatchRow { url: Some(row_url), version, }) => { self.version_by_url.put(row_url, version); } _ => { // Not found — cache as None to avoid re-querying self.version_by_url.put(url.to_string(), None); } } } /// Insert a cached entry for testing purposes. #[cfg(test)] pub(crate) fn insert_cached(&mut self, url: &str, version: &str) { self.version_by_url .put(url.to_string(), Some(version.to_string())); } } /// Create a new shared VCS watch cache. pub fn new_shared_vcswatch_cache(pool: crate::udd::SharedPool) -> SharedVcsWatchCache { Arc::new(RwLock::new(VcsWatchCache::new(pool))) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_get_version_from_cache() { let mut cache = VcsWatchCache::new(crate::udd::shared_pool()); cache.insert_cached( "https://salsa.debian.org/python-team/packages/dulwich.git", "1.1.0-1", ); let version = cache .get_version_for_url("https://salsa.debian.org/python-team/packages/dulwich.git") .await; assert_eq!(version, Some("1.1.0-1")); } #[tokio::test] async fn test_get_version_unknown_url() { let mut cache = VcsWatchCache::new(crate::udd::shared_pool()); cache.insert_cached( "https://salsa.debian.org/python-team/packages/dulwich.git", "1.1.0-1", ); // This URL is not in the cache so it will try UDD and fail (no network), // but fetch_version_for_url returns early on error without caching. // On second call it would retry, which is acceptable for network errors. let version = cache .get_version_for_url("https://example.com/nonexistent.git") .await; assert_eq!(version, None); } #[tokio::test] #[ignore] // requires network access to UDD async fn test_fetch_from_udd() { let mut cache = VcsWatchCache::new(crate::udd::shared_pool()); let version = cache .get_version_for_url("https://salsa.debian.org/python-team/packages/dulwich.git") .await; assert!(version.is_some(), "dulwich should be tracked in vcswatch"); } } debian-lsp-0.1.8/src/watch/completion.rs000064400000000000000000000421641046102023000162730ustar 00000000000000use crate::deb822::completion::FieldInfo; use tower_lsp_server::ls_types::{ CompletionItem, CompletionItemKind, Documentation, Position, Uri, }; use super::detection::is_watch_file; use super::fields::{OptionValueType, WATCH_FIELDS, WATCH_LINEBASED_VERSIONS, WATCH_VERSIONS}; use crate::position::Source; /// Build a `FieldInfo` slice for deb822 (v5) field name completions. fn deb822_field_infos() -> Vec { WATCH_FIELDS .iter() .map(|f| FieldInfo::new(f.deb822_name, f.description)) .collect() } /// Get completion items for a v1-4 (line-based) watch file using the CST. pub fn get_linebased_completions( uri: &Uri, wf: &debian_watch::linebased::WatchFile, src: Source<'_>, position: Position, ) -> Vec { if !is_watch_file(uri) { return Vec::new(); } let Some(offset) = src.try_position_to_offset(position) else { return Vec::new(); }; // Walk ancestors of the token at the cursor to determine context if let Some(token) = wf.syntax().token_at_offset(offset).right_biased() { for ancestor in token.parent_ancestors() { match ancestor.kind() { debian_watch::SyntaxKind::OPTION => { // If cursor is after '=', offer value completions let has_eq_before_cursor = ancestor.children_with_tokens().any(|el| { el.kind() == debian_watch::SyntaxKind::EQUALS && el.text_range().end() <= offset }); if has_eq_before_cursor { let key = ancestor.children_with_tokens().find_map(|el| { if el.kind() == debian_watch::SyntaxKind::KEY { Some(el.as_token()?.text().to_string()) } else { None } }); let prefix = if token.kind() == debian_watch::SyntaxKind::VALUE { token.text() } else { "" }; return key .and_then(|k| { WATCH_FIELDS .iter() .find(|f| f.linebased_name == Some(k.as_str())) }) .map(|f| (f.complete_values)(prefix)) .unwrap_or_default(); } // Cursor is on the key name let prefix = if token.kind() == debian_watch::SyntaxKind::KEY { token.text() } else { "" }; return get_linebased_option_completions_with_prefix(prefix); } debian_watch::SyntaxKind::OPTS_LIST => { // Cursor is in opts area but not inside an OPTION node return get_linebased_option_completions_with_prefix(""); } debian_watch::SyntaxKind::VERSION => { // If cursor is after '=', offer version number completions let has_eq_before_cursor = ancestor.children_with_tokens().any(|el| { el.kind() == debian_watch::SyntaxKind::EQUALS && el.text_range().end() <= offset }); if has_eq_before_cursor { return get_linebased_version_value_completions(); } } _ => {} } } } // Default: offer version and option completions let mut completions = Vec::new(); completions.extend(get_linebased_option_completions()); completions.extend(get_linebased_version_completions()); completions } /// Get option name completions filtered by prefix for v1-4 watch files. fn get_linebased_option_completions_with_prefix(prefix: &str) -> Vec { let normalized = prefix.trim().to_ascii_lowercase(); WATCH_FIELDS .iter() .filter_map(|field| { let name = field.linebased_name?; if !name.starts_with(&normalized) { return None; } let insert_text = match field.value_type { OptionValueType::Boolean => name.to_string(), OptionValueType::String | OptionValueType::Enum(_) => { format!("{}=", name) } }; Some(CompletionItem { label: name.to_string(), kind: Some(CompletionItemKind::PROPERTY), detail: Some(field.description.to_string()), documentation: Some(Documentation::String(field.description.to_string())), insert_text: Some(insert_text), ..Default::default() }) }) .collect() } /// Get completion items for a v5 (deb822) watch file, using position-aware completions. pub fn get_completions_deb822( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, ) -> Vec { let field_infos = deb822_field_infos(); crate::deb822::completion::get_completions( deb822, src, position, &field_infos, |field_name, prefix| { let lower = field_name.to_lowercase(); WATCH_FIELDS .iter() .find(|f| f.deb822_name.to_lowercase() == lower) .map(|f| (f.complete_values)(prefix)) .unwrap_or_default() }, ) } /// Get completion items for v1-4 watch file options (line-based format). pub fn get_linebased_option_completions() -> Vec { WATCH_FIELDS .iter() .filter_map(|field| { let name = field.linebased_name?; let insert_text = match field.value_type { OptionValueType::Boolean => name.to_string(), OptionValueType::String | OptionValueType::Enum(_) => { format!("{}=", name) } }; Some(CompletionItem { label: name.to_string(), kind: Some(CompletionItemKind::PROPERTY), detail: Some(field.description.to_string()), documentation: Some(Documentation::String(field.description.to_string())), insert_text: Some(insert_text), ..Default::default() }) }) .collect() } /// Get completion items for watch file `version=N` lines (used in default context). pub fn get_linebased_version_completions() -> Vec { WATCH_VERSIONS .iter() .map(|version| CompletionItem { label: format!("version={}", version), kind: Some(CompletionItemKind::KEYWORD), detail: Some(format!("Watch file format version {}", version)), insert_text: Some(format!("version={}", version)), ..Default::default() }) .collect() } /// Get completion items for version numbers (used when cursor is after `version=`). fn get_linebased_version_value_completions() -> Vec { WATCH_LINEBASED_VERSIONS .iter() .map(|version| CompletionItem { label: version.to_string(), kind: Some(CompletionItemKind::VALUE), detail: Some(format!("Watch file format version {}", version)), ..Default::default() }) .collect() } #[cfg(test)] mod tests { use super::*; fn parse_linebased(text: &str) -> debian_watch::linebased::WatchFile { debian_watch::linebased::parse_watch_file(text).tree() } #[test] fn test_get_linebased_completions_for_watch_file() { let uri: Uri = str::parse("file:///path/to/debian/watch").unwrap(); let text = "version=4\n"; let wf = parse_linebased(text); let idx = crate::position::LineIndex::new(text); let completions = get_linebased_completions(&uri, &wf, Source::new(text, &idx), Position::new(0, 0)); assert!(!completions.is_empty()); let option_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::PROPERTY)) .count(); let version_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::KEYWORD)) .count(); assert!(option_count > 0); assert!(version_count > 0); } #[test] fn test_get_linebased_completions_for_non_watch_file() { let uri: Uri = str::parse("file:///path/to/other.txt").unwrap(); let text = "version=4\n"; let wf = parse_linebased(text); let idx = crate::position::LineIndex::new(text); let completions = get_linebased_completions(&uri, &wf, Source::new(text, &idx), Position::new(0, 0)); assert!(completions.is_empty()); } #[test] fn test_get_linebased_completions_in_opts_option_name() { let uri: Uri = str::parse("file:///path/to/debian/watch").unwrap(); let text = "version=4\nopts=\"mode=git,\" https://example.com\n"; let wf = parse_linebased(text); // Cursor right after the comma — should offer option names let idx = crate::position::LineIndex::new(text); let completions = get_linebased_completions(&uri, &wf, Source::new(text, &idx), Position::new(1, 14)); assert!(!completions.is_empty()); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"pgpmode")); } #[test] fn test_get_linebased_completions_in_opts_option_value() { let uri: Uri = str::parse("file:///path/to/debian/watch").unwrap(); let text = "version=4\nopts=\"mode=git\" https://example.com\n"; let wf = parse_linebased(text); // Cursor on the value "git" — should offer mode values let idx = crate::position::LineIndex::new(text); let completions = get_linebased_completions(&uri, &wf, Source::new(text, &idx), Position::new(1, 13)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"git")); assert!(labels.contains(&"lwp")); assert!(labels.contains(&"svn")); } #[test] fn test_get_linebased_completions_version_value() { let uri: Uri = str::parse("file:///path/to/debian/watch").unwrap(); let text = "version=4\n"; let wf = parse_linebased(text); // Cursor on the version number after '=' let idx = crate::position::LineIndex::new(text); let completions = get_linebased_completions(&uri, &wf, Source::new(text, &idx), Position::new(0, 9)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["1", "2", "3", "4"]); assert!(completions .iter() .all(|c| c.kind == Some(CompletionItemKind::VALUE))); } #[test] fn test_get_completions_deb822_on_field_key() { let text = "Version: 5\n\nSource: https://example.com\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 3)); let field_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::FIELD)) .count(); assert!(field_count > 0); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert!(labels.contains(&"Source")); assert!(labels.contains(&"Matching-Pattern")); assert!(labels.contains(&"Version")); } #[test] fn test_get_completions_deb822_on_string_value() { let text = "Version: 5\n\nSource: https://example.com\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Source is a string field, no value completions let completions = get_completions_deb822(&deb822, src, Position::new(2, 15)); assert!(completions.is_empty()); } #[test] fn test_get_completions_deb822_on_boolean_value() { let text = "Version: 5\n\nBare: \n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 6)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["yes", "no"]); } #[test] fn test_get_completions_deb822_on_enum_value() { let text = "Version: 5\n\nMode: \n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 6)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["lwp", "git", "svn"]); } #[test] fn test_get_completions_deb822_on_enum_value_with_prefix() { let text = "Version: 5\n\nMode: g\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 7)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["git"]); } #[test] fn test_get_completions_deb822_on_template_value() { let text = "Version: 5\n\nTemplate: \n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 10)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!( labels, vec!["github", "gitlab", "pypi", "npmregistry", "metacpan"] ); } #[test] fn test_get_completions_deb822_on_template_value_with_prefix() { let text = "Version: 5\n\nTemplate: g\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(2, 11)); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, vec!["github", "gitlab"]); } #[test] fn test_get_completions_deb822_on_empty() { let text = ""; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let completions = get_completions_deb822(&deb822, src, Position::new(0, 0)); let field_count = completions .iter() .filter(|c| c.kind == Some(CompletionItemKind::FIELD)) .count(); assert!(field_count > 0); } #[test] fn test_option_completions() { let completions = get_linebased_option_completions(); assert!(!completions.is_empty()); for completion in &completions { assert!(!completion.label.is_empty()); assert_eq!(completion.kind, Some(CompletionItemKind::PROPERTY)); assert!(completion.detail.is_some()); assert!(completion.documentation.is_some()); assert!(completion.insert_text.is_some()); } let labels: Vec<_> = completions.iter().map(|c| &c.label).collect(); assert!(labels.iter().any(|l| *l == "mode")); assert!(labels.iter().any(|l| *l == "pgpmode")); assert!(labels.iter().any(|l| *l == "uversionmangle")); } #[test] fn test_option_completions_exclude_v5_only() { let completions = get_linebased_option_completions(); let labels: Vec<_> = completions.iter().map(|c| c.label.as_str()).collect(); // v5-only fields should not appear as line-based options assert!(!labels.contains(&"Source")); assert!(!labels.contains(&"Matching-Pattern")); assert!(!labels.contains(&"Version")); } #[test] fn test_version_completions() { let completions = get_linebased_version_completions(); assert_eq!(completions.len(), WATCH_VERSIONS.len()); for completion in &completions { assert!(!completion.label.is_empty()); assert_eq!(completion.kind, Some(CompletionItemKind::KEYWORD)); assert!(completion.label.starts_with("version=")); } } } debian-lsp-0.1.8/src/watch/detection.rs000064400000000000000000000023631046102023000160750ustar 00000000000000use tower_lsp_server::ls_types::Uri; /// Check if a given URL represents a Debian watch file pub fn is_watch_file(uri: &Uri) -> bool { let path = uri.as_str(); path.ends_with("/watch") || path.ends_with("/debian/watch") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_watch_file() { let watch_paths = vec![ "file:///path/to/debian/watch", "file:///project/debian/watch", "file:///watch", "file:///some/path/watch", ]; let non_watch_paths = vec![ "file:///path/to/other.txt", "file:///path/to/watch.txt", "file:///path/to/mywatch", "file:///path/to/debian/watch.backup", "file:///path/to/debian/control", "file:///path/to/debian/copyright", ]; for path in watch_paths { let uri = path.parse::().unwrap(); assert!(is_watch_file(&uri), "Should detect watch file: {}", path); } for path in non_watch_paths { let uri = path.parse::().unwrap(); assert!( !is_watch_file(&uri), "Should not detect as watch file: {}", path ); } } } debian-lsp-0.1.8/src/watch/fields.rs000064400000000000000000000316401046102023000153650ustar 00000000000000/// Type of value a watch field/option accepts #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OptionValueType { /// Boolean option (no value) Boolean, /// String value String, /// Enum with predefined values Enum(&'static [&'static str]), } use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind}; /// A watch file field definition, used for both v1-4 line-based options and v5 deb822 fields. pub struct WatchField { /// Canonical field name (deb822 / title-case form, used in v5). pub deb822_name: &'static str, /// Option name used in v1-4 line-based format, or `None` for v5-only fields. pub linebased_name: Option<&'static str>, /// Human-readable description. pub description: &'static str, /// Type of value the field accepts. pub value_type: OptionValueType, /// Callback returning completion items for this field's values. /// Receives the prefix already typed by the user for filtering. pub complete_values: fn(&str) -> Vec, } fn no_completions(_prefix: &str) -> Vec { vec![] } fn enum_completions(values: &[&str], prefix: &str) -> Vec { let normalized = prefix.trim().to_ascii_lowercase(); values .iter() .filter(|v| v.starts_with(&normalized)) .map(|&v| CompletionItem { label: v.to_string(), kind: Some(CompletionItemKind::VALUE), ..Default::default() }) .collect() } fn boolean_completions(prefix: &str) -> Vec { enum_completions(&["yes", "no"], prefix) } fn compression_completions(prefix: &str) -> Vec { enum_completions(&["gzip", "xz", "bzip2", "lzma", "default"], prefix) } fn mode_completions(prefix: &str) -> Vec { enum_completions(&["lwp", "git", "svn"], prefix) } fn pgpmode_completions(prefix: &str) -> Vec { enum_completions( &[ "auto", "default", "mangle", "next", "previous", "self", "gittag", ], prefix, ) } fn searchmode_completions(prefix: &str) -> Vec { enum_completions(&["html", "plain"], prefix) } fn gitmode_completions(prefix: &str) -> Vec { enum_completions(&["shallow", "full"], prefix) } fn gitexport_completions(prefix: &str) -> Vec { enum_completions(&["default", "all"], prefix) } fn ctype_completions(prefix: &str) -> Vec { enum_completions(&["perl", "nodejs"], prefix) } // TODO: derive template names from debian_watch::templates::Template enum fn template_completions(prefix: &str) -> Vec { enum_completions( &["github", "gitlab", "pypi", "npmregistry", "metacpan"], prefix, ) } impl WatchField { pub const fn new( deb822_name: &'static str, linebased_name: Option<&'static str>, description: &'static str, value_type: OptionValueType, complete_values: fn(&str) -> Vec, ) -> Self { Self { deb822_name, linebased_name, description, value_type, complete_values, } } } /// All known watch file fields/options. pub const WATCH_FIELDS: &[WatchField] = &[ // v5-only fields (no line-based equivalent) WatchField::new( "Version", None, "Watch file format version", OptionValueType::String, no_completions, ), WatchField::new( "Source", None, "URL to check for upstream releases", OptionValueType::String, no_completions, ), WatchField::new( "Matching-Pattern", None, "Regex pattern to match upstream files", OptionValueType::String, no_completions, ), WatchField::new( "Template", None, "URL template for constructing download URLs (github, gitlab, pypi, npmregistry, metacpan)", OptionValueType::Enum(&["github", "gitlab", "pypi", "npmregistry", "metacpan"]), template_completions, ), WatchField::new( "Owner", None, "Owner name for repository-based sources (used with github template)", OptionValueType::String, no_completions, ), WatchField::new( "Project", None, "Project name for repository-based sources (used with github template)", OptionValueType::String, no_completions, ), WatchField::new( "Dist", None, "Distribution or package name (used with gitlab, pypi, npmregistry, metacpan templates)", OptionValueType::String, no_completions, ), WatchField::new( "Release-Only", None, "Restrict to releases only, not all tags (used with github and gitlab templates)", OptionValueType::Boolean, boolean_completions, ), WatchField::new( "Version-Type", None, "Version pattern type (e.g. semantic, stable) — expands to @TYPE_VERSION@ in matching pattern", OptionValueType::String, no_completions, ), // Fields available in both v1-4 (as options) and v5 (as deb822 fields) WatchField::new( "Component", Some("component"), "Component name for multi-tarball packages", OptionValueType::String, no_completions, ), WatchField::new( "Compression", Some("compression"), "Compression format (gzip, xz, bzip2, lzma)", OptionValueType::Enum(&["gzip", "xz", "bzip2", "lzma", "default"]), compression_completions, ), WatchField::new( "Mode", Some("mode"), "Download mode (lwp, git, svn)", OptionValueType::Enum(&["lwp", "git", "svn"]), mode_completions, ), WatchField::new( "Pgpmode", Some("pgpmode"), "PGP verification mode", OptionValueType::Enum(&[ "auto", "default", "mangle", "next", "previous", "self", "gittag", ]), pgpmode_completions, ), WatchField::new( "Searchmode", Some("searchmode"), "Search mode for finding upstream versions", OptionValueType::Enum(&["html", "plain"]), searchmode_completions, ), WatchField::new( "Gitmode", Some("gitmode"), "Git clone mode", OptionValueType::Enum(&["shallow", "full"]), gitmode_completions, ), WatchField::new( "Gitexport", Some("gitexport"), "Git export mode", OptionValueType::Enum(&["default", "all"]), gitexport_completions, ), WatchField::new( "Pretty", Some("pretty"), "Pretty format for git tags", OptionValueType::String, no_completions, ), WatchField::new( "Uversionmangle", Some("uversionmangle"), "Upstream version mangling rules (s/pattern/replacement/)", OptionValueType::String, no_completions, ), WatchField::new( "Oversionmangle", Some("oversionmangle"), "Upstream version mangling rules (alternative name)", OptionValueType::String, no_completions, ), WatchField::new( "Dversionmangle", Some("dversionmangle"), "Debian version mangling rules (s/pattern/replacement/)", OptionValueType::String, no_completions, ), WatchField::new( "Dirversionmangle", Some("dirversionmangle"), "Directory version mangling rules for mode=git", OptionValueType::String, no_completions, ), WatchField::new( "Pagemangle", Some("pagemangle"), "Page content mangling rules", OptionValueType::String, no_completions, ), WatchField::new( "Downloadurlmangle", Some("downloadurlmangle"), "Download URL mangling rules", OptionValueType::String, no_completions, ), WatchField::new( "Pgpsigurlmangle", Some("pgpsigurlmangle"), "PGP signature URL mangling rules", OptionValueType::String, no_completions, ), WatchField::new( "Filenamemangle", Some("filenamemangle"), "Filename mangling rules", OptionValueType::String, no_completions, ), WatchField::new( "Versionmangle", Some("versionmangle"), "Version policy (debian, same, previous, ignore, group, checksum)", OptionValueType::String, no_completions, ), WatchField::new( "User-Agent", Some("user-agent"), "User agent string for HTTP requests", OptionValueType::String, no_completions, ), WatchField::new( "Useragent", Some("useragent"), "User agent string for HTTP requests (alternative name)", OptionValueType::String, no_completions, ), WatchField::new( "Ctype", Some("ctype"), "Component type (perl, nodejs)", OptionValueType::Enum(&["perl", "nodejs"]), ctype_completions, ), WatchField::new( "Repacksuffix", Some("repacksuffix"), "Suffix for repacked tarballs", OptionValueType::String, no_completions, ), WatchField::new( "Decompress", Some("decompress"), "Decompress downloaded files", OptionValueType::Boolean, boolean_completions, ), WatchField::new( "Bare", Some("bare"), "Use bare git clone for mode=git", OptionValueType::Boolean, boolean_completions, ), WatchField::new( "Repack", Some("repack"), "Repack the upstream tarball", OptionValueType::Boolean, boolean_completions, ), ]; /// Get the standard (canonical deb822) name for a watch field. pub fn get_standard_field_name(field_name: &str) -> Option<&'static str> { let lower = field_name.to_lowercase(); WATCH_FIELDS .iter() .find(|f| f.deb822_name.to_lowercase() == lower) .map(|f| f.deb822_name) } /// Watch file format versions pub const WATCH_VERSIONS: &[u32] = &[1, 2, 3, 4, 5]; /// Line-based watch file format versions (v5 uses deb822 format) pub const WATCH_LINEBASED_VERSIONS: &[u32] = &[1, 2, 3, 4]; #[cfg(test)] mod tests { use super::*; #[test] fn test_watch_fields() { assert!(!WATCH_FIELDS.is_empty()); assert!(WATCH_FIELDS.len() >= 20); let deb822_names: Vec<_> = WATCH_FIELDS.iter().map(|f| f.deb822_name).collect(); assert!(deb822_names.contains(&"Mode")); assert!(deb822_names.contains(&"Pgpmode")); assert!(deb822_names.contains(&"Uversionmangle")); assert!(deb822_names.contains(&"Compression")); assert!(deb822_names.contains(&"Source")); assert!(deb822_names.contains(&"Matching-Pattern")); assert!(deb822_names.contains(&"Version")); } #[test] fn test_linebased_options() { let options: Vec<_> = WATCH_FIELDS .iter() .filter_map(|f| f.linebased_name) .collect(); assert!(options.len() >= 20); assert!(options.contains(&"mode")); assert!(options.contains(&"pgpmode")); assert!(options.contains(&"uversionmangle")); assert!(options.contains(&"compression")); } #[test] fn test_v5_only_fields_have_no_linebased_name() { for field in WATCH_FIELDS { if [ "Version", "Source", "Matching-Pattern", "Template", "Owner", "Project", "Dist", "Release-Only", "Version-Type", ] .contains(&field.deb822_name) { assert!( field.linebased_name.is_none(), "{} should be v5-only", field.deb822_name ); } } } #[test] fn test_watch_field_validity() { for field in WATCH_FIELDS { assert!(!field.deb822_name.is_empty()); assert!(!field.description.is_empty()); if let OptionValueType::Enum(values) = field.value_type { assert!( !values.is_empty(), "Enum field {} has no values", field.deb822_name ); } } } #[test] fn test_get_standard_field_name() { assert_eq!(get_standard_field_name("Source"), Some("Source")); assert_eq!(get_standard_field_name("source"), Some("Source")); assert_eq!( get_standard_field_name("Matching-Pattern"), Some("Matching-Pattern") ); assert_eq!(get_standard_field_name("mode"), Some("Mode")); assert_eq!(get_standard_field_name("UnknownField"), None); } #[test] fn test_watch_versions() { assert_eq!(WATCH_VERSIONS, &[1, 2, 3, 4, 5]); } } debian-lsp-0.1.8/src/watch/folding.rs000064400000000000000000000070341046102023000155410ustar 00000000000000//! Folding range generation for Debian watch files. //! //! Supports both deb822 (v5) and line-based (v1-4) watch file formats. use crate::position::Source; use tower_lsp_server::ls_types::{FoldingRange, FoldingRangeKind}; /// Generate folding ranges for a watch file. /// /// For deb822 watch files, each paragraph becomes a foldable region. /// For line-based watch files, each entry becomes a foldable region. pub fn generate_folding_ranges( parse: &debian_watch::parse::Parse, src: Source<'_>, ) -> Vec { match parse.to_watch_file() { debian_watch::parse::ParsedWatchFile::Deb822(wf) => { crate::deb822::folding::generate_folding_ranges(wf.as_deb822(), src) } debian_watch::parse::ParsedWatchFile::LineBased(wf) => { generate_linebased_folding_ranges(&wf, src) } } } fn generate_linebased_folding_ranges( wf: &debian_watch::linebased::WatchFile, src: Source<'_>, ) -> Vec { wf.entries() .filter_map(|entry: debian_watch::linebased::Entry| { let range = src.text_range_to_lsp_range(entry.syntax().text_range()); let end_line = if range.end.character == 0 && range.end.line > range.start.line { range.end.line - 1 } else { range.end.line }; if range.start.line == end_line { return None; } Some(FoldingRange { start_line: range.start.line, start_character: None, end_line, end_character: None, kind: Some(FoldingRangeKind::Region), collapsed_text: None, }) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_v4_multiline_entry() { let text = "version=4\nopts=foo=bar \\\nhttps://example.com .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); // The entry with continuation line spans multiple lines assert!(!ranges.is_empty()); assert_eq!(ranges[0].kind, Some(FoldingRangeKind::Region)); } #[test] fn test_v4_single_line_entries_excluded() { let text = "version=4\nhttps://example.com .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); // Single-line entries should not produce folding ranges assert_eq!(ranges.len(), 0); } #[test] fn test_v5_deb822_paragraphs() { let text = "\ Version: 5 Source: https://github.com/owner/repo/tags Matching-Pattern: .*/v?(\\d[\\d.]*)/.tar.gz "; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); // The second paragraph (Source + Matching-Pattern) should be foldable assert!(!ranges.is_empty()); } #[test] fn test_empty_watch_file() { let text = "version=4\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let ranges = generate_folding_ranges(&parsed, Source::new(text, &idx)); assert_eq!(ranges.len(), 0); } } debian-lsp-0.1.8/src/watch/hover.rs000064400000000000000000000052601046102023000152410ustar 00000000000000use tower_lsp_server::ls_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position}; use crate::deb822::completion::{get_cursor_context, CursorContext}; use super::fields::WATCH_FIELDS; use crate::position::Source; /// Look up a watch field description by name (case-insensitive). fn get_field_description(field_name: &str) -> Option<(&'static str, &'static str)> { let lowercase = field_name.to_lowercase(); WATCH_FIELDS .iter() .find(|f| f.deb822_name.to_lowercase() == lowercase) .map(|f| (f.deb822_name, f.description)) } fn make_hover(field_name: &str, description: &str) -> Hover { Hover { contents: HoverContents::Markup(MarkupContent { kind: MarkupKind::Markdown, value: format!("**{}**\n\n{}", field_name, description), }), range: None, } } /// Get hover information for a debian/watch v5 (deb822) file at the given cursor position. pub fn get_hover( deb822: &deb822_lossless::Deb822, src: Source<'_>, position: Position, ) -> Option { let ctx = get_cursor_context(deb822, src, position)?; match ctx { CursorContext::FieldKey => { let offset = src.try_position_to_offset(position)?; let entry = deb822 .paragraphs() .flat_map(|p| p.entries().collect::>()) .find(|entry| { let r = entry.text_range(); r.start() <= offset && offset <= r.end() })?; let field_name = entry.key()?; get_field_description(&field_name) .map(|(canonical, description)| make_hover(canonical, description)) } CursorContext::FieldValue { field_name, .. } => get_field_description(&field_name) .map(|(canonical, description)| make_hover(canonical, description)), CursorContext::StartOfLine => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_hover_on_mode() { let text = "Version: 5\n\nSource: https://example.com\nMode: git\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(3, 2)); assert!(hover.is_some()); } #[test] fn test_hover_on_unknown_field() { let text = "Version: 5\n\nUnknown: value\n"; let deb822 = deb822_lossless::Deb822::parse(text).to_result().unwrap(); let idx = crate::position::LineIndex::new(text); let hover = get_hover(&deb822, Source::new(text, &idx), Position::new(2, 3)); assert!(hover.is_none()); } } debian-lsp-0.1.8/src/watch/mod.rs000064400000000000000000000005401046102023000146710ustar 00000000000000pub mod completion; pub mod detection; pub mod fields; pub mod folding; pub mod hover; pub mod selection_range; pub mod semantic; pub use completion::*; pub use detection::is_watch_file; pub use folding::generate_folding_ranges; pub use hover::get_hover; pub use selection_range::generate_selection_ranges; pub use semantic::generate_semantic_tokens; debian-lsp-0.1.8/src/watch/selection_range.rs000064400000000000000000000105131046102023000172540ustar 00000000000000//! Selection range generation for Debian watch files. //! //! Supports both deb822 (v5) and line-based (v1-4) watch file formats. //! For deb822: value → entry → paragraph → file (via generic deb822 support). //! For line-based: entry → file. use crate::position::Source; use text_size::TextSize; use tower_lsp_server::ls_types::{Position, Range, SelectionRange}; /// Generate selection ranges for a watch file. pub fn generate_selection_ranges( parse: &debian_watch::parse::Parse, src: Source<'_>, positions: &[Position], ) -> Vec { match parse.to_watch_file() { debian_watch::parse::ParsedWatchFile::Deb822(wf) => { crate::deb822::selection_range::generate_selection_ranges( wf.as_deb822(), src, positions, ) } debian_watch::parse::ParsedWatchFile::LineBased(wf) => { generate_linebased_selection_ranges(&wf, src, positions) } } } fn generate_linebased_selection_ranges( wf: &debian_watch::linebased::WatchFile, src: Source<'_>, positions: &[Position], ) -> Vec { let file_range = Range::new( Position::new(0, 0), src.offset_to_position(TextSize::from(src.text.len() as u32)), ); positions .iter() .map(|pos| { let file_sel = SelectionRange { range: file_range, parent: None, }; let Some(offset) = src.try_position_to_offset(*pos) else { return file_sel; }; let Some(entry) = wf.entries().find(|e| { let r = e.syntax().text_range(); r.contains(offset) || r.end() == offset }) else { return file_sel; }; SelectionRange { range: src.text_range_to_lsp_range(entry.syntax().text_range()), parent: Some(Box::new(file_sel)), } }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_v4_selection_in_entry() { let text = "version=4\nhttps://example.com .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let ranges = generate_selection_ranges(&parsed, src, &[Position::new(1, 5)]); assert_eq!(ranges.len(), 1); let sel = &ranges[0]; // Entry range assert_eq!(sel.range.start.line, 1); // Parent: file let file_sel = sel.parent.as_ref().unwrap(); assert_eq!(file_sel.range.start.line, 0); assert!(file_sel.parent.is_none()); } #[test] fn test_v4_selection_in_version_line() { let text = "version=4\nhttps://example.com .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position on the "version=4" line — not inside any entry let ranges = generate_selection_ranges(&parsed, src, &[Position::new(0, 3)]); assert_eq!(ranges.len(), 1); // Should fall back to file range assert!(ranges[0].parent.is_none()); } #[test] fn test_v5_deb822_selection() { let text = "\ Version: 5 Source: https://github.com/owner/repo/tags Matching-Pattern: .*/v?(\\d[\\d.]*)/.tar.gz "; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); // Position in "Source" value let ranges = generate_selection_ranges(&parsed, src, &[Position::new(2, 10)]); assert_eq!(ranges.len(), 1); // Should have value → entry → paragraph → file hierarchy let sel = &ranges[0]; assert!(sel.parent.is_some()); } #[test] fn test_empty_watch_file() { let text = "version=4\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let src = Source::new(text, &idx); let ranges = generate_selection_ranges(&parsed, src, &[Position::new(0, 3)]); assert_eq!(ranges.len(), 1); assert!(ranges[0].parent.is_none()); } } debian-lsp-0.1.8/src/watch/semantic.rs000064400000000000000000000141331046102023000157200ustar 00000000000000//! Semantic token generation for Debian watch files. //! //! Supports both deb822 (v5) and line-based (v1-4) watch file formats. use tower_lsp_server::ls_types::SemanticToken; use crate::deb822::semantic::{SemanticTokensBuilder, TokenType}; use crate::position::Source; /// Field validator for v5 watch files struct WatchFieldValidator; impl crate::deb822::semantic::FieldValidator for WatchFieldValidator { fn get_standard_field_name(&self, name: &str) -> Option<&'static str> { super::fields::get_standard_field_name(name) } } /// Generate semantic tokens for a watch file pub fn generate_semantic_tokens( parse: &debian_watch::parse::Parse, src: Source<'_>, ) -> Vec { match parse.to_watch_file() { debian_watch::parse::ParsedWatchFile::Deb822(wf) => { let validator = WatchFieldValidator; crate::deb822::semantic::generate_tokens(wf.as_deb822(), src, &validator) } debian_watch::parse::ParsedWatchFile::LineBased(_) => generate_linebased_tokens(src), } } /// Generate tokens for v1-4 line-based watch files fn generate_linebased_tokens(src: Source<'_>) -> Vec { use debian_watch::SyntaxKind; let parsed = debian_watch::linebased::parse_watch_file(src.text); let wf = parsed.tree(); let mut builder = SemanticTokensBuilder::new(); for element in wf.syntax().descendants_with_tokens() { if let rowan::NodeOrToken::Token(token) = element { let kind = token.kind(); let token_type = match kind { SyntaxKind::KEY => Some(TokenType::Field), SyntaxKind::VALUE => { let parent_kind = token.parent().map(|p| p.kind()); match parent_kind { Some(SyntaxKind::VERSION) | Some(SyntaxKind::OPTION) | Some(SyntaxKind::URL) | Some(SyntaxKind::MATCHING_PATTERN) | Some(SyntaxKind::VERSION_POLICY) | Some(SyntaxKind::SCRIPT) => Some(TokenType::Value), _ => None, } } SyntaxKind::COMMENT => Some(TokenType::Comment), _ => None, }; if let Some(tt) = token_type { let range = token.text_range(); let start_pos = src.offset_to_position(range.start()); let length = crate::position::utf16_len(token.text()); if length > 0 { builder.push(start_pos.line, start_pos.character, length, tt, 0); } } } } builder.build() } #[cfg(test)] mod tests { use super::*; #[test] fn test_v5_known_fields() { let text = "Version: 5\n\nSource: https://github.com/owner/repo/tags\nMatching-Pattern: .*/v?(\\d[\\d.]*)/.tar.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); assert!(!tokens.is_empty()); // "Version" is a known field assert_eq!(tokens[0].token_type, TokenType::Field as u32); assert_eq!(tokens[0].length, 7); } #[test] fn test_v5_unknown_field() { let text = "Version: 5\n\nSource: https://example.com\nX-Custom: value\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let unknown = tokens .iter() .find(|t| t.token_type == TokenType::UnknownField as u32); assert!(unknown.is_some(), "Should have an unknown field token"); } #[test] fn test_v4_produces_tokens() { let text = "version=4\nhttps://example.com/files .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); assert!(!tokens.is_empty(), "v4 watch files should produce tokens"); // Should have a field token for "version" let has_field = tokens .iter() .any(|t| t.token_type == TokenType::Field as u32); assert!(has_field, "Should have a field token"); } #[test] fn test_v4_comment() { let text = "version=4\n# This is a comment\nhttps://example.com .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let has_comment = tokens .iter() .any(|t| t.token_type == TokenType::Comment as u32); assert!(has_comment, "Should have a comment token"); } #[test] fn test_v4_url_and_pattern() { let text = "version=4\nhttps://example.com/files .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let parsed = debian_watch::parse::Parse::parse(text); let idx = crate::position::LineIndex::new(text); let tokens = generate_semantic_tokens(&parsed, Source::new(text, &idx)); let value_tokens: Vec<_> = tokens .iter() .filter(|t| t.token_type == TokenType::Value as u32) .collect(); assert!( value_tokens.len() >= 2, "Should have value tokens for version number, URL and/or pattern" ); } #[test] fn test_field_validator() { let validator = WatchFieldValidator; use crate::deb822::semantic::FieldValidator; assert_eq!(validator.get_standard_field_name("Source"), Some("Source")); assert_eq!(validator.get_standard_field_name("source"), Some("Source")); assert_eq!( validator.get_standard_field_name("Matching-Pattern"), Some("Matching-Pattern") ); assert_eq!(validator.get_standard_field_name("UnknownField"), None); } } debian-lsp-0.1.8/src/workspace.rs000064400000000000000000000601731046102023000150120ustar 00000000000000use std::collections::HashMap; use std::sync::Arc; use rowan::ast::AstNode; use salsa::Setter; use text_size::TextRange; use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Uri}; /// Information about a field casing issue #[derive(Debug, Clone)] pub struct FieldCasingIssue { pub field_name: String, pub standard_name: String, pub field_range: TextRange, } /// Information about an UNRELEASED entry that can be marked for upload #[derive(Debug, Clone)] pub struct UnreleasedUploadInfo { pub unreleased_range: TextRange, pub target_distribution: String, } #[salsa::input] #[derive(Debug)] pub struct SourceFile { pub url: Uri, /// Stored as `Arc` so `Workspace::source_text` is a cheap /// `Arc::clone` instead of an O(N) string clone — relevant for /// large changelogs / copyright files where every detector run /// would otherwise duplicate the whole buffer. pub text: Arc, } // Store the Parse type directly - it's thread-safe now! #[salsa::tracked] pub fn parse_control( db: &dyn salsa::Database, file: SourceFile, ) -> debian_control::lossless::Parse { let text = file.text(db); debian_control::lossless::Control::parse(&text) } #[salsa::tracked] pub fn parse_copyright( db: &dyn salsa::Database, file: SourceFile, ) -> debian_copyright::lossless::Parse { let text = file.text(db); debian_copyright::lossless::Parse::parse_relaxed(&text) } #[salsa::tracked] pub fn parse_watch(db: &dyn salsa::Database, file: SourceFile) -> debian_watch::parse::Parse { let text = file.text(db); debian_watch::parse::Parse::parse(&text) } #[salsa::tracked] pub fn parse_rules( db: &dyn salsa::Database, file: SourceFile, ) -> makefile_lossless::Parse { let text = file.text(db); makefile_lossless::Parse::parse_makefile(&text) } #[salsa::tracked] pub fn parse_changelog( db: &dyn salsa::Database, file: SourceFile, ) -> debian_changelog::Parse { let text = file.text(db); debian_changelog::ChangeLog::parse(&text) } #[salsa::tracked] pub fn parse_deb822( db: &dyn salsa::Database, file: SourceFile, ) -> deb822_lossless::Parse { let text = file.text(db); deb822_lossless::Deb822::parse(&text) } #[salsa::tracked] pub fn parse_upstream_metadata( db: &dyn salsa::Database, file: SourceFile, ) -> yaml_edit::Parse { let text = file.text(db); yaml_edit::YamlFile::parse(&text) } #[salsa::tracked] pub fn parse_patches_series( db: &dyn salsa::Database, file: SourceFile, ) -> patchkit::edit::Parse { let text = file.text(db); patchkit::edit::series::parse(&text) } /// Salsa-tracked parse of just the DEP-3 header at the top of a /// quilt patch file under debian/patches/. Returns the parsed /// deb822 along with the byte offset where the diff body starts — /// the diff body is left to diff-lsp. #[salsa::tracked] pub fn parse_dep3_header( db: &dyn salsa::Database, file: SourceFile, ) -> (deb822_lossless::Parse, usize) { let text = file.text(db); let header_end = dep3::lossless::header_end(&text); let parse = deb822_lossless::Deb822::parse(&text[..header_end]); (parse, header_end) } #[salsa::tracked] pub fn parse_lintian_overrides( db: &dyn salsa::Database, file: SourceFile, ) -> lintian_overrides::Parse { let text = file.text(db); lintian_overrides::LintianOverrides::parse(&text) } /// Salsa-tracked line index for a buffer. Walking the full buffer /// per LSP-position conversion is O(N); the index turns it into a /// log N binary search. See [`crate::position::LineIndex`]. #[salsa::tracked(returns(clone))] pub fn line_index(db: &dyn salsa::Database, file: SourceFile) -> Arc { let text = file.text(db); Arc::new(crate::position::LineIndex::new(&text)) } // The actual database implementation #[salsa::db] #[derive(Clone, Default)] pub struct Workspace { storage: salsa::Storage, files: HashMap, } impl salsa::Database for Workspace {} impl Workspace { pub fn new() -> Self { Self::default() } pub fn update_file(&mut self, url: Uri, text: String) -> SourceFile { let arc: Arc = Arc::from(text); if let Some(&existing) = self.files.get(&url) { existing.set_text(self).to(arc); existing } else { let sf = SourceFile::new(self, url.clone(), arc); self.files.insert(url, sf); sf } } pub fn get_parsed_control( &self, file: SourceFile, ) -> debian_control::lossless::Parse { parse_control(self, file) } /// Return the buffer text of `file`. Cheap — clones an `Arc`, /// not the underlying string. Callers that need an owned `String` /// (e.g. to splice in incremental edits) should call /// `.to_string()` on the result. pub fn source_text(&self, file: SourceFile) -> Arc { file.text(self).clone() } pub fn get_parsed_copyright(&self, file: SourceFile) -> debian_copyright::lossless::Parse { parse_copyright(self, file) } /// Find field casing issues in copyright files, optionally within a specific range pub fn find_copyright_field_casing_issues( &self, file: SourceFile, range: Option, ) -> Vec { let mut issues = Vec::new(); let copyright_parse = self.get_parsed_copyright(file); let copyright = copyright_parse.tree(); // Check header fields if let Some(header) = copyright.header() { for entry in header.as_deb822().entries() { let entry_range = entry.text_range(); // If a range is specified, check if this entry is within it if let Some(filter_range) = range { if entry_range.start() >= filter_range.end() || entry_range.end() <= filter_range.start() { continue; // Skip entries outside the range } } if let Some(key) = entry.key() { if let Some(standard_name) = crate::copyright::get_standard_field_name(&key) { if key != standard_name { if let Some(field_range) = entry.key_range() { issues.push(FieldCasingIssue { field_name: key.to_string(), standard_name: standard_name.to_string(), field_range, }); } } } } } } // Check files paragraphs for files_para in copyright.iter_files() { for entry in files_para.as_deb822().entries() { let entry_range = entry.text_range(); if let Some(filter_range) = range { if entry_range.start() >= filter_range.end() || entry_range.end() <= filter_range.start() { continue; } } if let Some(key) = entry.key() { if let Some(standard_name) = crate::copyright::get_standard_field_name(&key) { if key != standard_name { if let Some(field_range) = entry.key_range() { issues.push(FieldCasingIssue { field_name: key.to_string(), standard_name: standard_name.to_string(), field_range, }); } } } } } } // Check license paragraphs for license_para in copyright.iter_licenses() { for entry in license_para.as_deb822().entries() { let entry_range = entry.text_range(); if let Some(filter_range) = range { if entry_range.start() >= filter_range.end() || entry_range.end() <= filter_range.start() { continue; } } if let Some(key) = entry.key() { if let Some(standard_name) = crate::copyright::get_standard_field_name(&key) { if key != standard_name { if let Some(field_range) = entry.key_range() { issues.push(FieldCasingIssue { field_name: key.to_string(), standard_name: standard_name.to_string(), field_range, }); } } } } } } issues } pub fn get_copyright_diagnostics(&self, file: SourceFile) -> Vec { let source_text = self.source_text(file); let idx = self.get_line_index(file); let src = crate::position::Source::new(&source_text, &idx); let mut diagnostics = Vec::new(); // Add field casing diagnostics for issue in self.find_copyright_field_casing_issues(file, None) { let lsp_range = src.text_range_to_lsp_range(issue.field_range); diagnostics.push(Diagnostic { range: lsp_range, severity: Some(DiagnosticSeverity::WARNING), code: Some(NumberOrString::String("field-casing".to_string())), source: Some("debian-lsp".to_string()), message: format!( "Field name '{}' should be '{}'", issue.field_name, issue.standard_name ), ..Default::default() }); } diagnostics } pub fn get_parsed_rules( &self, file: SourceFile, ) -> makefile_lossless::Parse { parse_rules(self, file) } pub fn get_parsed_watch(&self, file: SourceFile) -> debian_watch::parse::Parse { parse_watch(self, file) } pub fn get_parsed_deb822( &self, file: SourceFile, ) -> deb822_lossless::Parse { parse_deb822(self, file) } pub fn get_parsed_upstream_metadata( &self, file: SourceFile, ) -> yaml_edit::Parse { parse_upstream_metadata(self, file) } pub fn get_parsed_changelog( &self, file: SourceFile, ) -> debian_changelog::Parse { parse_changelog(self, file) } pub fn get_parsed_patches_series( &self, file: SourceFile, ) -> patchkit::edit::Parse { parse_patches_series(self, file) } /// Salsa-cached deb822 parse of a quilt patch's DEP-3 header. /// Returns the parse and the byte offset where the diff body /// starts. See [`parse_dep3_header`] for details. pub fn get_parsed_dep3_header( &self, file: SourceFile, ) -> (deb822_lossless::Parse, usize) { parse_dep3_header(self, file) } /// Salsa-cached parse of a `debian/source/lintian-overrides` or /// `debian/.lintian-overrides` file. The parser is /// error-resilient — `parse.tree()` returns a usable tree even on /// malformed input, so consumers should walk it directly instead /// of dropping the document on `ok()`. pub fn get_parsed_lintian_overrides( &self, file: SourceFile, ) -> lintian_overrides::Parse { parse_lintian_overrides(self, file) } /// Salsa-cached line index for `file`. Use the methods on the /// returned [`crate::position::LineIndex`] to convert byte /// offsets to LSP positions and back. pub fn get_line_index(&self, file: SourceFile) -> Arc { line_index(self, file) } /// Find UNRELEASED entries in the given range that can be marked for upload pub fn find_unreleased_entries_in_range( &self, file: SourceFile, range: TextRange, ) -> Vec { let parsed = self.get_parsed_changelog(file); let changelog = parsed.tree(); // Determine target distribution from previous entries let target_distribution = crate::changelog::get_target_distribution(&changelog); let mut results = Vec::new(); // Use the new efficient entries_in_range API for entry in changelog.entries_in_range(range) { // Check if this entry has UNRELEASED if let Some(dists) = entry.distributions() { if !dists.is_empty() && dists[0] == "UNRELEASED" { // Find the exact position of "UNRELEASED" in the entry's syntax tree let entry_text = entry.syntax().text().to_string(); if let Some(offset) = entry_text.find(") UNRELEASED;") { let unreleased_start = offset + 2; // +2 for ") " let unreleased_end = unreleased_start + "UNRELEASED".len(); // Convert to absolute positions let entry_range = entry.syntax().text_range(); let abs_start = entry_range.start() + text_size::TextSize::from(unreleased_start as u32); let abs_end = entry_range.start() + text_size::TextSize::from(unreleased_end as u32); results.push(UnreleasedUploadInfo { unreleased_range: TextRange::new(abs_start, abs_end), target_distribution: target_distribution.clone(), }); } } } } results } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_copyright_with_correct_casing() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/copyright").unwrap(); let content = r#"Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: test-package Source: https://example.com/test Files: * Copyright: 2024 Test Author License: MIT "#; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_copyright(file); assert!(parsed.errors().is_empty()); let copyright = parsed.tree(); assert!(copyright.header().is_some()); assert_eq!(copyright.iter_files().count(), 1); } #[test] fn test_parse_copyright_with_incorrect_casing() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/copyright").unwrap(); let content = r#"format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ upstream-name: test-package source: https://example.com/test files: * copyright: 2024 Test Author license: MIT "#; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_copyright(file); assert!(parsed.errors().is_empty()); let copyright = parsed.tree(); assert!(copyright.header().is_some()); assert_eq!(copyright.iter_files().count(), 1); } #[test] fn test_copyright_field_casing_detection() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/copyright").unwrap(); let content = r#"format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ upstream-name: test-package files: * copyright: 2024 Test Author license: MIT "#; let file = workspace.update_file(url, content.to_string()); let issues = workspace.find_copyright_field_casing_issues(file, None); // Should detect incorrect casing for format, upstream-name, files, copyright, license assert!(issues.len() >= 3, "Expected at least 3 field casing issues"); // Check that we detect specific fields let field_names: Vec<_> = issues.iter().map(|i| i.field_name.as_str()).collect(); assert!(field_names.contains(&"format")); assert!(field_names.contains(&"files")); assert!(field_names.contains(&"license")); } #[test] fn test_copyright_diagnostics() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/copyright").unwrap(); let content = r#"format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ upstream-name: test files: * copyright: 2024 Test license: MIT "#; let file = workspace.update_file(url, content.to_string()); let diagnostics = workspace.get_copyright_diagnostics(file); assert!( !diagnostics.is_empty(), "Should have diagnostics for field casing" ); // All diagnostics should be warnings for field casing for diag in &diagnostics { assert_eq!(diag.severity, Some(DiagnosticSeverity::WARNING)); assert_eq!( diag.code, Some(NumberOrString::String("field-casing".to_string())) ); } } #[test] fn test_parse_watch_linebased_v4() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/watch").unwrap(); let content = "version=4\nhttps://example.com/files .*/foo-(\\d[\\d.]*)/.tar\\.gz\n"; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_watch(file); assert_eq!(parsed.version(), 4); } #[test] fn test_parse_watch_deb822_v5() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/watch").unwrap(); let content = r#"Version: 5 Source: https://github.com/owner/repo/tags Matching-Pattern: .*/v?(\d[\d.]*)\.tar\.gz "#; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_watch(file); assert_eq!(parsed.version(), 5); } #[test] fn test_parse_watch_auto_detect_v1() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/watch").unwrap(); let content = "https://example.com/files .*/foo-(\\d[\\d.]*).tar\\.gz\n"; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_watch(file); // Should default to version 1 assert_eq!(parsed.version(), 1); } #[test] fn test_parse_changelog_basic() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_changelog(file); assert!(parsed.errors().is_empty()); } #[test] fn test_parse_changelog_multiple_entries() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.2.0-1) unstable; urgency=high * New upstream release. * Fix security vulnerability. -- John Doe Tue, 02 Jan 2024 12:00:00 +0000 rust-foo (0.1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); let parsed = workspace.get_parsed_changelog(file); assert!(parsed.errors().is_empty()); } #[test] fn test_find_unreleased_entries_in_range() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.2.0-1) UNRELEASED; urgency=medium * New changes. -- John Doe Tue, 02 Jan 2024 12:00:00 +0000 rust-foo (0.1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); // Search the entire file let full_range = TextRange::new(0.into(), (content.len() as u32).into()); let unreleased_entries = workspace.find_unreleased_entries_in_range(file, full_range); assert_eq!(unreleased_entries.len(), 1); assert_eq!(unreleased_entries[0].target_distribution, "unstable"); // Verify the range points to "UNRELEASED" let unreleased_text = &content[unreleased_entries[0].unreleased_range.start().into() ..unreleased_entries[0].unreleased_range.end().into()]; assert_eq!(unreleased_text, "UNRELEASED"); } #[test] fn test_find_unreleased_entries_multiple() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.3.0-1) UNRELEASED; urgency=medium * More new changes. -- John Doe Wed, 03 Jan 2024 12:00:00 +0000 rust-foo (0.2.0-1) UNRELEASED; urgency=medium * New changes. -- John Doe Tue, 02 Jan 2024 12:00:00 +0000 rust-foo (0.1.0-1) experimental; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); // Search the entire file let full_range = TextRange::new(0.into(), (content.len() as u32).into()); let unreleased_entries = workspace.find_unreleased_entries_in_range(file, full_range); // Should find both UNRELEASED entries assert_eq!(unreleased_entries.len(), 2); // Target should be "experimental" from the first released entry assert_eq!(unreleased_entries[0].target_distribution, "experimental"); assert_eq!(unreleased_entries[1].target_distribution, "experimental"); } #[test] fn test_find_unreleased_entries_partial_range() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.3.0-1) UNRELEASED; urgency=medium * More new changes. -- John Doe Wed, 03 Jan 2024 12:00:00 +0000 rust-foo (0.2.0-1) UNRELEASED; urgency=medium * New changes. -- John Doe Tue, 02 Jan 2024 12:00:00 +0000 rust-foo (0.1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); // Search only the first entry (first 100 characters should be enough) let partial_range = TextRange::new(0.into(), 100.into()); let unreleased_entries = workspace.find_unreleased_entries_in_range(file, partial_range); // Should find only the first UNRELEASED entry assert_eq!(unreleased_entries.len(), 1); } #[test] fn test_find_unreleased_entries_no_matches() { let mut workspace = Workspace::new(); let url = str::parse("file:///debian/changelog").unwrap(); let content = r#"rust-foo (0.1.0-1) unstable; urgency=medium * Initial release. -- John Doe Mon, 01 Jan 2024 12:00:00 +0000 "#; let file = workspace.update_file(url, content.to_string()); // Search the entire file let full_range = TextRange::new(0.into(), (content.len() as u32).into()); let unreleased_entries = workspace.find_unreleased_entries_in_range(file, full_range); // Should find no UNRELEASED entries assert_eq!(unreleased_entries.len(), 0); } } debian-lsp-0.1.8/vscode-debian/README.md000064400000000000000000000055511046102023000156400ustar 00000000000000# Debian Language Support for VS Code Language Server Protocol extension for Debian package files, including: - `debian/control` - Package control files - `debian/copyright` - DEP-5 copyright files - `debian/watch` - Upstream watch files - `debian/source/options` - dpkg-source options files - `debian/source/local-options` - Local dpkg-source options files - `debian/tests/control` - Autopkgtest control files ## Features - **Field name completion**: Intelligent completion for Debian control file fields - **Package name suggestions**: Common package name suggestions for dependencies - **Diagnostics**: Real-time validation of field names and syntax - **Quick fixes**: Automatic corrections for common issues like incorrect field casing - **Syntax highlighting**: Support for Debian control, copyright, and watch files ## Requirements The `debian-lsp` language server binary is bundled with the extension for supported platforms (Linux x64/arm64, macOS x64/arm64). No separate installation is needed. If you are on an unsupported platform or want to use a different build of `debian-lsp`, you can install it manually and configure the path in settings (see Configuration below). ## Configuration This extension contributes the following settings: * `debian.enable`: Enable/disable the Debian language server (default: `true`) * `debian.serverPath`: Path to the debian-lsp executable. Override this to use a custom build instead of the bundled binary (default: `"debian-lsp"`) * `debian.trace.server`: Trace communication between VS Code and the language server (default: `"off"`) ### Example configuration Add to your VS Code `settings.json`: ```json { "debian.serverPath": "/usr/local/bin/debian-lsp", "debian.trace.server": "verbose" } ``` ### On-Type Formatting Format-on-type is enabled by default for deb822-based file types (control, copyright, watch, tests/control). This automatically inserts a space after typing `:` at the end of a field name and adds continuation-line indentation when pressing Enter inside a field value. To disable this, add to your `settings.json`: ```json { "[debcontrol]": { "editor.formatOnType": false }, "[debcopyright]": { "editor.formatOnType": false }, "[debwatch]": { "editor.formatOnType": false }, "[debtestscontrol]": { "editor.formatOnType": false } } ``` ## Usage Simply open any Debian package file: - `debian/control` - `debian/copyright` - `debian/watch` - `debian/source/options` - `debian/source/local-options` - `debian/tests/control` The extension will automatically activate and provide language features. ## Development ### Building the extension ```bash npm install npm run compile ``` ### Packaging the extension ```bash npm run package ``` This creates a `.vsix` file that can be installed in VS Code. ### Installing the extension locally ```bash code --install-extension vscode-debian-*.vsix ``` ## License Apache-2.0+