diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index d9517c1..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -# .cargo/config.toml - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1ac92d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: + - master + - dev + - boilerplate + - idiomatic + - rc1 + - rc2 + - rc3 + pull_request: + +permissions: + contents: read + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: Stable checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + + - name: cargo test (all targets) + run: cargo test --all-targets --locked + + - name: cargo test (no default features) + run: cargo test --no-default-features --locked + + - name: cargo clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: cargo doc + run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --locked + + - name: Install pinned nightly rustfmt + run: rustup toolchain install nightly-2026-08-08 --profile minimal --component rustfmt + + - 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 + + - name: cargo build (libver) + run: cargo build --bin libver --locked + + - name: cargo publish (dry run) + run: cargo publish --dry-run --locked + + path-semantics: + name: Path semantics (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: cargo test (Unix path semantics) + run: cargo test --lib --locked tests::unix:: + + - name: cargo test (Windows path semantics) + run: cargo test --lib --locked tests::windows:: + + msrv: + name: MSRV (1.74) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.74.0 + + - uses: Swatinem/rust-cache@v2 + + - name: cargo check (library) + run: cargo check --lib --locked diff --git a/.gitignore b/.gitignore index 37c2727..6931436 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ .DS_Store -Cargo.lock - # files (by pattern) diff --git a/.vimrc b/.vimrc index b7392f5..d34bf9e 100644 --- a/.vimrc +++ b/.vimrc @@ -1,6 +1,70 @@ +" Synesis C/C++ project .vimrc — aligned with .sis/.vscode/c_cxx/settings.json +set nocompatible +filetype indent plugin on +syntax enable set autoindent -set expandtab -set shiftwidth=4 -set softtabstop=4 -set tabstop=4 \ No newline at end of file +set backspace=indent,eol,start +set hlsearch +set incsearch +set number + +" files.insertFinalNewline +set eol +set fixeol + +" editor.renderWhitespace: all +set list +set listchars=tab:->,trail:-,extends:>,precedes:<,nbsp:+ + +" editor.detectIndentation: false — global defaults (editor.tabSize: 2, insertSpaces: false) +set tabstop=2 +set shiftwidth=2 +set softtabstop=2 +set noexpandtab +set colorcolumn=76 + +" colorcolumn draws a full-column tint in Vim (not a VS Code-style 1px line). +" Keep it subtle via the ColorColumn highlight group; reapply after colorscheme changes. +if has('termguicolors') + " set termguicolors +endif + +function! s:ConfigureColorColumn() abort + highlight ColorColumn ctermbg=236 guibg=#2a2a2a cterm=NONE gui=NONE +endfunction + +call s:ConfigureColorColumn() +autocmd ColorScheme * call s:ConfigureColorColumn() + +" files.trimTrailingWhitespace +autocmd BufWritePre * %s/\s\+$//e + +augroup sis_c_cxx + autocmd! + + " [c] / [cpp] + autocmd FileType c,cpp setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=60,64,68,72,76 + + " [rust] + autocmd FileType rs setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=76 + + " [cmake] + autocmd FileType cmake setlocal noexpandtab tabstop=4 shiftwidth=4 softtabstop=4 + + " [shellscript] + autocmd FileType sh,bash,zsh setlocal expandtab tabstop=2 shiftwidth=2 softtabstop=2 colorcolumn=60,76 + + " [bat] + autocmd FileType bat,dosbatch setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=60,76 + + " [json] / [markdown] / [yaml] / [ruby] + autocmd FileType json,markdown,yaml,ruby setlocal expandtab tabstop=2 shiftwidth=2 softtabstop=2 + + " [python] + autocmd FileType python setlocal expandtab tabstop=4 shiftwidth=4 softtabstop=4 colorcolumn=60,76 + + " [toml] + autocmd FileType toml setlocal noexpandtab tabstop=2 shiftwidth=2 softtabstop=2 +augroup END + diff --git a/CHANGES.md b/CHANGES.md index 68bb29e..a79f507 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,17 +1,26 @@ -# libpath.Rust - CHANGES +# libpath.Rust - Changes + + +## 0.0.3 - 31st August 2026 + +* Added complete package metadata, release documentation, and CI checks; +* Added reproducible pinned-nightly formatting and repository checkers; +* Updated **fastparse** and **test_help-rs** to current compatible releases; +* Documented the current public API and retained unfinished path semantics in **TODO.md**; ## 0.0.2 - 16th March 2025 -* ~ renamed `ClassificationResult#Entry` => `#EntryName`; -* + added **test/scratch/libver**; -* ~ tidying ; + adding in as much of 2024 work's tests as work with current definition; -* + added **CHANGES.md**, **NEWS.md**, and **TODO.md**; +* Renamed `ClassificationResult#Entry` to `#EntryName`; +* Added **test/scratch/libver**; +* Tidied the source and added compatible tests from the 2024 work; +* Added **CHANGES.md**, **NEWS.md**, and **TODO.md**; + ## 0.0.1 - 5th April 2024 -* initial version; +* Initial version; All history before this day is moot! diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..597e46c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,42 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base-traits" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69068402cce0b92d771a83fb17d617a975ce1b8122968fdd025e1ed6c7bd0d5e" +dependencies = [ + "bt-rs", +] + +[[package]] +name = "bt-rs" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c801a3e540d22c99661706133db245edae489c872cfdb2f5925b2077170285" + +[[package]] +name = "fastparse" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a61fd43821c8464ac7aebae8ef52dc009958ce8d93405d32576b3f779bec1ddd" + +[[package]] +name = "libpath" +version = "0.0.3" +dependencies = [ + "fastparse", + "test_help-rs", +] + +[[package]] +name = "test_help-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2279bf04d9491e2b3bcd59cbc12eb9d7ac914c81290c8e54cdc92225dbb52cbb" +dependencies = [ + "base-traits", + "bt-rs", +] diff --git a/Cargo.toml b/Cargo.toml index 5f9a35e..9138f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,33 @@ authors = [ "Matt Wilson ", ] -description = "Path parsing library (for Rust)" +categories = [ + "filesystem", + "parsing", +] +description = "Path parsing library for Rust" +documentation = "https://docs.rs/libpath" edition = "2021" -repository = "https://github.com/synesissoftware/libpath.Rust" +exclude = [ + ".cargo", + ".github", + ".vimrc", + ".vscode", + "scripts", + "target", +] +homepage = "https://github.com/synesissoftware/libpath.Rust" +keywords = [ + "filesystem", + "parsing", + "path", +] license = "BSD-3-Clause" name = "libpath" readme = "README.md" -version = "0.0.2" +repository = "https://github.com/synesissoftware/libpath.Rust" +rust-version = "1.74" +version = "0.0.3" # ########################################################## @@ -53,13 +73,13 @@ null-feature = [] [dependencies] -fastparse = { version = "~0.0", default-features = false, features = [ +fastparse = { version = "0.0.3", default-features = false, features = [ ] } [dev-dependencies] -test_help-rs = { version = "0.1" } +test_help-rs = { version = "0.2.1" } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/LICENSE b/LICENSE index 0373b14..77b2df1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ libpath.Rust - BSD 3-Clause License -Copyright (c) 2019-2024, Matthew Wilson and Synesis Information Systems +Copyright (c) 2019-2026, Matthew Wilson and Synesis Information Systems Copyright (c) 2012-2019, Matthew Wilson and Synesis Software All rights reserved. diff --git a/NEWS.md b/NEWS.md index b5e2b97..12678c3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,8 @@ -# libpath.Rust - NEWS +# libpath.Rust - News | Date | News Item | | --------------------- | ----------------------------------------- | +| 31st August 2026 | [libpath.Rust 0.0.3](https://github.com/synesissoftware/libpath.Rust/releases/tag/0.0.3) released | | 16th March 2025 | libpath.Rust 0.0.2 released | | 5th April 2024 | libpath.Rust 0.0.1 released | diff --git a/README.md b/README.md index ec15431..682c7c1 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,13 @@ Path parsing library, for Rust - -## Introduction - -**libpath** is a small Rust library that provides file-system path parsing and comparison, for platform-dependent - **Unix** and **Windows** - and platform-independent path manipulation. It is intended to be useful for application-level programming as well as a basis for other libraries. +![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/libpath.svg)](https://crates.io/crates/libpath) +[![GitHub release](https://img.shields.io/github/v/release/synesissoftware/libpath.Rust.svg)](https://github.com/synesissoftware/libpath.Rust/releases/latest) +![MSRV](https://img.shields.io/badge/MSRV-1.74-lightgrey) +[![CI](https://github.com/synesissoftware/libpath.Rust/actions/workflows/ci.yml/badge.svg)](https://github.com/synesissoftware/libpath.Rust/actions/workflows/ci.yml) +[![docs.rs](https://docs.rs/libpath/badge.svg)](https://docs.rs/libpath) ## Table of Contents @@ -13,34 +16,81 @@ Path parsing library, for Rust - [Introduction](#introduction) - [Installation](#installation) - [Components](#components) + - [Common result type](#common-result-type) + - [Unix path classification](#unix-path-classification) + - [Windows path classification](#windows-path-classification) - [Examples](#examples) - [Project Information](#project-information) - [Where to get help](#where-to-get-help) - [Contribution guidelines](#contribution-guidelines) - [Dependencies](#dependencies) + - [Efferent (fan-out)](#efferent-fan-out) + - [Runtime Dependencies](#runtime-dependencies) + - [Build Dependencies](#build-dependencies) + - [Development Dependencies](#development-dependencies) + - [Afferent (fan-in)](#afferent-fan-in) - [Related projects](#related-projects) - [License](#license) +## Introduction + +**libpath.Rust** is a small Rust library for classifying Unix-like and +Windows path syntax. It reports path components as positions into the +original input string, without accessing the file system. + + ## Installation -T.B.C. +Reference **libpath** from **Cargo.toml**: + +```toml +libpath = { version = "0.0.3" } +``` + +The repository retains **Cargo.lock** to make local and CI verification +reproducible. Release and validation commands use `--locked`. ## Components -T.B.C. +### Common result type + +`libpath::util::common::ClassificationResult` describes the positions of +the input, root, directory, entry name, stem, extension, and related path +parts. Its fields use `fastparse::fastparse::types::PositionalSlice`. + + +### Unix path classification + +`libpath::util::unix::path_classify()` classifies Unix-like paths and +returns `libpath::util::unix::Classification` together with a +`ClassificationResult`. The module also defines the parsing flag constants +in `classification_flags`. + + +### Windows path classification + +`libpath::util::windows::path_classify()` classifies paths using forward +or backward separators, drive-letter roots and relative drive paths, and +the currently implemented tilde-rooted form. It returns the Windows +`Classification` and a `ClassificationResult`. ## Examples -T.B.C. +There are no maintained example programs yet. The `libver` binary is a +deliberately retained scratch utility that prints the package name and +version: +```text +cargo run --bin libver +``` -## Project Information +It is a repository utility rather than an example of the public path API. -T.B.C. +## Project Information ### Where to get help @@ -49,12 +99,36 @@ T.B.C. ### Contribution guidelines -Defect reports, feature requests, and pull requests are welcome on https://github.com/synesissoftware/libpath.Rust. +Defect reports, feature requests, and pull requests are welcome on +https://github.com/synesissoftware/libpath.Rust. ### Dependencies -* [**FastParse.Rust**](https://github.com/synesissoftware/FastParse.Rust); +#### Efferent (fan-out) + +Libraries upon which **libpath.Rust** depends: + +##### Runtime Dependencies + +* [**FastParse.Rust**](https://github.com/synesissoftware/FastParse.Rust) — + provides the public `PositionalSlice` result type; + + +##### Build Dependencies + +None. + + +##### Development Dependencies + +* [**test_help-rs**](https://github.com/synesissoftware/test_help-rs) — + declared for repository test support; + + +#### Afferent (fan-in) + +No downstream consumers are currently recorded. ### Related projects @@ -67,7 +141,8 @@ Defect reports, feature requests, and pull requests are welcome on https://githu ### License -**libpath.Rust** is released under the 3-clause BSD license. See [LICENSE](./LICENSE) for details. +**libpath.Rust** is released under the 3-clause BSD license. See +[LICENSE](./LICENSE) for details. diff --git a/TODO.md b/TODO.md index 5ae4b68..922cc71 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,9 @@ ## Functional improvements +The following path semantics remain implementation work and are not part of +the boilerplate baseline. + * [ ] Full path support (in `libpath::util::windows`): * [ ] Drive-rooted paths; * [ ] Drive-relative paths; diff --git a/.rustfmt.toml b/rustfmt.toml similarity index 91% rename from .rustfmt.toml rename to rustfmt.toml index 7eefcc0..b176684 100644 --- a/.rustfmt.toml +++ b/rustfmt.toml @@ -1,7 +1,10 @@ - # rustfmt.toml for libpath.Rust # -# configured for cargo-fmt 1.7.0-nightly +# Requires pinned nightly rustfmt with unstable features enabled, e.g.: +# +# ./scripts/fmt +# +# The wrapper uses nightly-2026-08-08 by default. # array_width=60 # deprecated # attr_fn_like_width=70 # deprecated @@ -16,7 +19,7 @@ comment_width=100 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 @@ -74,11 +77,10 @@ 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 # version= where_single_line=false wrap_comments=false - diff --git a/scripts/check_derives.py b/scripts/check_derives.py new file mode 100755 index 0000000..c34617a --- /dev/null +++ b/scripts/check_derives.py @@ -0,0 +1,128 @@ +""" +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 100755 index 0000000..5d58d5b --- /dev/null +++ b/scripts/check_doc_76.py @@ -0,0 +1,65 @@ +""" +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 100755 index 0000000..0f2dece --- /dev/null +++ b/scripts/check_test_names.py @@ -0,0 +1,242 @@ +""" +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]*$") + +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 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() or seg.isdigit(): + parts = [seg] + i += 1 + while i < len(segments) and segments[i] and ( + segments[i].isupper() or segments[i].isdigit() + ): + 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(seg) + 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: + start = i + 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}'") + if i == start: + i += 1 + 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..0a06baf --- /dev/null +++ b/scripts/fmt @@ -0,0 +1,13 @@ +#! /usr/bin/env bash +set -euo pipefail + +RUSTFMT_TOOLCHAIN="${RUSTFMT_TOOLCHAIN:-nightly-2026-08-08}" +RUSTFMT="$(rustup which --toolchain "${RUSTFMT_TOOLCHAIN}" rustfmt 2>/dev/null || true)" +if [[ -z "${RUSTFMT}" ]]; then + echo "error: ${RUSTFMT_TOOLCHAIN} rustfmt is required (see rustfmt.toml)" >&2 + echo " rustup toolchain install ${RUSTFMT_TOOLCHAIN} --component rustfmt" >&2 + exit 1 +fi + +export RUSTFMT +exec cargo fmt -- --unstable-features "$@" diff --git a/src/lib.rs b/src/lib.rs index 74f5dbd..a9dcd25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,11 +4,11 @@ * Purpose: Primary implementation file for libpath.Rust. * * Created: 16th April 2021 - * Updated: 8th April 2025 + * Updated: 31st August 2026 * * Home: http://stlsoft.org/ * - * Copyright (c) 2021-2025, Matthew Wilson and Synesis Information Systems + * Copyright (c) 2021-2026, Matthew Wilson and Synesis Information Systems * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -38,6 +38,23 @@ * * ////////////////////////////////////////////////////////////////////// */ +//! `libpath` provides path classification for Unix-like and Windows path +//! syntax. It reports the root, directory, entry name, stem, and extension +//! as positions into the original input. +//! +//! The public API is organised under the `libpath::util` module. The +//! platform-specific modules expose `path_classify()` and their associated +//! classification types. +//! +//! # Example +//! +//! ``` +//! use libpath::libpath::util::unix::{path_classify, Classification}; +//! +//! let (classification, _) = path_classify("dir/name.txt", 0); +//! assert_eq!(Classification::Relative, classification); +//! ``` + pub mod libpath { @@ -56,7 +73,7 @@ pub mod libpath { /// - FullPath - the full /// - Prefix #[derive(Debug)] - #[derive(PartialEq, Eq)] + #[derive(Eq, PartialEq)] pub struct ClassificationResult { /// The input string's position. pub Input : PoSl, @@ -66,7 +83,8 @@ pub mod libpath { pub FullPath : PoSl, /// The prefix. pub Prefix : PoSl, - /// T.B.C. + /// The part of the input ending immediately before + /// `EntryName`. /// /// # Note: /// Equivalent to **recls**' `DirectoryPath`. @@ -133,11 +151,11 @@ pub mod libpath { pub mod classification_flags { - /// T.B.C. + /// Reserved flag for handling runs of path separators. pub const IGNORE_SLASH_RUNS : i32 = 0x00000001; - /// T.B.C. + /// Reserved flag for handling invalid path characters. pub const IGNORE_INVALID_CHARS : i32 = 0x00000002; - /// T.B.C. + /// Flag associated with tilde-home classification. pub const RECOGNISE_TILDE_HOME : i32 = 0x00000004; } @@ -161,6 +179,7 @@ pub mod libpath { } + #[allow(clippy::collapsible_if)] pub fn path_classify( path : &str, parse_flags : i32, @@ -196,7 +215,8 @@ pub mod libpath { cr.Directory = PoSl::new(root.len(), dir_len); - let (num_parts, num_dir_parts) = count_directory_parts_(cr.Directory.substring_of(path), parse_flags); + let (num_parts, num_dir_parts) = + count_directory_parts_(cr.Directory.substring_of(path), parse_flags); cr.NumDirectoryParts = num_parts; cr.NumDotsDirectoryParts = num_dir_parts; @@ -264,7 +284,11 @@ pub mod libpath { /// - `parse_flags` - flags that moderate the classification; /// /// # Returns: - /// `(classification : Classification, root : PositionalSlice, path_root_stripped : PositionalSlice)` + /// `(classification : Classification, root : PositionalSlice, + /// path_root_stripped : PositionalSlice)` + #[allow(clippy::collapsible_if)] + #[allow(unused_assignments)] + #[allow(unused_variables)] fn classify_root_( path : &str, parse_flags : i32, @@ -394,15 +418,7 @@ pub mod libpath { mod tests { #![allow(non_snake_case)] - use super::{ - char_is_path_name_separator_, - classification_flags, - classify_root_, - count_directory_parts_, - Classification, - }; - - use fastparse::fastparse::types::PositionalSlice as PoSl; + use super::char_is_path_name_separator_; #[test] @@ -442,13 +458,14 @@ pub mod libpath { pub mod classification_flags { - /// T.B.C. + /// Reserved flag for handling runs of path separators. pub const IGNORE_SLASH_RUNS : i32 = 0x00000001; - /// T.B.C. + /// Reserved flag for handling invalid path characters. pub const IGNORE_INVALID_CHARS : i32 = 0x00000002; - /// T.B.C. + /// Flag associated with tilde-home classification. pub const RECOGNISE_TILDE_HOME : i32 = 0x00000004; - /// T.B.C. + /// Reserved flag for handling invalid characters in long + /// paths. pub const IGNORE_INVALID_CHARS_IN_LONG_PATH : i32 = 0x00000002; } @@ -472,6 +489,7 @@ pub mod libpath { } + #[allow(clippy::collapsible_if)] pub fn path_classify( path : &str, parse_flags : i32, @@ -507,7 +525,8 @@ pub mod libpath { cr.Directory = PoSl::new(root.len(), dir_len); - let (num_parts, num_dir_parts) = count_directory_parts_(cr.Directory.substring_of(path), parse_flags); + let (num_parts, num_dir_parts) = + count_directory_parts_(cr.Directory.substring_of(path), parse_flags); cr.NumDirectoryParts = num_parts; cr.NumDotsDirectoryParts = num_dir_parts; @@ -575,7 +594,11 @@ pub mod libpath { /// - `parse_flags` - flags that moderate the classification; /// /// # Returns: - /// `(classification : Classification, root : PositionalSlice, path_root_stripped : PositionalSlice)` + /// `(classification : Classification, root : PositionalSlice, + /// path_root_stripped : PositionalSlice)` + #[allow(clippy::collapsible_if)] + #[allow(unused_assignments)] + #[allow(unused_variables)] fn classify_root_( path : &str, parse_flags : i32, @@ -672,6 +695,7 @@ pub mod libpath { } /// Evaluates whether a character is a path-name-separator. + #[allow(clippy::match_like_matches_macro)] fn char_is_path_name_separator_(c : char) -> bool { match c { '/' => true, @@ -754,6 +778,7 @@ pub mod libpath { } /// Indicates whether the given character is a drive letter. + #[allow(clippy::match_like_matches_macro)] fn char_is_drive_letter_(c : char) -> bool { match c { 'A'..='Z' => true, @@ -770,14 +795,8 @@ pub mod libpath { use super::{ char_is_drive_letter_, char_is_path_name_separator_, - classification_flags, - classify_root_, - count_directory_parts_, - Classification, }; - use fastparse::fastparse::types::PositionalSlice as PoSl; - #[test] fn TEST_char_is_drive_letter__1() { @@ -857,7 +876,7 @@ mod tests { #[test] fn TEST_path_classify_WITH_EMPTY_INPUT() { - let flag_max = 0 | IGNORE_SLASH_RUNS | IGNORE_INVALID_CHARS | RECOGNISE_TILDE_HOME; + let flag_max = IGNORE_SLASH_RUNS | IGNORE_INVALID_CHARS | RECOGNISE_TILDE_HOME; for flags in 0..=flag_max { let (cl, cr) = path_classify("", flags); @@ -1388,7 +1407,7 @@ mod tests { #[test] fn TEST_path_classify_WITH_EMPTY_INPUT() { let flag_max = - 0 | IGNORE_SLASH_RUNS | IGNORE_INVALID_CHARS | RECOGNISE_TILDE_HOME | IGNORE_INVALID_CHARS_IN_LONG_PATH; + IGNORE_SLASH_RUNS | IGNORE_INVALID_CHARS | RECOGNISE_TILDE_HOME | IGNORE_INVALID_CHARS_IN_LONG_PATH; for flags in 0..=flag_max { let (cl, cr) = path_classify("", flags);