Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
192 changes: 182 additions & 10 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@

mod structures;

use crate::structures::{flatten_ranges, Part, RangeOutput};
use crate::structures::{Part, RangeOutput, flatten_ranges};
use combine::{
attempt, between, choice, eof,
Parser, attempt, between, choice, eof,
error::{ParseError, StreamError},
many1, not_followed_by, optional,
parser::{
EasyParser,
char::{alpha_num, digit, spaces},
combinator::ignore,
repeat::repeat_until,
EasyParser,
token::satisfy,
},
sep_by1,
stream::{Stream, StreamErrorFor},
token, Parser,
token,
};
use itertools::Itertools as _;

Expand Down Expand Up @@ -69,6 +70,14 @@ where
many1(alpha_num().or(dash()).or(token('.')))
}

fn host_elements6<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
many1(alpha_num().or(dash()).or(token(':')))
}

fn digits<I>() -> impl Parser<I, Output = String>
where
I: Stream<Token = char>,
Expand All @@ -77,6 +86,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 +112,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 +174,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 +239,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 @@ -169,7 +281,19 @@ where
)
}

fn hostlist<I>() -> impl Parser<I, Output = Vec<Part>>
fn range6<I>() -> impl Parser<I, Output = Vec<RangeOutput>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
between(
open_bracket(),
close_bracket(),
sep_by1(attempt(range_hex()).or(attempt(disjoint_hex())), comma()),
)
}

fn hostlist4<I>() -> impl Parser<I, Output = Vec<Part>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
Expand All @@ -195,12 +319,41 @@ where
})
}

fn hostlist6<I>() -> impl Parser<I, Output = Vec<Part>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
repeat_until(
choice([
range6().map(Part::Range).left(),
optional_spaces()
.with(host_elements6())
.map(Part::String)
.right(),
]),
attempt(optional_spaces().skip(ignore(comma()).or(eof()))),
)
.and_then(|xs: Vec<_>| {
if xs.is_empty() {
Err(StreamErrorFor::<I>::unexpected_static_message(
"no host found",
))
} else {
Ok(xs)
}
})
}

fn hostlists<I>() -> impl Parser<I, Output = Vec<Vec<Part>>>
where
I: Stream<Token = char>,
I::Error: ParseError<I::Token, I::Range, I::Position>,
{
sep_by1(hostlist(), optional_spaces().with(comma()))
sep_by1(
choice([hostlist4().left(), hostlist6().right()]),
optional_spaces().with(comma()),
)
}

pub fn parse(input: &str) -> Result<Vec<String>, combine::stream::easy::Errors<char, &str, usize>> {
Expand Down Expand Up @@ -292,9 +445,9 @@ mod tests {

#[test]
fn test_hostlist() {
assert_debug_snapshot!(hostlist().easy_parse("oss1.local"));
assert_debug_snapshot!(hostlist().easy_parse("oss[1,2].local"));
assert_debug_snapshot!(hostlist().easy_parse(
assert_debug_snapshot!(hostlist4().easy_parse("oss1.local"));
assert_debug_snapshot!(hostlist4().easy_parse("oss[1,2].local"));
assert_debug_snapshot!(hostlist4().easy_parse(
"hostname[2,6,7].iml.com,hostname[10,11-12,2-3,5].iml.com,hostname[15-17].iml.com"
));
}
Expand Down Expand Up @@ -379,7 +532,12 @@ mod tests {
)
);

assert_debug_snapshot!("Multiple ranges per hostname in which the difference is 1", parse("hostname[1,2-3].iml[2,3].com,hostname[3,4,5].iml[2,3].com,hostname[5-6,7].iml[2,3].com"));
assert_debug_snapshot!(
"Multiple ranges per hostname in which the difference is 1",
parse(
"hostname[1,2-3].iml[2,3].com,hostname[3,4,5].iml[2,3].com,hostname[5-6,7].iml[2,3].com"
)
);

assert_debug_snapshot!(
"Multiple ranges per hostname in which the difference is 1 two formats",
Expand Down Expand Up @@ -492,4 +650,18 @@ 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]"));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Suggested change
assert_debug_snapshot!("IPv6 expansion with base 16", parse("2001:db8::[00-10]"));

This should also work. But maybe it makes sense to limit that. Its very easy to generate billions of addresses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For some reason the hex functions allowed only strings with alphabetic characters ? Removed this and your test works.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

What are your thoughts on limiting the amount of expansion to 2-3 chars? I'm not sure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inappropriate usage of these wild cards is always going to be an issue.

Copy link
Copy Markdown

@spoutn1k spoutn1k Jan 15, 2026

Choose a reason for hiding this comment

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

I mean we can easily count a range and abort if the amount is greater than a threshold

assert_debug_snapshot!("IPv4 with v6 range", parse("192.168.0.[0-f]").unwrap_err());
}
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",
],
)
24 changes: 24 additions & 0 deletions src/snapshots/hostlist_parser__tests__IPv4 with v6 range.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: src/lib.rs
expression: "parse(\"192.168.0.[0-f]\").unwrap_err()"
---
Errors {
position: 12,
errors: [
Unexpected(
Token(
'-',
),
),
Expected(
Token(
',',
),
),
Expected(
Token(
']',
),
),
],
}
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",
],
)
Loading