red v1.0.0

This commit is contained in:
Volodymyr Yavdoshenko
2026-01-08 22:52:12 +02:00
parent 97712187d2
commit be09c6a4a5
64 changed files with 28718 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
[alias]
cov = ["llvm-cov", "--workspace", "--lcov", "--output-path", "coverage/lcov.info", "--ignore-filename-regex", ".*/\\.cargo/registry/.*|.*/rustc/.*|.*/target/.*"]
+31
View File
@@ -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
+146
View File
@@ -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
+70
View File
@@ -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
+170
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+186
View File
@@ -0,0 +1,186 @@
# Red - Rust Stream Editor
An experimental drop-in replacement for GNU sed, written in Rust.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/Rust-1.70+-orange.svg?logo=rust)](https://www.rust-lang.org/)
[![CI](https://github.com/vyavdoshenko/red/actions/workflows/ci.yml/badge.svg)](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
+823
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
+12
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "stable"
components = ["rustfmt"]
profile = "minimal"
+167
View File
@@ -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}"
+102
View File
@@ -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
+117
View File
@@ -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"
+156
View File
@@ -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:]))
+379
View File
@@ -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
View File
@@ -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());
}
}
+54
View File
@@ -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