#[must_use]
pub fn string_array(text: &str) -> Option<Vec<String>> {
text.char_indices()
.filter(|&(_, c)| c == '[')
.find_map(|(start, _)| array_from(&text[start..]))
.or_else(|| list_items(text))
}
fn array_from(slice: &str) -> Option<Vec<String>> {
let bytes = slice.as_bytes();
let mut i = 1; let mut items = Vec::new();
loop {
while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b',') {
i += 1;
}
if i >= bytes.len() || bytes[i] == b']' || bytes[i] != b'"' {
break;
}
let Some((item, len)) = take_json_string(&slice[i..]) else {
break;
};
let item = item.trim();
if !item.is_empty() {
items.push(item.to_string());
}
i += len;
}
(!items.is_empty()).then_some(items)
}
fn take_json_string(slice: &str) -> Option<(String, usize)> {
let bytes = slice.as_bytes();
let mut i = 1; while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2, b'"' => {
let end = i + 1;
let value: String = serde_json::from_str(&slice[..end]).ok()?;
return Some((value, end));
},
_ => i += 1,
}
}
None
}
fn list_items(text: &str) -> Option<Vec<String>> {
let items: Vec<String> = text
.lines()
.filter_map(strip_marker)
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect();
(!items.is_empty()).then_some(items)
}
fn strip_marker(line: &str) -> Option<&str> {
let line = line.trim_start();
for marker in ['-', '*', '•'] {
if let Some(rest) = line.strip_prefix(marker)
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return Some(rest.trim_start());
}
}
let digits = line.chars().take_while(char::is_ascii_digit).count();
if digits > 0 {
let rest = &line[digits..];
if let Some(rest) = rest.strip_prefix('.').or_else(|| rest.strip_prefix(')')) {
return Some(rest.trim_start());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_fenced_and_wrapped_arrays_parse() {
let expected = Some(vec!["a".to_string(), "b".to_string()]);
assert_eq!(string_array(r#"["a", " b "]"#), expected);
assert_eq!(string_array("```json\n[\"a\", \"b\", \"\"]\n```"), expected);
assert_eq!(string_array("Here you go: [\"a\",\"b\"] Enjoy."), expected);
}
#[test]
fn anything_else_is_rejected() {
assert_eq!(string_array("no array"), None);
assert_eq!(string_array("] backwards ["), None);
assert_eq!(string_array("[1, 2]"), None);
assert_eq!(string_array("[\"unterminated"), None);
}
#[test]
fn a_count_in_brackets_before_the_array_is_skipped() {
assert_eq!(
string_array(r#"Here are [3] questions: ["a"]"#),
Some(vec!["a".to_string()])
);
}
#[test]
fn a_trailing_reference_marker_does_not_prevent_the_match() {
assert_eq!(
string_array(r#"["a", "b"] see reference [1]"#),
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn an_array_wrapped_in_an_object_is_matched() {
assert_eq!(
string_array(r#"{"questions": ["a", "b"]}"#),
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn a_truncated_array_keeps_its_complete_items() {
assert_eq!(
string_array(r#"["a", "b", "cut off"#),
Some(vec!["a".to_string(), "b".to_string()])
);
assert_eq!(
string_array(r#"["only one", "#),
Some(vec!["only one".to_string()])
);
}
#[test]
fn escapes_inside_strings_survive() {
assert_eq!(
string_array(r#"["a \"quote\" and \\ slash"]"#),
Some(vec![r#"a "quote" and \ slash"#.to_string()])
);
}
#[test]
fn a_numbered_list_is_read_when_there_is_no_array() {
assert_eq!(
string_array("1. first\n2) second\n3. third"),
Some(vec![
"first".to_string(),
"second".to_string(),
"third".to_string(),
])
);
}
#[test]
fn a_bulleted_list_is_read_when_there_is_no_array() {
assert_eq!(
string_array("Here they are:\n- one\n* two\n\u{2022} three\nthanks"),
Some(vec![
"one".to_string(),
"two".to_string(),
"three".to_string(),
])
);
}
#[test]
fn prose_without_markers_is_not_a_list() {
assert_eq!(string_array("I cannot help with that request."), None);
}
}