diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bcd5e07 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +# .cargo/config.toml +# +# Formatting requires nightly rustfmt — use ./scripts/fmt (see rustfmt.toml). diff --git a/.cursor/rules/rust-standards.mdc b/.cursor/rules/rust-standards.mdc new file mode 100644 index 0000000..5c0edd5 --- /dev/null +++ b/.cursor/rules/rust-standards.mdc @@ -0,0 +1,51 @@ +--- +description: Rust coding standards for shwild.Rust (aligned with Synesis Information Systems' internal project standards) +globs: **/*.rs +alwaysApply: false +--- + +# Rust Software Quality Rules + + +## DOC_76 (The 76-Rule for Documentation Comments) + +- Documentation comments (`///`, `//!`) on **public** constructs must be at + most **76 characters** per line, including leading indentation; +- Wrap text **greedily** (pack to maximum width) — do not wrap early if the + next word fits on the current line; +- Code blocks inside doc comments (triple backticks) are exempt; +- List items in doc comments should end with a semicolon; +- Inline comments (`//`) inside functions are limited to **100 characters** + per line, wrapped greedily; +- Private/non-`pub` constructs are exempt from DOC_76 for their doc + comments; + +Run `./scripts/check_doc_76.py` to verify compliance. + + +## DERIVE_LAYOUT (Derive Layout & Formatting) + +Multi-trait `#[derive(...)]` macros must be split into separate, +single-trait lines, ordered alphabetically by trait name. Tightly coupled +traits may remain on one line: `#[derive(Eq, PartialEq)]`, +`#[derive(Ord, PartialOrd)]`. + +`rustfmt.toml` sets `merge_derives = false` to preserve this layout. + +Run `./scripts/check_derives.py` to verify compliance. + + +## RUST_TEST_NAMING + +All test methods must use `SHOUTING_SNAKE_CASE` - e.g. `TEST_PARSING()` - +except where a word in the snake case represents a specific construct (such +as a struct, enum, type, function, macro, or field name), in which case that +word must preserve its exact correct case - e.g. +`TEST_AutoBuffer_WITH_INTERNAL_SIZE()`, `TEST_doom_scope_1()`, +`TEST_type_name_only_WITH_SomeCustomType()`. + +When a construct is embedded as a SHOUTING_SNAKE_CASE constant (or other +construct name), use an extra underscore on each side as a delimiter - e.g. +`TEST_MatcherSequence_WITH_Range_HAVING__IGNORE_CASE__1()`. + +Run `./scripts/check_test_names.py` to verify compliance. diff --git a/.gitattributes b/.gitattributes index e69de29..7beaa80 100644 --- a/.gitattributes +++ b/.gitattributes @@ -0,0 +1,2 @@ + +*.rs linguist-language=Rust diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5084a8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: + - master + - dev + - boilerplate + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Test, Clippy, Fmt, Checkers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install nightly rustfmt + run: rustup toolchain install nightly --profile minimal --component rustfmt + + - uses: Swatinem/rust-cache@v2 + + - name: cargo test + run: cargo test --locked + + - name: cargo clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: cargo build (examples) + run: cargo build --examples --locked + + - name: cargo build (character-play example) + run: cargo build --example character-play --features test-regex --locked + + - name: cargo doc + run: cargo doc --no-deps --locked + + - name: rustfmt + run: ./scripts/fmt --check + + - name: DOC_76 checker + run: python3 scripts/check_doc_76.py + + - name: RUST_TEST_NAMING checker + run: python3 scripts/check_test_names.py + + - name: DERIVE_LAYOUT checker + run: python3 scripts/check_derives.py + + msrv: + name: MSRV (1.79) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.79.0 + + - uses: Swatinem/rust-cache@v2 + + - name: cargo check (library) + # Full `cargo test` needs dev-deps (criterion → clap_lex 2024 edition), + # which exceeds MSRV; the stable job runs the full test suite. + # MSRV 1.79: default `lookup-ranges` → collect-rs → base-traits uses + # `CStr::count_bytes()` (stabilized in Rust 1.79). + run: cargo check --lib --locked diff --git a/.gitignore b/.gitignore index 3589c67..8ea0d84 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # directories (by name) /_build/ +/scripts/__pycache__/ /scratch/ /target/ diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..d4484d1 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,57 @@ +# shwild.Rust - CHANGES + + +## 0.1.4 - 8th July 2026 + +* added **CHANGES.md** (back-filled) and **NEWS.md**; +* added **EXAMPLES.md** and per-example documentation; +* **README.md** badges, dependency links, related projects, and `null-feature` documentation; +* added crate-level `//!` documentation; +* added CI (`.github/workflows/ci.yml`) and quality scripts (`scripts/fmt`, checkers); +* added `rust-version` (MSRV 1.79); +* renamed `.rustfmt.toml` => **rustfmt.toml**; updated formatting settings; +* added `.gitattributes`; +* shortened `description` in **Cargo.toml**; +* Clippy and test-naming fixes for CI; `check_test_names.py` allows `__CONSTRUCT__` padding; +* upgraded **criterion** from 0.5 => 0.8; + + +## 0.1.3 - 28th March 2025 + +* crates.io packaging metadata — `categories`, `keywords`, `documentation`, and expanded `description`; +* added `exclude` for `target` and `.github` in **Cargo.toml**; +* added **TODO.md**; +* **character-play** changed from `[[bin]]` to `[[example]]`; + + +## 0.1.2 - 3rd November 2024 + +* added `test-regex` feature — optional **regex** dependency for benchmarks and scratch programs; +* enabled `lookup-ranges` in default features; +* added **cw-regex** benchmark; +* added **character-play** scratch program; +* added `regex_comparision_tests` unit tests (gated on `test-regex`); + + +## 0.1.1 - 3rd November 2024 + +* added `lookup-ranges` feature — optional **collect-rs** dependency for `UnicodePointMap`-based range matching; +* added **Cargo.lock**; +* README: added Features section; clarified Wild-1 escape behaviour in pattern elements; + + +## 0.1.0 - 3rd November 2024 + +* first public release; +* added `matches()` and `shwild_matches!()`; +* added `CompiledMatcher`; +* added `Error`, `Result`, and `IGNORE_CASE`; +* added example programs **list-matching-files** and **list-matching-files-compiled**; +* added benchmarks **range_string-creation_functions**, **shwild-compiled_matcher**, and **shwild-matches**; +* added **README.md**; + + +All history before this day is moot! + + + diff --git a/Cargo.lock b/Cargo.lock index be726ec..7962111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "aho-corasick" @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "anes" version = "0.1.6" @@ -35,18 +44,22 @@ version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556031355dea4fc81a56aa4850032c9b433c926e7296c30780f63ece19ecf082" -[[package]] -name = "bumpalo" -version = "3.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" - [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -116,25 +129,22 @@ dependencies = [ [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", "itertools", "num-traits", - "once_cell", "oorandom", - "plotters", - "rayon", + "page_size", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -142,39 +152,14 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", "itertools", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" - [[package]] name = "crunchy" version = "0.2.2" @@ -187,6 +172,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "half" version = "2.4.1" @@ -197,28 +188,11 @@ dependencies = [ "crunchy", ] -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "itertools" -version = "0.10.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -229,26 +203,11 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "js-sys" -version = "0.3.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" -dependencies = [ - "wasm-bindgen", -] - [[package]] name = "libc" -version = "0.2.161" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" - -[[package]] -name = "log" -version = "0.4.22" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "memchr" @@ -265,12 +224,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "once_cell" -version = "1.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" - [[package]] name = "oorandom" version = "11.1.4" @@ -278,31 +231,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" [[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" +name = "page_size" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" dependencies = [ - "plotters-backend", + "libc", + "winapi", ] [[package]] @@ -323,26 +258,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "regex" version = "1.11.1" @@ -419,9 +334,15 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "shwild" -version = "0.1.3" +version = "0.1.4" dependencies = [ "collect-rs", "criterion", @@ -476,69 +397,20 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen" -version = "0.2.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" -dependencies = [ - "cfg-if", - "once_cell", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.95" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" - -[[package]] -name = "web-sys" -version = "0.3.72" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" -dependencies = [ - "js-sys", - "wasm-bindgen", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" @@ -546,17 +418,14 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-sys" diff --git a/Cargo.toml b/Cargo.toml index cb9475b..019941d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,7 @@ categories = [ "development-tools", "text-processing", ] -description = """ -**shwild** is a small, standalone library, implemented in C++ with a C and a C++ API, that provides shell-compatible wildcard matching. - -**shwild.Rust** is a **Rust** port, with minimal API differences. The design emphasis is on simplicity-of-use, modularity, and performance. -""" +description = "Shell-compatible wildcard matching library" documentation = "https://docs.rs/shwild" edition = "2021" exclude = [ @@ -35,7 +31,8 @@ license = "BSD-3-Clause" name = "shwild" readme = "README.md" repository = "https://github.com/synesissoftware/shwild.Rust" -version = "0.1.3" +rust-version = "1.79" +version = "0.1.4" # ########################################################## @@ -115,14 +112,19 @@ test-regex = [ [dependencies] -collect-rs = { version = "0.2", optional = true } -regex = { version = "1.11", optional = true } +collect-rs = { version = "0.2", optional = true, default-features = false, features = [ +]} +regex = { version = "1.11", optional = true, default-features = false, features = [ +]} [dev-dependencies] -criterion = { version = "0.5" } -test_help-rs = { version = "0.1" } +criterion = { version = "0.8", default-features = false, features = [ + "html_reports", +]} +test_help-rs = { version = "0.1", default-features = false, features = [ +]} # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/EXAMPLES.md b/EXAMPLES.md new file mode 100644 index 0000000..e1f50c6 --- /dev/null +++ b/EXAMPLES.md @@ -0,0 +1,11 @@ +# shwild.Rust Examples + +|Name|Source & Description|Summary| +|---|---|---| +|**list-matching-files**|[examples/list-matching-files/main.rs](/examples/list-matching-files/main.rs)
[examples/list-matching-files.md](/examples/list-matching-files.md)|Lists files in the current directory whose paths match one or more shell wildcard pattern(s), using `shwild::matches()`.| +|**list-matching-files-compiled**|[examples/list-matching-files-compiled/main.rs](/examples/list-matching-files-compiled/main.rs)
[examples/list-matching-files-compiled.md](/examples/list-matching-files-compiled.md)|Same as **list-matching-files**, but patterns are compiled once into `CompiledMatcher` instances before matching.| +|**character-play**|[test/scratch/character-play/main.rs](/test/scratch/character-play/main.rs)
[examples/character-play.md](/examples/character-play.md)|Scratch program exercising **regex** crate Unicode matching behaviour (requires feature `"test-regex"`).| + + + + diff --git a/NEWS.md b/NEWS.md new file mode 100644 index 0000000..6d66629 --- /dev/null +++ b/NEWS.md @@ -0,0 +1,12 @@ +# shwild.Rust - NEWS + +| Date | News Item | +| --------------------- | ----------------------------------------- | +| 8th July 2026 | shwild.Rust 0.1.4 released | +| 28th March 2025 | shwild.Rust 0.1.3 released | +| 3rd November 2024 | shwild.Rust 0.1.2 released | +| 3rd November 2024 | shwild.Rust 0.1.1 released | +| 3rd November 2024 | shwild.Rust 0.1.0 released | + + + diff --git a/README.md b/README.md index 20649fa..cdd359e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ -# shwild - -**SH**ell-compatible **WILD**cards, for **Rust**. +# shwild.Rust +![Language](https://img.shields.io/badge/Rust-000000?style=flat&logo=rust&logoColor=white) +[![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) [![Crates.io](https://img.shields.io/crates/v/shwild.svg)](https://crates.io/crates/shwild) +[![GitHub release](https://img.shields.io/github/v/release/synesissoftware/shwild.Rust.svg)](https://github.com/synesissoftware/shwild.Rust/releases/latest) +![MSRV](https://img.shields.io/badge/MSRV-1.79-lightgrey) +[![CI](https://github.com/synesissoftware/shwild.Rust/actions/workflows/ci.yml/badge.svg)](https://github.com/synesissoftware/shwild.Rust/actions/workflows/ci.yml) +[![Last Commit](https://img.shields.io/github/last-commit/synesissoftware/shwild.Rust)](https://github.com/synesissoftware/shwild.Rust/commits/master) +[![docs.rs](https://img.shields.io/docsrs/shwild/badge.svg)](https://docs.rs/shwild) + +**SH**ell-compatible **WILD**cards, for **Rust** — part of the cross-language **shwild** family. ## Table of Contents @@ -30,9 +37,7 @@ ## Introduction -**shwild** is a small, standalone library, implemented in C++ with a C and a C++ API, that provides shell-compatible wildcard matching. - -**shwild.Rust** is a **Rust** port, with minimal API differences. The design emphasis is on simplicity-of-use, modularity, and performance. +**shwild** is a small, standalone library, implemented in C++ with a C and a C++ API, that provides shell-compatible wildcard matching. **shwild.Rust** is a **Rust** port, with minimal API differences. The design emphasis is on simplicity-of-use, modularity, and performance. ```Rust let pattern = r"Where are the* [🐼🐻]s\?"; @@ -64,7 +69,7 @@ The library (and other **shwild** variants) support the following pattern elemen Reference in **Cargo.toml** in the usual way: ```toml -shwild = { version = "~0.1" } +shwild = { version = "0.1" } ``` @@ -104,6 +109,7 @@ The following crate features are defined: | Name | Effect | Is `"default"`? | Dependent feature(s) | | --------------------------- | ------------------------------------- | --------------- | ------------------------------------- | | `"lookup-ranges"` | Causes match/non-match ranges to be implemented in terms of `UnicodePointMap` (from **collect-rs** crate), resulting in significant performance improvements in parsing and matching | Yes | | +| `"null-feature"` | A feature that has no effect (and, thus, is useful for simplifying driver scripts) | **No** | | | `"test-regex"` | Introduces a dependency to **regex** crate to support benchmark/example program(s) | **No** | | @@ -167,7 +173,7 @@ No public traits are defined at this time. ## Examples -T.B.C. +Examples are provided in the ```examples``` directory, along with a markdown description for each. A detailed list TOC of them is provided in [EXAMPLES.md](./EXAMPLES.md). ## Project Information @@ -186,8 +192,8 @@ Defect reports, feature requests, and pull requests are welcome on https://githu **shwild.Rust** has two dependencies, both optional: -* [**collect-rs**]() - required, for more efficient range matching, if feature `"lookup-ranges"` is specified; -* [**regex**]() - required, by some benchmark/example programs only, if feature `"test-regex"` is specified; +* [**collect-rs**](https://github.com/synesissoftware/collect-rs) - required, for more efficient range matching, if feature `"lookup-ranges"` is specified; +* [**regex**](https://github.com/rust-lang/regex) - required, by some benchmark/example programs only, if feature `"test-regex"` is specified; #### Dev Dependencies @@ -200,7 +206,9 @@ Crates upon which **shwild** has development dependencies: ### Related projects -None at this time. +* [**shwild**](https://github.com/synesissoftware/shwild/); +* [**shwild.Go**](https://github.com/synesissoftware/shwild.Go/); +* [**collect-rs**](https://github.com/synesissoftware/collect-rs/); ### License diff --git a/TODO.md b/TODO.md index 57628ec..90c33f3 100644 --- a/TODO.md +++ b/TODO.md @@ -3,21 +3,101 @@ ## Table of Contents +- [0.1.4 boilerplate checklist](#014-boilerplate-checklist) +- [Source layout (deferred)](#source-layout-deferred) - [Functional improvements](#functional-improvements) - [Performance improvements](#performance-improvements) +## 0.1.4 boilerplate checklist + +### Cargo packaging + +* [x] `default-features = false` on optional and dev dependencies; +* [x] `rust-version` (MSRV 1.79); +* [x] `version = "0.1.4"`; +* [x] upgraded **criterion** from 0.5 => 0.8; +* [x] ~~narrow `exclude` (drop `.github`)~~ — **won't fix** (keep `exclude = ["target", ".github"]` since 0.1.3); + +### CI + +* [x] `.github/workflows/ci.yml` (test, clippy, examples, doc, fmt, checkers); +* [x] Clippy, DOC_76, and test-naming fixes; +* [x] MSRV job on 1.79 (`cargo check --lib --locked`); +* [x] `check_test_names.py` allows `__CONSTRUCT__` padding around construct names; + +### Documentation + +* [x] back-filled **CHANGES.md** (0.1.0–0.1.3) and **0.1.4** entry; +* [x] crate-level `//!` documentation; +* [x] **EXAMPLES.md** and per-example `.md` files; +* [x] **NEWS.md** release table (through 0.1.4); +* [x] **README.md** badges, dependency links, related projects, `null-feature`; +* [x] shortened `description` in **Cargo.toml**; + +### Formatting and quality tooling + +* [x] `.cargo/config.toml`; +* [x] `.cursor/rules/rust-standards.mdc`; +* [x] `.gitattributes`; +* [x] `.gitignore` (`/scripts/__pycache__/`); +* [x] `scripts/check_derives.py`, `check_doc_76.py`, `check_test_names.py`, `fmt`; +* [x] renamed `.rustfmt.toml` => **rustfmt.toml**; + +### Layout and structure + +* [x] ~~flatten examples to `examples/*.rs`~~ — **won't fix** (keep `examples/*/main.rs`); +* [x] ~~move **character-play** from `[[example]]` to `[[bin]]`~~ — **won't fix** (since 0.1.3); +* [x] ~~normalise bench filenames to snake_case~~ — **won't fix** (keep current names); + +### Release + +* [ ] merge `boilerplate` branch and tag **0.1.4** on GitHub; +* [ ] publish **0.1.4** to crates.io; + + +## Source layout (deferred) + +Split `src/lib.rs` (~3,450 lines) into multiple source files. **Defer past 0.1.4** +— behaviour-neutral refactor; ship as a dedicated follow-up change. + +**API unit tests remain in `src/lib.rs`** (`#[cfg(test)] mod tests { ... }` and +`regex_comparision_tests`). + +Proposed module layout: + +``` +src/ + lib.rs # crate docs, re-exports, matches(), shwild_matches! macro; + # #[cfg(test)] API unit tests (stay here) + constants.rs # IGNORE_CASE + error.rs # Error + Display/Error trait impls + traits.rs # Match trait + types.rs # CharacterRangeType + match_structures.rs # MatchLiteral, MatchRange, MatchWildN, ... + Match impls + utils.rs # MatcherSequence, parsing helpers, prepare_range_string + compiled_matcher.rs # CompiledMatcher, ParseState, parse logic +``` + +Suggested order of work: + +1. Extract internal modules (`match_structures`, `utils`, `compiled_matcher`, …) + — largest line-count reduction with lowest risk; +2. Extract `error`, `constants`, `traits`, `types` — thin `lib.rs` façade + (Diagnosticism-style); +3. Leave API tests in `lib.rs` (no `tests/mod.rs`). + + ## Functional improvements * [ ] **no-std**; -* [ ] ...; +* [ ] unit-test macros; ## Performance improvements * [ ] special cases (for compiled only) such as `"*brown*"` could just be `strstr()`; -* [ ] ...; +* [ ] thorough optimisation review (including optional "unsafe"); - diff --git a/benches/cw-regex.rs b/benches/cw-regex.rs index e890801..f4011af 100644 --- a/benches/cw-regex.rs +++ b/benches/cw-regex.rs @@ -2,17 +2,14 @@ #![allow(non_snake_case)] -use shwild; - -use regex::Regex; - use criterion::{ - black_box, criterion_group, criterion_main, Criterion, }; +use regex::Regex; +use std::hint::black_box; mod constants { pub(crate) const S_TQBFJOTLD : &str = "The quick brown fox jumps over the lazy dog"; diff --git a/benches/range_string-creation_functions.rs b/benches/range_string-creation_functions.rs index a366992..a6e5fc0 100644 --- a/benches/range_string-creation_functions.rs +++ b/benches/range_string-creation_functions.rs @@ -1,18 +1,16 @@ // benches/range_string-creation_functions.rs : evaluates performance of different range-string creation-function approaches #![allow(non_snake_case)] -#![feature(ascii_char)] - -use shwild; use criterion::{ - black_box, criterion_group, criterion_main, // BenchmarkId, Criterion, }; +use std::hint::black_box; + mod constants { #![allow(non_upper_case_globals)] @@ -68,7 +66,7 @@ mod utils { chars.dedup(); chars.into_iter().collect() // appears to be (marginally) faster for `Vec<>`, although it's very close - // chars.as_slice().iter().collect() + // chars.as_slice().iter().collect() } pub(super) fn range_string_from_slice_1( @@ -134,7 +132,7 @@ mod utils { chars.dedup(); chars.into_iter().collect() // appears to be (marginally) faster for `Vec<>`, although it's very close - // chars.as_slice().iter().collect() + // chars.as_slice().iter().collect() } pub(super) fn range_string_from_slice_3( @@ -241,37 +239,6 @@ mod utils { // chars.as_slice().iter().collect() } - #[allow(unexpected_cfgs)] - #[cfg(feature = "auto-buffer")] - pub(super) fn range_string_from_slice_6( - chars : &[char], - flags : i64, - ) -> String { - let mut chars = if 0 != (shwild::IGNORE_CASE & flags) { - let mut ci_chars = auto_buffer::AutoBuffer::::with_capacity(chars.len() * 2); - - for (ix, c) in chars.iter().enumerate() { - if c.is_alphabetic() { - ci_chars.push(c.to_ascii_uppercase()); - - ci_chars.push(c.to_ascii_lowercase()); - } else { - ci_chars.push(*c); - } - } - - ci_chars - } else { - chars.into() - }; - - chars.sort_unstable(); - - chars.dedup(); - - chars.into_iter().collect() - } - pub(super) fn range_string_from_slice_7( chars : &[char], flags : i64, diff --git a/benches/shwild-compiled_matcher.rs b/benches/shwild-compiled_matcher.rs index 8674850..bc7da1c 100644 --- a/benches/shwild-compiled_matcher.rs +++ b/benches/shwild-compiled_matcher.rs @@ -2,15 +2,14 @@ #![allow(non_snake_case)] -use shwild; - use criterion::{ - black_box, criterion_group, criterion_main, Criterion, }; +use std::hint::black_box; + mod constants { #![allow(non_upper_case_globals)] diff --git a/benches/shwild-matches.rs b/benches/shwild-matches.rs index e59e051..a2dec17 100644 --- a/benches/shwild-matches.rs +++ b/benches/shwild-matches.rs @@ -2,15 +2,14 @@ #![allow(non_snake_case)] -use shwild; - use criterion::{ - black_box, criterion_group, criterion_main, Criterion, }; +use std::hint::black_box; + mod constants { #![allow(non_upper_case_globals)] diff --git a/examples/character-play.md b/examples/character-play.md new file mode 100644 index 0000000..3266700 --- /dev/null +++ b/examples/character-play.md @@ -0,0 +1,99 @@ +# shwild.Rust Example - **character-play** + +## Summary + +A scratch program that exercises **regex** crate matching behaviour for Unicode text, including emoji and combining characters. It is intended to support development of the `"test-regex"` feature and the `regex_comparision_tests` unit tests; it does not call **shwild** APIs directly. + + +## Source + +```Rust +// test/scratch/character-play/main.rs : Unicode matching experiments with **regex** + +use regex::Regex; + +fn main() { + { + let re = Regex::new("abc").unwrap(); + + assert!(re.is_match("abc")); + assert!(re.is_match("abcd")); + } + + { + let re = Regex::new("abc$").unwrap(); + + assert!(re.is_match("abc")); + assert!(!re.is_match("abcd")); + } + + { + let re = Regex::new("a🐻c").unwrap(); + + assert!(re.is_match("a🐻c")); + assert!(!re.is_match("abc")); + assert!(re.is_match("a🐻cd")); + } + + { + let re = Regex::new("aéc").unwrap(); + + assert!(re.is_match("aéc")); + assert!(!re.is_match("abc")); + assert!(re.is_match("aécd")); + } + + { + let re = Regex::new("aéc").unwrap(); + + assert!(re.is_match("aéc")); + assert!(!re.is_match("abc")); + assert!(re.is_match("aécd")); + } + + { + let re = Regex::new("a[🐻👀🛑]c").unwrap(); + + assert!(re.is_match("a🐻c")); + assert!(re.is_match("a👀c")); + assert!(re.is_match("a🛑c")); + assert!(!re.is_match("abc")); + assert!(re.is_match("a🛑cd")); + } + + { + let re = Regex::new("a👁️c").unwrap(); + + assert!(!re.is_match("a🐻c")); + assert!(!re.is_match("a👀c")); + assert!(!re.is_match("a🛑c")); + assert!(re.is_match("a👁️c")); + assert!(!re.is_match("abc")); + assert!(!re.is_match("a🛑cd")); + } + + { + let re = Regex::new("a[🐻👀🛑]c").unwrap(); + + assert!(re.is_match("a🐻c")); + assert!(re.is_match("a👀c")); + assert!(re.is_match("a🛑c")); + assert!(!re.is_match("abc")); + assert!(re.is_match("a🛑cd")); + } +} +``` + + +## Running and output + +When executed, as in: + +```bash +$ cargo run --example character-play --features test-regex +``` + +it runs to completion with no output on success (all assertions pass). + + + diff --git a/examples/list-matching-files-compiled.md b/examples/list-matching-files-compiled.md new file mode 100644 index 0000000..e426bff --- /dev/null +++ b/examples/list-matching-files-compiled.md @@ -0,0 +1,113 @@ +# shwild.Rust Example - **list-matching-files-compiled** + +## Summary + +An example using **shwild.Rust**'s `CompiledMatcher` to list files in the current directory whose paths match one or more shell wildcard pattern(s) given on the command-line. Each pattern is parsed once up front; this is preferable when the same pattern(s) will be matched repeatedly. When no patterns are specified, `"*"` is assumed. + + +## Source + +```Rust +// examples/list-matching-files-compiled/main.rs : filter files using `CompiledMatcher` + +use std::{ + env as std_env, + fs as std_fs, + process as std_process, +}; + + +fn main() { + let directory = "."; + + let patterns = { + let r = std_env::args().skip(1).collect::>(); + + if r.is_empty() { + vec!["*".into()] + } else { + r + } + }; + let matchers = patterns + .iter() + .map(|pattern| { + shwild::CompiledMatcher::from_pattern_and_flags(&pattern, 0).unwrap_or_else(|e| { + eprintln!("failed to parse pattern '{pattern}': {e}"); + + std_process::exit(1); + }) + }) + .collect::>(); + + println!("searching in '{directory}' with pattern(s) {:?}", patterns); + + match std_fs::read_dir(directory) { + Ok(entries) => { + // for each file in the directory ... + for entry in entries { + match entry { + Ok(entry) => { + let path = entry.path(); + let path_s = format!("{}", path.display()); + + // ... check against ... + for matcher in &matchers { + // ... each pattern ... + if matcher.matches(&path_s) { + // ... and print when it matches any one. + println!("\t{path_s}"); + + break; + } + } + }, + Err(e) => { + eprintln!("failed to read file in '{directory}': {e}"); + }, + }; + } + }, + Err(e) => { + eprintln!("failed to read files in '{directory}': {e}"); + }, + }; +} +``` + + +## Running and output + +When executed, as in: + +```bash +$ cargo run --example list-matching-files-compiled +``` + +it gives output similar to: + +``` +searching in '.' with pattern(s) ["*"] + ./Cargo.toml + ./LICENSE + ./README.md + ... +``` + +With pattern argument(s), as in: + +```bash +$ cargo run --example list-matching-files-compiled -- '*.md' +``` + +it gives output similar to: + +``` +searching in '.' with pattern(s) ["*.md"] + ./README.md + ./CHANGES.md + ./TODO.md +``` + + + diff --git a/examples/list-matching-files-compiled/main.rs b/examples/list-matching-files-compiled/main.rs index 1ea0aa2..edeb487 100644 --- a/examples/list-matching-files-compiled/main.rs +++ b/examples/list-matching-files-compiled/main.rs @@ -23,7 +23,7 @@ fn main() { let matchers = patterns .iter() .map(|pattern| { - shwild::CompiledMatcher::from_pattern_and_flags(&pattern, 0).unwrap_or_else(|e| { + shwild::CompiledMatcher::from_pattern_and_flags(pattern, 0).unwrap_or_else(|e| { eprintln!("failed to parse pattern '{pattern}': {e}"); std_process::exit(1); diff --git a/examples/list-matching-files.md b/examples/list-matching-files.md new file mode 100644 index 0000000..93a84ca --- /dev/null +++ b/examples/list-matching-files.md @@ -0,0 +1,109 @@ +# shwild.Rust Example - **list-matching-files** + +## Summary + +An example using **shwild.Rust**'s `matches()` function to list files in the current directory whose paths match one or more shell wildcard pattern(s) given on the command-line. When no patterns are specified, `"*"` is assumed. + + +## Source + +```Rust +// examples/list-matching-files/main.rs : filter files by name using `matches()` + +use std::{ + env as std_env, + fs as std_fs, +}; + + +fn main() { + let directory = "."; + + let patterns = { + let r = std_env::args().skip(1).collect::>(); + + if r.is_empty() { + vec!["*".into()] + } else { + r + } + }; + + println!("searching in '{directory}' with pattern(s) {patterns:?}"); + + match std_fs::read_dir(directory) { + Ok(entries) => { + // for each file in the directory ... + for entry in entries { + match entry { + Ok(entry) => { + let path = entry.path(); + let path_s = format!("{}", path.display()); + + // ... check against ... + for pattern in &patterns { + // ... each pattern ... + match shwild::matches(pattern, &path_s, 0) { + Ok(is_matched) => { + if is_matched { + // ... and print when it matches any one. + println!("\t{path_s}"); + + break; + } + }, + Err(e) => { + eprintln!("failed to match against '{path_s}': {e}"); + }, + }; + } + }, + Err(e) => { + eprintln!("failed to read file in '{directory}': {e}"); + }, + }; + } + }, + Err(e) => { + eprintln!("failed to read files in '{directory}': {e}"); + }, + }; +} +``` + + +## Running and output + +When executed, as in: + +```bash +$ cargo run --example list-matching-files +``` + +it gives output similar to: + +``` +searching in '.' with pattern(s) ["*"] + ./Cargo.toml + ./LICENSE + ./README.md + ... +``` + +With pattern argument(s), as in: + +```bash +$ cargo run --example list-matching-files -- '*.md' +``` + +it gives output similar to: + +``` +searching in '.' with pattern(s) ["*.md"] + ./README.md + ./CHANGES.md + ./TODO.md +``` + + + diff --git a/.rustfmt.toml b/rustfmt.toml similarity index 92% rename from .rustfmt.toml rename to rustfmt.toml index 41189d4..c08d358 100644 --- a/.rustfmt.toml +++ b/rustfmt.toml @@ -1,22 +1,24 @@ # rustfmt.toml for shwild.Rust # +# Requires nightly rustfmt with unstable features enabled — use ./scripts/fmt +# # configured for cargo-fmt 1.84.0 # array_width=60 # deprecated # attr_fn_like_width=70 # deprecated binop_separator="Front" blank_lines_lower_bound=0 -blank_lines_upper_bound=2 +blank_lines_upper_bound=3 brace_style="SameLineWhere" # chain_width=60 # deprecated color="Auto" combine_control_expr=false -comment_width=100 +comment_width=76 condense_wildcard_suffixes=false control_brace_style="AlwaysSameLine" disable_all_formatting=false -edition="2018" +edition="2021" empty_item_single_line=false enum_discrim_align_threshold=0 error_on_line_overflow=false @@ -75,7 +77,7 @@ tab_spaces=4 # Q: do we want to move to 2?? trailing_comma="Vertical" trailing_semicolon=true type_punctuation_density="Wide" -# unstable_features=false +unstable_features=true use_field_init_shorthand=true use_small_heuristics="Default" use_try_shorthand=true diff --git a/scripts/check_derives.py b/scripts/check_derives.py new file mode 100755 index 0000000..5c595ec --- /dev/null +++ b/scripts/check_derives.py @@ -0,0 +1,129 @@ +#! /usr/bin/env python3 +""" +Verify DERIVE_LAYOUT: multi-trait `#[derive(...)]` macros must be split +into separate single-trait lines, ordered alphabetically by trait name, +except tightly coupled groups (Eq/PartialEq, Ord/PartialOrd). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +COUPLED_TRAIT_GROUPS = [ + ["Eq", "PartialEq"], + ["Ord", "PartialOrd"], +] + +PASS = "\N{WHITE HEAVY CHECK MARK}" # ✅ +FAIL = "\N{CROSS MARK}" # ❌ + + +def lint_file(filepath: Path) -> list[str]: + errors: list[str] = [] + + lines = filepath.read_text(encoding="utf-8").splitlines() + i = 0 + + while i < len(lines): + line = lines[i] + + if not re.match(r"^\s*#\[derive\(", line): + i += 1 + continue + + derive_block: list[tuple[int, str]] = [] + start_line_num = i + 1 + + while i < len(lines) and re.match(r"^\s*#\[derive\(", lines[i]): + derive_block.append((i + 1, lines[i])) + i += 1 + + parsed_lines: list[tuple[int, str, str]] = [] + block_has_error = False + + for line_num, line_str in derive_block: + match = re.search(r"#\[derive\((.*?)\)\]", line_str) + + if not match: + continue + + traits = [ + t.strip() + for t in match.group(1).split(",") + if t.strip() + ] + + if len(traits) > 1: + if traits not in COUPLED_TRAIT_GROUPS: + block_has_error = True + allowed = ", ".join( + f"'{', '.join(group)}'" + for group in COUPLED_TRAIT_GROUPS + ) + errors.append( + f"{filepath}:{line_num}: multi-trait derive " + f"'{line_str.strip()}' is not allowed " + f"(except coupled groups: {allowed})", + ) + elif len(traits) == 0: + block_has_error = True + errors.append( + f"{filepath}:{line_num}: empty derive attribute " + f"'{line_str.strip()}'", + ) + + sort_key = traits[0] if traits else "" + parsed_lines.append((line_num, line_str, sort_key)) + + if not block_has_error and len(parsed_lines) > 1: + sort_keys = [item[2] for item in parsed_lines] + + if sort_keys != sorted(sort_keys): + actual = [item[1].strip() for item in parsed_lines] + expected = [ + item[1].strip() + for item in sorted(parsed_lines, key=lambda x: x[2]) + ] + errors.append( + f"{filepath}:{start_line_num}: derive attributes not " + f"sorted alphabetically\n" + f" actual: {actual}\n" + f" expected: {expected}", + ) + + return errors + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + errors: list[str] = [] + + for directory in ("src", "examples", "benches", "test"): + base = root / directory + + if not base.is_dir(): + continue + + for path in sorted(base.rglob("*.rs")): + if "target" in path.parts: + continue + + errors.extend(lint_file(path)) + + if errors: + print( + f"{FAIL} DERIVE_LAYOUT violations:", + file=sys.stderr, + ) + print("\n".join(f" {FAIL} {error}" for error in errors), file=sys.stderr) + return 1 + + print(f"{PASS} DERIVE_LAYOUT: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_doc_76.py b/scripts/check_doc_76.py new file mode 100644 index 0000000..2a10562 --- /dev/null +++ b/scripts/check_doc_76.py @@ -0,0 +1,66 @@ +#! /usr/bin/env python3 +""" +Verify DOC_76: public documentation comment lines are at most 76 characters. + +Code blocks inside doc comments (``` ... ```) are exempt, matching Synesis +Information Systems' internal project standards. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +DOC_LINE = re.compile(r"^\s*(//!|///)") + +PASS = "\N{WHITE HEAVY CHECK MARK}" # ✅ +FAIL = "\N{CROSS MARK}" # ❌ + + +def iter_doc_violations(path: Path) -> list[str]: + violations: list[str] = [] + in_codeblock = False + + for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + stripped = line.rstrip() + + if DOC_LINE.match(stripped) and re.search(r"\s*```\s*$", stripped): + in_codeblock = not in_codeblock + continue + + if in_codeblock or not DOC_LINE.match(stripped): + continue + + if len(stripped) > 76: + violations.append( + f"{path}:{line_no} ({len(stripped)} chars): {stripped}" + ) + + return violations + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + errors: list[str] = [] + + for path in sorted(root.rglob("*.rs")): + if "target" in path.parts: + continue + errors.extend(iter_doc_violations(path)) + + if errors: + print( + f"{FAIL} DOC_76 violations (doc comment lines must be <= 76 characters):", + file=sys.stderr, + ) + print("\n".join(f" {FAIL} {error}" for error in errors), file=sys.stderr) + return 1 + + print(f"{PASS} DOC_76: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_test_names.py b/scripts/check_test_names.py new file mode 100644 index 0000000..f2084a9 --- /dev/null +++ b/scripts/check_test_names.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Verify RUST_TEST_NAMING: test functions and test modules use TEST_ prefix +and SHOUTING_SNAKE_CASE, except words that name a specific Rust construct +(type, function, macro, field, etc.) which must preserve exact case. + +When a construct name is embedded as a SHOUTING_SNAKE_CASE constant (or +PascalCase construct), it may be delimited with an extra underscore on +each side — e.g. HAVING__IGNORE_CASE__1. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +TEST_ATTR = re.compile(r"^\s*#\[(\w+::)?test(\(\))?\]") +FN_DEF = re.compile(r"^\s*fn\s+(\w+)") +MOD_DEF = re.compile(r"^\s*mod\s+(\w+)") +SNAKE_PART = re.compile(r"^[a-z][a-z0-9]*$") +CONSECUTIVE_UPPER = re.compile(r"[A-Z]{2,}") + +PASS = "\N{WHITE HEAVY CHECK MARK}" # ✅ +FAIL = "\N{CROSS MARK}" # ❌ + + +def is_pascal_case_atom(atom: str) -> bool: + return ( + atom[0].isupper() + and any(c.islower() for c in atom) + and not CONSECUTIVE_UPPER.search(atom) + and atom.isalnum() + ) + + +def atom_violation(atom: str) -> str | None: + if atom.isupper() or atom.isdigit(): + return None + + if atom[0].islower() and all(SNAKE_PART.match(part) for part in atom.split("_")): + return None + + if is_pascal_case_atom(atom): + return None + + return ( + f"segment '{atom}' must be SHOUTING_SNAKE_CASE, a PascalCase construct name, " + "or a Rust snake_case identifier" + ) + + +def parse_padded_construct( + segments: list[str], start: int +) -> tuple[str | None, int, list[str]]: + """Parse __CONSTRUCT__ padding around a shouting or PascalCase atom.""" + violations: list[str] = [] + i = start + while i < len(segments) and not segments[i]: + i += 1 + if i >= len(segments): + return None, i, [f"empty segment padding without construct"] + + seg = segments[i] + atom: str | None = None + + if seg.isupper(): + parts = [seg] + i += 1 + while i < len(segments) and segments[i] and segments[i].isupper(): + parts.append(segments[i]) + i += 1 + atom = "_".join(parts) + reason = atom_violation(atom) + if reason: + violations.append(reason) + elif seg[0].isupper() and is_pascal_case_atom(seg): + atom = seg + reason = atom_violation(atom) + if reason: + violations.append(reason) + i += 1 + else: + return None, start, [f"empty segment padding without construct"] + + while i < len(segments) and not segments[i]: + i += 1 + + return atom, i, violations + + +def parse_name_atoms(rest: str) -> tuple[list[str], list[str]]: + """Split a test name body into atoms; return (atoms, violations).""" + atoms: list[str] = [] + violations: list[str] = [] + segments = rest.split("_") + i = 0 + + while i < len(segments): + seg = segments[i] + if not seg: + atom, i, viols = parse_padded_construct(segments, i) + violations.extend(viols) + if atom: + atoms.append(atom) + elif not viols: + violations.append(f"empty segment in '{rest}'") + continue + + if seg.isupper() or seg.isdigit(): + reason = atom_violation(seg) + if reason: + violations.append(reason) + else: + atoms.append(seg) + i += 1 + continue + + if seg[0].isupper(): + reason = atom_violation(seg) + if reason: + violations.append(reason) + else: + atoms.append(seg) + i += 1 + continue + + if SNAKE_PART.match(seg): + parts = [seg] + i += 1 + while i < len(segments) and SNAKE_PART.match(segments[i]): + parts.append(segments[i]) + i += 1 + atom = "_".join(parts) + reason = atom_violation(atom) + if reason: + violations.append(reason) + else: + atoms.append(atom) + continue + + violations.append( + f"segment '{seg}' must be SHOUTING_SNAKE_CASE, a PascalCase construct name, " + "or a Rust snake_case identifier" + ) + i += 1 + + return atoms, violations + + +def iter_name_violations(name: str) -> list[str]: + if not name.startswith("TEST_"): + return ["must start with 'TEST_'"] + + rest = name[len("TEST_") :] + if not rest: + return ["must have a name after 'TEST_'"] + + _, violations = parse_name_atoms(rest) + return violations + + +def iter_test_results(path: Path, root: Path) -> list[tuple[bool, str]]: + results: list[tuple[bool, str]] = [] + pending_test = False + display = path.relative_to(root) + + for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + stripped = line.rstrip() + + if TEST_ATTR.match(stripped): + pending_test = True + continue + + if pending_test and stripped.startswith("#["): + continue + + fn_match = FN_DEF.match(stripped) + if fn_match: + name = fn_match.group(1) + if pending_test: + pending_test = False + reasons = iter_name_violations(name) + label = f"{display}:{line_no}: test function '{name}'" + if reasons: + for reason in reasons: + results.append((False, f"{label}: {reason}")) + else: + results.append((True, label)) + continue + + mod_match = MOD_DEF.match(stripped) + if mod_match: + pending_test = False + name = mod_match.group(1) + if name.startswith("TEST_"): + reasons = iter_name_violations(name) + label = f"{display}:{line_no}: test module '{name}'" + if reasons: + for reason in reasons: + results.append((False, f"{label}: {reason}")) + else: + results.append((True, label)) + continue + + if stripped and not stripped.startswith("#") and stripped.endswith("{"): + pending_test = False + + return results + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + results: list[tuple[bool, str]] = [] + + for path in sorted(root.rglob("*.rs")): + if "target" in path.parts: + continue + results.extend(iter_test_results(path, root)) + + failures = [line for ok, line in results if not ok] + if failures: + print( + f"{FAIL} RUST_TEST_NAMING violations " + "(test functions and modules must use TEST_ + SHOUTING_SNAKE_CASE):", + file=sys.stderr, + ) + for ok, line in results: + mark = PASS if ok else FAIL + print(f" {mark} {line}", file=sys.stderr) + return 1 + + print(f"{PASS} RUST_TEST_NAMING: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fmt b/scripts/fmt new file mode 100755 index 0000000..13731a1 --- /dev/null +++ b/scripts/fmt @@ -0,0 +1,12 @@ +#! /usr/bin/env bash +set -euo pipefail + +RUSTFMT="$(rustup which --toolchain nightly rustfmt 2>/dev/null || true)" +if [[ -z "${RUSTFMT}" ]]; then + echo "error: nightly rustfmt is required (see rustfmt.toml)" >&2 + echo " rustup component add rustfmt --toolchain nightly" >&2 + exit 1 +fi + +export RUSTFMT +exec cargo fmt -- --unstable-features "$@" diff --git a/src/lib.rs b/src/lib.rs index 3812d38..51167f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,51 @@ +//! Shell-compatible wildcard matching for Rust — part of the cross-language +//! **shwild** family. +//! +//! **shwild** provides shell-style pattern matching: literals, `?` (any one +//! character), `*` (any number of characters), `[`…`]` ranges, `[^`…`]` +//! not-ranges, escapes, and case flags. **shwild.Rust** is a port with +//! minimal API differences from the original **C/C++** library; the design +//! emphasis is simplicity-of-use, modularity, and performance. +//! +//! (See [**shwild.Go**][sg] for the Go implementation.) +//! +//! # Installation +//! +//! Reference in **Cargo.toml** in the usual way: +//! +//! ```toml +//! shwild = { version = "0.1" } +//! ``` +//! +//! # Components +//! +//! * [`matches()`] — parse `pattern` and test `input` in one step; +//! * [`shwild_matches!`] — shorthand for [`matches()`] (2- or 3-arg); +//! * [`CompiledMatcher`] — parse once, match many times; +//! * [`Error`] and [`Result`] — parse/match error reporting; +//! * [`IGNORE_CASE`] — flag for case-insensitive matching; +//! +//! # Features +//! +//! * `lookup-ranges` (default) — range matching via **collect-rs** +//! `UnicodePointMap`; +//! * `null-feature` — no effect; useful for driver scripts; +//! * `test-regex` — optional **regex** dependency for benchmarks and +//! scratch programs; +//! +//! # Examples +//! +//! ``` +//! use shwild::shwild_matches; +//! +//! assert_eq!(Ok(true), shwild_matches!("*.rs", "lib.rs")); +//! ``` +//! +//! Further examples are in the repository **examples** directory and in +//! the project [README](https://github.com/synesissoftware/shwild.Rust). +//! +//! [sg]: https://github.com/synesissoftware/shwild.Go + // src/lib.rs : Definition of the shwild Rust package // /////////////////////////////////////////////// @@ -101,7 +149,6 @@ mod traits { /// # Returns: /// - `true` - indicates a full match; or /// - `false` - if not a full match. - fn matches( &self, slice : &str, @@ -300,11 +347,7 @@ mod match_structures { let slice_starts_with_literal = slice.starts_with(&self.literal) || match &self.literal_uppercase { Some(literal_uppercase) => { - if slice.len() >= literal_uppercase.len() { - slice.to_uppercase().starts_with(literal_uppercase) - } else { - false - } + slice.len() >= literal_uppercase.len() && slice.to_uppercase().starts_with(literal_uppercase) }, None => false, }; @@ -426,13 +469,12 @@ mod match_structures { mod tests { #![allow(non_snake_case)] + #[cfg(not(feature = "lookup-ranges"))] + use super::super::utils::prepare_range_string; #[cfg(feature = "lookup-ranges")] use super::super::utils::prepare_range_upm_from_slice; use super::{ - super::{ - traits::Match, - utils::prepare_range_string, - }, + super::traits::Match, MatchEnd, MatchLiteral, MatchNotRange, @@ -1384,7 +1426,10 @@ pub type Result = std_result::Result; /// # Examples: /// /// ``` -/// let matcher = shwild::CompiledMatcher::from_pattern_and_flags("a[bc]c?", shwild::IGNORE_CASE).unwrap(); +/// let matcher = shwild::CompiledMatcher::from_pattern_and_flags( +/// "a[bc]c?", +/// shwild::IGNORE_CASE, +/// ).unwrap(); /// /// assert!(matcher.matches("abcd")); /// assert!(matcher.matches("accd")); @@ -1889,7 +1934,6 @@ mod tests { #![allow(non_snake_case)] use crate as shwild; - use crate::shwild_matches; mod TEST_CompiledMatcher_PARSING { @@ -3106,10 +3150,10 @@ mod tests { assert!(!matcher.matches("")); assert!(!matcher.matches("Where are the bears?")); - assert_eq!(true, matcher.matches("Where are the 🐻s?")); - assert_eq!(true, matcher.matches("Where are the 🐼s?")); - assert_eq!(true, matcher.matches("Where are their 🐻s?")); - assert_eq!(true, matcher.matches("Where are the big brown 🐻s?")); + assert!(matcher.matches("Where are the 🐻s?")); + assert!(matcher.matches("Where are the 🐼s?")); + assert!(matcher.matches("Where are their 🐻s?")); + assert!(matcher.matches("Where are the big brown 🐻s?")); assert!(!matcher.matches("Where are the teddy-🐻s?")); } }