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