Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 124 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
mod structures;

use crate::structures::{flatten_ranges, Part, RangeOutput};
use combine::parser::token::satisfy;
use combine::{
attempt, between, choice, eof,
error::{ParseError, StreamError},
Expand Down Expand Up @@ -66,7 +67,7 @@ where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
many1(alpha_num().or(dash()).or(token('.')))
many1(alpha_num().or(dash()).or(token('.')).or(token(':')))
}

fn digits<I>() -> impl Parser<I, Output = String>
Expand All @@ -77,6 +78,14 @@ where
many1(digit())
}

fn hex_digits<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
many1(satisfy(|c: char| c.is_ascii_hexdigit()))
}

fn leading_zeros<I>() -> impl Parser<I, Output = (usize, u64)>
where
I: Stream<Token = char>,
Expand All @@ -95,6 +104,28 @@ where
})
}

fn leading_hex<I>() -> impl Parser<I, Output = (usize, u64, bool)>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
hex_digits().and_then(|x| {
let mut digits = x.chars().take_while(|x| x == &'0').count();

if x.len() == digits {
digits -= 1;
}

let has_alpha = x
.chars()
.any(|c| c.is_ascii_hexdigit() && !c.is_ascii_digit());

u64::from_str_radix(&x, 16)
.map(|num| (digits, num, has_alpha))
.map_err(StreamErrorFor::<I>::other)
})
}

fn range_digits<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
Expand Down Expand Up @@ -135,6 +166,49 @@ where
})
}

fn range_hex<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
attempt((
leading_hex(),
optional_spaces().with(dash()),
optional_spaces().with(leading_hex()),
))
.and_then(|((start_zeros, start, a1), _, (end_zeros, end, a2))| {
if !(a1 || a2) {
return Err(StreamErrorFor::<I>::unexpected_static_message("not hex"));
}
let mut xs = [start, end];
xs.sort_unstable();

let same_prefix_len = start_zeros == end_zeros;

let (range, start_zeros, end_zeros) = if start > end {
(
RangeOutput::HexRangeReversed(end_zeros, same_prefix_len, end, start),
end_zeros,
start_zeros,
)
} else {
(
RangeOutput::HexRange(start_zeros, same_prefix_len, start, end),
start_zeros,
end_zeros,
)
};

if end_zeros > start_zeros {
Err(StreamErrorFor::<I>::unexpected_static_message(
"larger end padding",
))
} else {
Ok(range)
}
})
}

fn disjoint_digits<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
Expand All @@ -157,6 +231,36 @@ where
.map(RangeOutput::Disjoint)
}

fn disjoint_hex<I>() -> impl Parser<I, Output = RangeOutput>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
let not_name = not_followed_by(
optional_spaces()
.with(hex_digits())
.skip(optional_spaces())
.skip(dash())
.map(|_| ""),
);

sep_by1(
optional_spaces()
.with(leading_hex())
.skip(optional_spaces()),
attempt(comma().skip(not_name)),
)
.and_then(|xs: Vec<(usize, u64, bool)>| {
if xs.iter().any(|(_, _, a)| *a) {
Ok(RangeOutput::HexDisjoint(
xs.into_iter().map(|(z, n, _)| (z, n)).collect(),
))
} else {
Err(StreamErrorFor::<I>::unexpected_static_message("not hex"))
}
})
}

fn range<I>() -> impl Parser<I, Output = Vec<RangeOutput>>
where
I: Stream<Token = char>,
Expand All @@ -165,7 +269,13 @@ where
between(
open_bracket(),
close_bracket(),
sep_by1(range_digits().or(disjoint_digits()), comma()),
sep_by1(
attempt(range_hex())
.or(range_digits())
.or(attempt(disjoint_hex()))
.or(attempt(disjoint_digits())),
comma(),
),
)
}

