use crate::address::parse_a1;
pub fn is_valid_name(name: &str) -> bool {
if !is_bare_identifier(name) {
return false;
}
if looks_like_a1(name) || looks_like_r1c1(name) || is_boolean_literal(name) {
return false;
}
true
}
pub fn is_bare_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
fn looks_like_a1(s: &str) -> bool {
parse_a1(s).is_some()
}
fn looks_like_r1c1(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() || !bytes[0].eq_ignore_ascii_case(&b'R') {
return false;
}
let rest = &bytes[1..];
let c_pos = rest.iter().position(|b| b.eq_ignore_ascii_case(&b'C'));
let Some(c_pos) = c_pos else { return false };
let row_digits = &rest[..c_pos];
let col_digits = &rest[c_pos + 1..];
!row_digits.is_empty()
&& !col_digits.is_empty()
&& row_digits.iter().all(u8::is_ascii_digit)
&& col_digits.iter().all(u8::is_ascii_digit)
}
fn is_boolean_literal(s: &str) -> bool {
s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("false")
}
pub struct ParsedRef {
pub sheet: String,
}
pub fn parse_canonical_ref(r: &str) -> Result<ParsedRef, String> {
let (sheet, a1_part) = split_sheet_ref(r)?;
let (start_raw, end_raw) = match a1_part.split_once(':') {
Some((a, b)) => (a, Some(b)),
None => (a1_part.as_str(), None),
};
let start =
parse_a1(start_raw).ok_or_else(|| format!("ref {r:?} has an invalid start cell"))?;
match end_raw {
None => { }
Some(end_raw) => {
let end =
parse_a1(end_raw).ok_or_else(|| format!("ref {r:?} has an invalid end cell"))?;
if start.row == end.row && start.column == end.column {
return Err(format!(
"ref {r:?} is not canonical: a degenerate range must collapse to the \
single-cell form (schema spec §7)"
));
}
if start.row > end.row || start.column > end.column {
return Err(format!(
"ref {r:?} is not canonical: range endpoints must be top-left first \
(schema spec §7)"
));
}
}
}
let canonical_sheet_token = quote_sheet_if_needed(&sheet);
let canonical_a1 = match end_raw {
None => start_raw.to_owned(),
Some(end_raw) => format!("{start_raw}:{end_raw}"),
};
let canonical = format!("{canonical_sheet_token}!{canonical_a1}");
if canonical != r {
return Err(format!(
"ref {r:?} is not in canonical form (expected {canonical:?}, schema spec §7)"
));
}
Ok(ParsedRef { sheet })
}
fn split_sheet_ref(r: &str) -> Result<(String, String), String> {
let bytes = r.as_bytes();
if bytes.first() == Some(&b'\'') {
let mut sheet = String::new();
let mut i = 1;
loop {
match bytes.get(i) {
None => return Err(format!("ref {r:?} has an unterminated quoted sheet name")),
Some(&b'\'') => {
if bytes.get(i + 1) == Some(&b'\'') {
sheet.push('\'');
i += 2;
} else {
i += 1; break;
}
}
Some(_) => {
let ch = r[i..].chars().next().unwrap();
sheet.push(ch);
i += ch.len_utf8();
}
}
}
if bytes.get(i) != Some(&b'!') {
return Err(format!("ref {r:?} must separate sheet and cell with '!'"));
}
Ok((sheet, r[i + 1..].to_owned()))
} else {
match r.split_once('!') {
Some((sheet, a1)) => Ok((sheet.to_owned(), a1.to_owned())),
None => Err(format!("ref {r:?} must be sheet-qualified (Sheet!A1)")),
}
}
}
pub fn quote_sheet_if_needed(sheet: &str) -> String {
if is_bare_identifier(sheet) && !looks_like_a1(sheet) {
sheet.to_owned()
} else {
format!("'{}'", sheet.replace('\'', "''"))
}
}