mirror of
https://github.com/uutils/red.git
synced 2026-06-10 16:14:42 -07:00
red v1.0.0
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
cov = ["llvm-cov", "--workspace", "--lcov", "--output-path", "coverage/lcov.info", "--ignore-filename-regex", ".*/\\.cargo/registry/.*|.*/rustc/.*|.*/target/.*"]
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
# Run Rust formatter before commit. This script is intended to be installed as:
|
||||
# git config core.hooksPath .githooks
|
||||
# Then it will run automatically on `git commit`.
|
||||
|
||||
REPO_ROOT_DIR=$(cd "$(dirname "$0")"/.. && pwd)
|
||||
|
||||
echo "[pre-commit] Checking Rust formatting..."
|
||||
|
||||
# First, check formatting to decide whether to block the commit
|
||||
if python3 "${REPO_ROOT_DIR}/red/scripts/format_rust.py" --check; then
|
||||
echo "[pre-commit] Formatting check passed."
|
||||
exit 0
|
||||
else
|
||||
status=$?
|
||||
if [ "$status" -eq 2 ]; then
|
||||
echo "[pre-commit] Formatter prerequisites missing. Aborting commit."
|
||||
exit 2
|
||||
fi
|
||||
echo "[pre-commit] Code is not properly formatted. Running formatter..."
|
||||
python3 "${REPO_ROOT_DIR}/red/scripts/format_rust.py"
|
||||
echo "[pre-commit] Code was reformatted. Review changes and re-run commit."
|
||||
# Block commit; do not stage files automatically
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,146 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
ubuntu:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build
|
||||
working-directory: ./red
|
||||
run: cargo build --release --verbose
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: ./red
|
||||
run: cargo test --verbose
|
||||
|
||||
- name: Install dependencies for tests
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y locales locales-all valgrind
|
||||
sudo localedef -i en_US -f UTF-8 en_US.UTF-8 || true
|
||||
sudo localedef -i ru_RU -f UTF-8 ru_RU.UTF-8 || true
|
||||
sudo localedef -i uk_UA -f UTF-8 uk_UA.UTF-8 || true
|
||||
sudo localedef -i ja_JP -f EUC-JP ja_JP.eucjp || true
|
||||
sudo localedef -i ja_JP -f SHIFT_JIS ja_JP.shiftjis || true
|
||||
sudo localedef -i el_GR -f ISO-8859-7 el_GR.iso88597 || true
|
||||
|
||||
- name: Fetch GNU sed tests
|
||||
working-directory: ./red
|
||||
run: bash scripts/fetch_gnused_tests.sh
|
||||
|
||||
- name: GNU sed tests
|
||||
working-directory: ./red
|
||||
run: bash scripts/run_gnused_tests.sh --fail-on-error --compact --no-build --expensive --timeout-sec 300
|
||||
|
||||
- name: Verify implementation
|
||||
working-directory: ./red
|
||||
run: RED_BIN=target/release/red bash tests/scripts/verify_implementation.sh
|
||||
|
||||
- name: Compare with GNU sed
|
||||
working-directory: ./red
|
||||
run: RED_BIN=target/release/red bash tests/scripts/compare_all_tests.sh
|
||||
|
||||
- name: Multibyte regex tests
|
||||
working-directory: ./red
|
||||
run: RED_BIN=target/release/red bash tests/scripts/mb_regex_tests.sh
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build
|
||||
working-directory: ./red
|
||||
run: cargo build --release --verbose
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: ./red
|
||||
run: cargo test --verbose
|
||||
|
||||
- name: Fetch GNU sed tests
|
||||
working-directory: ./red
|
||||
run: bash scripts/fetch_gnused_tests.sh
|
||||
|
||||
- name: GNU sed tests
|
||||
working-directory: ./red
|
||||
run: bash scripts/run_gnused_tests.sh --fail-on-error --compact --no-build --expensive --timeout-sec 300
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build
|
||||
working-directory: ./red
|
||||
run: cargo build --release --verbose
|
||||
|
||||
- name: Unit tests (library and selected integration tests)
|
||||
working-directory: ./red
|
||||
run: cargo test --verbose
|
||||
|
||||
- name: Fetch GNU sed tests
|
||||
working-directory: ./red
|
||||
shell: bash
|
||||
run: bash scripts/fetch_gnused_tests.sh
|
||||
|
||||
- name: Windows binary mode test (obinary.sh)
|
||||
working-directory: ./red
|
||||
shell: bash
|
||||
run: bash scripts/run_gnused_tests.sh --fail-on-error --compact --no-build --test-pattern "obinary.sh" --timeout-sec 60
|
||||
|
||||
selinux:
|
||||
runs-on: [self-hosted, selinux]
|
||||
if: github.repository == 'vyavdoshenko/red' # Only run on main repo, not forks
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Verify SELinux is enforcing
|
||||
run: |
|
||||
mode=$(getenforce)
|
||||
echo "SELinux mode: $mode"
|
||||
if [ "$mode" != "Enforcing" ] && [ "$mode" != "Permissive" ]; then
|
||||
echo "SELinux is not enabled!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build with SELinux support
|
||||
working-directory: ./red
|
||||
run: cargo build --release --features selinux
|
||||
|
||||
- name: Verify SELinux in version
|
||||
working-directory: ./red
|
||||
run: |
|
||||
./target/release/red --version
|
||||
./target/release/red --version | grep -q "with SELinux" || exit 1
|
||||
./target/release/red --version | grep -q "SELinux is enabled" || exit 1
|
||||
|
||||
- name: Unit tests with SELinux feature
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
working-directory: ./red
|
||||
run: cargo test --features selinux
|
||||
|
||||
- name: Fetch GNU sed tests
|
||||
working-directory: ./red
|
||||
run: bash scripts/fetch_gnused_tests.sh
|
||||
|
||||
- name: Run SELinux test
|
||||
working-directory: ./red
|
||||
run: bash scripts/run_gnused_tests.sh --test-pattern "inplace-selinux.sh" --no-build --compact --fail-on-error --timeout-sec 60
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Coverage
|
||||
|
||||
'on':
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fail_threshold:
|
||||
description: 'Minimum coverage percentage (0 to disable)'
|
||||
required: false
|
||||
default: '80'
|
||||
|
||||
jobs:
|
||||
rust-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
run: cargo install cargo-llvm-cov --locked
|
||||
|
||||
- name: Generate coverage
|
||||
working-directory: ./red
|
||||
run: |
|
||||
# Determine threshold: use input for workflow_dispatch, otherwise default to 80
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
THRESHOLD="${{ github.event.inputs.fail_threshold }}"
|
||||
else
|
||||
THRESHOLD="80"
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " Coverage threshold: ${THRESHOLD}%"
|
||||
echo "========================================"
|
||||
|
||||
# Run coverage with threshold
|
||||
if [ "$THRESHOLD" != "0" ] && [ -n "$THRESHOLD" ]; then
|
||||
bash scripts/coverage.sh --fail-under "$THRESHOLD"
|
||||
else
|
||||
bash scripts/coverage.sh
|
||||
fi
|
||||
|
||||
- name: Show coverage summary
|
||||
if: always()
|
||||
working-directory: ./red
|
||||
run: |
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " COVERAGE STATISTICS"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
cargo llvm-cov report --ignore-filename-regex ".*/\.cargo/registry/.*|.*/rustc/.*|.*/target/.*"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
|
||||
- name: Upload lcov artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lcov.info
|
||||
path: red/coverage/lcov.info
|
||||
@@ -0,0 +1,170 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Linux musl (static, portable)
|
||||
- name: linux-x86_64-musl
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact: red-linux-x86_64-musl
|
||||
features: ""
|
||||
cross: true
|
||||
|
||||
- name: linux-aarch64-musl
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
artifact: red-linux-aarch64-musl
|
||||
features: ""
|
||||
cross: true
|
||||
|
||||
# Linux gnu with SELinux
|
||||
- name: linux-x86_64-gnu-selinux
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact: red-linux-x86_64-gnu-selinux
|
||||
features: "selinux"
|
||||
cross: false
|
||||
|
||||
- name: linux-aarch64-gnu-selinux
|
||||
os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
artifact: red-linux-aarch64-gnu-selinux
|
||||
features: "selinux"
|
||||
cross: true
|
||||
|
||||
# macOS
|
||||
- name: darwin-x86_64
|
||||
os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
artifact: red-darwin-x86_64
|
||||
features: ""
|
||||
cross: false
|
||||
|
||||
- name: darwin-aarch64
|
||||
os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact: red-darwin-aarch64
|
||||
features: ""
|
||||
cross: false
|
||||
|
||||
# Windows
|
||||
- name: windows-x86_64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact: red-windows-x86_64
|
||||
features: ""
|
||||
cross: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.cross
|
||||
run: cargo install cross --git https://github.com/cross-rs/cross
|
||||
|
||||
- name: Install SELinux dependencies
|
||||
if: contains(matrix.features, 'selinux') && !matrix.cross
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libselinux1-dev
|
||||
|
||||
- name: Build with cargo
|
||||
if: ${{ !matrix.cross }}
|
||||
working-directory: ./red
|
||||
run: |
|
||||
if [ -n "${{ matrix.features }}" ]; then
|
||||
cargo build --release --target ${{ matrix.target }} --features ${{ matrix.features }}
|
||||
else
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Build with cross
|
||||
if: matrix.cross
|
||||
working-directory: ./red
|
||||
run: |
|
||||
if [ -n "${{ matrix.features }}" ]; then
|
||||
cross build --release --target ${{ matrix.target }} --features ${{ matrix.features }}
|
||||
else
|
||||
cross build --release --target ${{ matrix.target }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
cd red/target/${{ matrix.target }}/release
|
||||
tar -czvf ${{ matrix.artifact }}.tar.gz red
|
||||
mv ${{ matrix.artifact }}.tar.gz ../../../../
|
||||
|
||||
- name: Package (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
cd red/target/${{ matrix.target }}/release
|
||||
7z a ${{ matrix.artifact }}.zip red.exe
|
||||
mv ${{ matrix.artifact }}.zip ../../../../
|
||||
shell: bash
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: |
|
||||
${{ matrix.artifact }}.tar.gz
|
||||
${{ matrix.artifact }}.zip
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec mv {} release/ \;
|
||||
ls -la release/
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
release/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, '-') }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug
|
||||
target
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Generated by cargo mutants
|
||||
# Contains mutation testing data
|
||||
**/mutants.out*/
|
||||
|
||||
# Local GNU sed tests (fetched separately)
|
||||
red/tests/gnused-tests/
|
||||
red/coverage/
|
||||
|
||||
red.code-workspace
|
||||
+777
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Volodymyr Yavdoshenko
|
||||
Copyright (c) 2026 Red Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Red - Rust Stream Editor
|
||||
|
||||
An experimental drop-in replacement for GNU sed, written in Rust.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://github.com/vyavdoshenko/red/actions/workflows/ci.yml)
|
||||
|
||||
---
|
||||
|
||||
## About This Project
|
||||
|
||||
This project started as an experiment to understand how far one can go in creating a Rust replacement for GNU sed.
|
||||
|
||||
**Functional Compatibility**: As far as I know, `red` currently achieves 100% functional compatibility with GNU sed behavior.
|
||||
|
||||
**Performance**: There's still work to be done on the performance front. For performance comparisons and benchmarks, please refer to the [Benchmarking](#benchmarking) section below.
|
||||
|
||||
**Documentation**: For usage instructions, please refer to the [GNU sed manual](https://www.gnu.org/software/sed/manual/). Red is designed to work exactly like GNU sed.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **GNU sed Compatible**: Drop-in replacement for GNU sed
|
||||
- **Cross-Platform**: Runs on Linux, macOS, and Windows
|
||||
- **SELinux Support**: Preserves SELinux contexts during in-place editing (Linux)
|
||||
- **Memory Safe**: Written in Rust
|
||||
- **Full Command Support**: All GNU sed commands
|
||||
|
||||
---
|
||||
|
||||
## Command-Line Options
|
||||
|
||||
```
|
||||
Usage: red [OPTION]... {SCRIPT} [FILE]...
|
||||
red [OPTION]... -e SCRIPT... -f FILE... [FILE]...
|
||||
|
||||
Options:
|
||||
-n, --quiet, --silent suppress automatic printing of pattern space
|
||||
-e SCRIPT, --expression=SCRIPT
|
||||
add the script to the commands to be executed
|
||||
-f FILE, --file=FILE add the contents of script-file to the commands
|
||||
-i[SUFFIX], --in-place[=SUFFIX]
|
||||
edit files in place (makes backup if SUFFIX supplied)
|
||||
-r, -E, --regexp-extended
|
||||
use extended regular expressions
|
||||
-s, --separate consider files as separate rather than continuous
|
||||
-l N, --line-length=N specify line-wrap length for 'l' command
|
||||
-u, --unbuffered load minimal amounts of data and flush output often
|
||||
-z, --null-data use NUL character as line separator
|
||||
--posix disable all GNU extensions
|
||||
--follow-symlinks follow symlinks when processing in place
|
||||
--sandbox disable e/r/w commands
|
||||
--help display this help and exit
|
||||
--version output version information and exit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Red uses a two-phase architecture:
|
||||
|
||||
1. **Compile Phase**: Parse and validate scripts once
|
||||
2. **Execute Phase**: Apply commands to input data
|
||||
|
||||
For detailed documentation, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
cargo test
|
||||
|
||||
# Fetch GNU sed tests (required once)
|
||||
./scripts/fetch_gnused_tests.sh
|
||||
|
||||
# GNU sed compatibility tests
|
||||
./scripts/run_gnused_tests.sh
|
||||
|
||||
# Specific test
|
||||
./scripts/run_gnused_tests.sh --test-pattern "help.sh"
|
||||
|
||||
# Fast mode
|
||||
./scripts/run_gnused_tests.sh --fast
|
||||
```
|
||||
|
||||
### Benchmarking
|
||||
|
||||
Compare performance with GNU sed:
|
||||
|
||||
```bash
|
||||
# Install hyperfine
|
||||
cargo install hyperfine
|
||||
|
||||
# Run benchmarks
|
||||
./scripts/benchmark.sh
|
||||
|
||||
# Custom runs
|
||||
RUNS=50 WARMUP=5 ./scripts/benchmark.sh
|
||||
|
||||
RED_BIN=./target/release/red SED_BIN=/usr/bin/sed ./scripts/benchmark.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
cargo build
|
||||
|
||||
# Release build
|
||||
cargo build --release
|
||||
|
||||
# Build with SELinux support (Linux only)
|
||||
cargo build --release --features selinux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Releases
|
||||
|
||||
### Download
|
||||
|
||||
Pre-built binaries are available on the [Releases](https://github.com/vyavdoshenko/red/releases) page:
|
||||
|
||||
| Platform | File | Notes |
|
||||
|----------|------|-------|
|
||||
| Linux x86_64 | `red-linux-x86_64-musl.tar.gz` | Static binary, works on any Linux |
|
||||
| Linux ARM64 | `red-linux-aarch64-musl.tar.gz` | Static binary for ARM64 |
|
||||
| Linux x86_64 + SELinux | `red-linux-x86_64-gnu-selinux.tar.gz` | For Fedora, RHEL, CentOS |
|
||||
| Linux ARM64 + SELinux | `red-linux-aarch64-gnu-selinux.tar.gz` | ARM64 with SELinux |
|
||||
| macOS Intel | `red-darwin-x86_64.tar.gz` | macOS 10.15+ |
|
||||
| macOS Apple Silicon | `red-darwin-aarch64.tar.gz` | macOS 11+ (M1/M2/M3) |
|
||||
| Windows | `red-windows-x86_64.zip` | Windows 10+ |
|
||||
|
||||
### Install
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
curl -LO https://github.com/vyavdoshenko/red/releases/latest/download/red-linux-x86_64-musl.tar.gz
|
||||
tar -xzf red-linux-x86_64-musl.tar.gz
|
||||
sudo mv red /usr/local/bin/
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
curl -LO https://github.com/vyavdoshenko/red/releases/latest/download/red-darwin-aarch64.tar.gz
|
||||
tar -xzf red-darwin-aarch64.tar.gz
|
||||
xattr -d com.apple.quarantine red # Remove Gatekeeper quarantine
|
||||
sudo mv red /usr/local/bin/
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
red --version
|
||||
```
|
||||
|
||||
### Creating a Release (Maintainers)
|
||||
|
||||
1. Update version in `red/Cargo.toml`
|
||||
2. Commit changes
|
||||
3. Create and push a tag:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
GitHub Actions will automatically build all platforms and create a release with artifacts.
|
||||
|
||||
Use `-` in tag for pre-releases (e.g., `v1.0.0-beta`).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+823
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "red"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
selinux = ["dep:selinux", "dep:selinux-sys"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
lazy_static = "1.4"
|
||||
encoding_rs = "0.8"
|
||||
lexopt = "0.3.1"
|
||||
rustc-hash = "2.0" # Fast hash for regex state tracking
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
signal-hook = "0.3"
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
selinux = { version = "0.5", optional = true }
|
||||
selinux-sys = { version = "0.6", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
tempfile = "3.10"
|
||||
assert_fs = "1.1"
|
||||
@@ -0,0 +1,12 @@
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
pre-build = [
|
||||
"dpkg --add-architecture arm64",
|
||||
"apt-get update",
|
||||
"apt-get install -y libselinux1-dev:arm64"
|
||||
]
|
||||
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
# No special configuration needed for musl
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
# No special configuration needed for musl
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt"]
|
||||
profile = "minimal"
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
# Benchmark red vs GNU sed
|
||||
# Requires: hyperfine (cargo install hyperfine)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RED_BIN="${RED_BIN:-$SCRIPT_DIR/../target/release/red}"
|
||||
SED_BIN="${SED_BIN:-sed}"
|
||||
RUNS="${RUNS:-20}"
|
||||
WARMUP="${WARMUP:-3}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}=== Red vs GNU sed Benchmark ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Check dependencies
|
||||
if ! command -v hyperfine &> /dev/null; then
|
||||
echo -e "${RED}Error: hyperfine not found${NC}"
|
||||
echo "Install with: cargo install hyperfine"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$RED_BIN" ]]; then
|
||||
echo -e "${YELLOW}Building red in release mode...${NC}"
|
||||
(cd "$SCRIPT_DIR/.." && cargo build --release)
|
||||
fi
|
||||
|
||||
echo "Red binary: $RED_BIN"
|
||||
echo "Sed binary: $SED_BIN"
|
||||
echo "Runs: $RUNS, Warmup: $WARMUP"
|
||||
echo ""
|
||||
|
||||
# Create temp directory
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap "rm -rf $TMPDIR" EXIT
|
||||
|
||||
# Generate test files
|
||||
echo -e "${YELLOW}Generating test files...${NC}"
|
||||
|
||||
# Small file (1K lines)
|
||||
yes "hello world foo bar baz" | head -1000 > "$TMPDIR/small.txt"
|
||||
|
||||
# Medium file (100K lines)
|
||||
yes "The quick brown fox jumps over the lazy dog. foo bar baz 12345" | head -100000 > "$TMPDIR/medium.txt"
|
||||
|
||||
# Large file (1M lines)
|
||||
yes "Lorem ipsum dolor sit amet, consectetur adipiscing elit. foo bar" | head -1000000 > "$TMPDIR/large.txt"
|
||||
|
||||
# File with many matches
|
||||
seq 1 100000 | while read n; do echo "line $n: foo foo foo bar bar baz"; done > "$TMPDIR/many_matches.txt"
|
||||
|
||||
echo " small.txt: $(wc -l < "$TMPDIR/small.txt") lines ($(du -h "$TMPDIR/small.txt" | cut -f1))"
|
||||
echo " medium.txt: $(wc -l < "$TMPDIR/medium.txt") lines ($(du -h "$TMPDIR/medium.txt" | cut -f1))"
|
||||
echo " large.txt: $(wc -l < "$TMPDIR/large.txt") lines ($(du -h "$TMPDIR/large.txt" | cut -f1))"
|
||||
echo " many_matches.txt: $(wc -l < "$TMPDIR/many_matches.txt") lines ($(du -h "$TMPDIR/many_matches.txt" | cut -f1))"
|
||||
echo ""
|
||||
|
||||
# Benchmark functions
|
||||
run_bench() {
|
||||
local name="$1"
|
||||
local sed_cmd="$2"
|
||||
local red_cmd="$3"
|
||||
|
||||
echo -e "${GREEN}--- $name ---${NC}"
|
||||
hyperfine --warmup "$WARMUP" --runs "$RUNS" \
|
||||
--command-name "sed" "$sed_cmd" \
|
||||
--command-name "red" "$red_cmd"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Benchmarks
|
||||
# ============================================
|
||||
|
||||
echo -e "${GREEN}=== Simple Substitution ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Small file - simple s///" \
|
||||
"$SED_BIN 's/foo/FOO/' $TMPDIR/small.txt > /dev/null" \
|
||||
"$RED_BIN 's/foo/FOO/' $TMPDIR/small.txt > /dev/null"
|
||||
|
||||
run_bench "Medium file - simple s///" \
|
||||
"$SED_BIN 's/foo/FOO/' $TMPDIR/medium.txt > /dev/null" \
|
||||
"$RED_BIN 's/foo/FOO/' $TMPDIR/medium.txt > /dev/null"
|
||||
|
||||
run_bench "Large file - simple s///" \
|
||||
"$SED_BIN 's/foo/FOO/' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN 's/foo/FOO/' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Global Substitution ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Many matches - s///g" \
|
||||
"$SED_BIN 's/foo/FOO/g' $TMPDIR/many_matches.txt > /dev/null" \
|
||||
"$RED_BIN 's/foo/FOO/g' $TMPDIR/many_matches.txt > /dev/null"
|
||||
|
||||
run_bench "Large file - s///g" \
|
||||
"$SED_BIN 's/foo/FOO/g' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN 's/foo/FOO/g' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Regex Patterns ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Medium file - word boundary regex" \
|
||||
"$SED_BIN 's/\\bfoo\\b/FOO/g' $TMPDIR/medium.txt > /dev/null" \
|
||||
"$RED_BIN 's/\\bfoo\\b/FOO/g' $TMPDIR/medium.txt > /dev/null"
|
||||
|
||||
run_bench "Medium file - capture groups" \
|
||||
"$SED_BIN 's/\\(foo\\) \\(bar\\)/\\2 \\1/g' $TMPDIR/medium.txt > /dev/null" \
|
||||
"$RED_BIN 's/\\(foo\\) \\(bar\\)/\\2 \\1/g' $TMPDIR/medium.txt > /dev/null"
|
||||
|
||||
run_bench "Large file - extended regex" \
|
||||
"$SED_BIN -E 's/(foo|bar|baz)/WORD/g' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN -E 's/(foo|bar|baz)/WORD/g' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Delete Lines ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Large file - delete matching lines" \
|
||||
"$SED_BIN '/foo/d' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN '/foo/d' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
run_bench "Large file - delete line range" \
|
||||
"$SED_BIN '1000,2000d' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN '1000,2000d' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Print Lines ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Large file - print matching (-n /p/p)" \
|
||||
"$SED_BIN -n '/foo/p' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN -n '/foo/p' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
run_bench "Large file - print range" \
|
||||
"$SED_BIN -n '50000,60000p' $TMPDIR/large.txt > /dev/null" \
|
||||
"$RED_BIN -n '50000,60000p' $TMPDIR/large.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Multiple Commands ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Medium file - multiple -e" \
|
||||
"$SED_BIN -e 's/foo/FOO/g' -e 's/bar/BAR/g' -e 's/baz/BAZ/g' $TMPDIR/medium.txt > /dev/null" \
|
||||
"$RED_BIN -e 's/foo/FOO/g' -e 's/bar/BAR/g' -e 's/baz/BAZ/g' $TMPDIR/medium.txt > /dev/null"
|
||||
|
||||
echo -e "${GREEN}=== Complex Script ===${NC}"
|
||||
echo ""
|
||||
|
||||
run_bench "Medium file - complex script" \
|
||||
"$SED_BIN '/^$/d; s/foo/FOO/g; s/bar/BAR/g; /Lorem/s/ipsum/IPSUM/' $TMPDIR/medium.txt > /dev/null" \
|
||||
"$RED_BIN '/^$/d; s/foo/FOO/g; s/bar/BAR/g; /Lorem/s/ipsum/IPSUM/' $TMPDIR/medium.txt > /dev/null"
|
||||
|
||||
# ============================================
|
||||
# Summary
|
||||
# ============================================
|
||||
|
||||
echo -e "${GREEN}=== Benchmark Complete ===${NC}"
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
# This script generates Rust code coverage for the whole project using cargo-llvm-cov.
|
||||
# It installs cargo-llvm-cov if missing, runs tests, and produces lcov and HTML reports.
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Generate coverage report for the whole Rust project using cargo-llvm-cov.
|
||||
|
||||
Usage:
|
||||
coverage.sh [--html] [--open] [--fail-under PCT] [--profile dev|release] [--no-install]
|
||||
|
||||
Options:
|
||||
--html Generate HTML report (in addition to lcov.info)
|
||||
--open Open the HTML report in a browser (implies --html)
|
||||
--fail-under PCT Fail if line coverage is below PCT (integer, e.g., 80)
|
||||
--profile P Build profile to use: dev or release (default: dev)
|
||||
--no-install Do not auto-install cargo-llvm-cov
|
||||
|
||||
Outputs:
|
||||
- coverage/lcov.info
|
||||
- coverage/html/ (when --html)
|
||||
USAGE
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CRATE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PROFILE="dev"
|
||||
GENERATE_HTML="false"
|
||||
OPEN_HTML="false"
|
||||
FAIL_UNDER=""
|
||||
NO_INSTALL="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--html) GENERATE_HTML="true"; shift ;;
|
||||
--open) GENERATE_HTML="true"; OPEN_HTML="true"; shift ;;
|
||||
--fail-under) FAIL_UNDER="${2:-}"; shift 2 ;;
|
||||
--profile) PROFILE="${2:-dev}"; shift 2 ;;
|
||||
--no-install) NO_INSTALL="true"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "[error] Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${NO_INSTALL}" != "true" ]]; then
|
||||
if ! command -v cargo-llvm-cov >/dev/null 2>&1; then
|
||||
echo "[info] Installing cargo-llvm-cov"
|
||||
cargo install cargo-llvm-cov --locked
|
||||
fi
|
||||
fi
|
||||
|
||||
pushd "${CRATE_ROOT}" >/dev/null
|
||||
|
||||
# Clean previous coverage data to avoid contamination
|
||||
echo "[info] Resetting coverage counters"
|
||||
cargo llvm-cov clean --workspace
|
||||
|
||||
RUN_FLAGS=(
|
||||
--workspace
|
||||
--lcov --output-path coverage/lcov.info
|
||||
--ignore-filename-regex ".*/\.cargo/registry/.*|.*/rustc/.*|.*/target/.*"
|
||||
)
|
||||
|
||||
# Apply cargo profile flag only for release; dev is default and does not accept --dev
|
||||
if [[ "${PROFILE}" == "release" ]]; then
|
||||
RUN_FLAGS+=(--release)
|
||||
fi
|
||||
|
||||
if [[ -n "${FAIL_UNDER}" ]]; then
|
||||
RUN_FLAGS+=(--fail-under-lines "${FAIL_UNDER}")
|
||||
fi
|
||||
|
||||
echo "[info] Running tests with coverage"
|
||||
# Ensure output directories exist
|
||||
mkdir -p coverage
|
||||
cargo llvm-cov ${RUN_FLAGS[@]}
|
||||
|
||||
if [[ "${GENERATE_HTML}" == "true" ]]; then
|
||||
echo "[info] Generating HTML report"
|
||||
mkdir -p coverage/html
|
||||
REPORT_FLAGS=(--html --output-dir coverage/html)
|
||||
if [[ "${OPEN_HTML}" == "true" ]]; then
|
||||
REPORT_FLAGS+=(--open)
|
||||
fi
|
||||
cargo llvm-cov report ${REPORT_FLAGS[@]}
|
||||
fi
|
||||
|
||||
echo "[info] Coverage artifacts:"
|
||||
echo " - $(realpath coverage/lcov.info)"
|
||||
if [[ -d coverage/html ]]; then
|
||||
echo " - $(realpath coverage/html)"
|
||||
fi
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Fetch GNU sed tests via 'git clone'.
|
||||
|
||||
Usage:
|
||||
fetch_gnused_tests.sh [--dest /path/to/dir] [--git-url URL] [--branch BRANCH]
|
||||
|
||||
Options:
|
||||
--dest DIR Destination directory to place cloned tests (must NOT exist).
|
||||
Default: <crate_root>/tests/gnused-tests
|
||||
--git-url URL Override Git URL (default: git://git.sv.gnu.org/sed)
|
||||
--branch BRANCH Git branch to clone (default: master)
|
||||
|
||||
Notes:
|
||||
- Requires 'git' to be installed.
|
||||
- The script only downloads the full GNU sed repository; the tests are in testsuite/.
|
||||
- Running tests is handled by a separate script.
|
||||
USAGE
|
||||
}
|
||||
|
||||
GIT_URL_DEFAULT="git://git.sv.gnu.org/sed"
|
||||
GIT_BRANCH="master"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CRATE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
DEST_DIR="${CRATE_ROOT}/tests/gnused-tests"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dest)
|
||||
DEST_DIR="${2:-}"; shift 2 ;;
|
||||
--git-url)
|
||||
GIT_URL_DEFAULT="${2:-}"; shift 2 ;;
|
||||
--branch)
|
||||
GIT_BRANCH="${2:-}"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage; exit 0 ;;
|
||||
*)
|
||||
echo "[error] Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[error] 'git' not found. Install git." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -e "${DEST_DIR}" ]]; then
|
||||
echo "[error] Destination already exists: ${DEST_DIR}. Remove it or provide a different --dest." >&2
|
||||
exit 2
|
||||
fi
|
||||
mkdir -p "$(dirname "${DEST_DIR}")"
|
||||
|
||||
echo "[info] Cloning GNU sed repository"
|
||||
echo "[info] URL: ${GIT_URL_DEFAULT}"
|
||||
echo "[info] Branch: ${GIT_BRANCH}"
|
||||
echo "[info] Dest: ${DEST_DIR}"
|
||||
|
||||
# Clone with depth 1 to save space and time
|
||||
git clone --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL_DEFAULT}" "${DEST_DIR}" 2>&1 | \
|
||||
grep -v "^remote:" || true
|
||||
|
||||
if [[ ! -d "${DEST_DIR}/testsuite" ]]; then
|
||||
echo "[error] Expected testsuite/ directory not found in cloned repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Initialize gnulib submodule (required for proper test framework)
|
||||
echo "[info] Initializing gnulib submodule (test framework)"
|
||||
cd "${DEST_DIR}"
|
||||
git submodule update --init --depth 1 gnulib 2>&1 | grep -v "^remote:" || true
|
||||
|
||||
if [[ ! -f "${DEST_DIR}/gnulib/tests/init.sh" ]]; then
|
||||
echo "[error] gnulib/tests/init.sh not found after submodule initialization" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Compile test helpers (used by locale detection in tests)
|
||||
# Original files require autotools-generated headers, so we provide stubs
|
||||
# On Windows/systems without gcc, skip compilation (some locale tests will be skipped)
|
||||
if command -v gcc >/dev/null 2>&1; then
|
||||
echo "[info] Compiling test helpers"
|
||||
touch "${DEST_DIR}/testsuite/config.h"
|
||||
cat > "${DEST_DIR}/testsuite/progname.h" << 'EOF'
|
||||
#define set_program_name(x) (void)0
|
||||
#define program_name "test-helper"
|
||||
EOF
|
||||
# error() is a GNU extension, provide a stub for Windows/MinGW
|
||||
cat > "${DEST_DIR}/testsuite/error.h" << 'EOF'
|
||||
#if defined(_WIN32) || defined(__MINGW32__) || defined(__CYGWIN__)
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#define error(status, errnum, ...) do { fprintf(stderr, __VA_ARGS__); if (status) exit(status); } while(0)
|
||||
#else
|
||||
#include_next <error.h>
|
||||
#endif
|
||||
EOF
|
||||
gcc -I"${DEST_DIR}/testsuite" -o "${DEST_DIR}/testsuite/get-mb-cur-max" \
|
||||
"${DEST_DIR}/testsuite/get-mb-cur-max.c" 2>/dev/null || \
|
||||
echo "[warn] Failed to compile get-mb-cur-max (some locale tests will be skipped)"
|
||||
gcc -I"${DEST_DIR}/testsuite" -include "${DEST_DIR}/testsuite/error.h" \
|
||||
-o "${DEST_DIR}/testsuite/test-mbrtowc" \
|
||||
"${DEST_DIR}/testsuite/test-mbrtowc.c" 2>/dev/null || \
|
||||
echo "[warn] Failed to compile test-mbrtowc (some locale tests will be skipped)"
|
||||
else
|
||||
echo "[warn] gcc not found - skipping test helper compilation (some locale tests will be skipped)"
|
||||
fi
|
||||
|
||||
echo "[info] Done. GNU sed repository cloned to: ${DEST_DIR}"
|
||||
echo "[info] Tests are located in: ${DEST_DIR}/testsuite"
|
||||
echo "[info] Test framework (gnulib) initialized: ${DEST_DIR}/gnulib"
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
"""
|
||||
Rust code formatter helper.
|
||||
|
||||
Usage:
|
||||
- Fix formatting in-place:
|
||||
python3 red/scripts/format_rust.py
|
||||
|
||||
- Check formatting without changing files (CI-friendly):
|
||||
python3 red/scripts/format_rust.py --check
|
||||
|
||||
This script locates the crate root relative to its own location and runs
|
||||
`cargo fmt --all` (or with `-- --check` in check mode).
|
||||
|
||||
Project convention: all code comments must be in English.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_crate_root() -> Path:
|
||||
"""Return the crate root directory that contains Cargo.toml.
|
||||
|
||||
The script lives under <repo>/red/scripts/, so the crate root is its parent.
|
||||
"""
|
||||
script_path = Path(__file__).resolve()
|
||||
crate_root = script_path.parent.parent
|
||||
cargo_toml = crate_root / "Cargo.toml"
|
||||
if not cargo_toml.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"Cargo.toml not found at expected location: {cargo_toml}"
|
||||
)
|
||||
return crate_root
|
||||
|
||||
|
||||
def ensure_tools_available(preferred_toolchain: str | None) -> None:
|
||||
"""Ensure required tools are available in PATH and rustfmt component exists.
|
||||
|
||||
If a specific toolchain is preferred (e.g., "nightly" for edition 2024),
|
||||
verify rustfmt for that toolchain.
|
||||
"""
|
||||
if shutil.which("cargo") is None:
|
||||
raise RuntimeError(
|
||||
"cargo not found in PATH. Install Rust (https://rustup.rs) and retry."
|
||||
)
|
||||
|
||||
cargo_cmd: list[str] = ["cargo"]
|
||||
if preferred_toolchain:
|
||||
cargo_cmd += [f"+{preferred_toolchain}"]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[*cargo_cmd, "fmt", "--", "--version"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if preferred_toolchain == "nightly":
|
||||
raise RuntimeError(
|
||||
"rustfmt for nightly is not installed. Install it via:\n"
|
||||
" rustup toolchain install nightly\n"
|
||||
" rustup component add rustfmt --toolchain nightly"
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
"rustfmt is not installed. Install it via: rustup component add rustfmt"
|
||||
) from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError("cargo executable is not available") from exc
|
||||
|
||||
|
||||
def run_cargo_fmt(check: bool, preferred_toolchain: str | None) -> int:
|
||||
"""Run cargo fmt in the crate root. Return the process exit code."""
|
||||
crate_root = get_crate_root()
|
||||
cmd: list[str] = ["cargo"]
|
||||
if preferred_toolchain:
|
||||
cmd += [f"+{preferred_toolchain}"]
|
||||
cmd += ["fmt", "--all"]
|
||||
if check:
|
||||
cmd += ["--", "--check"]
|
||||
|
||||
process = subprocess.run(cmd, cwd=str(crate_root))
|
||||
return process.returncode
|
||||
|
||||
|
||||
def read_edition(crate_root: Path) -> str | None:
|
||||
"""Read edition from Cargo.toml if present (e.g., "2021", "2024")."""
|
||||
cargo_toml = crate_root / "Cargo.toml"
|
||||
try:
|
||||
content = cargo_toml.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
return None
|
||||
import re
|
||||
|
||||
match = re.search(r"^\s*edition\s*=\s*\"(\d{4})\"\s*$", content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Format Rust code using cargo fmt",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Check formatting without writing changes (non-zero exit on diff)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
|
||||
crate_root = get_crate_root()
|
||||
edition = read_edition(crate_root)
|
||||
preferred_toolchain = "nightly" if edition and edition >= "2024" else None
|
||||
|
||||
try:
|
||||
ensure_tools_available(preferred_toolchain)
|
||||
except Exception as error:
|
||||
print(f"[format_rust] Error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
exit_code = run_cargo_fmt(check=args.check, preferred_toolchain=preferred_toolchain)
|
||||
if exit_code == 0:
|
||||
action = "checked" if args.check else "formatted"
|
||||
print(f"[format_rust] Successfully {action} Rust code.")
|
||||
else:
|
||||
if args.check:
|
||||
print(
|
||||
"[format_rust] Formatting check failed. Run without --check to fix.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"[format_rust] Formatting command failed. See output above.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
Executable
+379
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Copyright (c) 2026 Red Authors
|
||||
# License: MIT
|
||||
#
|
||||
|
||||
# Run GNU sed tests against the local 'red' binary.
|
||||
# Note: GNU sed tests use init.sh from gnulib framework.
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Run GNU sed tests against the local 'red' binary.
|
||||
|
||||
Usage:
|
||||
run_gnused_tests.sh [--tests-dir /path/to/gnused/testsuite] [--debug] [--fail-on-error] [--timeout-sec N] [--no-build] [--compact] [--fast] [--test-pattern PATTERN]
|
||||
|
||||
Options:
|
||||
--tests-dir DIR Path to GNU sed testsuite directory
|
||||
(default: <crate_root>/tests/gnused-tests/testsuite).
|
||||
--debug Build 'red' in debug mode (default: release).
|
||||
--fail-on-error Exit with failure if any test fails
|
||||
--timeout-sec N Per-test timeout in seconds (0 disables; default: 30)
|
||||
--no-build Do not rebuild the binary (use the existing target output).
|
||||
--compact Print compact output (only test results and errors).
|
||||
--fast Convenience flag: implies --debug --compact --timeout-sec 10.
|
||||
--expensive Run expensive/very-expensive tests (e.g., >2GB file tests)
|
||||
--test-pattern PAT Only run tests matching the pattern (e.g., "execute-tests.sh")
|
||||
|
||||
Notes:
|
||||
- GNU sed tests require gnulib's init.sh framework
|
||||
- If init.sh is not available, tests that depend on it will be skipped
|
||||
- This runner prepares PATH so that 'sed' resolves to the local 'red' binary.
|
||||
- Some tests may require specific locales or tools (perl, etc.)
|
||||
USAGE
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CRATE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
DEFAULT_TESTS_DIR="${CRATE_ROOT}/red/tests/gnused-tests/testsuite"
|
||||
TESTS_DIR="${DEFAULT_TESTS_DIR}"
|
||||
BUILD_MODE="release"
|
||||
FAIL_ON_ERROR="false"
|
||||
TIMEOUT_SEC=30
|
||||
NO_BUILD="false"
|
||||
COMPACT="false"
|
||||
EXPENSIVE="false"
|
||||
TEST_PATTERN="*.sh"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tests-dir)
|
||||
TESTS_DIR="${2:-}"; shift 2 ;;
|
||||
--debug)
|
||||
BUILD_MODE="debug"; shift ;;
|
||||
--fail-on-error)
|
||||
FAIL_ON_ERROR="true"; shift ;;
|
||||
--timeout-sec)
|
||||
TIMEOUT_SEC="${2:-}"; shift 2 ;;
|
||||
--no-build)
|
||||
NO_BUILD="true"; shift ;;
|
||||
--compact)
|
||||
COMPACT="true"; shift ;;
|
||||
--fast)
|
||||
BUILD_MODE="debug"; COMPACT="true"; TIMEOUT_SEC=10; shift ;;
|
||||
--expensive)
|
||||
EXPENSIVE="true"; shift ;;
|
||||
--test-pattern)
|
||||
TEST_PATTERN="${2:-}"; shift 2 ;;
|
||||
-h|--help)
|
||||
usage; exit 0 ;;
|
||||
*)
|
||||
echo "[error] Unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[info] Tests dir: ${TESTS_DIR}"
|
||||
|
||||
if [[ ! -d "${TESTS_DIR}" ]]; then
|
||||
echo "[error] Tests directory not found: ${TESTS_DIR}" >&2
|
||||
echo "[info] Run 'scripts/fetch_gnused_tests.sh' first to download the tests" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${NO_BUILD}" != "true" ]]; then
|
||||
echo "[info] Building red (${BUILD_MODE})"
|
||||
pushd "${CRATE_ROOT}/red" >/dev/null
|
||||
if [[ "${BUILD_MODE}" == "release" ]]; then
|
||||
cargo build --release
|
||||
else
|
||||
cargo build
|
||||
fi
|
||||
popd >/dev/null
|
||||
else
|
||||
echo "[info] Skipping build (using existing binary)"
|
||||
fi
|
||||
|
||||
if [[ "${BUILD_MODE}" == "release" ]]; then
|
||||
RED_BIN="${CRATE_ROOT}/red/target/release/red"
|
||||
else
|
||||
RED_BIN="${CRATE_ROOT}/red/target/debug/red"
|
||||
fi
|
||||
|
||||
if [[ ! -x "${RED_BIN}" ]]; then
|
||||
echo "[error] red binary not found at ${RED_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
cleanup() { rm -rf "${WORKDIR}" || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Create a bin directory with sed -> red symlink
|
||||
mkdir -p "${WORKDIR}/bin"
|
||||
ln -s "${RED_BIN}" "${WORKDIR}/bin/sed"
|
||||
|
||||
# Set up test framework using gnulib's init.sh
|
||||
# GNU sed tests expect ./testsuite/init.sh and init.cfg relative to test location
|
||||
TESTS_PARENT="$(dirname "${TESTS_DIR}")"
|
||||
|
||||
# Also create sed/ directory in tests parent (tests use path_prepend_ ./sed)
|
||||
mkdir -p "${TESTS_PARENT}/sed"
|
||||
ln -sf "${RED_BIN}" "${TESTS_PARENT}/sed/sed"
|
||||
GNULIB_INIT_SH="${TESTS_PARENT}/gnulib/tests/init.sh"
|
||||
INIT_CFG="${TESTS_PARENT}/init.cfg"
|
||||
TESTSUITE_INIT_SH="${TESTS_PARENT}/testsuite/init.sh"
|
||||
|
||||
# Check if gnulib is available
|
||||
if [[ ! -f "${GNULIB_INIT_SH}" ]]; then
|
||||
echo "[error] gnulib/tests/init.sh not found at: ${GNULIB_INIT_SH}" >&2
|
||||
echo "[error] Run 'scripts/fetch_gnused_tests.sh' to download tests and gnulib" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create symlink to gnulib's init.sh in testsuite directory
|
||||
echo "[info] Using gnulib test framework from: ${GNULIB_INIT_SH}"
|
||||
mkdir -p "$(dirname "${TESTSUITE_INIT_SH}")"
|
||||
ln -sf "../gnulib/tests/init.sh" "${TESTSUITE_INIT_SH}" 2>/dev/null || {
|
||||
# If symlink fails (e.g., on some systems), copy the file
|
||||
cp "${GNULIB_INIT_SH}" "${TESTSUITE_INIT_SH}"
|
||||
}
|
||||
|
||||
# Ensure init.cfg exists (it's sourced by init.sh)
|
||||
if [[ -f "${INIT_CFG}" ]]; then
|
||||
echo "[info] Using existing init.cfg"
|
||||
else
|
||||
echo "[info] init.cfg not found - tests will use defaults"
|
||||
fi
|
||||
|
||||
# Update PATH to use our sed wrapper and testsuite helper scripts
|
||||
export PATH="${WORKDIR}/bin:${TESTS_DIR}:${PATH}"
|
||||
echo "[info] Using sed: $(command -v sed) ($(${WORKDIR}/bin/sed --version 2>/dev/null | head -1 || echo 'unknown version'))"
|
||||
|
||||
# Set up environment variables expected by GNU sed tests
|
||||
# srcdir=. because tests run from gnused-tests parent directory
|
||||
export srcdir="."
|
||||
export abs_top_srcdir="${TESTS_PARENT}"
|
||||
export abs_srcdir="${TESTS_DIR}"
|
||||
export LC_ALL=C
|
||||
|
||||
# Locale variables for tests (normally set by configure)
|
||||
export LOCALE_JA="ja_JP.eucjp"
|
||||
|
||||
# Variables for help-version.sh test
|
||||
# Extract version from our red binary
|
||||
RED_VERSION=$(${WORKDIR}/bin/sed --version 2>/dev/null | head -1 | sed 's/.* //' || echo "unknown")
|
||||
export VERSION="${RED_VERSION}"
|
||||
export built_programs="sed"
|
||||
|
||||
# Enable expensive tests if requested
|
||||
if [[ "${EXPENSIVE}" == "true" ]]; then
|
||||
export RUN_EXPENSIVE_TESTS=yes
|
||||
export RUN_VERY_EXPENSIVE_TESTS=yes
|
||||
fi
|
||||
|
||||
# Store logs
|
||||
LOGDIR="${WORKDIR}/logs"
|
||||
mkdir -p "${LOGDIR}"
|
||||
|
||||
# Change to parent of tests directory (gnulib expects srcdir=. and testsuite/ subdirectory)
|
||||
pushd "${TESTS_PARENT}" >/dev/null
|
||||
|
||||
# Find all test scripts matching pattern (in testsuite subdirectory)
|
||||
# Note: using while read loop instead of mapfile for bash 3.2 compatibility (macOS)
|
||||
TEST_SCRIPTS=()
|
||||
while IFS= read -r script; do
|
||||
TEST_SCRIPTS+=("$script")
|
||||
done < <(find testsuite -maxdepth 1 -name "${TEST_PATTERN}" -type f | sort)
|
||||
|
||||
if [[ ${#TEST_SCRIPTS[@]} -eq 0 ]]; then
|
||||
echo "[error] No test scripts found matching pattern: ${TEST_PATTERN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[info] Found ${#TEST_SCRIPTS[@]} test scripts matching '${TEST_PATTERN}'"
|
||||
[[ "${COMPACT}" != "true" ]] && echo ""
|
||||
|
||||
# Run tests
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
SKIPPED=0
|
||||
TIMEOUT=0
|
||||
|
||||
for test_script in "${TEST_SCRIPTS[@]}"; do
|
||||
test_name="$(basename "${test_script}")"
|
||||
test_log="${LOGDIR}/${test_name}.log"
|
||||
|
||||
# panic-tests.sh requires a filesystem that enforces directory permissions
|
||||
# VirtioFS (used in some container/VM setups) doesn't enforce dir write permissions
|
||||
# So we copy the test directory to /tmp (which uses overlay fs) and run from there
|
||||
if [[ "${test_name}" == "panic-tests.sh" ]]; then
|
||||
[[ "${COMPACT}" != "true" ]] && echo "[test] Running ${test_name} (from /tmp for permission enforcement)..."
|
||||
TMP_TEST_DIR=$(mktemp -d)
|
||||
cp -r "${TESTS_PARENT}"/* "${TMP_TEST_DIR}/"
|
||||
mkdir -p "${TMP_TEST_DIR}/sed"
|
||||
ln -sf "${RED_BIN}" "${TMP_TEST_DIR}/sed/sed"
|
||||
|
||||
set +e
|
||||
pushd "${TMP_TEST_DIR}" >/dev/null
|
||||
if [[ "${TIMEOUT_SEC}" != "0" && -n "$(command -v timeout)" ]]; then
|
||||
timeout "${TIMEOUT_SEC}"s bash "testsuite/${test_name}" >"${test_log}" 2>&1
|
||||
rc=$?
|
||||
else
|
||||
bash "testsuite/${test_name}" >"${test_log}" 2>&1
|
||||
rc=$?
|
||||
fi
|
||||
popd >/dev/null
|
||||
set -e
|
||||
rm -rf "${TMP_TEST_DIR}"
|
||||
|
||||
if [[ ${rc} -eq 0 ]]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
[[ "${COMPACT}" == "true" ]] && echo " [PASS]"
|
||||
[[ "${COMPACT}" != "true" ]] && echo " [PASS]"
|
||||
elif [[ ${rc} -eq 77 ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
skip_msg=$(grep -E "(^SKIP:|skipped test:)" "${test_log}" | head -1 || true)
|
||||
if [[ -z "${skip_msg}" ]]; then
|
||||
skip_msg="(no message)"
|
||||
else
|
||||
# Extract just the reason part using bash string manipulation (avoid sed/locale issues)
|
||||
if [[ "${skip_msg}" == *"skipped test: "* ]]; then
|
||||
skip_msg="${skip_msg##*skipped test: }"
|
||||
elif [[ "${skip_msg}" == "SKIP: "* ]]; then
|
||||
skip_msg="${skip_msg#SKIP: }"
|
||||
fi
|
||||
fi
|
||||
[[ "${COMPACT}" == "true" ]] && echo " [SKIP]: ${skip_msg}"
|
||||
[[ "${COMPACT}" != "true" ]] && echo " [SKIP]: ${skip_msg}"
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
[[ "${COMPACT}" == "true" ]] && echo " [FAIL] (rc=${rc})"
|
||||
[[ "${COMPACT}" != "true" ]] && { echo " [FAIL] (rc=${rc})"; echo " Log: ${test_log}"; head -50 "${test_log}"; }
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip tests requiring special handling (documented limitations)
|
||||
# Note: 8bit.sh, newjis.sh, 8to7.sh, and mac-mf.sh now pass after byte-level implementation
|
||||
case "${test_name}" in
|
||||
# mac-mf.sh now passes after fixing raw bytes tracking and address regex parsing
|
||||
binary.sh)
|
||||
# binary.sh passes in release but too slow in debug mode
|
||||
if [[ "${BUILD_MODE}" == "debug" ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
[[ "${COMPACT}" == "true" ]] && echo "SKIP ${test_name}: Too slow in debug mode - use release build"
|
||||
[[ "${COMPACT}" != "true" ]] && echo "[test] Skipping ${test_name} (too slow in debug mode)"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
# dc.sh now works after fixing greedy matching and ^ literal handling
|
||||
# Keeping commented out for reference:
|
||||
# dc.sh)
|
||||
# SKIPPED=$((SKIPPED + 1))
|
||||
# [[ "${COMPACT}" == "true" ]] && echo "SKIP ${test_name}: Complex sed script (calculator)"
|
||||
# [[ "${COMPACT}" != "true" ]] && echo "[test] Skipping ${test_name} (complex calculator script)"
|
||||
# continue
|
||||
# ;;
|
||||
# bsd-wrapper.sh now passes - all BSD tests working
|
||||
#bsd-wrapper.sh)
|
||||
# SKIPPED=$((SKIPPED + 1))
|
||||
# [[ "${COMPACT}" == "true" ]] && echo "SKIP ${test_name}: BSD sed compatibility layer N/A"
|
||||
# [[ "${COMPACT}" != "true" ]] && echo "[test] Skipping ${test_name} (BSD wrapper not applicable)"
|
||||
# continue
|
||||
# ;;
|
||||
esac
|
||||
|
||||
[[ "${COMPACT}" != "true" ]] && echo "[test] Running ${test_name}..."
|
||||
|
||||
set +e
|
||||
if [[ "${TIMEOUT_SEC}" != "0" && -n "$(command -v timeout)" ]]; then
|
||||
timeout "${TIMEOUT_SEC}"s bash "${test_script}" >"${test_log}" 2>&1
|
||||
rc=$?
|
||||
else
|
||||
bash "${test_script}" >"${test_log}" 2>&1
|
||||
rc=$?
|
||||
fi
|
||||
set -e
|
||||
|
||||
# Analyze result
|
||||
# 0 = success, 77 = skipped, 99 = framework failure, 124 = timeout
|
||||
if [[ ${rc} -eq 0 ]]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
[[ "${COMPACT}" != "true" ]] && echo " [PASS]"
|
||||
elif [[ ${rc} -eq 77 ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
# Handle both "SKIP: ..." and "testname: skipped test: ..." formats
|
||||
skip_msg=$(grep -E "(^SKIP:|skipped test:)" "${test_log}" | head -1 || true)
|
||||
if [[ -z "${skip_msg}" ]]; then
|
||||
skip_msg="(no message)"
|
||||
else
|
||||
# Extract just the reason part using bash string manipulation (avoid sed/locale issues)
|
||||
if [[ "${skip_msg}" == *"skipped test: "* ]]; then
|
||||
skip_msg="${skip_msg##*skipped test: }"
|
||||
elif [[ "${skip_msg}" == "SKIP: "* ]]; then
|
||||
skip_msg="${skip_msg#SKIP: }"
|
||||
fi
|
||||
fi
|
||||
if [[ "${COMPACT}" == "true" ]]; then
|
||||
echo "SKIP ${test_name}: ${skip_msg}"
|
||||
else
|
||||
echo " [SKIP]: ${skip_msg}"
|
||||
fi
|
||||
elif [[ ${rc} -eq 124 ]]; then
|
||||
TIMEOUT=$((TIMEOUT + 1))
|
||||
FAILED=$((FAILED + 1))
|
||||
if [[ "${COMPACT}" == "true" ]]; then
|
||||
echo "TIMEOUT ${test_name}"
|
||||
else
|
||||
echo " [TIMEOUT] (${TIMEOUT_SEC}s)"
|
||||
fi
|
||||
else
|
||||
FAILED=$((FAILED + 1))
|
||||
if [[ "${COMPACT}" == "true" ]]; then
|
||||
echo "FAIL ${test_name} (exit ${rc})"
|
||||
# Show last few lines of output
|
||||
tail -10 "${test_log}" | sed 's/^/ | /'
|
||||
else
|
||||
echo " [FAIL] (exit ${rc})"
|
||||
echo " Log: ${test_log}"
|
||||
# Show error output
|
||||
tail -20 "${test_log}" | sed 's/^/ | /'
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ "${COMPACT}" != "true" ]] && echo ""
|
||||
done
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Test Results Summary"
|
||||
echo "=========================================="
|
||||
echo "Total: $((PASSED + FAILED + SKIPPED))"
|
||||
echo "Passed: ${PASSED}"
|
||||
echo "Failed: ${FAILED}"
|
||||
echo "Skipped: ${SKIPPED}"
|
||||
if [[ ${TIMEOUT} -gt 0 ]]; then
|
||||
echo "Timeout: ${TIMEOUT}"
|
||||
fi
|
||||
echo ""
|
||||
echo "Logs saved at: ${LOGDIR}"
|
||||
echo "=========================================="
|
||||
|
||||
# Exit status
|
||||
if [[ "${FAIL_ON_ERROR}" == "true" && ${FAILED} -gt 0 ]]; then
|
||||
echo "[error] ${FAILED} test(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${FAILED} -gt 0 ]]; then
|
||||
echo "[warn] ${FAILED} test(s) failed (use --fail-on-error to make this fatal)"
|
||||
exit 0
|
||||
else
|
||||
echo "[info] All tests passed!"
|
||||
exit 0
|
||||
fi
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
// Copyright (c) 2026 Red Authors
|
||||
// License: MIT
|
||||
//
|
||||
|
||||
//! Command-line argument parsing using lexopt.
|
||||
//!
|
||||
//! Parses sed-compatible options with single parsing path and natural argument order preservation.
|
||||
|
||||
use std::io::{self, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lexopt::prelude::*;
|
||||
use red::constants::DEFAULT_LINE_LENGTH;
|
||||
use red::errors::{Result, SedError};
|
||||
|
||||
/// Parsed command-line arguments
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CliArgs {
|
||||
/// Scripts with raw bytes and their sources (for error reporting)
|
||||
/// Collected from -e and -f options in the order they appear
|
||||
/// Tuple: (converted_string, raw_bytes, source)
|
||||
/// raw_bytes is needed for accurate multibyte delimiter detection
|
||||
pub scripts_with_sources: Vec<(String, Vec<u8>, red::errors::ScriptSource)>,
|
||||
|
||||
/// Input files with raw bytes (empty = stdin)
|
||||
/// Tuple: (converted_string, raw_bytes) to support raw bytes in inline scripts
|
||||
pub files: Vec<(String, Vec<u8>)>,
|
||||
|
||||
/// Suppress automatic printing (-n)
|
||||
pub quiet: bool,
|
||||
|
||||
/// In-place editing suffix
|
||||
/// None = no in-place editing
|
||||
/// Some("") = in-place without backup
|
||||
/// Some(suffix) = in-place with backup
|
||||
pub in_place: Option<String>,
|
||||
|
||||
/// Use Extended Regular Expressions (-r/-E)
|
||||
pub extended_regex: bool,
|
||||
|
||||
/// Treat files separately (-s)
|
||||
pub separate_files: bool,
|
||||
|
||||
/// Line length for l command
|
||||
pub line_length: usize,
|
||||
|
||||
/// Unbuffered output (-u)
|
||||
pub unbuffered: bool,
|
||||
|
||||
/// POSIX mode (--posix)
|
||||
pub posix: bool,
|
||||
|
||||
/// Follow symlinks (--follow-symlinks)
|
||||
pub follow_symlinks: bool,
|
||||
|
||||
/// Sandbox mode (--sandbox)
|
||||
pub sandbox: bool,
|
||||
|
||||
/// Null-data mode (-z)
|
||||
pub null_data: bool,
|
||||
|
||||
/// Binary mode (-b) - disable CRLF conversion on Windows
|
||||
pub binary: bool,
|
||||
}
|
||||
|
||||
impl Default for CliArgs {
|
||||
fn default() -> Self {
|
||||
CliArgs {
|
||||
scripts_with_sources: vec![],
|
||||
files: vec![],
|
||||
quiet: false,
|
||||
in_place: None,
|
||||
extended_regex: false,
|
||||
separate_files: false,
|
||||
line_length: DEFAULT_LINE_LENGTH,
|
||||
unbuffered: false,
|
||||
posix: false,
|
||||
follow_symlinks: false,
|
||||
sandbox: false,
|
||||
null_data: false,
|
||||
binary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse command-line arguments using lexopt
|
||||
///
|
||||
/// ## Argument Semantics
|
||||
///
|
||||
/// - `-e SCRIPT` / `--expression SCRIPT`: Add script expression
|
||||
/// - `-f FILE` / `--file FILE`: Add script from file (or stdin if FILE is -)
|
||||
/// - `-n` / `--quiet` / `--silent`: Suppress auto-print
|
||||
/// - `-i[SUFFIX]` / `--in-place[=SUFFIX]`: Edit in-place
|
||||
/// - `-i` = in-place without backup
|
||||
/// - `-iSUFFIX` = in-place with backup (any suffix, including "s", "p", etc.)
|
||||
/// - `-r` / `-E` / `--regexp-extended`: Use ERE instead of BRE
|
||||
/// - `-s` / `--separate`: Treat files separately
|
||||
/// - `-l N` / `--line-length N`: Set line wrap length
|
||||
/// - `-u` / `--unbuffered`: Unbuffered output
|
||||
/// - `--posix`: Strict POSIX mode
|
||||
/// - `--follow-symlinks`: Follow symlinks in-place
|
||||
/// - `--sandbox`: Sandbox mode
|
||||
/// - `-z` / `--null-data`: Use NUL as line separator
|
||||
/// - `--help`: Print help
|
||||
/// - `--version`: Print version
|
||||
///
|
||||
/// ## Order Preservation
|
||||
///
|
||||
/// Scripts from `-e` and `-f` are collected in the exact order they appear.
|
||||
/// This is critical for `#n` quiet mode detection (only first script matters).
|
||||
pub fn parse_args() -> Result<CliArgs> {
|
||||
let mut args = CliArgs::default();
|
||||
let mut parser = lexopt::Parser::from_env();
|
||||
let mut expr_index = 0; // Track expression index for error messages
|
||||
|
||||
while let Some(arg) = parser.next()? {
|
||||
match arg {
|
||||
// -e SCRIPT / --expression SCRIPT
|
||||
Short('e') | Long("expression") => {
|
||||
// Get raw bytes and lossy-converted string
|
||||
let os_value = parser.value()?;
|
||||
#[cfg(unix)]
|
||||
let raw_bytes: Vec<u8> = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
os_value.as_bytes().to_vec()
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let raw_bytes: Vec<u8> = os_value.to_string_lossy().as_bytes().to_vec();
|
||||
let script = os_value.to_string_lossy().into_owned();
|
||||
args.scripts_with_sources.push((
|
||||
script,
|
||||
raw_bytes,
|
||||
red::errors::ScriptSource::Expression(expr_index),
|
||||
));
|
||||
expr_index += 1;
|
||||
}
|
||||
|
||||
// -f FILE / --file FILE
|
||||
Short('f') | Long("file") => {
|
||||
let file_path = parser.value()?.string()?;
|
||||
let (script_content, raw_bytes, path_display) = read_script_file(&file_path)?;
|
||||
args.scripts_with_sources.push((
|
||||
script_content,
|
||||
raw_bytes,
|
||||
red::errors::ScriptSource::File(path_display),
|
||||
));
|
||||
}
|
||||
|
||||
// -n / --quiet / --silent
|
||||
Short('n') | Long("quiet") | Long("silent") => {
|
||||
args.quiet = true;
|
||||
}
|
||||
|
||||
// -i[SUFFIX] / --in-place[=SUFFIX]
|
||||
Short('i') | Long("in-place") => {
|
||||
// Try to get a value - if present, use it as suffix
|
||||
// If not present (separate -i), use empty string (no backup)
|
||||
let suffix = match parser.optional_value() {
|
||||
Some(val) => {
|
||||
let s = val.string()?;
|
||||
// lexopt quirk: it consumes '=' as delimiter for short options
|
||||
// so -i==foo becomes "=foo". Prepend the '=' back if needed.
|
||||
if s.starts_with('=') {
|
||||
format!("={}", s)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
args.in_place = Some(suffix);
|
||||
}
|
||||
|
||||
// -r / -E / --regexp-extended
|
||||
Short('r') | Short('E') | Long("regexp-extended") => {
|
||||
args.extended_regex = true;
|
||||
}
|
||||
|
||||
// -s / --separate
|
||||
Short('s') | Long("separate") => {
|
||||
args.separate_files = true;
|
||||
}
|
||||
|
||||
// -l N / --line-length N
|
||||
Short('l') | Long("line-length") => {
|
||||
let value = parser.value()?.string()?;
|
||||
args.line_length = value
|
||||
.parse()
|
||||
.map_err(|_| SedError::usage(&format!("invalid line length: {}", value)))?;
|
||||
}
|
||||
|
||||
// -u / --unbuffered
|
||||
Short('u') | Long("unbuffered") => {
|
||||
args.unbuffered = true;
|
||||
}
|
||||
|
||||
// --posix
|
||||
Long("posix") => {
|
||||
args.posix = true;
|
||||
}
|
||||
|
||||
// --follow-symlinks
|
||||
Long("follow-symlinks") => {
|
||||
args.follow_symlinks = true;
|
||||
}
|
||||
|
||||
// --sandbox
|
||||
Long("sandbox") => {
|
||||
args.sandbox = true;
|
||||
}
|
||||
|
||||
// -z / --null-data
|
||||
Short('z') | Long("null-data") => {
|
||||
args.null_data = true;
|
||||
}
|
||||
|
||||
// -b / --binary (disable CRLF conversion on Windows)
|
||||
Short('b') | Long("binary") => {
|
||||
args.binary = true;
|
||||
}
|
||||
|
||||
// --help
|
||||
Long("help") => {
|
||||
// Use write! to detect errors (e.g., when stdout redirected to /dev/full)
|
||||
use std::io::Write;
|
||||
if writeln!(std::io::stdout(), "{}", help_text()).is_err() {
|
||||
std::process::exit(4); // sed exit code for write errors
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
// --version
|
||||
Long("version") => {
|
||||
// Use write! to detect errors (e.g., when stdout redirected to /dev/full)
|
||||
use std::io::Write;
|
||||
#[allow(unused_mut)]
|
||||
let mut version = format!("red {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[cfg(feature = "selinux")]
|
||||
{
|
||||
version.push_str("\nwith SELinux");
|
||||
if red::selinux::is_selinux_enabled() {
|
||||
version.push_str("\nSELinux is enabled");
|
||||
}
|
||||
}
|
||||
|
||||
if writeln!(std::io::stdout(), "{}", version).is_err() {
|
||||
std::process::exit(4); // sed exit code for write errors
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
// Positional arguments (files or script if no -e/-f)
|
||||
// Store both lossy string and raw bytes for inline script support
|
||||
Value(val) => {
|
||||
#[cfg(unix)]
|
||||
let raw_bytes: Vec<u8> = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
val.as_bytes().to_vec()
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let raw_bytes: Vec<u8> = val.to_string_lossy().as_bytes().to_vec();
|
||||
let file = val.to_string_lossy().into_owned();
|
||||
args.files.push((file, raw_bytes));
|
||||
}
|
||||
|
||||
// Unknown short option
|
||||
Short(ch) => {
|
||||
return Err(SedError::usage(&format!("invalid option -- '{}'", ch)));
|
||||
}
|
||||
|
||||
// Unknown long option
|
||||
Long(opt) => {
|
||||
return Err(SedError::usage(&format!("invalid option -- --{}", opt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no -e or -f provided, first positional argument is the script
|
||||
if args.scripts_with_sources.is_empty() {
|
||||
if args.files.is_empty() {
|
||||
// GNU sed compatibility: print usage when no script provided
|
||||
print_usage();
|
||||
std::process::exit(4); // sed exit code for usage errors
|
||||
}
|
||||
let (script, raw_bytes) = args.files.remove(0);
|
||||
args.scripts_with_sources.push((
|
||||
script,
|
||||
raw_bytes,
|
||||
red::errors::ScriptSource::Expression(0),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Read script from file or stdin
|
||||
///
|
||||
/// Returns (script_content, raw_bytes, display_path) for error reporting
|
||||
/// Uses lossy UTF-8 conversion to handle scripts with invalid bytes
|
||||
/// The raw_bytes are needed for accurate multibyte delimiter detection
|
||||
fn read_script_file(path: &str) -> Result<(String, Vec<u8>, String)> {
|
||||
if path == "-" {
|
||||
// Read from stdin as bytes
|
||||
let mut content = Vec::new();
|
||||
io::stdin()
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|e| SedError::io("can't read", "-", e))?;
|
||||
let converted = String::from_utf8_lossy(&content).into_owned();
|
||||
Ok((converted, content, "-".to_string()))
|
||||
} else {
|
||||
// Read from file as bytes
|
||||
let file_path = PathBuf::from(path);
|
||||
let path_str = file_path.display().to_string();
|
||||
let content =
|
||||
std::fs::read(&file_path).map_err(|e| SedError::io("can't read", &path_str, e))?;
|
||||
let converted = String::from_utf8_lossy(&content).into_owned();
|
||||
Ok((converted, content, path_str))
|
||||
}
|
||||
}
|
||||
|
||||
/// Print usage information (for errors - without bug report footer)
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
r#"Usage: red [OPTION]... {{SCRIPT}} [FILE]...
|
||||
red [OPTION]... -e SCRIPT... -f FILE... [FILE]...
|
||||
|
||||
stream editor for filtering and transforming text.
|
||||
|
||||
Options:
|
||||
-n, --quiet, --silent suppress automatic printing of pattern space
|
||||
-e SCRIPT, --expression=SCRIPT
|
||||
add the script to the commands to be executed
|
||||
-f FILE, --file=FILE add the contents of script-file to the commands
|
||||
-i[SUFFIX], --in-place[=SUFFIX]
|
||||
edit files in place (makes backup if SUFFIX supplied)
|
||||
-r, -E, --regexp-extended
|
||||
use extended regular expressions
|
||||
-s, --separate consider files as separate rather than continuous
|
||||
-l N, --line-length=N specify line-wrap length for 'l' command
|
||||
-u, --unbuffered load minimal amounts of data and flush output often
|
||||
-z, --null-data use NUL character as line separator
|
||||
-b, --binary open files in binary mode (no CRLF conversion)
|
||||
--posix disable all GNU extensions
|
||||
--follow-symlinks follow symlinks when processing in place
|
||||
--sandbox disable e/r/w commands
|
||||
--help display this help and exit
|
||||
--version output version information and exit
|
||||
|
||||
If no -e or -f option is given, the first non-option argument is the script.
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
/// Return help text (with bug report footer)
|
||||
fn help_text() -> &'static str {
|
||||
r#"Usage: red [OPTION]... {SCRIPT} [FILE]...
|
||||
red [OPTION]... -e SCRIPT... -f FILE... [FILE]...
|
||||
|
||||
stream editor for filtering and transforming text.
|
||||
|
||||
Options:
|
||||
-n, --quiet, --silent suppress automatic printing of pattern space
|
||||
-e SCRIPT, --expression=SCRIPT
|
||||
add the script to the commands to be executed
|
||||
-f FILE, --file=FILE add the contents of script-file to the commands
|
||||
-i[SUFFIX], --in-place[=SUFFIX]
|
||||
edit files in place (makes backup if SUFFIX supplied)
|
||||
-r, -E, --regexp-extended
|
||||
use extended regular expressions
|
||||
-s, --separate consider files as separate rather than continuous
|
||||
-l N, --line-length=N specify line-wrap length for 'l' command
|
||||
-u, --unbuffered load minimal amounts of data and flush output often
|
||||
-z, --null-data use NUL character as line separator
|
||||
-b, --binary open files in binary mode (no CRLF conversion)
|
||||
--posix disable all GNU extensions
|
||||
--follow-symlinks follow symlinks when processing in place
|
||||
--sandbox disable e/r/w commands
|
||||
--help display this help and exit
|
||||
--version output version information and exit
|
||||
|
||||
If no -e or -f option is given, the first non-option argument is the script.
|
||||
|
||||
E-mail bug reports to: <v.yavdoshenko@gmail.com>"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_in_place_semantics() {
|
||||
// -i without value = no backup
|
||||
// This is tested through integration tests as lexopt parses env::args
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_script_file_error() {
|
||||
let result = read_script_file("/nonexistent/file/path.sed");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Red Authors
|
||||
// License: MIT
|
||||
//
|
||||
|
||||
//! Global constants for Red sed implementation
|
||||
//!
|
||||
//! This module centralizes all magic numbers and configuration values
|
||||
//! to improve maintainability and reduce duplication.
|
||||
|
||||
/// GNU sed compatibility version
|
||||
///
|
||||
/// Red aims to be compatible with GNU sed 4.9 behavior.
|
||||
/// Commands or features from later versions will be rejected.
|
||||
pub const GNU_SED_COMPAT_VERSION: &str = "4.9";
|
||||
|
||||
/// Maximum symlink resolution depth
|
||||
///
|
||||
/// Matches GNU sed and typical Unix SYMLOOP_MAX limits.
|
||||
/// Prevents infinite loops in symlink chains.
|
||||
pub const MAX_SYMLINK_DEPTH: usize = 40;
|
||||
|
||||
/// Default line length for 'l' command
|
||||
///
|
||||
/// Used when neither -l flag nor COLS environment variable is set.
|
||||
/// This value matches GNU sed's default.
|
||||
pub const DEFAULT_LINE_LENGTH: usize = 70;
|
||||
|
||||
/// Maximum regex backtracking iterations
|
||||
///
|
||||
/// Prevents stack overflow and infinite loops on pathological regex patterns.
|
||||
/// This limit applies to the custom backtracking engine.
|
||||
pub const MAX_REGEX_BACKTRACK_ITERATIONS: usize = 100_000;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_constants_valid() {
|
||||
assert!(!GNU_SED_COMPAT_VERSION.is_empty());
|
||||
assert!(MAX_SYMLINK_DEPTH > 0);
|
||||
assert!(DEFAULT_LINE_LENGTH > 0);
|
||||
assert!(MAX_REGEX_BACKTRACK_ITERATIONS > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compat_version_format() {
|
||||
// Should be in format "major.minor"
|
||||
let parts: Vec<&str> = GNU_SED_COMPAT_VERSION.split('.').collect();
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].parse::<u32>().is_ok());
|
||||
assert!(parts[1].parse::<u32>().is_ok());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user