Skip to content

Sort Lines & Remove Duplicates — Rust source

Alphabetize, reverse, shuffle, dedupe, or length-sort lines of text. Supports case-insensitive and natural sorting (file2 before file10).

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

// sort-lines — Rust polyglot showcase port.
//
// Pure line-sorting logic — deterministic, stdlib-only. Splits on newlines
// and applies the requested operation. Never panics.
//
// CosmoDev polyglot showcase port of sort-lines, ported from
// src/lib/sortLines.ts. Display source — part of CosmoDev's polyglot
// tool pages.

use std::cmp::Ordering;
use std::collections::HashSet;

/// Selects the line operation to apply.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SortMode {
    Asc,
    Desc,
    LengthAsc,
    LengthDesc,
    Reverse,
    Shuffle,
    Unique,
}

/// Optional tweaks to comparison and shuffle behaviour. `None` means "use the
/// default", mirroring the TS optional fields (omitted !== false).
#[derive(Default)]
pub struct SortOptions {
    pub case_sensitive: Option<bool>, // default true
    pub trim: Option<bool>,            // default false
    pub natural: Option<bool>,         // default false
    pub seed: Option<u32>,             // default 1
}

/// The output of [`sort_lines`].
pub struct SortResult {
    pub lines: Vec<String>,
    pub text: String,
    pub removed_duplicates: usize,
}

/// mulberry32 — a small deterministic PRNG returning floats in [0, 1).
///
/// Deterministic by design (not cryptographic): a given seed reproduces the
/// same shuffle. All arithmetic uses wrapping 32-bit ops; `>>` on `u32` is the
/// unsigned shift, so the bitstream matches JS's bitwise reference exactly.
pub fn mulberry32(seed: u32) -> impl FnMut() -> f64 {
    let mut a = seed;
    move || {
        a = a.wrapping_add(0x6d2b79f5);
        let mut t = (a ^ (a >> 15)).wrapping_mul(1 | a);
        t = (t.wrapping_add((t ^ (t >> 7)).wrapping_mul(61 | t))) ^ t;
        f64::from(t ^ (t >> 14)) / 4_294_967_296.0
    }
}

/// Splits `s` into alternating runs of ASCII digits and non-digits — the
/// basis of natural-order comparison.
///
/// Walks byte-by-byte, grouping consecutive bytes with the same digit-ness.
/// Because ASCII digits (0x30..=0x39) are always single-byte leading bytes in
/// UTF-8, slice boundaries always land on char boundaries, so the returned
/// `&str` slices are always valid.
fn digit_chunks(s: &str) -> Vec<&str> {
    let bytes = s.as_bytes();
    let mut out = Vec::new();
    let mut start = 0;
    while start < bytes.len() {
        let cur_is_digit = bytes[start].is_ascii_digit();
        let mut i = start;
        while i < bytes.len() && bytes[i].is_ascii_digit() == cur_is_digit {
            i += 1;
        }
        out.push(&s[start..i]);
        start = i;
    }
    out
}

/// Reports whether `s` begins with an ASCII digit. Empty strings return false
/// (mirrors `/^\d/.test('') === false`), which matters for the empty-chunk
/// fallback path.
fn starts_with_digit(s: &str) -> bool {
    match s.as_bytes().first() {
        Some(&b) => b.is_ascii_digit(),
        None => false,
    }
}

