argtokens 0.5.0

Command-line argument parser, supporting POSIX+GNU syntax and no-std, no-alloc usage
Documentation
// Copyright (C) 2025  Johannes Sasongko <johannes sasongko org>
// SPDX-License-Identifier: MPL-2.0
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Contains implementation details related to argument strings.
//!
//! This is only useful if you want to create a new [`Argument`] implementation.

#[cfg(feature = "std")]
use std::ffi::OsStr;

/// Error returned by [`Argument::split_short_option`] if the first character is
/// not ASCII.
#[derive(Debug, PartialEq)]
pub struct NotAscii;

/// Argument string.
///
/// There are implementations of this for `&[u8]`, `&[u16]`, `&OsStr`, and
/// `&str` depending on the feature flags enabled.
pub trait Argument: Copy {
	/// The type for a long option.
	///
	/// This is usually just `Self`, except when a more convenient alternative
	/// is available.
	type LongOpt;

	/// Returns whether the argument is an empty string.
	#[must_use]
	fn is_empty(self) -> bool;

	/// Tries to parse an argument as a long option.
	///
	/// For example, `--opt=val` is parsed as `Some(("opt", Some("val")))`.
	#[must_use]
	fn parse_long_option(self) -> Option<(Self::LongOpt, Option<Self>)>;

	/// Tries to parse an argument as a short option group.
	///
	/// For example, `-abc` is parsed as `Some("abc")`.
	#[must_use]
	fn parse_short_option_group(self) -> Option<Self>;

	/// Splits an argument at the first character, checking that it is ASCII.
	#[must_use]
	fn split_short_option(self) -> Option<Result<(u8, Self), NotAscii>>;
}

impl Argument for &[u8] {
	type LongOpt = Self;

	#[inline]
	fn is_empty(self) -> bool {
		self.is_empty()
	}

	#[inline]
	fn parse_long_option(self) -> Option<(Self::LongOpt, Option<Self>)> {
		match self {
			[b'-', b'-', rest @ ..] if !rest.is_empty() => {
				// Waiting for slice::split_once
				// <https://github.com/rust-lang/rust/issues/112811>.
				let mut split = rest.splitn(2, |&ch| ch == b'=');
				// splitn(n>0) has at least one item.
				Some((split.next().unwrap(), split.next()))
			}
			_ => None,
		}
	}

	#[inline]
	fn parse_short_option_group(self) -> Option<Self> {
		match self {
			[b'-', rest @ ..] if !matches!(rest, [] | [b'-', ..]) => Some(rest),
			_ => None,
		}
	}

	#[inline]
	fn split_short_option(self) -> Option<Result<(u8, Self), NotAscii>> {
		self.split_first().map(|(&first, rest)| {
			if first.is_ascii() {
				Ok((first, rest))
			} else {
				Err(NotAscii)
			}
		})
	}
}

impl Argument for &[u16] {
	type LongOpt = Self;

	#[inline]
	fn is_empty(self) -> bool {
		self.is_empty()
	}

	#[inline]
	fn parse_long_option(self) -> Option<(Self::LongOpt, Option<Self>)> {
		const DASH: u16 = b'-' as u16;
		match self {
			[DASH, DASH, rest @ ..] if !rest.is_empty() => {
				// Waiting for slice::split_once
				// <https://github.com/rust-lang/rust/issues/112811>.
				let mut split = rest.splitn(2, |&ch| ch == u16::from(b'='));
				// splitn(n>0) has at least one item.
				Some((split.next().unwrap(), split.next()))
			}
			_ => None,
		}
	}

	#[inline]
	fn parse_short_option_group(self) -> Option<Self> {
		const DASH: u16 = b'-' as u16;
		match self {
			[DASH, rest @ ..] if !matches!(rest, [] | [DASH, ..]) => Some(rest),
			_ => None,
		}
	}

	#[inline]
	fn split_short_option(self) -> Option<Result<(u8, Self), NotAscii>> {
		self.split_first().map(|(&first, rest)| {
			if let Some(first) = u8::try_from(first).ok().filter(u8::is_ascii) {
				Ok((first, rest))
			} else {
				Err(NotAscii)
			}
		})
	}
}

#[cfg(feature = "std")]
impl<'a> Argument for &'a OsStr {
	type LongOpt = &'a [u8];

	#[inline]
	fn is_empty(self) -> bool {
		self.is_empty()
	}

	#[inline]
	fn parse_long_option(self) -> Option<(Self::LongOpt, Option<Self>)> {
		match self.as_encoded_bytes() {
			[b'-', b'-', rest @ ..] if !rest.is_empty() => {
				// Waiting for slice::split_once
				// <https://github.com/rust-lang/rust/issues/112811>.
				let mut split = rest.splitn(2, |&ch| ch == b'=');
				// splitn(n>0) has at least one item.
				let opt = split.next().unwrap();
				let value = split.next();
				Some((
					opt,
					// SAFETY: OsStr can be split safely before or after valid UTF-8
					// sequence ("=").
					value.map(|s| unsafe { OsStr::from_encoded_bytes_unchecked(s) }),
				))
			}
			_ => None,
		}
	}

	#[inline]
	fn parse_short_option_group(self) -> Option<Self> {
		match self.as_encoded_bytes() {
			[b'-', rest @ ..] if !matches!(rest, [] | [b'-', ..]) => {
				// SAFETY: OsStr can be split safely before or after valid UTF-8
				// sequence ("-").
				Some(unsafe { OsStr::from_encoded_bytes_unchecked(rest) })
			}
			_ => None,
		}
	}

	#[inline]
	fn split_short_option(self) -> Option<Result<(u8, Self), NotAscii>> {
		self.as_encoded_bytes().split_first().map(|(&first, rest)| {
			if first.is_ascii() {
				// SAFETY: OsStr can be split safely before or after valid UTF-8
				// sequence (`first` is ASCII).
				Ok((first, unsafe { OsStr::from_encoded_bytes_unchecked(rest) }))
			} else {
				Err(NotAscii)
			}
		})
	}
}

#[cfg(feature = "str")]
impl Argument for &str {
	type LongOpt = Self;

	#[inline]
	fn is_empty(self) -> bool {
		self.is_empty()
	}

	#[inline]
	fn parse_long_option(self) -> Option<(Self::LongOpt, Option<Self>)> {
		self.strip_prefix("--")
			.filter(|rest| !rest.is_empty())
			.map(|rest| match rest.split_once('=') {
				Some((opt, value)) => (opt, Some(value)),
				None => (rest, None),
			})
	}

	#[inline]
	fn parse_short_option_group(self) -> Option<Self> {
		self.strip_prefix('-')
			.filter(|rest| !rest.is_empty() && !rest.starts_with('-'))
	}

	#[inline]
	fn split_short_option(self) -> Option<Result<(u8, Self), NotAscii>> {
		let mut chars = self.chars();
		chars.next().map(|first| {
			if first.is_ascii() {
				Ok((first as u8, chars.as_str()))
			} else {
				Err(NotAscii)
			}
		})
	}
}