├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── default.nix ├── dist ├── .gitignore ├── build.sh ├── clean.sh ├── debian.nix ├── fpm.nix └── platforms.nix └── src ├── cmdline.rs ├── main.rs ├── refold.rs └── util.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "argtea" 7 | version = "1.0.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "e4174b041266795cd28cb6ba48dbb25f7131725471f26f6fc29c7241cff2fe4d" 10 | 11 | [[package]] 12 | name = "refold" 13 | version = "0.1.2" 14 | dependencies = [ 15 | "argtea", 16 | "textwrap", 17 | ] 18 | 19 | [[package]] 20 | name = "smawk" 21 | version = "0.3.2" 22 | source = "registry+https://github.com/rust-lang/crates.io-index" 23 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 24 | 25 | [[package]] 26 | name = "textwrap" 27 | version = "0.16.1" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" 30 | dependencies = [ 31 | "smawk", 32 | "unicode-linebreak", 33 | ] 34 | 35 | [[package]] 36 | name = "unicode-linebreak" 37 | version = "0.1.5" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 40 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "refold" 3 | version = "0.1.2" 4 | edition = "2021" 5 | 6 | license = "GPL-2.0-or-later" 7 | repository = "https://github.com/wr7/refold" 8 | description = "A command-line utility for wrapping text" 9 | keywords = ["fold", "wrap", "line_wrapping"] 10 | categories = ["command-line-utilities"] 11 | include = ["/src"] 12 | 13 | [dependencies] 14 | argtea = "1.0.0" 15 | textwrap = { version = "0.16.1", default-features = false, features = ["smawk", "unicode-linebreak"] } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Refold 2 | `refold` is a commandline tool for performing text-wrapping, similar to unix `fold`. Unlike `fold`, 3 | `refold` will recombine lines before performing line-wrapping, and it will automatically detect 4 | line prefixes. 5 | 6 | ### Comparison to `fold` 7 | 8 | | | `refold` | unix `fold` | 9 | | :-----------------: | :----------------------------------: | :---------: | 10 | | Rewrapping | Yes | No | 11 | | Line prefix support | Yes | No | 12 | | Line endings | LF and can auto detect CRLF | LF only | 13 | | Default wrapping | Soft via Unicode splittable property | Hard | 14 | | Hard wrapping | Yes | Yes | 15 | | Soft wrapping | Yes | Yes* | 16 | 17 | *: `fold` leaves trailing spaces and can only split ASCII space-separated words. 18 | 19 | ### Example: 20 | 21 | `refold --spaces --width=100`: 22 | ``` 23 | /// I'd just like to interject for a moment. What you're refering to as Linux, is in fact, GNU/Linux, 24 | /// or as I've recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the 25 | /// GNU corelibs, shell utilities 26 | /// and vital system components comprising a full OS as defined by POSIX. 27 | ``` 28 | -> 29 | ``` 30 | /// I'd just like to interject for a moment. What you're refering to as Linux, is in fact, 31 | /// GNU/Linux, or as I've recently taken to calling it, GNU plus Linux. Linux is not an operating 32 | /// system unto itself, but rather another free component of a fully functioning GNU system made 33 | /// useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as 34 | /// defined by POSIX. 35 | ``` 36 | 37 | ## Installing 38 | ### Cargo (most platforms) 39 | 1. [Install Cargo](https://www.rust-lang.org/tools/install). 40 | 2. Run `cargo install refold`. 41 | 42 | ### Binaries 43 | Binaries for `arm64` and `x86_64` can be found in 44 | [the releases section](https://github.com/wr7/refold/releases/latest) of the github. 45 | 46 | These are statically linked, so they should run on pretty much any linux distribution including 47 | NixOS and Alpine. 48 | 49 | Additionally, these binaries are packaged in the following formats: 50 | - APK 51 | - DEB 52 | - RPM 53 | 54 | ### Nix 55 | Alternatively, a Nix package can be found in the releases section. Unfortunately, `refold` will not 56 | be updated automatically when using this method. 57 | 58 | 1. Download `refold.nix` [from the `Releases` page](https://github.com/wr7/refold/releases/latest). 59 | 2. Add `(pkgs.callPackage /PATH/TO/REFOLD.NIX {})` to your `configuration.nix` file under 60 | `environment.systemPackages`, `users.users.YOUR_USERNAME.packages`, or in any other place that 61 | you can list packages. 62 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | {pkgs, lib}: 2 | 3 | pkgs.rustPlatform.buildRustPackage { 4 | pname = "refold"; 5 | version = "v0.1.2"; 6 | 7 | src = lib.fileset.toSource { 8 | root = ./.; 9 | fileset = lib.fileset.unions [./src ./Cargo.toml ./Cargo.lock]; 10 | }; 11 | 12 | cargoHash = "sha256-E7Xx1lz0OhiS5JM2ZCcaXfheCxjRDkBOUt95f256TCo="; 13 | } 14 | -------------------------------------------------------------------------------- /dist/.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | -------------------------------------------------------------------------------- /dist/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | platforms=(x86_64 arm64) 4 | package_types=(deb apk rpm) 5 | 6 | for platform in "${platforms[@]}"; do 7 | path="`nix-build --no-out-link -E '(import ./platforms.nix {}).bin'.$platform`" 8 | install "$path/bin/refold" "build/refold-$platform" 9 | 10 | for type in "${package_types[@]}"; do 11 | path="`nix-build --no-out-link -E '(import ./platforms.nix {})'.$type.$platform`" 12 | cp "$path/refold.$type" "build/refold-$platform.$type" 13 | chmod +w "build/refold-$platform.$type" 14 | done 15 | done 16 | -------------------------------------------------------------------------------- /dist/clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -f build/refold-* 3 | -------------------------------------------------------------------------------- /dist/debian.nix: -------------------------------------------------------------------------------- 1 | {pkgs 2 | ,stdenv 3 | ,lib 4 | ,writeTextFile 5 | }: 6 | {package}: 7 | 8 | let 9 | name = package.pname; 10 | control = writeTextFile { 11 | name = "control"; 12 | text = '' 13 | Package: ${name} 14 | Version: ${lib.strings.removePrefix "v" package.version}-1 15 | Architecture: any 16 | Description: A commandline utility for line wrapping 17 | ''; 18 | }; 19 | in 20 | stdenv.mkDerivation { 21 | pname = name + "-deb"; 22 | version = package.version; 23 | 24 | nativeBuildInputs = [ 25 | pkgs.dpkg 26 | ]; 27 | 28 | dontUnpack = true; 29 | 30 | buildPhase = '' 31 | mkdir -p '${name}/bin' 32 | mkdir -p '${name}/DEBIAN' 33 | 34 | install '${package}/bin/refold' '${name}/bin/refold' 35 | install '${control}' '${name}/DEBIAN/control' 36 | 37 | dpkg-deb --root-owner-group --build '${name}' 38 | ''; 39 | 40 | installPhase = '' 41 | mkdir -p "$out" 42 | cp '${name}.deb' "$out/" 43 | ''; 44 | } 45 | -------------------------------------------------------------------------------- /dist/fpm.nix: -------------------------------------------------------------------------------- 1 | { pkgs 2 | , lib 3 | , stdenv 4 | }: 5 | 6 | # The .deb package 7 | { deb_package 8 | # Either "apk" or "rpm" 9 | , type 10 | }: 11 | 12 | # Converts the .deb package into a .apk or .rpm 13 | 14 | let 15 | name = lib.strings.removeSuffix "-deb" deb_package.pname; 16 | in 17 | stdenv.mkDerivation { 18 | pname = name + "-${type}"; 19 | version = deb_package.version; 20 | 21 | nativeBuildInputs = [ 22 | pkgs.fpm 23 | pkgs.rpm 24 | ]; 25 | 26 | dontUnpack = true; 27 | 28 | buildPhase = '' 29 | mkdir -p "$out" 30 | fpm -s deb -t '${type}' -p "$out"'/${name}.${type}' '${deb_package}/${name}.deb' 31 | ''; 32 | } 33 | -------------------------------------------------------------------------------- /dist/platforms.nix: -------------------------------------------------------------------------------- 1 | {pkgs ? import {}}: 2 | 3 | let 4 | targets = { 5 | x86_64 = "x86_64-unknown-linux-musl"; 6 | arm64 = "aarch64-unknown-linux-musl"; 7 | }; 8 | fpm_types = ["rpm" "apk"]; 9 | base_packages = builtins.mapAttrs ( 10 | name: triple: 11 | let pkgs = import {crossSystem.config = triple;}; in 12 | pkgs.pkgsStatic.callPackage ../default.nix {} 13 | ) targets; 14 | deb_packages = builtins.mapAttrs ( 15 | name: triple: 16 | pkgs.callPackage ./debian.nix {} {package = builtins.getAttr name base_packages;} 17 | ) targets; 18 | fpm_packages = builtins.listToAttrs ( 19 | map ( 20 | type: 21 | { 22 | name = type; 23 | value = builtins.mapAttrs ( 24 | name: deb_package: 25 | pkgs.callPackage ./fpm.nix {} {inherit deb_package type;} 26 | ) deb_packages; 27 | } 28 | ) fpm_types 29 | ); 30 | in 31 | fpm_packages // { 32 | bin = base_packages; 33 | deb = deb_packages; 34 | } 35 | -------------------------------------------------------------------------------- /src/cmdline.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use argtea::{argtea_impl, simple_format}; 4 | use textwrap::WordSeparator; 5 | 6 | #[derive(Debug)] 7 | pub enum SplitMode { 8 | Spaces, 9 | Boundaries, 10 | Characters, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct Parameters { 15 | pub split_mode: SplitMode, 16 | pub width: usize, 17 | pub prefix: Option, 18 | } 19 | 20 | argtea_impl! { 21 | { 22 | /// Prints this help message. 23 | ("--help" | "-h") => { 24 | eprint!("{}", Self::HELP); 25 | 26 | std::process::exit(1); 27 | } 28 | 29 | /// Sets the width to wrap at (default 80). 30 | ("--width" | "-w", width) => { 31 | let width = width.ok_or("expected width")?; 32 | 33 | width_ = width 34 | .parse() 35 | .map_err(|_| format!("invalid width `\x1b[1m{width}`\x1b[m"))?; 36 | } 37 | 38 | /// Sets the prefix for each line (default: auto detect). 39 | /// 40 | /// Set to an empty string to disable prefixing entirely. 41 | ("--prefix" | "-p", prefix) => { 42 | let prefix = prefix.ok_or("expected prefix")?; 43 | 44 | prefix_ = Some(prefix); 45 | } 46 | 47 | /// Makes `refold` autodetect the prefix for each line (default). 48 | /// 49 | /// To disable, pass an empty string to the `--prefix` flag. 50 | ("--auto-prefix" | "-a", prefix) => { 51 | let prefix = prefix.ok_or("expected prefix")?; 52 | 53 | prefix_ = None; 54 | } 55 | 56 | /// Sets the split mode to "boundaries" mode (default). 57 | /// 58 | /// In boundaries mode, line wrapping may occur in-between unicode breakable 59 | /// characters. 60 | ("--boundaries" | "-b" | "--unicode-boundaries") => { 61 | split_mode = SplitMode::Boundaries; 62 | } 63 | 64 | /// Sets the split mode to "space" mode. 65 | /// 66 | /// In space mode, line wrapping may occur in-between words separated by ASCII 67 | /// spaces. 68 | ("--spaces" | "-s") => { 69 | split_mode = SplitMode::Spaces; 70 | } 71 | 72 | /// Sets the split mode to "character" mode. 73 | /// 74 | /// In character mode, line wrapping may occur in-between any two characters. 75 | ("--characters" | "-c" | "--break-words" | "--break") => { 76 | split_mode = SplitMode::Characters; 77 | } 78 | 79 | #[hidden] 80 | (invalid_flag) => { 81 | return Err(format!("invalid flag `\x1b[1m{invalid_flag}\x1b[m`").into()); 82 | } 83 | } 84 | impl Parameters { 85 | const HELP: &'static str = simple_format!( 86 | "refold: rewraps line of text" 87 | "" 88 | "Usage: refold [FLAGS...]" 89 | "" 90 | "refold reads from stdin and writes to stdout" 91 | "" 92 | "Options:" 93 | docs!() 94 | ); 95 | 96 | fn parse() -> Result> { 97 | let mut split_mode = SplitMode::Boundaries; 98 | let mut width_ = 80; 99 | let mut prefix_ = None; 100 | 101 | let mut args = std::env::args().skip(1); 102 | 103 | parse!(args); 104 | 105 | return Ok(Self { split_mode, width: width_, prefix: prefix_ }); 106 | } 107 | } 108 | } 109 | 110 | impl SplitMode { 111 | pub fn break_words(&self) -> bool { 112 | match self { 113 | SplitMode::Spaces | SplitMode::Boundaries => false, 114 | SplitMode::Characters => true, 115 | } 116 | } 117 | pub fn word_separator(&self) -> WordSeparator { 118 | match self { 119 | SplitMode::Spaces => WordSeparator::AsciiSpace, 120 | SplitMode::Boundaries | SplitMode::Characters => WordSeparator::UnicodeBreakProperties, 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use cmdline::Parameters; 2 | use textwrap::LineEnding; 3 | 4 | mod cmdline; 5 | mod refold; 6 | pub(crate) mod util; 7 | 8 | impl Parameters { 9 | fn textwrap_options<'a>( 10 | &'_ self, 11 | line_ending: &'_ mut Option, 12 | prefix: &'a str, 13 | ) -> textwrap::Options<'a> { 14 | let line_ending = if let Some(le) = *line_ending { 15 | le 16 | } else { 17 | *line_ending = Some(LineEnding::LF); 18 | LineEnding::LF 19 | }; 20 | 21 | textwrap::Options::new(self.width) 22 | .word_separator(self.split_mode.word_separator()) 23 | .break_words(self.split_mode.break_words()) 24 | .line_ending(line_ending) 25 | .initial_indent(prefix) 26 | .subsequent_indent(prefix) 27 | } 28 | } 29 | 30 | fn main() { 31 | let parameters = Parameters::parse().unwrap_or_else(|err| { 32 | eprintln!("\x1b[1;31mrefold error:\x1b[m{err}"); 33 | std::process::exit(1) 34 | }); 35 | 36 | let refolder = refold::Refolder::new(¶meters); 37 | refolder.refold(); 38 | } 39 | -------------------------------------------------------------------------------- /src/refold.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, StdoutLock, Write as _}; 2 | 3 | use textwrap::LineEnding; 4 | 5 | use crate::{cmdline::Parameters, util}; 6 | 7 | pub struct Refolder<'a> { 8 | stdin: util::Lines, 9 | stdout: StdoutLock<'static>, 10 | 11 | parameters: &'a Parameters, 12 | 13 | prefix: Option, 14 | line_ending: Option, 15 | 16 | /// Whether or not to put a trailing newline at the end of the output 17 | trailing_newline: bool, 18 | 19 | /// The accumulated text that will be wrapped when the current paragraph 20 | /// ends 21 | current_paragraph: String, 22 | /// Buffer for current line of stdin 23 | line_buf: String, 24 | } 25 | 26 | impl<'a> Refolder<'a> { 27 | pub fn new(parameters: &'a Parameters) -> Self { 28 | Self { 29 | stdin: util::Lines::new(std::io::stdin().lock()), 30 | stdout: std::io::stdout().lock(), 31 | 32 | parameters, 33 | 34 | prefix: parameters.prefix.clone(), 35 | line_ending: None, 36 | trailing_newline: false, 37 | 38 | current_paragraph: String::new(), 39 | line_buf: String::new(), 40 | } 41 | } 42 | 43 | fn detect_prefix(&mut self) { 44 | if self.prefix.is_some() || self.line_buf.is_empty() { 45 | return; 46 | } 47 | 48 | let mut prefix_len = 0; 49 | 50 | for (i, char) in self.line_buf.char_indices() { 51 | if char.is_ascii_punctuation() | char.is_ascii_whitespace() { 52 | prefix_len = i + 1; 53 | } else { 54 | break; 55 | } 56 | } 57 | 58 | self.prefix = Some(self.line_buf[..prefix_len].to_owned()); 59 | } 60 | 61 | pub fn refold(mut self) { 62 | loop { 63 | self.stdin.set_buf(self.line_buf); 64 | 65 | self.line_buf = self 66 | .stdin 67 | .next() 68 | .transpose() 69 | .unwrap_or_else(|err| { 70 | eprintln!("refold: failed to read from stdin: {err}"); 71 | 72 | std::process::exit(err.raw_os_error().unwrap_or(1)); 73 | }) 74 | .unwrap_or_default(); 75 | 76 | let mut eof = true; 77 | 78 | if self.line_buf.ends_with('\n') { 79 | eof = false; 80 | self.line_buf.pop(); 81 | 82 | if self.line_buf.ends_with('\r') { 83 | self.line_ending = Some(LineEnding::CRLF); 84 | self.line_buf.pop(); 85 | } else { 86 | self.line_ending = Some(LineEnding::LF); 87 | } 88 | } 89 | 90 | self.detect_prefix(); 91 | 92 | let line = self 93 | .prefix 94 | .as_deref() 95 | .and_then(|prefix| self.line_buf.strip_prefix(prefix)) 96 | .unwrap_or(&self.line_buf); 97 | 98 | if !line.is_empty() { 99 | self.current_paragraph += line; 100 | 101 | if !eof { 102 | self.current_paragraph += " "; 103 | } 104 | 105 | self.trailing_newline = !eof; 106 | 107 | continue; 108 | } 109 | 110 | self.wrap_paragraph(eof).unwrap_or_else(|err| { 111 | eprintln!("refold: failed to write to stdout: {err}"); 112 | 113 | std::process::exit(err.raw_os_error().unwrap_or(1)); 114 | }); 115 | 116 | if eof { 117 | break; 118 | } 119 | } 120 | } 121 | 122 | fn wrap_paragraph(&mut self, eof: bool) -> io::Result<()> { 123 | self.stdout.write_all( 124 | textwrap::fill( 125 | &self.current_paragraph, 126 | self.parameters 127 | .textwrap_options(&mut self.line_ending, self.prefix.as_deref().unwrap_or("")), 128 | ) 129 | .as_bytes(), 130 | )?; 131 | 132 | if eof { 133 | if self.trailing_newline { 134 | if self.line_ending == Some(LineEnding::CRLF) { 135 | self.stdout.write_all(b"\r\n")?; 136 | } else { 137 | self.stdout.write_all(b"\n")?; 138 | } 139 | } 140 | 141 | return Ok(()); 142 | } 143 | 144 | self.trailing_newline = false; 145 | 146 | if self.line_ending == Some(LineEnding::CRLF) { 147 | self.stdout.write_all(b"\r\n")?; 148 | self.stdout 149 | .write_all(self.prefix.as_deref().unwrap_or("").trim_end().as_bytes())?; 150 | self.stdout.write_all(b"\r\n")?; 151 | } else { 152 | self.stdout.write_all(b"\n")?; 153 | self.stdout 154 | .write_all(self.prefix.as_deref().unwrap_or("").trim_end().as_bytes())?; 155 | self.stdout.write_all(b"\n")?; 156 | } 157 | 158 | self.current_paragraph.clear(); 159 | 160 | Ok(()) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, BufRead as _, StdinLock}, 3 | mem, 4 | }; 5 | 6 | /// Iterator of lines in stdin. 7 | /// 8 | /// This differs from the version in `std` in two ways: 9 | /// - the `\n` and `\r\n` are not removed from the end 10 | /// - if there are >= 2 trailing newlines, one is ignored 11 | /// 12 | pub struct Lines { 13 | stdin: StdinLock<'static>, 14 | buf: String, 15 | next_line: io::Result, 16 | } 17 | 18 | impl Lines { 19 | pub fn new(mut stdin: StdinLock<'static>) -> Self { 20 | let mut next_line_buf = String::new(); 21 | let next_line = stdin.read_line(&mut next_line_buf).map(|_| next_line_buf); 22 | 23 | Self { 24 | stdin, 25 | buf: String::new(), 26 | next_line, 27 | } 28 | } 29 | 30 | pub fn set_buf(&mut self, mut buf: String) -> String { 31 | self.buf.clear(); 32 | mem::swap(&mut self.buf, &mut buf); 33 | buf 34 | } 35 | } 36 | 37 | impl Iterator for Lines { 38 | type Item = io::Result; 39 | 40 | fn next(&mut self) -> Option { 41 | let mut buf: String = self.set_buf(String::new()); 42 | 43 | let mut line = self.stdin.read_line(&mut buf).map(|_| buf); 44 | std::mem::swap(&mut self.next_line, &mut line); 45 | 46 | let line: String = match line { 47 | Ok(l) => l, 48 | Err(e) => return Some(Err(e)), 49 | }; 50 | 51 | // Ignore last newline if there are >= 2 trailing newlines 52 | if (&line == "\n" || &line == "\r\n") && self.next_line.as_ref().is_ok_and(String::is_empty) 53 | { 54 | return None; 55 | } 56 | 57 | if line.is_empty() { 58 | None 59 | } else { 60 | Some(Ok(line)) 61 | } 62 | } 63 | } 64 | --------------------------------------------------------------------------------