/// Natural-order comparator: numeric chunks compare by value so "file2" sorts
/// before "file10" instead of lexicographically.
fn natural_compare(a: &str, b: &str, case_sensitive: bool) -> Ordering {
    // Build the normalised views once and borrow the cheaper representation.
    let owned_a;
    let owned_b;
    let ax: &str = if case_sensitive {
        a
    } else {
        owned_a = a.to_lowercase();
        &owned_a
    };
    let bx: &str = if case_sensitive {
        b
    } else {
        owned_b = b.to_lowercase();
        &owned_b
    };

    let mut aa = digit_chunks(ax);
    let mut bb = digit_chunks(bx);
    if aa.is_empty() {
        aa.push(ax);
    }
    if bb.is_empty() {
        bb.push(bx);
    }

    let n = aa.len().min(bb.len());
    for i in 0..n {
        let an = starts_with_digit(aa[i]);
        let bn = starts_with_digit(bb[i]);
        if an != bn {
            // A digit-run vs a text-run: compare as raw strings.
            return aa[i].cmp(bb[i]);
        }
        if an {
            // Chunks are pure ASCII digits, so parse never fails.
            let av: i64 = aa[i].parse().unwrap_or(0);
            let bv: i64 = bb[i].parse().unwrap_or(0);
            match av.cmp(&bv) {
                Ordering::Equal => {}
                other => return other,
            }
        } else if aa[i] != bb[i] {
            return aa[i].cmp(bb[i]);
        }
    }
    // All compared chunks equal: the shorter run-list wins.
    aa.len().cmp(&bb.len())
}

/// Character count (Unicode codepoints). Matches the TS `.length` for BMP
/// characters; used for length-based modes.
fn char_len(s: &str) -> usize {
    s.chars().count()
}

/// Apply `mode` to the lines of `input` and return the result. All non-unique
/// modes return `removed_duplicates == 0`.
pub fn sort_lines(input: &str, mode: SortMode, opts: &SortOptions) -> SortResult {
    let case_sensitive = opts.case_sensitive.unwrap_or(true);
    let should_trim = opts.trim.unwrap_or(false);
    let natural = opts.natural.unwrap_or(false);
    let seed = opts.seed.unwrap_or(1);

    // "".split('\n') yields [""] — one empty line — and the rest degrades.
    let mut lines: Vec<String> = input.split('\n').map(String::from).collect();
    if should_trim {
        for l in lines.iter_mut() {
            *l = String::from(l.trim());
        }
    }

    let norm = |s: &str| -> String {
        if case_sensitive {
            s.to_owned()
        } else {
            s.to_lowercase()
        }
    };

    let mut removed_duplicates = 0usize;

    match mode {
        SortMode::Unique => {
            // Keep first occurrence of each normalised line; count the rest.
            let mut seen: HashSet<String> = HashSet::with_capacity(lines.len());
            let mut out: Vec<String> = Vec::with_capacity(lines.len());
            for l in lines.drain(..) {
                let key = norm(&l);
                if !seen.insert(key) {
                    removed_duplicates += 1;
                } else {
                    out.push(l);
                }
            }
            lines = out;
        }

        SortMode::Shuffle => {
            // Fisher-Yates with the seeded PRNG -> reproducible ordering.
            let mut rng = mulberry32(seed);
            let len = lines.len();
            for i in (1..len).rev() {
                let j = (rng() * (i as f64 + 1.0)) as usize;
                lines.swap(i, j);
            }
        }

        SortMode::Reverse => {
            lines.reverse();
        }

        SortMode::LengthAsc | SortMode::LengthDesc => {
            // sort_by is stable, so equal lengths keep input order — equivalent
            // to the TS decorate-by-index step.
            lines.sort_by(|a, b| char_len(a).cmp(&char_len(b)));
            if mode == SortMode::LengthDesc {
                lines.reverse();
            }
        }

        _ => {
            // Asc / Desc. Direction is folded into the comparator, so equal
            // elements keep their input order in BOTH directions (stable).
            let reverse = mode == SortMode::Desc;
            if natural {
                // Natural compare isn't expressible as a plain sort key, so
                // go through Ordering directly; stability handles ties.
                lines.sort_by(|a, b| {
                    let c = natural_compare(a, b, case_sensitive);
                    if reverse {
                        c.reverse()
                    } else {
                        c
                    }
                });
            } else {
                // Codepoint ordering (Rust str::cmp) matches JS localeCompare's
                // default for ASCII input.
                lines.sort_by(|a, b| {
                    let c = norm(a).cmp(&norm(b));
                    if reverse {
                        c.reverse()
                    } else {
                        c
                    }
                });
            }
        }
    }

    let text = lines.join("\n");
    SortResult {
        lines,
        text,
        removed_duplicates,
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →