linsel 0.1.0-alpha.1

A small program to print out selected lines of a file
Documentation
// SPDX-FileCopyrightText: 2026 David Zaslavsky <diazona@ellipsix.net>
//
// SPDX-License-Identifier: GPL-3.0-or-later

/// This module defines the `Range` class, which represents a finite arithmetic
/// sequence of integers. Unlike the various `range`s in the standard library,
/// this one allows for a step size.
use std::num;
use std::str::FromStr;

pub type RangeElement = usize;

/// Errors that can occur when constructing a `Range`.
#[derive(Debug, PartialEq)]
pub enum CreateRangeError {
    /// The `step` value is zero.
    NonPositiveStep(RangeElement),
    /// The `end` value is less than the `start` value.
    InvertedBounds(RangeElement, RangeElement),
}

/// A finite arithmetic sequence defined by a start, end, and step size.
///
/// `Range` does not implement `Iterable` because this program has no need to
/// iterate over it. The only feature needed is testing whether a given number
/// (a line number) is an element of the range, which will be the case for every
/// `step`th number starting from `start` up to `end`, inclusive.
///
/// # Examples
///
/// ```
/// use linsel::Range;
/// let range = Range::new(3, 9, 2).unwrap();
/// let selected: Vec<usize> = (0..10).filter(|x| range.contains(*x)).collect();
/// assert_eq!(selected, [3, 5, 7, 9]);
/// ```
#[derive(Debug, PartialEq)]
pub struct Range {
    start: RangeElement,
    end: RangeElement,
    step: RangeElement, // Make this the same type to avoid the inconvenience of casting
}

impl Range {
    /// Create a new `Range`.
    ///
    /// # Errors
    ///
    /// - `CreateRangeError::InvertedBounds(start, end)` if `start > end`
    /// - `CreateRangeError::NonPositiveStep(step)` if `step == 0`
    pub fn new(
        start: RangeElement,
        end: RangeElement,
        step: RangeElement,
    ) -> Result<Self, CreateRangeError> {
        if step < 1 {
            Err(CreateRangeError::NonPositiveStep(step))
        }
        else if start > end {
            Err(CreateRangeError::InvertedBounds(start, end))
        }
        else {
            // The step doesn't matter if start == end so we normalize it to 1.
            if start == end {
                Ok(Self { start, end, step: 1 })
            }
            else {
                Ok(Self { start, end, step })
            }
        }
    }

    /// Return whether the input value is one of the elements in this range.
    ///
    /// # Examples
    ///
    /// ```
    /// use linsel::Range;
    /// let range = Range::new(3, 9, 2).unwrap();
    /// assert!(range.contains(3));
    /// assert!(range.contains(5));
    /// assert!(!range.contains(6));
    /// ```
    pub fn contains(&self, i: RangeElement) -> bool {
        i >= self.start && i <= self.end && (i - self.start).is_multiple_of(self.step)
    }
}

// It might be interesting to make `Range` implement `IntoIterator`, but that is
// not useful for this program.

/// Errors that can occur when parsing a `Range`.
#[derive(Debug, PartialEq)]
pub enum ParseRangeError {
    /// The string being parsed is empty.
    EmptyString,
    /// The string being parsed doesn't fit the expected format for a `Range`,
    /// for some reason that can't be more precisely specified.
    RangeSyntaxError,
    /// One of the numeric components of the range could not be parsed as an
    /// integer.
    ParseIntError(num::ParseIntError),
    /// The values for the range do not meet the appropriate criteria.
    CreateRangeError(CreateRangeError),
}

impl From<num::ParseIntError> for ParseRangeError {
    fn from(error: num::ParseIntError) -> Self {
        ParseRangeError::ParseIntError(error)
    }
}

impl From<CreateRangeError> for ParseRangeError {
    fn from(error: CreateRangeError) -> Self {
        ParseRangeError::CreateRangeError(error)
    }
}

/// Parse a string into `RangeElement` but return a default if the string is
/// empty.
fn parse_or_default_if_empty(
    s: &str,
    default_if_empty: RangeElement,
) -> Result<RangeElement, num::ParseIntError> {
    if s.is_empty() {
        Ok(default_if_empty)
    }
    else {
        s.parse::<RangeElement>()
    }
}

impl FromStr for Range {
    type Err = ParseRangeError;

    /// Parse a `Range` from a string.
    ///
    /// # Syntax
    ///
    /// The expected syntax for a string to be parsed into a `Range` is one of
    /// these:
    ///
    /// - `<number>`
    /// - `<number>-<number>`; one of the numbers may be omitted
    /// - `<number>-<number>/<positive number>`; either of the first two numbers
    ///   may be omitted
    ///
    /// The hyphen may alternatively be replaced with a colon. Each `<number>`
    /// represents something that can be parsed as an integer, and
    /// `<positive number>` is something that can be parsed as a positive
    /// integer (strictly greater than zero). No whitespace is allowed.
    ///
    /// # Examples
    ///
    /// ```
    /// use linsel::{Range, RangeElement};
    /// fn check(s: &str, start: RangeElement, end: RangeElement, step: RangeElement) {
    ///     assert_eq!(s.parse::<Range>().unwrap(), Range::new(start, end, step).unwrap());
    /// }
    /// check("1", 1, 1, 1);
    /// check("1-10", 1, 10, 1);
    /// check("1-", 1, RangeElement::MAX, 1);
    /// check("-10", RangeElement::MIN, 10, 1);
    /// check("1:10", 1, 10, 1);
    /// check("1-10/2", 1, 10, 2);
    /// check("1-/2", 1, RangeElement::MAX, 2);
    /// check("-10/2", RangeElement::MIN, 10, 2);
    /// check("1:10/2", 1, 10, 2);
    /// ```
    ///
    /// # Errors
    ///
    /// - `ParseRangeError::EmptyString` if the string is empty
    /// - `ParseRangeError::RangeSyntaxError` if the string did not match any of
    ///   the patterns listed above
    /// - `ParseRangeError::ParseIntError` if any of the things that should have
    ///   been a number could not be parsed as an integer
    /// - `ParseRangeError::CreateRange` if `Range::new()` returned an error
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s.is_empty() {
            return Err(ParseRangeError::EmptyString);
        }
        let bounds_and_step: (&str, RangeElement) = match s.split_once('/') {
            Some((bs, ss)) => (bs, ss.parse::<RangeElement>()?),
            None => (s, 1),
        };
        let bounds_str = bounds_and_step.0;
        let (start_str, end_str) = match bounds_str.split_once([':', '-']) {
            Some(t) => t,
            None => (bounds_str, bounds_str),
        };
        let start = parse_or_default_if_empty(start_str, RangeElement::MIN)?;
        let end = parse_or_default_if_empty(end_str, RangeElement::MAX)?;
        Ok(Range::new(start, end, bounds_and_step.1)?)
    }
}