Repository: chmln/sd
Branch: master
Commit: 44febdf86343
Files: 42
Total size: 106.3 KB
Directory structure:
gitextract_6tl_8up9/
├── .cargo/
│ └── config.toml
├── .editorconfig
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .rustfmt.toml
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── README_zh-CN.md
├── RELEASE.md
├── gen/
│ ├── completions/
│ │ ├── _sd
│ │ ├── _sd.ps1
│ │ ├── sd.bash
│ │ ├── sd.elv
│ │ └── sd.fish
│ └── sd.1
├── proptest-regressions/
│ └── replacer/
│ ├── tests.txt
│ └── validate.txt
├── release.toml
├── sd/
│ ├── Cargo.toml
│ └── src/
│ ├── error.rs
│ ├── input.rs
│ ├── lib.rs
│ ├── output.rs
│ ├── replacer/
│ │ ├── mod.rs
│ │ ├── tests.rs
│ │ └── validate.rs
│ ├── snapshots/
│ │ └── sd__unescape__test__unescape.snap
│ └── unescape.rs
├── sd-cli/
│ ├── Cargo.toml
│ ├── src/
│ │ ├── cli.rs
│ │ └── main.rs
│ └── tests/
│ ├── cli.rs
│ └── snapshots/
│ ├── cli__cli__correctly_fails_on_missing_file.snap
│ ├── cli__cli__correctly_fails_on_unreadable_file.snap
│ └── cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap
└── xtask/
├── Cargo.toml
└── src/
├── generate.rs
└── main.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .cargo/config.toml
================================================
[alias]
xtask = "run --package xtask --"
================================================
FILE: .editorconfig
================================================
[*]
indent_style = space
indent_size = 4
[*.rs]
max_line_length = 80
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish
on:
push:
tags:
- '*'
jobs:
publish:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
use-cross: false
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
use-cross: false
- os: ubuntu-latest
target: arm-unknown-linux-gnueabihf
use-cross: true
- os: windows-latest
target: x86_64-pc-windows-gnu
use-cross: false
- os: windows-latest
target: x86_64-pc-windows-msvc
use-cross: false
- os: windows-latest
target: aarch64-pc-windows-msvc
use-cross: false
- os: macos-latest
target: x86_64-apple-darwin
use-cross: false
- os: macos-latest
target: aarch64-apple-darwin
use-cross: false
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
use-cross: true
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
use-cross: true
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
use-cross: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set the version
shell: bash
if: env.SD_VERSION == ''
run: |
echo "SD_VERSION=$GITHUB_REF_NAME" >> $GITHUB_ENV
echo "version is: ${{ env.SD_VERSION }}"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup native compilation
if: ${{ matrix.use-cross == false }}
shell: bash
run: |
echo "CARGO=cargo" >> $GITHUB_ENV
- name: Setup cross compilation
if: ${{ matrix.use-cross == true }}
shell: bash
run: |
dir="$RUNNER_TEMP/cross-download"
mkdir "$dir"
echo "$dir" >> $GITHUB_PATH
cd "$dir"
curl -LO "https://github.com/cross-rs/cross/releases/download/v0.2.5/cross-x86_64-unknown-linux-musl.tar.gz"
tar xf cross-x86_64-unknown-linux-musl.tar.gz
echo "CARGO=cross" >> $GITHUB_ENV
echo "RUSTFLAGS=--cfg sd_cross_compile" >> $GITHUB_ENV
echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV
- name: Build
shell: bash
run: |
$CARGO --version
$CARGO build --release --locked --target ${{ matrix.target }}
# Handle windows being an oddity
if [ "${{ matrix.os }}" = "windows-latest" ]; then
echo "BIN_NAME=sd.exe" >> $GITHUB_ENV
else
echo "BIN_NAME=sd" >> $GITHUB_ENV
fi
- name: Setup archive
shell: bash
run: |
staging="sd-${{ env.SD_VERSION }}-${{ matrix.target }}"
mkdir -p "$staging"
cp -r {README.md,LICENSE,CHANGELOG.md,gen/*} "$staging"
if [ "${{ matrix.os }}" = "windows-latest" ]; then
cp "target/${{ matrix.target }}/release/sd.exe" "$staging/"
7z a "$staging.zip" "$staging"
echo "ASSET=$staging.zip" >> $GITHUB_ENV
else
cp "target/${{ matrix.target }}/release/sd" "$staging/"
tar czf "$staging.tar.gz" "$staging"
echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV
fi
- name: Upload binaries to release
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ env.ASSET }}
asset_name: ${{ env.ASSET }}
tag: ${{ github.ref }}
================================================
FILE: .github/workflows/test.yml
================================================
name: Test
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
use-cross: false
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
use-cross: false
- os: ubuntu-latest
target: arm-unknown-linux-gnueabihf
use-cross: true
- os: windows-latest
target: x86_64-pc-windows-gnu
use-cross: false
- os: windows-latest
target: x86_64-pc-windows-msvc
use-cross: false
- os: windows-latest
target: aarch64-pc-windows-msvc
use-cross: false
- os: macos-latest
target: x86_64-apple-darwin
use-cross: false
- os: macos-latest
target: aarch64-apple-darwin
use-cross: false
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
use-cross: true
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
use-cross: true
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
use-cross: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Setup native compilation
if: ${{ matrix.use-cross == false }}
shell: bash
run: |
echo "CARGO=cargo" >> $GITHUB_ENV
- name: Install Cross
if: ${{ matrix.use-cross == true }}
shell: bash
run: |
dir="$RUNNER_TEMP/cross-download"
mkdir "$dir"
echo "$dir" >> $GITHUB_PATH
cd "$dir"
curl -LO "https://github.com/cross-rs/cross/releases/download/v0.2.5/cross-x86_64-unknown-linux-musl.tar.gz"
tar xf cross-x86_64-unknown-linux-musl.tar.gz
echo "CARGO=cross" >> $GITHUB_ENV
echo "RUSTFLAGS=--cfg sd_cross_compile" >> $GITHUB_ENV
echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV
- name: Test
shell: bash
run: |
$CARGO --version
# For legal reasons, cross doesn't support Apple Silicon. See this:
# https://github.com/cross-rs/cross-toolchains#darwin-targets
# It builds and runs fine, but there's no easy way to test it in CI
if [ "${{ matrix.use-cross }}" = "true" ] || [ "${{ matrix.target }}" = "aarch64-apple-darwin" ] || [ "${{ matrix.target }}" = "aarch64-pc-windows-msvc" ]; then
$CARGO build --target ${{ matrix.target }}
else
$CARGO test --target ${{ matrix.target }}
fi
================================================
FILE: .gitignore
================================================
/target
**/*.rs.bk
================================================
FILE: .rustfmt.toml
================================================
edition = "2018"
max_width = 80
use_field_init_shorthand = true
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Breaking
- #328 Make line-by-line processing the default and add `--across` / `-A` (@Orion Gonzalez)
- `sd` now processes input line-by-line by default, reducing memory usage and
enabling streaming output for stdin
- The previous whole-file behavior is still available via `--across` / `-A`
### Improvements
- #313 Replace the unescape implementation with a more lenient one (@Orion Gonzalez)
- Avoids the previous all-or-nothing behavior when escape parsing partially fails
- #326 Retain file ownership on atomic writes (@Gregory)
- Preserves original file uid/gid when replacing files through the atomic write path
### Docs
- #279 Update man page examples for the renamed string literal flag (@Philipp Gillé)
- #281 Update README for string literal argument changes (@Evan Platzer)
- #292 Fix capture group example in the man page (@John Careaga)
- #299 Add `README_zh-CN.md` and follow-up note adjustments (@zhangyanming)
- #320 Fix missing single quotes in a man page example (@Freimut Diener)
### Pre-built Releases
- (90bc67d) Add Windows ARM64 (`aarch64-pc-windows-msvc`) release targets (@Orion Gonzalez)
- (c864c58) Add `aarch64-unknown-linux-gnu` target to CI and releases (@Orion Gonzalez)
- #293 Bump `svenstaro/upload-release-action` from `2.7.0` to `2.9.0` (@dependabot[bot])
### Internal
- #265 Overall codebase reorganization (@Blair Noctis)
- Refactors application structure, error handling, and tests
- `xtask` cleanup and test setup refactors (@Gregory, @Orion Gonzalez)
- Dependency and tooling updates
- Bump `libc` from `0.2.149` to `0.2.155` (@Jingyun Hua)
- Upgrade `assert_cmd` and fix clippy warnings (@Gregory)
## [1.0.0] - 2023-11-07
A quick note to any packages. The generated shell completions and man page are
now in the `gen` directory of the repo. They're also included in the pre-built
release artifacts on the releases page.
### Improvements
- #115 Do not replace symlink with output file (@SimplyDanny)
- Fixes an issue where a symlink would be replaced with a regular file
- #124 Fix tests (@Linus789)
- Removed displaying the file path when passing the `--preview` flag and fixed
how text coloring was handled in tests
### Breaking
- #192 Rename `--string-mode` to `--fixed-strings` (@CosmicHorrorDev)
- Renamed `-s` `--string-mode` to `-f` `--fixed-strings` to better match
similar tools
- `-s` and `--string-mode` will still continue to work for backwards
compatibility, but are no longer documented
- #258 Error on `$<num><non_num>` capture replacement names (@CosmicHorrorDev)
- Previously when you tried to use a numbered capture group right before some
letters in the replacement text (e.g. `$1foo`) then it would be considered
the impossible-to-use `1foo` capture. The correct way to pass the numbered
capture group in this case would be to surround the number with curly braces
like so `${1}foo`. The error just detects this case and informs the user of
the issue
### Docs
- #93 Add note about in-place file modification to --help output (@jchook)
- #148 Doc: nitpick `--` has no special meaning to shells (@hexagonrecursion)
- #181 Fix man page -f flag help text (@ulope)
- Fixed copy-pasted text in the man page's `-f` flag's help text
- #186 Improve error message for failed replacements (@CosmicHorrorDev)
- #187 Freshen up README (@CosmicHorrorDev)
- Added a repology badge to document different installation methods
- Improved the formatting of the benchmarks
- #207 Documenting `$` escape (@yahkbar)
- Adds a section in the README that covers that `$$` is a literal `$` in the
replacement text
- #227 Improve README readability (@vassudanagunta)
- Various formatting improvements
- #231 Use `clap_mangen` and `roff` to generate manpage (@nc7s)
- This change ensures the man page contents stay in sync with the CLI
automatically, and fixes some broken rendering of the existing manpage
- #243 Exclude unsupported packages from the repology badge (@CosmicHorrorDev)
### Pre-built Releases
- (11295fb) Add ARM target (@chmln)
- Added the `arm-unknown-linux-gnueabihf` target to CI and releases
- #114 Adding `aarch64-apple-darwin` target (@yahkbar)
- #143 Fix paths to release binary in "publish" action (@skrattaren)
- #179 Build Adjustments (@yahkbar)
- `strip`ed release binaries and added the `aarch64-ubuntu-linux-musl` target
- #204 Adding `armv7-unknown-linux-gnueabihf` target (@yahkbar)
- Added the `armv7-unknown-linux-gnueabihf` target to the list of targets to
build in CI and for each release
- #205 Resolving broken `aarch64-apple-darwin` tests (@yahkbar)
- Switched `aarch64-apple-darwin` to only try building the executable without
running the tests since there seems to be no easy way to test for ARM Apple
targets
- #206 Adding Windows builds back (@yahkbar)
- Added the `x86_64-pc-windows-gnu` and `x86_64-windows-musl` targets back to
the list of targets to build in CI and for each release
### Internal
- #118 Fix master (@SimplyDanny)
- Fixes several cross-compilation issues that effected different targets in CI
- #182 `cargo update` (@CosmicHorrorDev)
- Bumps dependencies to their latest compatible versions
- #183 Switch file-mapping crate implementation (@CosmicHorrorDev)
- Switches away from an unmaintained crate
- #184 Add editor config file matching rustfmt config (@CosmicHorrorDev)
- Adds an `.editorconfig` file matching the settings listed in the
`.rustfmt.toml` file
- #185 Fix warnings and clippy lints (@CosmicHorrorDev)
- #188 Switch `atty` for `is-terminal` (@CosmicHorrorDev)
- Switches away from an unmaintained crate
- #189 Replace structopt with clap v4 (@CosmicHorrorDev)
- Switches away from a defacto deprecated crate
- #190 Change how all shell variants are expressed (@CosmicHorrorDev)
- Tiny tidying up PR
- #196 Move generating static assets to a `cargo-xtask` task (@CosmicHorrorDev)
- Moves the generation of the man page and shell completions from a build
script to a [`cargo-xtask`](https://github.com/matklad/cargo-xtask) task
- #197 Add a release checklist (@CosmicHorrorDev)
- #209 Dependency updates (@yahkbar)
- #235 Update generated assets (@CosmicHorrorDev)
- #236 Tone down dependabot (@CosmicHorrorDev)
- #245 Update sd to 2021 edition (@CosmicHorrorDev)
- Updates `sd` to the Rust 2021 edition
- #248 Misc Cargo.toml tweaks (@CosmicHorrorDev)
- Switches to use workspace edition and dependencies where appropriate
- #249 Resolve CI warnings (@CosmicHorrorDev)
- Switched from `actions-rs` actions to `dtolnay@rust-toolchain`
- Switched from using `::set-output` to `$GITHUB_ENV`
- #251 Update dependencies (@CosmicHorrorDev)
- A lot of sad CI tweaking:
- #252 Fix build target usage in CI (@CosmicHorrorDev)
- #253 Improve publishing CI job (@CosmicHorrorDev)
- #256 More CI tweaks (@CosmicHorrorDev)
- #257 Fix publish action (@CosmicHorrorDev)
- #267 Rework the replacements flag (@CosmicHorrorDev)
- #269 Make modified text blue instead of green (@CosmicHorrorDev)
- #271 Fix release checklist indentation (@CosmicHorrorDev)
- #272 Remove outdated release checklist step (@CosmicHorrorDev)
- #274 Prepare 1.0.0-beta.0 release (@CosmicHorrorDev)
- #275 Update `sd` version in lockfile (@CosmicHorrorDev)
## (History listed in here is missing from v0.6.3 - v0.7.6)
## [0.6.2]
- Fixed pre-allocated file-mapping buffer size
- Fixed failing tests
## [0.6.0] - 2019-06-15
### Improvements
- `sd` now uses memory-mapped files, allowing replacement on files of any size
- `-p`/`--preview` flag is now added to preview changes
- as of right now, results are simply emitted to stdout
- in a future version, the output will be changed to contain only relevant information
- a `w` regex flag is added to match full words only, e.g. `sd -f w foo bar file.txt`
### Deprecations
- `--in-place` is now deprecated and assumed whenever a list of files is given
## [0.5.0] - 2019-02-22
### Improvements
- Windows support (thanks to @ErichDonGubler)
## [0.4.2] - 2019-01-02
### Improvements
- Support for unicode and special characters (like `\n`) in replacement expressions
- Only in regex mode
- Fixed edge-cases when replacing content containing unescaped characters
## [0.4.1] - 2019-01-01
### Improvements
- Significant performance improvements (see benchmarks in README)
## [0.4.0] - 2018-12-30
### Added
- Option to set regex flags via `-f` or `--flags`:
- `m` (multi-line)
- `c` (case-sensitive)
- `i` (case-insensitive)
- Smart case-sensitivity is used by default with regular expressions
### Improvements
- You may now pass multiple files to `sd`
- this is now valid: `sd -i "\n" "," *.txt`
## [0.3.0] - 2018-12-29
**Breaking Change**: the `-i`/`--input` is revamped per [#1](https://github.com/chmln/sd/issues/1). The file path now comes at the end, instead of `-i`.
Transforming the file in-place:
- Before: `sd -s 'str' '' -i file.txt'`
- Now: `sd -i -s 'str' '' file.txt`
- Future: `sd -i -s 'str' '' *.txt`
To reflect this change, `--input` is also renamed to `--in-place`. This is the first and most likely the last breaking change in this project.
### Improvements
- Files are now written to [atomically](https://github.com/chmln/sd/issues/3)
================================================
FILE: Cargo.toml
================================================
[workspace]
resolver = "3"
members = [
"sd",
"sd-cli",
"xtask",
]
[workspace.dependencies]
tempfile = "3.8.0"
clap = {version = "4.4.6", features = ["derive", "wrap_help"]}
[workspace.package]
edition = "2024"
version = "1.0.0"
authors = ["Gregory <gregory.mkv@gmail.com>", "Orión <oriongonza42@pm.me>"]
description = "An intuitive find & replace CLI"
readme = "../README.md"
keywords = ["sed", "find", "replace", "regex"]
license = "MIT"
homepage = "https://github.com/chmln/sd"
repository = "https://github.com/chmln/sd.git"
categories = ["command-line-utilities", "text-processing", "development-tools"]
rust-version = "1.86.0"
[profile.release]
opt-level = 3
lto = true
strip = true
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Gregory
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# sd - `s`earch & `d`isplace
`sd` is an intuitive find & replace CLI.
## The Pitch
Why use it over any existing tools?
*Painless regular expressions.* `sd` uses regex syntax that you already know from JavaScript and Python. Forget about dealing with quirks of `sed` or `awk` - get productive immediately.
*String-literal mode.* Non-regex find & replace. No more backslashes or remembering which characters are special and need to be escaped.
*Easy to read, easy to write.* Find & replace expressions are split up, which makes them easy to read and write. No more messing with unclosed and escaped slashes.
*Smart, common-sense defaults.* Defaults follow common sense and are tailored for typical daily use.
## Comparison to sed
While sed does a whole lot more, sd focuses on doing just one thing and doing it well. Here are some cherry-picked examples where sd shines.
Simpler syntax for replacing all occurrences:
- sd: `sd before after`
- sed: `sed s/before/after/g`
Replace newlines with commas:
- sd: `sd -A '\n' ','`
- sed: `sed ':a;N;$!ba;s/\n/,/g'`
Note: this requires `-A` (across mode) since `\n` is a cross-line pattern.
Extracting stuff out of strings containing slashes:
- sd: `echo "sample with /path/" | sd '.*(/.*/)' '$1'`
- sed: `echo "sample with /path/" | sed -E 's/.*(\\/.*\\/)/\1/g'`
With sed, you can make it better with a different delimiter,
but it is still messy:
`echo "sample with /path/" | sed -E 's|.*(/.*/)|\1|g'`
In place modification of files:
- sd: `sd before after file.txt`
- sed: `sed -i -e 's/before/after/g' file.txt`
With sed, you need to remember to use `-e` or else some
platforms will consider the next argument to be a backup suffix.
## Benchmarks
**Simple replacement on ~1.5 gigabytes of JSON**
```sh
hyperfine --warmup 3 --export-markdown out.md \
'sed -E "s/\"/'"'"'/g" *.json > /dev/null' \
'sed "s/\"/'"'"'/g" *.json > /dev/null' \
'sd "\"" "'"'"'" *.json > /dev/null'
```
| Command | Mean [s] | Min…Max [s] |
|:---|---:|---:|
| `sed -E "s/\"/'/g" *.json > /dev/null` | 2.338 ± 0.008 | 2.332…2.358 |
| `sed "s/\"/'/g" *.json > /dev/null` | 2.365 ± 0.009 | 2.351…2.378 |
| `sd "\"" "'" *.json > /dev/null` | **0.997 ± 0.006** | 0.987…1.007 |
Result: ~2.35 times faster
**Regex replacement on a ~55M json file**:
```sh
hyperfine --warmup 3 --export-markdown out.md \
'sed -E "s:(\w+):\1\1:g" dump.json > /dev/null' \
'sed "s:\(\w\+\):\1\1:g" dump.json > /dev/null' \
'sd "(\w+)" "$1$1" dump.json > /dev/null'
```
| Command | Mean [s] | Min…Max [s] |
|:---|---:|---:|
| `sed -E "s:(\w+):\1\1:g" dump.json > /dev/null` | 11.315 ± 0.215 | 11.102…11.725 |
| `sed "s:\(\w\+\):\1\1:g" dump.json > /dev/null` | 11.239 ± 0.208 | 11.057…11.762 |
| `sd "(\w+)" "$1$1" dump.json > /dev/null` | **0.942 ± 0.004** | 0.936…0.951 |
Result: ~11.93 times faster
**Line-by-line vs across mode** (1M lines, ~36MB file):
| Command | Mean [ms] | Relative |
|:---|---:|---:|
| `sd -A 'foo' 'qux'` (across) | 125.6 ± 14.3 | 1.00 |
| `sed s/foo/qux/g` | 316.4 ± 30.0 | 2.52 |
| `sd 'foo' 'qux'` (line-by-line, default) | 357.0 ± 15.0 | 2.84 |
| Command | Mean [ms] | Relative |
|:---|---:|---:|
| `sd -A '(\w+) world' '$1 earth'` (across) | 254.0 ± 11.2 | 1.00 |
| `sd '(\w+) world' '$1 earth'` (line-by-line, default) | 566.7 ± 16.7 | 2.23 |
| `sed -E 's/(\w+) world/\1 earth/g'` | 4432.7 ± 173.2 | 17.45 |
Line-by-line mode is ~2-3x slower than across mode but still faster than sed for regex replacements. The tradeoff is dramatically lower memory usage:
| Mode | Peak RSS |
|:---|---:|
| `sd -A` (across) | 74 MB |
| `sd` (line-by-line, default) | 3 MB |
## Installation
Install through
[`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html) with
`cargo install sd`, or through various package managers
[](https://repology.org/project/sd-find-replace/versions)
## Quick Guide
1. **String-literal mode**. By default, expressions are treated as regex. Use `-F` or `--fixed-strings` to disable regex.
```sh
> echo 'lots((([]))) of special chars' | sd -F '((([])))' ''
lots of special chars
```
2. **Basic regex use** - let's trim some trailing whitespace
```sh
> echo 'lorem ipsum 23 ' | sd '\s+$' ''
lorem ipsum 23
```
3. **Capture groups**
Indexed capture groups:
```sh
> echo 'cargo +nightly watch' | sd '(\w+)\s+\+(\w+)\s+(\w+)' 'cmd: $1, channel: $2, subcmd: $3'
cmd: cargo, channel: nightly, subcmd: watch
```
Named capture groups:
```sh
> echo "123.45" | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '$dollars dollars and $cents cents'
123 dollars and 45 cents
```
In the unlikely case you stumble upon ambiguities, resolve them by using `${var}` instead of `$var`. Here's an example:
```sh
> echo '123.45' | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '$dollars_dollars and $cents_cents'
and
> echo '123.45' | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '${dollars}_dollars and ${cents}_cents'
123_dollars and 45_cents
```
4. **Find & replace in a file**
```sh
> sd 'window.fetch' 'fetch' http.js
```
That's it. The file is modified in-place.
To preview changes:
```sh
> sd -p 'window.fetch' 'fetch' http.js
```
5. **Find & replace across project**
This example uses [fd](https://github.com/sharkdp/fd).
Good ol' unix philosophy to the rescue.
```sh
fd --type file --exec sd 'from "react"' 'from "preact"'
```
Same, but with backups (consider version control).
```bash
fd --type file --exec cp {} {}.bk \; --exec sd 'from "react"' 'from "preact"'
```
### Edge cases
sd will interpret every argument starting with `-` as a (potentially unknown) flag.
The common convention of using `--` to signal the end of flags is respected:
```bash
$ echo "./hello foo" | sd "foo" "-w"
error: Found argument '-w' which wasn't expected, or isn't valid in this context
USAGE:
sd [OPTIONS] <find> <replace-with> [files]...
For more information try --help
$ echo "./hello foo" | sd "foo" -- "-w"
./hello -w
$ echo "./hello --foo" | sd -- "--foo" "-w"
./hello -w
```
### Processing modes
By default, sd processes input **line by line**. This means:
- Low memory usage (only one line in memory at a time)
- Streaming output for stdin (results appear before EOF)
- `^` and `$` match the start/end of each line without phantom matches
- `\s+$` trims trailing whitespace without eating newlines
If you need patterns to match **across line boundaries** (e.g. replacing `\n` or matching multi-line patterns), use the `-A` / `--across` flag:
```sh
> echo -e "hello\nworld" | sd -A '\n' ','
hello,world
```
### Escaping special characters
To escape the `$` character, use `$$`:
```bash
❯ echo "foo" | sd 'foo' '$$bar'
$bar
```
================================================
FILE: README_zh-CN.md
================================================
# sd - `搜索`与`替换`
`sd` 是一个直观的查找与替换命令行工具。
## 主要优点
为什么要使用它而不是现有的任何工具?
*更好的正则表达式* `sd` 使用您已经熟悉的来自 JavaScript 和 Python 的正则表达式语法。不用再去处理 `sed` 或 `awk` 的生僻语法 - 立即提高生产力。
*字符串文本模式* 非正则表达式的查找和替换。不再需要反斜杠或记住哪些字符是特殊的并且需要转义。
*易读易写* 查找和替换表达式被拆分开来,这样更容易阅读和编写。不再需要处理未闭合和转义的斜杠。
*智能、符合常识的默认设置* 默认设置遵循常识,并且针对典型的日常使用进行了调整。
## 与 sed 相比
虽然 sed 可以做更多的事情,但 sd 专注于做一件事情,并且做得很好。以下是一些精选的例子,展示了 sd 的优势所在。
替换所有出现的内容的更简单语法:
- sd: `sd before after`
- sed: `sed s/before/after/g`
将换行符替换为逗号:
- sd: `sd '\n' ','`
- sed: `sed ':a;N;$!ba;s/\n/,/g'`
从包含斜杠的字符串中提取内容:
- sd: `echo "sample with /path/" | sd '.*(/.*/)' '$1'`
- sed: `echo "sample with /path/" | sed -E 's/.*(\\/.*\\/)/\1/g'`
使用 sed,你可以使用不同的分隔符来改善,但仍然有些混乱:
`echo "sample with /path/" | sed -E 's|.*(/.*/)|\1|g'`
原地修改文件:
- sd: `sd before after file.txt`
- sed: `sed -i -e 's/before/after/g' file.txt`
在使用 sed 时,需要记住使用 `-e`,否则某些平台会将下一个参数视为备份后缀。
## 基准测试
**在大约 1.5GB 大小的 JSON 文件上进行简单的替换**
```sh
hyperfine --warmup 3 --export-markdown out.md \
'sed -E "s/\"/'"'"'/g" *.json > /dev/null' \
'sed "s/\"/'"'"'/g" *.json > /dev/null' \
'sd "\"" "'"'"'" *.json > /dev/null'
```
| 命令 | 平均 [s] | 最小耗时…最大耗时 [s] |
|:---|---:|---:|
| `sed -E "s/\"/'/g" *.json > /dev/null` | 2.338 ± 0.008 | 2.332…2.358 |
| `sed "s/\"/'/g" *.json > /dev/null` | 2.365 ± 0.009 | 2.351…2.378 |
| `sd "\"" "'" *.json > /dev/null` | **0.997 ± 0.006** | 0.987…1.007 |
结果:速度提高了大约 2.35 倍
**对一个约 55M 大小的 JSON 文件进行正则表达式替换**:
```sh
hyperfine --warmup 3 --export-markdown out.md \
'sed -E "s:(\w+):\1\1:g" dump.json > /dev/null' \
'sed "s:\(\w\+\):\1\1:g" dump.json > /dev/null' \
'sd "(\w+)" "$1$1" dump.json > /dev/null'
```
| 命令 | 平均 [s] | 最低…最高 [s] |
|:---|---:|---:|
| `sed -E "s:(\w+):\1\1:g" dump.json > /dev/null` | 11.315 ± 0.215 | 11.102…11.725 |
| `sed "s:\(\w\+\):\1\1:g" dump.json > /dev/null` | 11.239 ± 0.208 | 11.057…11.762 |
| `sd "(\w+)" "$1$1" dump.json > /dev/null` | **0.942 ± 0.004** | 0.936…0.951 |
结果:速度提高了大约 11.93 倍
## 安装
通过 [`cargo`](https://doc.rust-lang.org/cargo/getting-started/installation.html) 使用 `cargo install sd` 命令安装,或通过各种包管理器安装。
[](https://repology.org/project/sd-find-replace/versions)
## 快速指南
1. **字符串文字**模式。默认情况下,表达式被视为正则表达式。使用 `-F` 或 `--fixed-strings` 可以禁用正则表达式。
```sh
> echo 'lots((([]))) of special chars' | sd -s '((([])))' ''
lots of special chars
```
2. **基本正则表达式的使用** - 让我们去掉一些末尾的空白符
```sh
> echo 'lorem ipsum 23 ' | sd '\s+$' ''
lorem ipsum 23
```
3. **捕获组**
索引捕获组:
```sh
> echo 'cargo +nightly watch' | sd '(\w+)\s+\+(\w+)\s+(\w+)' 'cmd: $1, channel: $2, subcmd: $3'
cmd: cargo, channel: nightly, subcmd: watch
```
命名捕获组:
```sh
> echo "123.45" | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '$dollars dollars and $cents cents'
123 dollars and 45 cents
```
在不太可能出现歧义的情况下,通过使用 `${var}` 而不是 `$var` 来解决。这里有一个例子:
```sh
> echo '123.45' | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '$dollars_dollars and $cents_cents'
and
> echo '123.45' | sd '(?P<dollars>\d+)\.(?P<cents>\d+)' '${dollars}_dollars and ${cents}_cents'
123_dollars and 45_cents
```
4. **在文件中查找并替换**
```sh
> sd 'window.fetch' 'fetch' http.js
```
就是这样,文件将直接在原地修改。
预览更改:
```sh
> sd -p 'window.fetch' 'fetch' http.js
```
5. **在整个项目中查找并替换**
这个例子使用了 [fd](https://github.com/sharkdp/fd)。
好的 Unix 哲学来拯救我们了。
```sh
fd --type file --exec sd 'from "react"' 'from "preact"'
```
同理,但带有备份(考虑版本控制)。
```bash
fd --type file --exec cp {} {}.bk \; --exec sd 'from "react"' 'from "preact"'
```
### 特殊情况
sd 会将以 `-` 开头的每个参数解释为(可能是未知的)标志。
尊重常见的惯例,使用 `--` 来表示标志的结束:
```bash
$ echo "./hello foo" | sd "foo" "-w"
error: Found argument '-w' which wasn't expected, or isn't valid in this context
USAGE:
sd [OPTIONS] <find> <replace-with> [files]...
For more information try --help
$ echo "./hello foo" | sd "foo" -- "-w"
./hello -w
$ echo "./hello --foo" | sd -- "--foo" "-w"
./hello -w
```
### 转义特殊字符
要转义 `$` 字符,需使用 `$$`:
```bash
❯ echo "foo" | sd 'foo' '$$bar'
$bar
```
### 帮助
使用方法
```shell
sd [OPTIONS] <FIND> <REPLACE_WITH> [FILES]...
[选项] <查找> <替换为> [文件列表]...
参数:
<FIND>
要搜索的正则表达式或字符串(如果使用 `-F` 选项)
<REPLACE_WITH>
替换每个匹配项的内容。除非处于字符串模式,否则您可以使用类似 $1、$2 等捕获值
[FILES]...
文件路径。这是可选项, - sd 也可以从标准输入 STDIN 中读取。
请注意,sd 默认会直接修改文件。请参阅文档中的示例。
选项:
-p, --preview
以可阅读的方式显示更改(具体格式的细节可能会在将来更改)
-F, --fixed-strings
将 FIND 和 REPLACE_WITH 参数视为文字字符串
-n, --max-replacements <LIMIT>
限制每个文件的替换次数。0 表示无限制替换
[默认值为:0]
-f, --flags <FLAGS>
正则表达式标志。可以组合使用(如 `-f mc`)。
c - 区分大小写
e - 禁用多行匹配
i - 不区分大小写
m - 多行匹配
s - 使 `.` 匹配换行符
w - 仅匹配完整单词
-h, --help
打印帮助信息(使用 '-h' 可以查看摘要)
-V, --version
打印版本信息
```
================================================
FILE: RELEASE.md
================================================
# Release checklist
1. [ ] Create a new _"Release v{VERSION}"_ issue with this checklist
- `$ cat RELEASE.md | sd '\{VERSION\}' '{NEW_VERSION}' | xclip -sel clip`
- Create the issue in GitHub
1. [ ] Regenerate static assets
- `$ cargo xtask gen`
1. [ ] Update `rust-version` in `Cargo.toml`
- `$ cargo msrv --min 1.60 -- cargo check`
1. [ ] Bump `version` in `Cargo.toml`
1. [ ] Run `cargo check` to propogate the change to `Cargo.lock`
1. [ ] Update the `CHANGELOG.md`
1. [ ] Merge changes through a PR to make sure that CI passes
1. [ ] Publish on [crates.io](crates.io)
- `$ cargo publish`
1. [ ] Publish on GitHub by pushing a version tag
- Make sure the branch you're on is fully up to date
- `$ git tag v{VERSION}`
- `$ git push upstream/origin v{VERSION}`
1. [ ] Make a release announcement on GitHub after the release workflow finishes
================================================
FILE: gen/completions/_sd
================================================
#compdef sd
autoload -U is-at-least
_sd() {
typeset -A opt_args
typeset -a _arguments_options
local ret=1
if is-at-least 5.2; then
_arguments_options=(-s -S -C)
else
_arguments_options=(-s -C)
fi
local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" \
'-n+[Limit the number of replacements that can occur per file. 0 indicates unlimited replacements]:LIMIT: ' \
'--max-replacements=[Limit the number of replacements that can occur per file. 0 indicates unlimited replacements]:LIMIT: ' \
'-f+[Regex flags. May be combined (like \`-f mc\`).]:FLAGS: ' \
'--flags=[Regex flags. May be combined (like \`-f mc\`).]:FLAGS: ' \
'-p[Display changes in a human reviewable format (the specifics of the format are likely to change in the future)]' \
'--preview[Display changes in a human reviewable format (the specifics of the format are likely to change in the future)]' \
'-F[Treat FIND and REPLACE_WITH args as literal strings]' \
'--fixed-strings[Treat FIND and REPLACE_WITH args as literal strings]' \
'-A[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \
'--across[Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming]' \
'-h[Print help (see more with '\''--help'\'')]' \
'--help[Print help (see more with '\''--help'\'')]' \
'-V[Print version]' \
'--version[Print version]' \
':find -- The regexp or string (if using `-F`) to search for:' \
':replace_with -- What to replace each match with. Unless in string mode, you may use captured values like $1, $2, etc:' \
'*::files -- The path to file(s). This is optional - sd can also read from STDIN:_files' \
&& ret=0
}
(( $+functions[_sd_commands] )) ||
_sd_commands() {
local commands; commands=()
_describe -t commands 'sd commands' commands "$@"
}
if [ "$funcstack[1]" = "_sd" ]; then
_sd "$@"
else
compdef _sd sd
fi
================================================
FILE: gen/completions/_sd.ps1
================================================
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
Register-ArgumentCompleter -Native -CommandName 'sd' -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$command = @(
'sd'
for ($i = 1; $i -lt $commandElements.Count; $i++) {
$element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-') -or
$element.Value -eq $wordToComplete) {
break
}
$element.Value
}) -join ';'
$completions = @(switch ($command) {
'sd' {
[CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'Limit the number of replacements that can occur per file. 0 indicates unlimited replacements')
[CompletionResult]::new('--max-replacements', 'max-replacements', [CompletionResultType]::ParameterName, 'Limit the number of replacements that can occur per file. 0 indicates unlimited replacements')
[CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Regex flags. May be combined (like `-f mc`).')
[CompletionResult]::new('--flags', 'flags', [CompletionResultType]::ParameterName, 'Regex flags. May be combined (like `-f mc`).')
[CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)')
[CompletionResult]::new('--preview', 'preview', [CompletionResultType]::ParameterName, 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)')
[CompletionResult]::new('-F', 'F ', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings')
[CompletionResult]::new('--fixed-strings', 'fixed-strings', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings')
[CompletionResult]::new('-A', 'A ', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming')
[CompletionResult]::new('--across', 'across', [CompletionResultType]::ParameterName, 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming')
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version')
[CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version')
break
}
})
$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
Sort-Object -Property ListItemText
}
================================================
FILE: gen/completions/sd.bash
================================================
_sd() {
local i cur prev opts cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd=""
opts=""
for i in ${COMP_WORDS[@]}
do
case "${cmd},${i}" in
",$1")
cmd="sd"
;;
*)
;;
esac
done
case "${cmd}" in
sd)
opts="-p -F -n -f -A -h -V --preview --fixed-strings --max-replacements --flags --across --help --version <FIND> <REPLACE_WITH> [FILES]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
fi
case "${prev}" in
--max-replacements)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-n)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
--flags)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
-f)
COMPREPLY=($(compgen -f "${cur}"))
return 0
;;
*)
COMPREPLY=()
;;
esac
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0
;;
esac
}
complete -F _sd -o nosort -o bashdefault -o default sd
================================================
FILE: gen/completions/sd.elv
================================================
use builtin;
use str;
set edit:completion:arg-completer[sd] = {|@words|
fn spaces {|n|
builtin:repeat $n ' ' | str:join ''
}
fn cand {|text desc|
edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
}
var command = 'sd'
for word $words[1..-1] {
if (str:has-prefix $word '-') {
break
}
set command = $command';'$word
}
var completions = [
&'sd'= {
cand -n 'Limit the number of replacements that can occur per file. 0 indicates unlimited replacements'
cand --max-replacements 'Limit the number of replacements that can occur per file. 0 indicates unlimited replacements'
cand -f 'Regex flags. May be combined (like `-f mc`).'
cand --flags 'Regex flags. May be combined (like `-f mc`).'
cand -p 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)'
cand --preview 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)'
cand -F 'Treat FIND and REPLACE_WITH args as literal strings'
cand --fixed-strings 'Treat FIND and REPLACE_WITH args as literal strings'
cand -A 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming'
cand --across 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming'
cand -h 'Print help (see more with ''--help'')'
cand --help 'Print help (see more with ''--help'')'
cand -V 'Print version'
cand --version 'Print version'
}
]
$completions[$command]
}
================================================
FILE: gen/completions/sd.fish
================================================
complete -c sd -s n -l max-replacements -d 'Limit the number of replacements that can occur per file. 0 indicates unlimited replacements' -r
complete -c sd -s f -l flags -d 'Regex flags. May be combined (like `-f mc`).' -r
complete -c sd -s p -l preview -d 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)'
complete -c sd -s F -l fixed-strings -d 'Treat FIND and REPLACE_WITH args as literal strings'
complete -c sd -s A -l across -d 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming'
complete -c sd -s h -l help -d 'Print help (see more with \'--help\')'
complete -c sd -s V -l version -d 'Print version'
================================================
FILE: gen/sd.1
================================================
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH sd 1 "sd 1.0.0"
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH NAME
sd
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH SYNOPSIS
\fBsd\fR [\fB\-p\fR|\fB\-\-preview\fR] [\fB\-F\fR|\fB\-\-fixed\-strings\fR] [\fB\-n\fR|\fB\-\-max\-replacements\fR] [\fB\-f\fR|\fB\-\-flags\fR] [\fB\-A\fR|\fB\-\-across\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR]
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH DESCRIPTION
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH OPTIONS
.TP
\fB\-p\fR, \fB\-\-preview\fR
Display changes in a human reviewable format (the specifics of the format are likely to change in the future)
.TP
\fB\-F\fR, \fB\-\-fixed\-strings\fR
Treat FIND and REPLACE_WITH args as literal strings
.TP
\fB\-n\fR, \fB\-\-max\-replacements\fR=\fILIMIT\fR [default: 0]
Limit the number of replacements that can occur per file. 0 indicates unlimited replacements
.TP
\fB\-f\fR, \fB\-\-flags\fR=\fIFLAGS\fR
Regex flags. May be combined (like `\-f mc`).
c \- case\-sensitive
e \- disable multi\-line matching
i \- case\-insensitive
m \- multi\-line matching
s \- make `.` match newlines
w \- match full words only
.TP
\fB\-A\fR, \fB\-\-across\fR
Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming
.TP
\fB\-h\fR, \fB\-\-help\fR
Print help (see a summary with \*(Aq\-h\*(Aq)
.TP
\fB\-V\fR, \fB\-\-version\fR
Print version
.TP
<\fIFIND\fR>
The regexp or string (if using `\-F`) to search for
.TP
<\fIREPLACE_WITH\fR>
What to replace each match with. Unless in string mode, you may use captured values like $1, $2, etc
.TP
[\fIFILES\fR]
The path to file(s). This is optional \- sd can also read from STDIN.
Note: sd modifies files in\-place by default. See documentation for examples.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH "EXIT STATUS"
.IP 0
Successful program execution.
.IP 1
Unsuccessful program execution.
.IP 101
The program panicked.
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.SH EXAMPLES
.TP
String\-literal mode
\fB$ echo \*(Aqlots((([]))) of special chars\*(Aq | sd \-F \*(Aq((([])))\*(Aq \*(Aq\*(Aq\fR
.br
lots of special chars
.TP
Regex use. Let\*(Aqs trim some trailing whitespace
\fB$ echo \*(Aqlorem ipsum 23 \*(Aq | sd \*(Aq\\s+$\*(Aq \*(Aq\*(Aq\fR
.br
lorem ipsum 23
.TP
Indexed capture groups
\fB$ echo \*(Aqcargo +nightly watch\*(Aq | sd \*(Aq(\\w+)\\s+\\+(\\w+)\\s+(\\w+)\*(Aq \*(Aqcmd: $1, channel: $2, subcmd: $3\*(Aq\fR
.br
cmd: cargo, channel: nightly, subcmd: watch
.TP
Find & replace in file
\fB$ sd \*(Aqwindow.fetch\*(Aq \*(Aqfetch\*(Aq http.js\fR
.br
.TP
Find & replace from STDIN an emit to STDOUT
\fB$ sd \*(Aqwindow.fetch\*(Aq \*(Aqfetch\*(Aq < http.js\fR
.br
================================================
FILE: proptest-regressions/replacer/tests.txt
================================================
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 3a23ade8355ca034558ea8635e4ea2ee96ecb38b7b1cb9a854509d7633d45795 # shrinks to s = ""
cc 8c8d1e7497465f26416bddb7607df0de1fce48d098653eeabac0ad2aeba1fa0a # shrinks to s = "$0$0a"
================================================
FILE: proptest-regressions/replacer/validate.txt
================================================
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc cfacd65058c8dae0ac7b91c56b8096c36ef68cb35d67262debebac005ea9c677 # shrinks to s = ""
cc 61e5dc6ce0314cde48b5cbc839fbf46a49fcf8d0ba02cfeecdcbff52fca8c786 # shrinks to s = "$a"
cc 8e5fd9dbb58ae762a751349749320664715056ef63aad58215397e87ee42c722 # shrinks to s = "$$"
cc 37c2e41ceeddbecbc4e574f82b58a4007923027ad1a6756bf2f547aa3f748d13 # shrinks to s = "$$0"
================================================
FILE: release.toml
================================================
no-dev-version = true
================================================
FILE: sd/Cargo.toml
================================================
[package]
name = "sd"
version.workspace = true
edition.workspace = true
authors = ["Gregory <gregory.mkv@gmail.com>", "Orión <oriongonza42@pm.me>"]
description = "Core library for the sd find & replace tool"
readme = "../README.md"
keywords = ["sed", "find", "replace", "regex"]
license = "MIT"
homepage = "https://github.com/chmln/sd"
repository = "https://github.com/chmln/sd.git"
categories = ["command-line-utilities", "text-processing", "development-tools"]
rust-version = "1.86.0"
[dependencies]
regex = "1.10.2"
rayon = "1.8.0"
thiserror = "1.0.50"
tempfile.workspace = true
[dev-dependencies]
proptest = "1.3.1"
regex-automata = "0.4.3"
insta = "1.34.0"
================================================
FILE: sd/src/error.rs
================================================
use std::{fmt, path::PathBuf};
use crate::replacer::InvalidReplaceCapture;
#[derive(thiserror::Error)]
pub enum Error {
#[error("invalid regex {0}")]
Regex(#[from] regex::Error),
#[error(transparent)]
File(#[from] std::io::Error),
#[error("failed to move file: {0}")]
TempfilePersist(#[from] tempfile::PersistError),
#[error("invalid path: {0}")]
InvalidPath(PathBuf),
#[error("{0}")]
InvalidReplaceCapture(#[from] InvalidReplaceCapture),
#[error("{0}")]
FailedJobs(FailedJobs),
}
// pretty-print the error
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub struct FailedJobs(pub Vec<(PathBuf, Error)>);
impl fmt::Display for FailedJobs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Failed processing some inputs\n")?;
for (source, error) in &self.0 {
writeln!(f, " {}: {}", source.display(), error)?;
}
Ok(())
}
}
impl fmt::Debug for FailedJobs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self)
}
}
================================================
FILE: sd/src/input.rs
================================================
use std::{
fs::File,
io::{BufRead, BufReader, Read, stdin},
path::PathBuf,
};
use crate::error::{Error, Result};
#[derive(Debug, PartialEq)]
pub enum Source {
Stdin,
File(PathBuf),
}
impl Source {
pub fn from_paths(paths: Vec<PathBuf>) -> Result<Vec<Self>> {
paths
.into_iter()
.map(|path| {
if path.exists() {
Ok(Source::File(path))
} else {
Err(Error::InvalidPath(path.clone()))
}
})
.collect()
}
pub fn from_stdin() -> Vec<Self> {
vec![Self::Stdin]
}
pub fn display(&self) -> String {
match self {
Self::Stdin => "STDIN".to_string(),
Self::File(path) => format!("FILE {}", path.display()),
}
}
}
pub fn open_source(source: &Source) -> Result<Box<dyn BufRead + '_>> {
match source {
Source::File(path) => {
let file = File::open(path)?;
Ok(Box::new(BufReader::new(file)))
}
Source::Stdin => {
let stdin = stdin().lock();
Ok(Box::new(BufReader::new(stdin)))
}
}
}
pub fn read_source(source: &Source) -> Result<Vec<u8>> {
let mut handle = open_source(source)?;
let mut buf = Vec::new();
handle.read_to_end(&mut buf)?;
Ok(buf)
}
================================================
FILE: sd/src/lib.rs
================================================
mod error;
mod input;
pub mod replacer;
mod unescape;
use std::{
fs,
io::{BufRead, BufWriter, Read, Write},
path::PathBuf,
};
pub use self::error::{Error, FailedJobs, Result};
pub use self::input::{Source, open_source, read_source};
pub use self::replacer::Replacer;
/// Core processing function that handles file replacement
pub fn process_sources(
replacer: &Replacer,
sources: &[Source],
preview: bool,
line_by_line: bool,
output_writer: &mut dyn Write,
) -> Result<()> {
if line_by_line {
return process_sources_line_by_line(
replacer,
sources,
preview,
output_writer,
);
}
let mut inputs = Vec::new();
for source in sources.iter() {
let input = match source {
Source::File(path) => {
if path.exists() {
read_source(source)?
} else {
return Err(Error::InvalidPath(path.to_owned()));
}
}
Source::Stdin => read_source(source)?,
};
inputs.push(input);
}
let needs_separator = sources.len() > 1;
let replaced: Vec<_> = {
use rayon::prelude::*;
inputs
.par_iter()
.map(|input| replacer.replace(input))
.collect()
};
if preview || sources.first() == Some(&Source::Stdin) {
for (source, replaced) in sources.iter().zip(replaced) {
if needs_separator {
writeln!(output_writer, "----- {} -----", source.display())?;
}
output_writer.write_all(&replaced)?;
}
} else {
let mut failed_jobs = Vec::new();
for (source, replaced) in sources.iter().zip(replaced) {
match source {
Source::File(path) => {
if let Err(e) = write_with_temp(path, &replaced) {
failed_jobs.push((path.to_owned(), e));
}
}
_ => unreachable!("stdin should go previous branch"),
}
}
if !failed_jobs.is_empty() {
return Err(Error::FailedJobs(FailedJobs(failed_jobs)));
}
}
Ok(())
}
fn process_sources_line_by_line(
replacer: &Replacer,
sources: &[Source],
preview: bool,
output_writer: &mut dyn Write,
) -> Result<()> {
let needs_separator = sources.len() > 1;
if preview || sources.first() == Some(&Source::Stdin) {
for source in sources {
if needs_separator {
writeln!(output_writer, "----- {} -----", source.display())?;
}
let reader = open_source(source)?;
process_reader_line_by_line(replacer, reader, output_writer)?;
}
} else {
// Pre-validate all files before modifying any, matching the
// whole-file processing path which opens all inputs upfront.
for source in sources {
match source {
Source::File(path) => {
if !path.exists() {
return Err(Error::InvalidPath(path.to_owned()));
}
std::fs::File::open(path)?;
}
_ => unreachable!("stdin should go previous branch"),
}
}
let mut failed_jobs = Vec::new();
for source in sources {
match source {
Source::File(path) => {
if let Err(e) = write_file_line_by_line(replacer, path) {
failed_jobs.push((path.to_owned(), e));
}
}
_ => unreachable!("stdin should go previous branch"),
}
}
if !failed_jobs.is_empty() {
return Err(Error::FailedJobs(FailedJobs(failed_jobs)));
}
}
Ok(())
}
fn process_reader_line_by_line(
replacer: &Replacer,
mut reader: Box<dyn BufRead + '_>,
writer: &mut dyn Write,
) -> Result<()> {
const CHUNK_SIZE: usize = 8192;
let mut chunk = vec![0u8; CHUNK_SIZE];
let mut line = Vec::with_capacity(256);
loop {
let n = reader.read(&mut chunk)?;
if n == 0 {
// Finish any remaining line
if !line.is_empty() {
let replaced = replacer.replace(&line);
writer.write_all(&replaced)?;
}
break;
}
let mut start = 0;
for (i, &byte) in chunk[..n].iter().enumerate() {
if byte == b'\n' {
// Found a complete line
line.extend_from_slice(&chunk[start..i]);
let replaced = replacer.replace(&line);
writer.write_all(&replaced)?;
writer.write_all(b"\n")?;
line.clear();
start = i + 1;
}
}
// Keep partial line for next chunk
if start < n {
line.extend_from_slice(&chunk[start..n]);
}
}
Ok(())
}
fn write_file_line_by_line(replacer: &Replacer, path: &PathBuf) -> Result<()> {
let canonical = fs::canonicalize(path)?;
let temp = tempfile::NamedTempFile::new_in(
canonical
.parent()
.ok_or_else(|| Error::InvalidPath(canonical.to_path_buf()))?,
)?;
if let Ok(metadata) = fs::metadata(&canonical) {
temp.as_file().set_permissions(metadata.permissions()).ok();
}
{
let source = Source::File(path.clone());
let reader = open_source(&source)?;
let mut writer = BufWriter::new(temp.as_file());
process_reader_line_by_line(replacer, reader, &mut writer)?;
writer.flush()?;
}
temp.persist(&canonical)?;
Ok(())
}
fn write_with_temp(path: &PathBuf, data: &[u8]) -> Result<()> {
let path = fs::canonicalize(path)?;
let mut temp = tempfile::NamedTempFile::new_in(
path.parent()
.ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?,
)?;
let file = temp.as_file();
file.set_len(data.len() as u64)?;
if let Ok(metadata) = fs::metadata(&path) {
file.set_permissions(metadata.permissions()).ok();
}
if !data.is_empty() {
temp.as_file_mut().write_all(data)?;
temp.as_file_mut().flush()?;
}
temp.persist(&path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_process_sources_with_preview() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "abc123def").unwrap();
let replacer =
Replacer::new("abc".into(), "xyz".into(), false, None, 0)?;
let sources = vec![Source::File(file_path)];
let mut output = Vec::new();
process_sources(&replacer, &sources, true, false, &mut output)?;
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "xyz123def");
Ok(())
}
#[test]
fn test_process_sources_in_place() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "abc123def").unwrap();
let replacer =
Replacer::new("abc".into(), "xyz".into(), false, None, 0)?;
let sources = vec![Source::File(file_path.clone())];
let mut output = Vec::new();
process_sources(&replacer, &sources, false, false, &mut output)?;
let result = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(result, "xyz123def");
Ok(())
}
#[test]
fn test_process_sources_nonexistent_file() {
let replacer =
Replacer::new("abc".into(), "def".into(), false, None, 0).unwrap();
let nonexistent = PathBuf::from("/nonexistent/file.txt");
let sources = vec![Source::File(nonexistent.clone())];
let mut output = Vec::new();
let result =
process_sources(&replacer, &sources, false, false, &mut output);
assert!(result.is_err());
match result.unwrap_err() {
Error::InvalidPath(path) => assert_eq!(path, nonexistent),
_ => panic!("Expected InvalidPath error"),
}
}
#[test]
fn test_write_with_temp() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "original").unwrap();
let new_data = b"new content";
write_with_temp(&file_path, new_data)?;
let result = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(result, "new content");
Ok(())
}
#[test]
fn test_process_sources_line_by_line_preview() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "abc123\ndef456\n").unwrap();
let replacer =
Replacer::new("abc".into(), "xyz".into(), false, None, 0)?;
let sources = vec![Source::File(file_path)];
let mut output = Vec::new();
process_sources(&replacer, &sources, true, true, &mut output)?;
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "xyz123\ndef456\n");
Ok(())
}
#[test]
fn test_process_sources_line_by_line_in_place() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "abc123\ndef456\n").unwrap();
let replacer =
Replacer::new("abc".into(), "xyz".into(), false, None, 0)?;
let sources = vec![Source::File(file_path.clone())];
let mut output = Vec::new();
process_sources(&replacer, &sources, false, true, &mut output)?;
let result = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(result, "xyz123\ndef456\n");
Ok(())
}
#[test]
fn test_line_by_line_no_trailing_newline() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "abc").unwrap();
let replacer =
Replacer::new("abc".into(), "xyz".into(), false, None, 0)?;
let sources = vec![Source::File(file_path)];
let mut output = Vec::new();
process_sources(&replacer, &sources, true, true, &mut output)?;
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "xyz");
Ok(())
}
#[test]
fn test_line_by_line_caret_no_phantom() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "1\n2\n3\n").unwrap();
let replacer = Replacer::new("^".into(), "p-".into(), false, None, 0)?;
let sources = vec![Source::File(file_path)];
let mut output = Vec::new();
process_sources(&replacer, &sources, true, true, &mut output)?;
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "p-1\np-2\np-3\n");
Ok(())
}
#[test]
fn test_line_by_line_whitespace_trim() -> Result<()> {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "a \nb \n").unwrap();
let replacer =
Replacer::new(r"\s+$".into(), "".into(), false, None, 0)?;
let sources = vec![Source::File(file_path)];
let mut output = Vec::new();
process_sources(&replacer, &sources, true, true, &mut output)?;
let result = String::from_utf8(output).unwrap();
assert_eq!(result, "a\nb\n");
Ok(())
}
}
================================================
FILE: sd/src/output.rs
================================================
use crate::{Error, Result};
use std::{fs, io::Write, path::Path};
pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
let path = fs::canonicalize(path)?;
let mut temp = tempfile::NamedTempFile::new_in(
path.parent()
.ok_or_else(|| Error::InvalidPath(path.to_path_buf()))?,
)?;
let file = temp.as_file();
file.set_len(data.len() as u64)?;
if let Ok(metadata) = fs::metadata(&path) {
file.set_permissions(metadata.permissions()).ok();
// Explicitly retain ownership
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, fchown};
fchown(file, Some(metadata.uid()), Some(metadata.gid()))?;
metadata.gid();
}
}
if !data.is_empty() {
temp.as_file_mut().write_all(data)?;
temp.as_file_mut().flush()?;
}
temp.persist(&path)?;
Ok(())
}
================================================
FILE: sd/src/replacer/mod.rs
================================================
use std::borrow::Cow;
use crate::{Result, unescape};
use regex::bytes::Regex;
#[cfg(test)]
mod tests;
mod validate;
pub use validate::{InvalidReplaceCapture, validate_replace};
pub struct Replacer {
regex: Regex,
replace_with: Vec<u8>,
is_literal: bool,
replacements: usize,
}
impl Replacer {
pub fn new(
look_for: String,
replace_with: String,
is_literal: bool,
flags: Option<String>,
replacements: usize,
) -> Result<Self> {
let (look_for, replace_with) = if is_literal {
(regex::escape(&look_for), replace_with.into_bytes())
} else {
validate_replace(&replace_with)?;
(look_for, unescape::unescape(&replace_with).into_bytes())
};
let mut regex = regex::bytes::RegexBuilder::new(&look_for);
regex.multi_line(true);
if let Some(flags) = flags {
flags.chars().for_each(|c| {
#[rustfmt::skip]
match c {
'c' => { regex.case_insensitive(false); },
'i' => { regex.case_insensitive(true); },
'm' => {},
'e' => { regex.multi_line(false); },
's' => {
if !flags.contains('m') {
regex.multi_line(false);
}
regex.dot_matches_new_line(true);
},
'w' => {
regex = regex::bytes::RegexBuilder::new(&format!(
"\\b{}\\b",
look_for
));
},
_ => {},
};
});
};
Ok(Self {
regex: regex.build()?,
replace_with,
is_literal,
replacements,
})
}
pub fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> {
let regex = &self.regex;
let limit = self.replacements;
let use_color = false;
if self.is_literal {
Self::replacen(
regex,
limit,
content,
use_color,
regex::bytes::NoExpand(&self.replace_with),
)
} else {
Self::replacen(
regex,
limit,
content,
use_color,
&*self.replace_with,
)
}
}
/// A modified form of [`regex::bytes::Regex::replacen`] that supports
/// coloring replacements
pub fn replacen<'haystack, R: regex::bytes::Replacer>(
regex: ®ex::bytes::Regex,
limit: usize,
haystack: &'haystack [u8],
_use_color: bool,
mut rep: R,
) -> Cow<'haystack, [u8]> {
let mut it = regex.captures_iter(haystack).enumerate().peekable();
if it.peek().is_none() {
return Cow::Borrowed(haystack);
}
let mut new = Vec::with_capacity(haystack.len());
let mut last_match = 0;
for (i, cap) in it {
// unwrap on 0 is OK because captures only reports matches
let m = cap.get(0).unwrap();
new.extend_from_slice(&haystack[last_match..m.start()]);
rep.replace_append(&cap, &mut new);
last_match = m.end();
if limit > 0 && i >= limit - 1 {
break;
}
}
new.extend_from_slice(&haystack[last_match..]);
Cow::Owned(new)
}
}
================================================
FILE: sd/src/replacer/tests.rs
================================================
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn validate_doesnt_panic(s in r"(\PC*\$?){0,5}") {
let _ = validate::validate_replace(&s);
}
// $ followed by a digit and a non-ident char or an ident char
#[test]
fn validate_ok(s in r"([^\$]*(\$([0-9][^a-zA-Z_0-9\$]|a-zA-Z_))?){0,5}") {
validate::validate_replace(&s).unwrap();
}
// Force at least one $ followed by a digit and an ident char
#[test]
fn validate_err(s in r"[^\$]*?\$[0-9][a-zA-Z_]\PC*") {
validate::validate_replace(&s).unwrap_err();
}
}
#[derive(Default)]
struct Replace {
look_for: &'static str,
replace_with: &'static str,
literal: bool,
flags: Option<&'static str>,
src: &'static str,
expected: &'static str,
}
impl Replace {
fn test(&self) {
const UNLIMITED_REPLACEMENTS: usize = 0;
let replacer = Replacer::new(
self.look_for.into(),
self.replace_with.into(),
self.literal,
self.flags.map(ToOwned::to_owned),
UNLIMITED_REPLACEMENTS,
)
.unwrap();
let binding = replacer.replace(self.src.as_bytes());
let actual = std::str::from_utf8(&binding).unwrap();
assert_eq!(self.expected, actual);
}
}
#[test]
fn default_global() {
Replace {
look_for: "a",
replace_with: "b",
src: "aaa",
expected: "bbb",
..Default::default()
}
.test();
}
#[test]
fn escaped_char_preservation() {
Replace {
look_for: "a",
replace_with: "b",
src: r#"a\n"#,
expected: r#"b\n"#,
..Default::default()
}
.test();
}
#[test]
fn case_sensitive_default() {
Replace {
look_for: "abc",
replace_with: "x",
src: "abcABC",
expected: "xABC",
..Default::default()
}
.test();
Replace {
look_for: "abc",
replace_with: "x",
literal: true,
src: "abcABC",
expected: "xABC",
..Default::default()
}
.test();
}
#[test]
fn sanity_check_literal_replacements() {
Replace {
look_for: "((special[]))",
replace_with: "x",
literal: true,
src: "((special[]))y",
expected: "xy",
..Default::default()
}
.test();
}
#[test]
fn unescape_regex_replacements() {
Replace {
look_for: "test",
replace_with: r"\n",
src: "testtest",
expected: "\n\n",
..Default::default()
}
.test();
}
#[test]
fn no_unescape_literal_replacements() {
Replace {
look_for: "test",
replace_with: r"\n",
literal: true,
src: "testtest",
expected: r"\n\n",
..Default::default()
}
.test();
}
#[test]
fn full_word_replace() {
Replace {
look_for: "abc",
replace_with: "def",
flags: Some("w"),
src: "abcd abc",
expected: "abcd def",
..Default::default()
}
.test();
}
#[test]
fn escaping_unnecessarily() {
// https://github.com/chmln/sd/issues/313
Replace {
look_for: "abc",
replace_with: r#"\n{"#,
src: "abc",
expected: "\n{",
..Default::default()
}
.test();
Replace {
look_for: "abc",
replace_with: r#"\n\{"#,
src: "abc",
expected: "\n\\{",
..Default::default()
}
.test();
}
================================================
FILE: sd/src/replacer/validate.rs
================================================
use std::{error::Error, fmt, str::CharIndices};
#[derive(Debug)]
pub struct InvalidReplaceCapture {
original_replace: String,
invalid_ident: Span,
num_leading_digits: usize,
}
impl Error for InvalidReplaceCapture {}
// NOTE: This code is much more allocation heavy than it needs to be, but it's
// only displayed as a hard error to the user, so it's not a big deal
impl fmt::Display for InvalidReplaceCapture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
#[derive(Clone, Copy)]
enum SpecialChar {
Newline,
CarriageReturn,
Tab,
}
impl SpecialChar {
fn new(c: char) -> Option<Self> {
match c {
'\n' => Some(Self::Newline),
'\r' => Some(Self::CarriageReturn),
'\t' => Some(Self::Tab),
_ => None,
}
}
/// Renders as the character from the "Control Pictures" block
///
/// https://en.wikipedia.org/wiki/Control_Pictures
fn render(self) -> char {
match self {
Self::Newline => '␊',
Self::CarriageReturn => '␍',
Self::Tab => '␉',
}
}
}
let Self {
original_replace,
invalid_ident,
num_leading_digits,
} = self;
// Build up the error to show the user
let mut formatted = String::new();
let mut arrows_start = Span::start_at(0);
for (byte_index, c) in original_replace.char_indices() {
let (prefix, suffix, text) = match SpecialChar::new(c) {
Some(c) => {
(
Some("" /* special prefix */),
Some("" /* special suffix */),
c.render(),
)
}
None => {
let (prefix, suffix) = if byte_index == invalid_ident.start
{
(Some("" /* error prefix */), None)
} else if byte_index
== invalid_ident.end.checked_sub(1).unwrap()
{
(None, Some("" /* error suffix */))
} else {
(None, None)
};
(prefix, suffix, c)
}
};
if let Some(prefix) = prefix {
formatted.push_str(prefix);
}
formatted.push(text);
if let Some(suffix) = suffix {
formatted.push_str(suffix);
}
if byte_index < invalid_ident.start {
// Assumes that characters have a base display width of 1. While
// that's not technically true, it's near impossible to do right
// since the specifics on text rendering is up to the user's
// terminal/font. This _does_ rely on variable-width characters
// like \n, \r, and \t getting converting to single character
// representations above
arrows_start.start += 1;
}
}
let ident = invalid_ident.slice(original_replace);
let (number, the_rest) = ident.split_at(*num_leading_digits);
writeln!(
f,
"The numbered capture group `${number}` in the replacement text is ambiguous."
)?;
let disambiguous = format!("${{{number}}}{the_rest}");
writeln!(
f,
"hint: Use curly braces to disambiguate it `{disambiguous}`."
)?;
writeln!(f, "{}", formatted)?;
// This relies on all non-curly-braced capture chars being 1 byte
let arrows_span = arrows_start.end_offset(invalid_ident.len());
let mut arrows = " ".repeat(arrows_span.start);
arrows.push_str(&"^".repeat(arrows_span.len()));
write!(f, "{}", arrows)
}
}
pub fn validate_replace(s: &str) -> Result<(), InvalidReplaceCapture> {
for ident in ReplaceCaptureIter::new(s) {
let mut char_it = ident.name.char_indices();
let (_, c) = char_it.next().unwrap();
if c.is_ascii_digit() {
for (i, c) in char_it {
if !c.is_ascii_digit() {
return Err(InvalidReplaceCapture {
original_replace: s.to_owned(),
invalid_ident: ident.span,
num_leading_digits: i,
});
}
}
}
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct Span {
start: usize,
end: usize,
}
impl Span {
fn start_at(start: usize) -> SpanOpen {
SpanOpen { start }
}
fn new(start: usize, end: usize) -> Self {
// `<` instead of `<=` because `Span` is exclusive on the upper bound
assert!(start < end);
Self { start, end }
}
fn slice(self, s: &str) -> &str {
&s[self.start..self.end]
}
fn len(self) -> usize {
self.end - self.start
}
}
#[derive(Clone, Copy)]
struct SpanOpen {
start: usize,
}
impl SpanOpen {
fn end_at(self, end: usize) -> Span {
let Self { start } = self;
Span::new(start, end)
}
fn end_offset(self, offset: usize) -> Span {
assert_ne!(offset, 0);
let Self { start } = self;
self.end_at(start + offset)
}
}
#[derive(Debug)]
struct Capture<'rep> {
name: &'rep str,
span: Span,
}
impl<'rep> Capture<'rep> {
fn new(name: &'rep str, span: Span) -> Self {
Self { name, span }
}
}
/// An iterator over the capture idents in an interpolated replacement string
///
/// This code is adapted from the `regex` crate
/// <https://docs.rs/regex-automata/latest/src/regex_automata/util/interpolate.rs.html>
/// (hence the high quality doc comments).
struct ReplaceCaptureIter<'rep>(CharIndices<'rep>);
impl<'rep> ReplaceCaptureIter<'rep> {
fn new(s: &'rep str) -> Self {
Self(s.char_indices())
}
}
impl<'rep> Iterator for ReplaceCaptureIter<'rep> {
type Item = Capture<'rep>;
fn next(&mut self) -> Option<Self::Item> {
// Continually seek to `$` until we find one that has a capture group
loop {
let (start, _) = self.0.find(|(_, c)| *c == '$')?;
let replacement = self.0.as_str();
let rep = replacement.as_bytes();
let open_span = Span::start_at(start + 1);
let maybe_cap = match rep.first()? {
// Handle escaping of '$'.
b'$' => {
self.0.next().unwrap();
None
}
b'{' => find_cap_ref_braced(rep, open_span),
_ => find_cap_ref(rep, open_span),
};
if let Some(cap) = maybe_cap {
// Advance the inner iterator to consume the capture
let mut remaining_bytes = cap.name.len();
while remaining_bytes > 0 {
let (_, c) = self.0.next().unwrap();
remaining_bytes =
remaining_bytes.checked_sub(c.len_utf8()).unwrap();
}
return Some(cap);
}
}
}
}
/// Parses a possible reference to a capture group name in the given text,
/// starting at the beginning of `replacement`.
///
/// If no such valid reference could be found, None is returned.
fn find_cap_ref(rep: &[u8], open_span: SpanOpen) -> Option<Capture<'_>> {
if rep.is_empty() {
return None;
}
let mut cap_end = 0;
while rep.get(cap_end).copied().is_some_and(is_valid_cap_letter) {
cap_end += 1;
}
if cap_end == 0 {
return None;
}
// We just verified that the range 0..cap_end is valid ASCII, so it must
// therefore be valid UTF-8. If we really cared, we could avoid this UTF-8
// check via an unchecked conversion or by parsing the number straight from
// &[u8].
let name = core::str::from_utf8(&rep[..cap_end])
.expect("valid UTF-8 capture name");
Some(Capture::new(name, open_span.end_offset(name.len())))
}
/// Looks for a braced reference, e.g., `${foo1}`. This then looks for a
/// closing brace and returns the capture reference within the brace.
fn find_cap_ref_braced(rep: &[u8], open_span: SpanOpen) -> Option<Capture<'_>> {
assert_eq!(b'{', rep[0]);
let mut cap_end = 1;
while rep.get(cap_end).is_some_and(|&b| b != b'}') {
cap_end += 1;
}
if rep.get(cap_end).is_none_or(|&b| b != b'}') {
return None;
}
// When looking at braced names, we don't put any restrictions on the name,
// so it's possible it could be invalid UTF-8. But a capture group name
// can never be invalid UTF-8, so if we have invalid UTF-8, then we can
// safely return None.
let name = core::str::from_utf8(&rep[..cap_end + 1]).ok()?;
Some(Capture::new(name, open_span.end_offset(name.len())))
}
fn is_valid_cap_letter(b: u8) -> bool {
matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_')
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn literal_dollar_sign() {
let replace = "$$0";
let mut cap_iter = ReplaceCaptureIter::new(replace);
assert!(cap_iter.next().is_none());
}
#[test]
fn wacky_captures() {
let replace =
"$foo $1 $1invalid ${1}valid ${valid} $__${__weird__}${${__}";
let cap_iter = ReplaceCaptureIter::new(replace);
let expecteds = &[
"foo",
"1",
"1invalid",
"{1}",
"{valid}",
"__",
"{__weird__}",
"{${__}",
];
for (&expected, cap) in expecteds.iter().zip(cap_iter) {
assert_eq!(expected, cap.name, "name didn't match");
assert_eq!(expected, cap.span.slice(replace), "span didn't match");
}
}
const INTERPOLATED_CAPTURE: &str = "<interpolated>";
fn upstream_interpolate(s: &str) -> String {
let mut dst = String::new();
regex_automata::util::interpolate::string(
s,
|_, dst| dst.push_str(INTERPOLATED_CAPTURE),
|_| Some(0),
&mut dst,
);
dst
}
fn our_interpolate(s: &str) -> String {
let mut after_last_write = 0;
let mut dst = String::new();
for cap in ReplaceCaptureIter::new(s) {
// This only iterates over the capture groups, so copy any text
// before the capture
// -1 here to exclude the `$` that starts a capture
dst.push_str(
&s[after_last_write..cap.span.start.checked_sub(1).unwrap()],
);
// Interpolate our capture
dst.push_str(INTERPOLATED_CAPTURE);
after_last_write = cap.span.end;
}
if after_last_write < s.len() {
// And now any text that was after the last capture
dst.push_str(&s[after_last_write..]);
}
// Handle escaping literal `$`s
dst.replace("$$", "$")
}
proptest! {
// `regex-automata` doesn't expose a way to iterate over replacement
// captures, but we can use our iterator to mimic interpolation, so that
// we can pit the two against each other
#[test]
fn interpolation_matches_upstream(s in r"\PC*(\$\PC*){0,5}") {
assert_eq!(our_interpolate(&s), upstream_interpolate(&s));
}
}
}
================================================
FILE: sd/src/snapshots/sd__unescape__test__unescape.snap
================================================
---
source: src/unescape.rs
expression: out
---
empty: `` -> ``
single backslash: `\` -> `\`
two backslashes: `\\` -> `\`
newline: `\n` -> `
`
tab: `\t` -> ` `
carriage return: `\r` -> `
`
escaped double quote: `\"` -> `"`
escaped single quote: `\'` -> `'`
escaped backslash: `\\` -> `\`
unicode escape: `\u0042` -> `B`
hex escape: `\x41` -> `A`
invalid hex escape: `\xG` -> `\xG`
invalid unicode escape: `\u00Z1` -> `\u00Z1`
mixed valid and invalid escapes: `a\t\xG\n` -> `a \xG
`
non-escape characters: `ab` -> `ab`
incomplete escape sequence: `\u004` -> `\u004`
single characters: `a` -> `a`
issue #313: `\t{` -> ` {`
issue #313: `\t\{` -> ` \{`
================================================
FILE: sd/src/unescape.rs
================================================
use std::char;
use std::str::Chars;
/// Takes in a string with backslash escapes written out with literal backslash characters and
/// converts it to a string with the proper escaped characters.
pub fn unescape(input: &str) -> String {
let mut chars = input.chars();
let mut s = String::new();
while let Some(c) = chars.next() {
if c != '\\' {
s.push(c);
continue;
}
let Some(char) = chars.next() else {
// This means that the last char is a `\\`
assert_eq!(c, '\\');
s.push('\\');
break;
};
let escaped: Option<char> = match char {
'n' => Some('\n'),
'r' => Some('\r'),
't' => Some('\t'),
'\'' => Some('\''),
'\"' => Some('\"'),
'\\' => Some('\\'),
'u' => escape_n_chars(&mut chars, 4),
'x' => escape_n_chars(&mut chars, 2),
_ => None,
};
if let Some(char) = escaped {
// Successfully escaped a sequence
s.push(char);
} else {
// User didn't meant to escape that
s.push('\\');
s.push(char);
}
}
s
}
/// This is for sequences such as `\x08` or `\u1234`
fn escape_n_chars(chars: &mut Chars<'_>, length: usize) -> Option<char> {
let s = chars.as_str().get(0..length)?;
let u = u32::from_str_radix(s, 16).ok()?;
let ch = char::from_u32(u)?;
_ = chars.nth(length);
Some(ch)
}
#[cfg(test)]
mod test {
use std::fmt::Write as _;
#[test]
fn test_unescape() {
let mut out = String::new();
let mut test = |s: &str, name: &str| {
writeln!(out, "{name}: `{s}` -> `{}`", super::unescape(s)).unwrap();
};
test("", "empty");
test("\\", "single backslash");
test("\\\\", "two backslashes");
test("\\n", "newline");
test("\\t", "tab");
test("\\r", "carriage return");
test("\\\"", "escaped double quote");
test("\\'", "escaped single quote");
test("\\\\", "escaped backslash");
test("\\u0042", "unicode escape");
test("\\x41", "hex escape");
test("\\xG", "invalid hex escape");
test("\\u00Z1", "invalid unicode escape");
test("a\\t\\xG\\n", "mixed valid and invalid escapes");
test("ab", "non-escape characters");
test("\\u004", "incomplete escape sequence");
test("a", "single characters");
test("\\t{", "issue #313");
test("\\t\\{", "issue #313");
insta::assert_snapshot!(out);
}
}
================================================
FILE: sd-cli/Cargo.toml
================================================
[package]
name = "sd-cli"
version.workspace = true
edition.workspace = true
[[bin]]
name = "sd"
path = "src/main.rs"
[dependencies]
sd = { path = "../sd" }
clap.workspace = true
[dev-dependencies]
assert_cmd = "2.0.12"
anyhow = "1.0.75"
clap_mangen = "0.2.14"
console = "0.15.7"
regex = "1.10.2"
insta = "1.34.0"
ansi-to-html = "0.1.3"
tempfile.workspace = true
================================================
FILE: sd-cli/src/cli.rs
================================================
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "sd",
author,
version,
about,
max_term_width = 100,
help_template = "\
{before-help}{name} v{version}
{about-with-newline}
{usage-heading} {usage}
{all-args}{after-help}"
)]
pub struct Options {
#[arg(short, long)]
/// Display changes in a human reviewable format (the specifics of the
/// format are likely to change in the future).
pub preview: bool,
#[arg(
short = 'F',
long = "fixed-strings",
short_alias = 's',
alias = "string-mode"
)]
/// Treat FIND and REPLACE_WITH args as literal strings
pub literal_mode: bool,
#[arg(
short = 'n',
long = "max-replacements",
value_name = "LIMIT",
default_value_t
)]
/// Limit the number of replacements that can occur per file. 0 indicates
/// unlimited replacements.
pub replacements: usize,
#[arg(short, long, verbatim_doc_comment)]
#[rustfmt::skip]
/** Regex flags. May be combined (like `-f mc`).
c - case-sensitive
e - disable multi-line matching
i - case-insensitive
m - multi-line matching
s - make `.` match newlines
w - match full words only
*/
pub flags: Option<String>,
#[arg(short = 'A', long = "across")]
/// Process each input as a whole rather than line by line. This allows
/// patterns to match across line boundaries but uses more memory and
/// prevents streaming.
pub across: bool,
/// The regexp or string (if using `-F`) to search for.
pub find: String,
/// What to replace each match with. Unless in string mode, you may
/// use captured values like $1, $2, etc.
pub replace_with: String,
/// The path to file(s). This is optional - sd can also read from STDIN.
///
/// Note: sd modifies files in-place by default. See documentation for
/// examples.
pub files: Vec<std::path::PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn debug_assert() {
let cmd = Options::command();
cmd.debug_assert();
}
}
================================================
FILE: sd-cli/src/main.rs
================================================
mod cli;
use clap::Parser;
use std::{io::stdout, process};
use sd::{Replacer, Result, Source, process_sources};
fn main() {
if let Err(e) = try_main() {
eprintln!("error: {e}");
process::exit(1);
}
}
fn try_main() -> Result<()> {
let options = cli::Options::parse();
let replacer = Replacer::new(
options.find,
options.replace_with,
options.literal_mode,
options.flags,
options.replacements,
)?;
let sources = if !options.files.is_empty() {
Source::from_paths(options.files)
} else {
Ok(Source::from_stdin())
};
let sources = sources?;
let mut handle = stdout().lock();
process_sources(
&replacer,
&sources,
options.preview,
!options.across,
&mut handle,
)
}
================================================
FILE: sd-cli/tests/cli.rs
================================================
#[cfg(test)]
mod cli {
use anyhow::Result;
use assert_cmd::{Command, cargo_bin};
use std::{fs, io::prelude::*, path::Path};
fn sd() -> Command {
Command::new(cargo_bin!("sd"))
}
fn assert_file(path: &std::path::Path, content: &str) {
assert_eq!(content, std::fs::read_to_string(path).unwrap());
}
// This should really be cfg_attr(target_family = "windows"), but wasi impl
// is nightly for now, and other impls are not part of std
#[cfg_attr(
not(target_family = "unix"),
ignore = "Windows symlinks are privileged"
)]
fn create_soft_link<P: AsRef<std::path::Path>>(
_src: &P,
_dst: &P,
) -> Result<()> {
#[cfg(target_family = "unix")]
std::os::unix::fs::symlink(_src, _dst)?;
Ok(())
}
#[test]
fn in_place() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"abc123def")?;
let path = file.into_temp_path();
sd().args(["abc\\d+", "", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "def");
Ok(())
}
#[test]
fn in_place_with_empty_result_file() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"a7c")?;
let path = file.into_temp_path();
sd().args(["a\\dc", "", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "");
Ok(())
}
#[cfg_attr(
target_family = "windows",
ignore = "Windows symlinks are privileged"
)]
#[test]
fn in_place_following_symlink() -> Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path();
let file = path.join("file");
let link = path.join("link");
create_soft_link(&file, &link)?;
std::fs::write(&file, "abc123def")?;
sd().args(["abc\\d+", "", link.to_str().unwrap()])
.assert()
.success();
assert_file(&file, "def");
assert!(std::fs::symlink_metadata(link)?.file_type().is_symlink());
Ok(())
}
#[test]
fn replace_into_stdout() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"abc123def")?;
sd().args(["-p", "abc\\d+", "", file.path().to_str().unwrap()])
.assert()
.success()
.stdout("def");
assert_file(file.path(), "abc123def");
Ok(())
}
#[test]
fn stdin() -> Result<()> {
sd().args(["abc\\d+", ""])
.write_stdin("abc123def")
.assert()
.success()
.stdout("def");
Ok(())
}
fn bad_replace_helper_styled(replace: &str) -> String {
let err = sd()
.args(["find", replace])
.write_stdin("stdin")
.unwrap_err();
String::from_utf8(err.as_output().unwrap().stderr.clone()).unwrap()
}
#[test]
fn fixed_strings_ambiguous_replace_is_fine() {
sd().args([
"--fixed-strings",
"foo",
"inner_before $1fine inner_after",
])
.write_stdin("outer_before foo outer_after")
.assert()
.success()
.stdout("outer_before inner_before $1fine inner_after outer_after");
}
#[test]
fn ambiguous_replace_basic() {
let plain_stderr = bad_replace_helper_styled("before $1bad after");
insta::assert_snapshot!(plain_stderr, @r###"
error: The numbered capture group `$1` in the replacement text is ambiguous.
hint: Use curly braces to disambiguate it `${1}bad`.
before $1bad after
^^^^
"###);
}
#[test]
fn ambiguous_replace_variable_width() {
let plain_stderr = bad_replace_helper_styled("\r\n\t$1bad\r");
insta::assert_snapshot!(plain_stderr, @r###"
error: The numbered capture group `$1` in the replacement text is ambiguous.
hint: Use curly braces to disambiguate it `${1}bad`.
␍␊␉$1bad␍
^^^^
"###);
}
#[test]
fn ambiguous_replace_multibyte_char() {
let plain_stderr = bad_replace_helper_styled("😈$1bad😇");
insta::assert_snapshot!(plain_stderr, @r###"
error: The numbered capture group `$1` in the replacement text is ambiguous.
hint: Use curly braces to disambiguate it `${1}bad`.
😈$1bad😇
^^^^
"###);
}
#[test]
fn ambiguous_replace_issue_44() {
let plain_stderr =
bad_replace_helper_styled("$1Call $2($5, GetFM20ReturnKey(), $6)");
insta::assert_snapshot!(plain_stderr, @r###"
error: The numbered capture group `$1` in the replacement text is ambiguous.
hint: Use curly braces to disambiguate it `${1}Call`.
$1Call $2($5, GetFM20ReturnKey(), $6)
^^^^^
"###);
}
// NOTE: styled terminal output is platform dependent, so convert to a
// common format, in this case HTML, to check
#[ignore = "TODO: wait for proper colorization"]
#[test]
fn ambiguous_replace_ensure_styling() {
let styled_stderr = bad_replace_helper_styled("\t$1bad after");
let html_stderr =
ansi_to_html::convert(&styled_stderr, true, true).unwrap();
insta::assert_snapshot!(html_stderr, @r###"
<b><span style='color:#a00'>error</span></b>: The numbered capture group `<b>$1</b>` in the replacement text is ambiguous.
<b><span style='color:#00a'>hint</span></b>: Use curly braces to disambiguate it `<b>${1}bad</b>`.
<b>␉</b>$<b><span style='color:#a00'>1bad</span></b> after
<b>^^^^</b>
"###);
}
#[test]
fn limit_replacements_file() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"foo\nfoo\nfoo")?;
let path = file.into_temp_path();
sd().args(["-A", "-n", "1", "foo", "bar", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "bar\nfoo\nfoo");
Ok(())
}
#[test]
fn limit_replacements_file_preview() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"foo\nfoo\nfoo")?;
let path = file.into_temp_path();
sd().args([
"-A",
"--preview",
"-n",
"1",
"foo",
"bar",
path.to_str().unwrap(),
])
.assert()
.success()
.stdout("bar\nfoo\nfoo");
Ok(())
}
#[test]
fn limit_replacements_stdin() {
sd().args(["-A", "-n", "1", "foo", "bar"])
.write_stdin("foo\nfoo\nfoo")
.assert()
.success()
.stdout("bar\nfoo\nfoo");
}
#[test]
fn limit_replacements_stdin_preview() {
sd().args(["-A", "--preview", "-n", "1", "foo", "bar"])
.write_stdin("foo\nfoo\nfoo")
.assert()
.success()
.stdout("bar\nfoo\nfoo");
}
const UNTOUCHED_CONTENTS: &str = "untouched";
fn assert_fails_correctly(
command: &mut Command,
valid: &Path,
test_home: &Path,
snap_name: &str,
) {
let failed_command = command.assert().failure().code(1);
assert_eq!(fs::read_to_string(valid).unwrap(), UNTOUCHED_CONTENTS);
let stderr_orig =
std::str::from_utf8(&failed_command.get_output().stderr).unwrap();
// Normalize unstable path bits
let stderr_norm = stderr_orig
.replace(test_home.to_str().unwrap(), "<test_home>")
.replace('\\', "/");
insta::assert_snapshot!(snap_name, stderr_norm);
}
#[test]
fn correctly_fails_on_missing_file() -> Result<()> {
let test_dir = tempfile::Builder::new().prefix("sd-test-").tempdir()?;
let test_home = test_dir.path();
let valid = test_home.join("valid");
fs::write(&valid, UNTOUCHED_CONTENTS)?;
let missing = test_home.join("missing");
assert_fails_correctly(
sd().args([".*", ""]).arg(&valid).arg(&missing),
&valid,
test_home,
"correctly_fails_on_missing_file",
);
Ok(())
}
#[test]
fn line_by_line_stdin() -> Result<()> {
sd().args(["foo", "bar"])
.write_stdin("foo\nbaz\nfoo\n")
.assert()
.success()
.stdout("bar\nbaz\nbar\n");
Ok(())
}
#[test]
fn line_by_line_in_place() -> Result<()> {
let mut file = tempfile::NamedTempFile::new()?;
file.write_all(b"foo\nbaz\nfoo\n")?;
let path = file.into_temp_path();
sd().args(["foo", "bar", path.to_str().unwrap()])
.assert()
.success();
assert_file(&path, "bar\nbaz\nbar\n");
Ok(())
}
#[test]
fn line_by_line_preserves_no_trailing_newline() -> Result<()> {
sd().args(["abc", "xyz"])
.write_stdin("abc")
.assert()
.success()
.stdout("xyz");
Ok(())
}
#[test]
fn line_by_line_caret_no_phantom() -> Result<()> {
sd().args(["^", "p-"])
.write_stdin("1\n2\n3\n")
.assert()
.success()
.stdout("p-1\np-2\np-3\n");
Ok(())
}
#[test]
fn line_by_line_whitespace_trim() -> Result<()> {
sd().args([r"\s+$", ""])
.write_stdin("a \nb \n")
.assert()
.success()
.stdout("a\nb\n");
Ok(())
}
#[cfg(unix)]
mod unix_only {
use super::*;
#[test]
fn correctly_fails_on_unreadable_file() -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let test_dir =
tempfile::Builder::new().prefix("sd-test-").tempdir()?;
let test_home = test_dir.path();
let valid = test_home.join("valid");
fs::write(&valid, UNTOUCHED_CONTENTS)?;
let write_only = {
let path = test_home.join("write_only");
let mut write_only_file = std::fs::OpenOptions::new()
.mode(0o333)
.create(true)
.truncate(true)
.write(true)
.open(&path)?;
write!(write_only_file, "unreadable")?;
path
};
assert_fails_correctly(
sd().args([".*", ""]).arg(&valid).arg(&write_only),
&valid,
test_home,
"correctly_fails_on_unreadable_file",
);
Ok(())
}
// Failing to create a temporary file in the same directory as the
// input is one of the failure cases that is past the "point of no
// return" (after we already start making replacements). This means
// that any files that could be modified are, and we report any failure
// cases
#[test]
fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
const FIND_REPLACE: [&str; 2] = ["able", "ed"];
const ORIG_TEXT: &str = "modifiable";
const MODIFIED_TEXT: &str = "modified";
let test_dir =
tempfile::Builder::new().prefix("sd-test-").tempdir()?;
let test_home = test_dir.path().canonicalize()?;
let writable_dir = test_home.join("writable");
fs::create_dir(&writable_dir)?;
let writable_dir_file = writable_dir.join("foo");
fs::write(&writable_dir_file, ORIG_TEXT)?;
let unwritable_dir = test_home.join("unwritable");
fs::create_dir(&unwritable_dir)?;
let unwritable_dir_file1 = unwritable_dir.join("bar");
fs::write(&unwritable_dir_file1, ORIG_TEXT)?;
let unwritable_dir_file2 = unwritable_dir.join("baz");
fs::write(&unwritable_dir_file2, ORIG_TEXT)?;
let mut perms = fs::metadata(&unwritable_dir)?.permissions();
perms.set_mode(0o555);
fs::set_permissions(&unwritable_dir, perms)?;
let failed_command = sd()
// Force whole-file processing so this test exercises the
// atomic temp-file swap path (and not line-by-line preflight).
.arg("--across")
.args(FIND_REPLACE)
.arg(&writable_dir_file)
.arg(&unwritable_dir_file1)
.arg(&unwritable_dir_file2)
.assert()
.failure()
.code(1);
// Confirm that we modified the one file that we were able to
assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT);
assert_eq!(fs::read_to_string(&unwritable_dir_file1)?, ORIG_TEXT);
assert_eq!(fs::read_to_string(&unwritable_dir_file2)?, ORIG_TEXT);
let stderr_orig = std::str::from_utf8(
&failed_command.get_output().stderr,
)
.unwrap();
// Normalize unstable path bits
let stderr_partial_norm = stderr_orig
.replace(test_home.to_str().unwrap(), "<test_home>")
.replace('\\', "/");
let tmp_file_rep = regex::Regex::new(r"\.tmp\w+")?;
let stderr_norm =
tmp_file_rep.replace_all(&stderr_partial_norm, "<tmp_file>");
insta::assert_snapshot!(stderr_norm);
// Make the unwritable dir writable again, so it can be cleaned up
// when dropping the temp dir
let mut perms = fs::metadata(&unwritable_dir)?.permissions();
perms.set_mode(0o777);
fs::set_permissions(&unwritable_dir, perms)?;
test_dir.close()?;
Ok(())
}
}
#[cfg(windows)]
mod windows_only {
use super::*;
use std::os::windows::fs::OpenOptionsExt;
const FIND_REPLACE: [&str; 2] = ["able", "ed"];
const ORIG_TEXT: &str = "modifiable";
const MODIFIED_TEXT: &str = "modified";
const FILE_SHARE_NONE: u32 = 0;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_WRITE: u32 = 0x00000002;
#[test]
fn correctly_fails_on_unreadable_file() -> Result<()> {
let test_dir =
tempfile::Builder::new().prefix("sd-test-").tempdir()?;
let test_home = test_dir.path();
let valid = test_home.join("valid");
fs::write(&valid, UNTOUCHED_CONTENTS)?;
let locked = test_home.join("locked");
fs::write(&locked, "unreadable")?;
let _lock = std::fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(FILE_SHARE_NONE)
.open(&locked)?;
let failed_command = sd()
.args([".*", ""])
.arg(&valid)
.arg(&locked)
.assert()
.failure()
.code(1);
assert_eq!(fs::read_to_string(&valid)?, UNTOUCHED_CONTENTS);
let stderr =
std::str::from_utf8(&failed_command.get_output().stderr)?;
assert!(
!stderr.is_empty(),
"expected an error message for locked file failure"
);
Ok(())
}
#[test]
fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> {
let test_dir =
tempfile::Builder::new().prefix("sd-test-").tempdir()?;
let test_home = test_dir.path();
let writable_dir = test_home.join("writable");
fs::create_dir(&writable_dir)?;
let writable_dir_file = writable_dir.join("foo");
fs::write(&writable_dir_file, ORIG_TEXT)?;
let locked_dir = test_home.join("locked");
fs::create_dir(&locked_dir)?;
let locked_file1 = locked_dir.join("bar");
fs::write(&locked_file1, ORIG_TEXT)?;
let locked_file2 = locked_dir.join("baz");
fs::write(&locked_file2, ORIG_TEXT)?;
// Allow reads/writes so the file can be processed, but deny
// delete-sharing so the final atomic replace (rename/persist)
// fails.
let _lock1 = std::fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(&locked_file1)?;
let _lock2 = std::fs::OpenOptions::new()
.read(true)
.write(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.open(&locked_file2)?;
let failed_command = sd()
// Force whole-file processing so this test exercises the
// atomic temp-file swap path (and not line-by-line preflight).
.arg("--across")
.args(FIND_REPLACE)
.arg(&writable_dir_file)
.arg(&locked_file1)
.arg(&locked_file2)
.assert()
.failure()
.code(1);
assert_eq!(fs::read_to_string(&writable_dir_file)?, MODIFIED_TEXT);
assert_eq!(fs::read_to_string(&locked_file1)?, ORIG_TEXT);
assert_eq!(fs::read_to_string(&locked_file2)?, ORIG_TEXT);
let stderr =
std::str::from_utf8(&failed_command.get_output().stderr)?;
assert!(
!stderr.is_empty(),
"expected an error message for atomic swap failure"
);
Ok(())
}
}
}
================================================
FILE: sd-cli/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap
================================================
---
source: tests/cli.rs
expression: stderr_norm
---
error: invalid path: <test_home>/missing
================================================
FILE: sd-cli/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap
================================================
---
source: tests/cli.rs
expression: stderr_norm
---
error: Permission denied (os error 13)
================================================
FILE: sd-cli/tests/snapshots/cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap
================================================
---
source: tests/cli.rs
expression: stderr_norm
---
error: Failed processing some inputs
<test_home>/unwritable/bar: Permission denied (os error 13) at path "<test_home>/unwritable/<tmp_file>"
<test_home>/unwritable/baz: Permission denied (os error 13) at path "<test_home>/unwritable/<tmp_file>"
================================================
FILE: xtask/Cargo.toml
================================================
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
publish = false
[dependencies]
clap.workspace = true
clap_complete = "4.4.3"
clap_mangen = "0.2.14"
roff = "0.2.1"
================================================
FILE: xtask/src/generate.rs
================================================
mod sd {
include!("../../sd-cli/src/cli.rs");
}
use sd::Options;
use std::{fs, path::Path};
use clap::{CommandFactory, ValueEnum};
use clap_complete::{Shell, generate_to};
use roff::{Roff, bold, roman};
pub fn generate() {
let gen_dir = Path::new("gen");
gen_shell(gen_dir);
gen_man(gen_dir);
}
fn gen_shell(base_dir: &Path) {
let completions_dir = base_dir.join("completions");
fs::create_dir_all(&completions_dir).unwrap();
let mut cmd = Options::command();
for &shell in Shell::value_variants() {
generate_to(shell, &mut cmd, "sd", &completions_dir).unwrap();
}
}
fn gen_man(base_dir: &Path) {
let man_path = base_dir.join("sd.1");
let cmd = Options::command();
let mut buffer: Vec<u8> = Vec::new();
let man = clap_mangen::Man::new(cmd);
man.render_title(&mut buffer)
.expect("failed to render title section");
man.render_name_section(&mut buffer)
.expect("failed to render name section");
man.render_synopsis_section(&mut buffer)
.expect("failed to render synopsis section");
man.render_description_section(&mut buffer)
.expect("failed to render description section");
man.render_options_section(&mut buffer)
.expect("failed to render options section");
let statuses = [
("0", "Successful program execution."),
("1", "Unsuccessful program execution."),
("101", "The program panicked."),
];
let mut sect = Roff::new();
sect.control("SH", ["EXIT STATUS"]);
for (code, reason) in statuses {
sect.control("IP", [code]).text([roman(reason)]);
}
sect.to_writer(&mut buffer)
.expect("failed to render exit status section");
let examples = [
// (description, command, result), result can be empty
(
"String-literal mode",
"echo 'lots((([]))) of special chars' | sd -F '((([])))' ''",
"lots of special chars",
),
(
"Regex use. Let's trim some trailing whitespace",
"echo 'lorem ipsum 23 ' | sd '\\s+$' ''",
"lorem ipsum 23",
),
(
"Indexed capture groups",
r"echo 'cargo +nightly watch' | sd '(\w+)\s+\+(\w+)\s+(\w+)' 'cmd: $1, channel: $2, subcmd: $3'",
"cmd: cargo, channel: nightly, subcmd: watch",
),
(
"Find & replace in file",
r#"sd 'window.fetch' 'fetch' http.js"#,
"",
),
(
"Find & replace from STDIN an emit to STDOUT",
r#"sd 'window.fetch' 'fetch' < http.js"#,
"",
),
];
let mut sect = Roff::new();
sect.control("SH", ["EXAMPLES"]);
for (desc, command, result) in examples {
sect.control("TP", [])
.text([roman(desc)])
.text([bold(format!("$ {}", command))])
.control("br", [])
.text([roman(result)]);
}
sect.to_writer(&mut buffer)
.expect("failed to render example section");
std::fs::write(man_path, buffer).expect("failed to write manpage");
}
================================================
FILE: xtask/src/main.rs
================================================
use std::{
env,
path::{Path, PathBuf},
};
use clap::{Parser, Subcommand};
mod generate;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Generate static assets
Gen,
}
fn main() {
let Cli { command } = Cli::parse();
env::set_current_dir(project_root()).unwrap();
match command {
Commands::Gen => generate::generate(),
}
}
fn project_root() -> PathBuf {
Path::new(
&env::var("CARGO_MANIFEST_DIR")
.unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").to_owned()),
)
.ancestors()
.nth(1)
.unwrap()
.to_path_buf()
}
gitextract_6tl_8up9/
├── .cargo/
│ └── config.toml
├── .editorconfig
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .rustfmt.toml
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── README_zh-CN.md
├── RELEASE.md
├── gen/
│ ├── completions/
│ │ ├── _sd
│ │ ├── _sd.ps1
│ │ ├── sd.bash
│ │ ├── sd.elv
│ │ └── sd.fish
│ └── sd.1
├── proptest-regressions/
│ └── replacer/
│ ├── tests.txt
│ └── validate.txt
├── release.toml
├── sd/
│ ├── Cargo.toml
│ └── src/
│ ├── error.rs
│ ├── input.rs
│ ├── lib.rs
│ ├── output.rs
│ ├── replacer/
│ │ ├── mod.rs
│ │ ├── tests.rs
│ │ └── validate.rs
│ ├── snapshots/
│ │ └── sd__unescape__test__unescape.snap
│ └── unescape.rs
├── sd-cli/
│ ├── Cargo.toml
│ ├── src/
│ │ ├── cli.rs
│ │ └── main.rs
│ └── tests/
│ ├── cli.rs
│ └── snapshots/
│ ├── cli__cli__correctly_fails_on_missing_file.snap
│ ├── cli__cli__correctly_fails_on_unreadable_file.snap
│ └── cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap
└── xtask/
├── Cargo.toml
└── src/
├── generate.rs
└── main.rs
SYMBOL INDEX (117 symbols across 13 files)
FILE: sd-cli/src/cli.rs
type Options (line 17) | pub struct Options {
function debug_assert (line 87) | fn debug_assert() {
FILE: sd-cli/src/main.rs
function main (line 8) | fn main() {
function try_main (line 15) | fn try_main() -> Result<()> {
FILE: sd-cli/tests/cli.rs
function sd (line 7) | fn sd() -> Command {
function assert_file (line 11) | fn assert_file(path: &std::path::Path, content: &str) {
function create_soft_link (line 21) | fn create_soft_link<P: AsRef<std::path::Path>>(
function in_place (line 32) | fn in_place() -> Result<()> {
function in_place_with_empty_result_file (line 46) | fn in_place_with_empty_result_file() -> Result<()> {
function in_place_following_symlink (line 64) | fn in_place_following_symlink() -> Result<()> {
function replace_into_stdout (line 84) | fn replace_into_stdout() -> Result<()> {
function stdin (line 99) | fn stdin() -> Result<()> {
function bad_replace_helper_styled (line 109) | fn bad_replace_helper_styled(replace: &str) -> String {
function fixed_strings_ambiguous_replace_is_fine (line 118) | fn fixed_strings_ambiguous_replace_is_fine() {
function ambiguous_replace_basic (line 131) | fn ambiguous_replace_basic() {
function ambiguous_replace_variable_width (line 142) | fn ambiguous_replace_variable_width() {
function ambiguous_replace_multibyte_char (line 153) | fn ambiguous_replace_multibyte_char() {
function ambiguous_replace_issue_44 (line 164) | fn ambiguous_replace_issue_44() {
function ambiguous_replace_ensure_styling (line 179) | fn ambiguous_replace_ensure_styling() {
function limit_replacements_file (line 192) | fn limit_replacements_file() -> Result<()> {
function limit_replacements_file_preview (line 206) | fn limit_replacements_file_preview() -> Result<()> {
function limit_replacements_stdin (line 228) | fn limit_replacements_stdin() {
function limit_replacements_stdin_preview (line 237) | fn limit_replacements_stdin_preview() {
constant UNTOUCHED_CONTENTS (line 245) | const UNTOUCHED_CONTENTS: &str = "untouched";
function assert_fails_correctly (line 247) | fn assert_fails_correctly(
function correctly_fails_on_missing_file (line 267) | fn correctly_fails_on_missing_file() -> Result<()> {
function line_by_line_stdin (line 286) | fn line_by_line_stdin() -> Result<()> {
function line_by_line_in_place (line 297) | fn line_by_line_in_place() -> Result<()> {
function line_by_line_preserves_no_trailing_newline (line 311) | fn line_by_line_preserves_no_trailing_newline() -> Result<()> {
function line_by_line_caret_no_phantom (line 322) | fn line_by_line_caret_no_phantom() -> Result<()> {
function line_by_line_whitespace_trim (line 333) | fn line_by_line_whitespace_trim() -> Result<()> {
function correctly_fails_on_unreadable_file (line 348) | fn correctly_fails_on_unreadable_file() -> Result<()> {
function reports_errors_on_atomic_file_swap_creation_failure (line 385) | fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> {
constant FIND_REPLACE (line 457) | const FIND_REPLACE: [&str; 2] = ["able", "ed"];
constant ORIG_TEXT (line 458) | const ORIG_TEXT: &str = "modifiable";
constant MODIFIED_TEXT (line 459) | const MODIFIED_TEXT: &str = "modified";
constant FILE_SHARE_NONE (line 460) | const FILE_SHARE_NONE: u32 = 0;
constant FILE_SHARE_READ (line 461) | const FILE_SHARE_READ: u32 = 0x00000001;
constant FILE_SHARE_WRITE (line 462) | const FILE_SHARE_WRITE: u32 = 0x00000002;
function correctly_fails_on_unreadable_file (line 465) | fn correctly_fails_on_unreadable_file() -> Result<()> {
function reports_errors_on_atomic_file_swap_creation_failure (line 501) | fn reports_errors_on_atomic_file_swap_creation_failure() -> Result<()> {
FILE: sd/src/error.rs
type Error (line 6) | pub enum Error {
method fmt (line 23) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
type Result (line 28) | pub type Result<T, E = Error> = std::result::Result<T, E>;
type FailedJobs (line 30) | pub struct FailedJobs(pub Vec<(PathBuf, Error)>);
method fmt (line 33) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
method fmt (line 44) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
FILE: sd/src/input.rs
type Source (line 10) | pub enum Source {
method from_paths (line 16) | pub fn from_paths(paths: Vec<PathBuf>) -> Result<Vec<Self>> {
method from_stdin (line 29) | pub fn from_stdin() -> Vec<Self> {
method display (line 33) | pub fn display(&self) -> String {
function open_source (line 41) | pub fn open_source(source: &Source) -> Result<Box<dyn BufRead + '_>> {
function read_source (line 54) | pub fn read_source(source: &Source) -> Result<Vec<u8>> {
FILE: sd/src/lib.rs
function process_sources (line 17) | pub fn process_sources(
function process_sources_line_by_line (line 86) | fn process_sources_line_by_line(
function process_reader_line_by_line (line 136) | fn process_reader_line_by_line(
function write_file_line_by_line (line 179) | fn write_file_line_by_line(replacer: &Replacer, path: &PathBuf) -> Resul...
function write_with_temp (line 205) | fn write_with_temp(path: &PathBuf, data: &[u8]) -> Result<()> {
function test_process_sources_with_preview (line 235) | fn test_process_sources_with_preview() -> Result<()> {
function test_process_sources_in_place (line 254) | fn test_process_sources_in_place() -> Result<()> {
function test_process_sources_nonexistent_file (line 273) | fn test_process_sources_nonexistent_file() {
function test_write_with_temp (line 291) | fn test_write_with_temp() -> Result<()> {
function test_process_sources_line_by_line_preview (line 306) | fn test_process_sources_line_by_line_preview() -> Result<()> {
function test_process_sources_line_by_line_in_place (line 325) | fn test_process_sources_line_by_line_in_place() -> Result<()> {
function test_line_by_line_no_trailing_newline (line 344) | fn test_line_by_line_no_trailing_newline() -> Result<()> {
function test_line_by_line_caret_no_phantom (line 363) | fn test_line_by_line_caret_no_phantom() -> Result<()> {
function test_line_by_line_whitespace_trim (line 381) | fn test_line_by_line_whitespace_trim() -> Result<()> {
FILE: sd/src/output.rs
function write_atomic (line 4) | pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
FILE: sd/src/replacer/mod.rs
type Replacer (line 13) | pub struct Replacer {
method new (line 21) | pub fn new(
method replace (line 72) | pub fn replace<'a>(&'a self, content: &'a [u8]) -> Cow<'a, [u8]> {
method replacen (line 97) | pub fn replacen<'haystack, R: regex::bytes::Replacer>(
FILE: sd/src/replacer/tests.rs
type Replace (line 24) | struct Replace {
method test (line 34) | fn test(&self) {
function default_global (line 53) | fn default_global() {
function escaped_char_preservation (line 65) | fn escaped_char_preservation() {
function case_sensitive_default (line 77) | fn case_sensitive_default() {
function sanity_check_literal_replacements (line 99) | fn sanity_check_literal_replacements() {
function unescape_regex_replacements (line 112) | fn unescape_regex_replacements() {
function no_unescape_literal_replacements (line 124) | fn no_unescape_literal_replacements() {
function full_word_replace (line 137) | fn full_word_replace() {
function escaping_unnecessarily (line 150) | fn escaping_unnecessarily() {
FILE: sd/src/replacer/validate.rs
type InvalidReplaceCapture (line 4) | pub struct InvalidReplaceCapture {
method fmt (line 15) | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
function validate_replace (line 121) | pub fn validate_replace(s: &str) -> Result<(), InvalidReplaceCapture> {
type Span (line 142) | struct Span {
method start_at (line 148) | fn start_at(start: usize) -> SpanOpen {
method new (line 152) | fn new(start: usize, end: usize) -> Self {
method slice (line 158) | fn slice(self, s: &str) -> &str {
method len (line 162) | fn len(self) -> usize {
type SpanOpen (line 168) | struct SpanOpen {
method end_at (line 173) | fn end_at(self, end: usize) -> Span {
method end_offset (line 178) | fn end_offset(self, offset: usize) -> Span {
type Capture (line 186) | struct Capture<'rep> {
function new (line 192) | fn new(name: &'rep str, span: Span) -> Self {
type ReplaceCaptureIter (line 202) | struct ReplaceCaptureIter<'rep>(CharIndices<'rep>);
function new (line 205) | fn new(s: &'rep str) -> Self {
type Item (line 211) | type Item = Capture<'rep>;
method next (line 213) | fn next(&mut self) -> Option<Self::Item> {
function find_cap_ref (line 249) | fn find_cap_ref(rep: &[u8], open_span: SpanOpen) -> Option<Capture<'_>> {
function find_cap_ref_braced (line 273) | fn find_cap_ref_braced(rep: &[u8], open_span: SpanOpen) -> Option<Captur...
function is_valid_cap_letter (line 292) | fn is_valid_cap_letter(b: u8) -> bool {
function literal_dollar_sign (line 303) | fn literal_dollar_sign() {
function wacky_captures (line 310) | fn wacky_captures() {
constant INTERPOLATED_CAPTURE (line 331) | const INTERPOLATED_CAPTURE: &str = "<interpolated>";
function upstream_interpolate (line 333) | fn upstream_interpolate(s: &str) -> String {
function our_interpolate (line 344) | fn our_interpolate(s: &str) -> String {
FILE: sd/src/unescape.rs
function unescape (line 6) | pub fn unescape(input: &str) -> String {
function escape_n_chars (line 47) | fn escape_n_chars(chars: &mut Chars<'_>, length: usize) -> Option<char> {
function test_unescape (line 60) | fn test_unescape() {
FILE: xtask/src/generate.rs
function generate (line 12) | pub fn generate() {
function gen_shell (line 18) | fn gen_shell(base_dir: &Path) {
function gen_man (line 28) | fn gen_man(base_dir: &Path) {
FILE: xtask/src/main.rs
type Cli (line 11) | struct Cli {
type Commands (line 17) | enum Commands {
function main (line 22) | fn main() {
function project_root (line 32) | fn project_root() -> PathBuf {
Condensed preview — 42 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (116K chars).
[
{
"path": ".cargo/config.toml",
"chars": 41,
"preview": "[alias]\nxtask = \"run --package xtask --\"\n"
},
{
"path": ".editorconfig",
"chars": 70,
"preview": "[*]\nindent_style = space\nindent_size = 4\n\n[*.rs]\nmax_line_length = 80\n"
},
{
"path": ".github/dependabot.yml",
"chars": 441,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/workflows/publish.yml",
"chars": 3713,
"preview": "name: Publish\n\non:\n push:\n tags:\n - '*'\n\njobs:\n publish:\n name: ${{ matrix.target }}\n runs-on: ${{ matri"
},
{
"path": ".github/workflows/test.yml",
"chars": 2869,
"preview": "name: Test\n\non:\n pull_request:\n workflow_dispatch:\n\njobs:\n test:\n name: ${{ matrix.target }}\n runs-on: ${{ matr"
},
{
"path": ".gitignore",
"chars": 19,
"preview": "/target\n**/*.rs.bk\n"
},
{
"path": ".rustfmt.toml",
"chars": 64,
"preview": "edition = \"2018\"\nmax_width = 80\nuse_field_init_shorthand = true\n"
},
{
"path": "CHANGELOG.md",
"chars": 9469,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThis project adheres to [Semantic Ver"
},
{
"path": "Cargo.toml",
"chars": 706,
"preview": "[workspace]\nresolver = \"3\"\nmembers = [\n \"sd\",\n \"sd-cli\",\n \"xtask\",\n]\n\n[workspace.dependencies]\ntempfile = \"3.8."
},
{
"path": "LICENSE",
"chars": 1064,
"preview": "MIT License\n\nCopyright (c) 2018 Gregory\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof"
},
{
"path": "README.md",
"chars": 6980,
"preview": "# sd - `s`earch & `d`isplace\n\n`sd` is an intuitive find & replace CLI.\n\n## The Pitch\n\nWhy use it over any existing tools"
},
{
"path": "README_zh-CN.md",
"chars": 5072,
"preview": "# sd - `搜索`与`替换`\n\n`sd` 是一个直观的查找与替换命令行工具。\n\n## 主要优点\n\n为什么要使用它而不是现有的任何工具?\n\n*更好的正则表达式* `sd` 使用您已经熟悉的来自 JavaScript 和 Py"
},
{
"path": "RELEASE.md",
"chars": 877,
"preview": "# Release checklist\n\n1. [ ] Create a new _\"Release v{VERSION}\"_ issue with this checklist\n - `$ cat RELEASE.md | sd '"
},
{
"path": "gen/completions/_sd",
"chars": 2075,
"preview": "#compdef sd\n\nautoload -U is-at-least\n\n_sd() {\n typeset -A opt_args\n typeset -a _arguments_options\n local ret=1\n"
},
{
"path": "gen/completions/_sd.ps1",
"chars": 3316,
"preview": "\nusing namespace System.Management.Automation\nusing namespace System.Management.Automation.Language\n\nRegister-ArgumentCo"
},
{
"path": "gen/completions/sd.bash",
"chars": 1512,
"preview": "_sd() {\n local i cur prev opts cmd\n COMPREPLY=()\n cur=\"${COMP_WORDS[COMP_CWORD]}\"\n prev=\"${COMP_WORDS[COMP_C"
},
{
"path": "gen/completions/sd.elv",
"chars": 1893,
"preview": "\nuse builtin;\nuse str;\n\nset edit:completion:arg-completer[sd] = {|@words|\n fn spaces {|n|\n builtin:repeat $n '"
},
{
"path": "gen/completions/sd.fish",
"chars": 772,
"preview": "complete -c sd -s n -l max-replacements -d 'Limit the number of replacements that can occur per file. 0 indicates unlimi"
},
{
"path": "gen/sd.1",
"chars": 2755,
"preview": ".ie \\n(.g .ds Aq \\(aq\n.el .ds Aq '\n.TH sd 1 \"sd 1.0.0\" \n.ie \\n(.g .ds Aq \\(aq\n.el .ds Aq '\n.SH NAME\nsd\n.ie \\n(.g .ds Aq"
},
{
"path": "proptest-regressions/replacer/tests.txt",
"chars": 476,
"preview": "# Seeds for failure cases proptest has generated in the past. It is\n# automatically read and these particular cases re-r"
},
{
"path": "proptest-regressions/replacer/validate.txt",
"chars": 654,
"preview": "# Seeds for failure cases proptest has generated in the past. It is\n# automatically read and these particular cases re-r"
},
{
"path": "release.toml",
"chars": 22,
"preview": "no-dev-version = true\n"
},
{
"path": "sd/Cargo.toml",
"chars": 664,
"preview": "[package]\nname = \"sd\"\nversion.workspace = true\nedition.workspace = true\nauthors = [\"Gregory <gregory.mkv@gmail.com>\", \"O"
},
{
"path": "sd/src/error.rs",
"chars": 1229,
"preview": "use std::{fmt, path::PathBuf};\n\nuse crate::replacer::InvalidReplaceCapture;\n\n#[derive(thiserror::Error)]\npub enum Error "
},
{
"path": "sd/src/input.rs",
"chars": 1375,
"preview": "use std::{\n fs::File,\n io::{BufRead, BufReader, Read, stdin},\n path::PathBuf,\n};\n\nuse crate::error::{Error, Res"
},
{
"path": "sd/src/lib.rs",
"chars": 11914,
"preview": "mod error;\nmod input;\npub mod replacer;\nmod unescape;\n\nuse std::{\n fs,\n io::{BufRead, BufWriter, Read, Write},\n "
},
{
"path": "sd/src/output.rs",
"chars": 906,
"preview": "use crate::{Error, Result};\nuse std::{fs, io::Write, path::Path};\n\npub(crate) fn write_atomic(path: &Path, data: &[u8]) "
},
{
"path": "sd/src/replacer/mod.rs",
"chars": 3583,
"preview": "use std::borrow::Cow;\n\nuse crate::{Result, unescape};\n\nuse regex::bytes::Regex;\n\n#[cfg(test)]\nmod tests;\nmod validate;\n\n"
},
{
"path": "sd/src/replacer/tests.rs",
"chars": 3449,
"preview": "use super::*;\nuse proptest::prelude::*;\n\nproptest! {\n #[test]\n fn validate_doesnt_panic(s in r\"(\\PC*\\$?){0,5}\") {\n"
},
{
"path": "sd/src/replacer/validate.rs",
"chars": 11759,
"preview": "use std::{error::Error, fmt, str::CharIndices};\n\n#[derive(Debug)]\npub struct InvalidReplaceCapture {\n original_replac"
},
{
"path": "sd/src/snapshots/sd__unescape__test__unescape.snap",
"chars": 649,
"preview": "---\nsource: src/unescape.rs\nexpression: out\n---\nempty: `` -> ``\nsingle backslash: `\\` -> `\\`\ntwo backslashes: `\\\\` -> `\\"
},
{
"path": "sd/src/unescape.rs",
"chars": 2640,
"preview": "use std::char;\nuse std::str::Chars;\n\n/// Takes in a string with backslash escapes written out with literal backslash cha"
},
{
"path": "sd-cli/Cargo.toml",
"chars": 365,
"preview": "[package]\nname = \"sd-cli\"\nversion.workspace = true\nedition.workspace = true\n\n[[bin]]\nname = \"sd\"\npath = \"src/main.rs\"\n\n["
},
{
"path": "sd-cli/src/cli.rs",
"chars": 2137,
"preview": "use clap::Parser;\n\n#[derive(Parser, Debug)]\n#[command(\n name = \"sd\",\n author,\n version,\n about,\n max_term"
},
{
"path": "sd-cli/src/main.rs",
"chars": 826,
"preview": "mod cli;\n\nuse clap::Parser;\nuse std::{io::stdout, process};\n\nuse sd::{Replacer, Result, Source, process_sources};\n\nfn ma"
},
{
"path": "sd-cli/tests/cli.rs",
"chars": 17970,
"preview": "#[cfg(test)]\nmod cli {\n use anyhow::Result;\n use assert_cmd::{Command, cargo_bin};\n use std::{fs, io::prelude::"
},
{
"path": "sd-cli/tests/snapshots/cli__cli__correctly_fails_on_missing_file.snap",
"chars": 95,
"preview": "---\nsource: tests/cli.rs\nexpression: stderr_norm\n---\nerror: invalid path: <test_home>/missing\n\n"
},
{
"path": "sd-cli/tests/snapshots/cli__cli__correctly_fails_on_unreadable_file.snap",
"chars": 93,
"preview": "---\nsource: tests/cli.rs\nexpression: stderr_norm\n---\nerror: Permission denied (os error 13)\n\n"
},
{
"path": "sd-cli/tests/snapshots/cli__cli__unix_only__reports_errors_on_atomic_file_swap_creation_failure.snap",
"chars": 308,
"preview": "---\nsource: tests/cli.rs\nexpression: stderr_norm\n---\nerror: Failed processing some inputs\n <test_home>/unwritable/bar"
},
{
"path": "xtask/Cargo.toml",
"chars": 191,
"preview": "[package]\nname = \"xtask\"\nversion.workspace = true\nedition.workspace = true\npublish = false\n\n[dependencies]\nclap.workspac"
},
{
"path": "xtask/src/generate.rs",
"chars": 3115,
"preview": "mod sd {\n include!(\"../../sd-cli/src/cli.rs\");\n}\nuse sd::Options;\n\nuse std::{fs, path::Path};\n\nuse clap::{CommandFact"
},
{
"path": "xtask/src/main.rs",
"chars": 673,
"preview": "use std::{\n env,\n path::{Path, PathBuf},\n};\n\nuse clap::{Parser, Subcommand};\n\nmod generate;\n\n#[derive(Parser)]\nstr"
}
]
About this extraction
This page contains the full source code of the chmln/sd GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 42 files (106.3 KB), approximately 30.5k tokens, and a symbol index with 117 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.