Expand Down Expand Up @@ -492,4 +602,16 @@ mod tests {
fn test_parse_osts() {
assert_debug_snapshot!("Leading 0s", parse("OST01[00,01]"));
}

#[test]
fn test_parse_ip_addresses() {
assert_debug_snapshot!("IPv4 single", parse("192.168.0.1"));
assert_debug_snapshot!("IPv6 compressed", parse("2001:db8::1"));
assert_debug_snapshot!(
"IPv6 full",
parse("fe80:1234:5678:9abc:def0:1234:5678:9abc")
);
assert_debug_snapshot!("Multiple IPv6 literals", parse("2001:db8::1, 2001:db8::2"));
assert_debug_snapshot!("IPv6 expansion", parse("2001:db8::[0-f]"));
}
Comment on lines +645 to +662
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new hex parsing functions (leading_hex, range_hex, disjoint_hex) lack unit tests similar to their decimal equivalents (test_leading_zeros, test_range_digits, test_disjoint_digits). While integration tests are provided, unit tests would help ensure each parser component works correctly in isolation and make debugging easier if issues arise. Consider adding unit tests that directly test these functions.

Copilot uses AI. Check for mistakes.
Comment on lines +645 to +662
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite doesn't include a test case for hex disjoint ranges (e.g., parse("2001:db8::[a,b,c]")). While the "IPv6 expansion" test covers hex ranges with the dash operator, adding a test for comma-separated hex values would provide more complete coverage of the disjoint_hex parser functionality.

Copilot uses AI. Check for mistakes.
Comment on lines +645 to +662
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite doesn't include a test case for reversed hex ranges (e.g., parse("2001:db8::[f-0]")). While reversed decimal ranges are tested (line 466), adding a test for reversed hex ranges would provide more complete coverage of the HexRangeReversed functionality.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Err(
'.',
),
),
Expected(
Token(
':',
),
),
Unexpected(
Token(
'h',
Expand Down
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 literal.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.1\")"
---
Ok(
[
"192.168.0.1",
],
)
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 single.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.1\")"
---
Ok(
[
"192.168.0.1",
],
)
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv6 compressed.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"2001:db8::1\")"
---
Ok(
[
"2001:db8::1",
],
)
24 changes: 24 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv6 expansion.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: src/lib.rs
expression: "parse(\"2001:db8::[0-f]\")"
---
Ok(
[
"2001:db8::0",
"2001:db8::1",
"2001:db8::2",
"2001:db8::3",
"2001:db8::4",
"2001:db8::5",
"2001:db8::6",
"2001:db8::7",
"2001:db8::8",
"2001:db8::9",
"2001:db8::a",
"2001:db8::b",
"2001:db8::c",
"2001:db8::d",
"2001:db8::e",
"2001:db8::f",
],
)
9 changes: 9 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv6 full.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"fe80:1234:5678:9abc:def0:1234:5678:9abc\")"
---
Ok(
[
"fe80:1234:5678:9abc:def0:1234:5678:9abc",
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: src/lib.rs
expression: "parse(\"fe80::1\")"
---
Ok(
[
"fe80::1",
],
)
10 changes: 10 additions & 0 deletions src/snapshots/hostlist_parser__tests__Multiple IPv6 literals.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: src/lib.rs
expression: "parse(\"2001:db8::1, 2001:db8::2\")"
---
Ok(
[
"2001:db8::1",
"2001:db8::2",
],
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ Err(
"digit",
),
),
Unexpected(
Token(
'1',
),
),
],
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ Err(
"digit",
),
),
Unexpected(
Token(
'1',
),
),
],
},
)
42 changes: 42 additions & 0 deletions src/structures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ pub(crate) enum RangeOutput {
Range(usize, bool, u64, u64),
RangeReversed(usize, bool, u64, u64),
Disjoint(Vec<(usize, u64)>),
HexRange(usize, bool, u64, u64),
HexRangeReversed(usize, bool, u64, u64),
HexDisjoint(Vec<(usize, u64)>),
}

impl RangeOutput {
Expand All @@ -25,13 +28,28 @@ impl RangeOutput {
RangeOutput::Disjoint(xs) => {
RangeOutputIter::Internal(Box::new(xs.clone().into_iter()))
}
RangeOutput::HexRange(prefix, same_prefix_len, start, end) => {
RangeOutputIter::ExternalHex(*prefix, *same_prefix_len, Box::new(*start..=*end))
}
RangeOutput::HexRangeReversed(prefix, same_prefix_len, end, start) => {
RangeOutputIter::ExternalHex(
*prefix,
*same_prefix_len,
Box::new((*end..=*start).rev()),
)
}
RangeOutput::HexDisjoint(xs) => {
RangeOutputIter::InternalHex(Box::new(xs.clone().into_iter()))
}
}
}
}

pub(crate) enum RangeOutputIter {
External(usize, bool, Box<dyn Iterator<Item = u64>>),
Internal(Box<dyn Iterator<Item = (usize, u64)>>),
ExternalHex(usize, bool, Box<dyn Iterator<Item = u64>>),
InternalHex(Box<dyn Iterator<Item = (usize, u64)>>),
}

impl Iterator for RangeOutputIter {
Expand All @@ -45,6 +63,12 @@ impl Iterator for RangeOutputIter {
RangeOutputIter::Internal(xs) => xs
.next()
.map(|(prefix, x)| format_num_prefix(x, prefix, true)),
RangeOutputIter::ExternalHex(prefix, same_prefix_len, xs) => xs
.next()
.map(|x| format_hex_prefix(x, *prefix, *same_prefix_len)),
RangeOutputIter::InternalHex(xs) => xs
.next()
.map(|(prefix, x)| format_hex_prefix(x, prefix, true)),
}
}
}
Expand All @@ -59,6 +83,24 @@ pub(crate) fn format_num_prefix(num: u64, prefix: usize, same_prefix_len: bool)
format!("{num:0>width$}")
}

pub(crate) fn format_hex_prefix(num: u64, prefix: usize, same_prefix_len: bool) -> String {
let s = format!("{:x}", num);
let width = if same_prefix_len {
prefix + s.len()
} else {
prefix + 1
};

if width <= s.len() {
s
} else {
let mut out = String::with_capacity(width);
out.extend(std::iter::repeat_n('0', width - s.len()));
out.push_str(&s);
out
}
}
Comment on lines +103 to +119
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite in structures.rs only tests the decimal range variants (Range, Disjoint) but doesn't test the new hex variants (HexRange, HexRangeReversed, HexDisjoint). Consider adding tests similar to test_range_output_range_iter and test_range_output_disjoint_iter for the hex variants to ensure the iterator implementation and formatting work correctly.

Copilot uses AI. Check for mistakes.

#[derive(Debug, Clone)]
pub(crate) enum Part {
String(String),
Expand Down