use super::policy::{Literal, Notation, is_extractable};
#[derive(Debug, Clone, Copy)]
struct Dialect {
separator: Option<u8>,
legacy_octal: bool,
bigint: bool,
}
const PLAIN: Dialect = Dialect {
separator: None,
legacy_octal: false,
bigint: false,
};
fn dialect(language: &str) -> Dialect {
match language {
"python" | "rust" | "kotlin" | "csharp" => Dialect {
separator: Some(b'_'),
..PLAIN
},
"go" | "java" => Dialect {
separator: Some(b'_'),
legacy_octal: true,
..PLAIN
},
"javascript" | "typescript" => Dialect {
separator: Some(b'_'),
bigint: true,
..PLAIN
},
"c" | "cpp" => Dialect {
separator: Some(b'\''),
legacy_octal: true,
..PLAIN
},
_ => PLAIN,
}
}
pub(crate) fn extract(text: &str, language: &str) -> Vec<Literal> {
spanned(text, language)
.into_iter()
.map(|(literal, _)| literal)
.collect()
}
pub(crate) fn spanned(text: &str, language: &str) -> Vec<(Literal, usize)> {
let dialect = dialect(language);
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut at = 0;
while at < bytes.len() {
let Some(token) = token_at(bytes, at, dialect) else {
at += 1;
continue;
};
if let Some(literal) = token.literal {
out.push((literal, at));
}
at = token.end;
}
out
}
struct Token {
literal: Option<Literal>,
end: usize,
}
fn token_at(bytes: &[u8], start: usize, dialect: Dialect) -> Option<Token> {
let here = *bytes.get(start)?;
let signed = matches!(here, b'+' | b'-');
if signed && !sign_may_begin(bytes, start) {
return None;
}
let body = if signed { start + 1 } else { start };
if !body_may_begin(bytes, body) {
return None;
}
let (value, notation, end) = read_body(bytes, body, dialect)?;
let negative = signed && here == b'-';
Some(Token {
literal: value
.map(|value| if negative { -value } else { value })
.filter(|value| is_extractable(*value))
.map(|value| Literal { value, notation }),
end,
})
}
fn is_word(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') || byte >= 0x80
}
fn sign_may_begin(bytes: &[u8], at: usize) -> bool {
let Some(previous) = at.checked_sub(1).map(|before| bytes[before]) else {
return true;
};
!is_word(previous) && !matches!(previous, b')' | b']')
}
fn body_may_begin(bytes: &[u8], at: usize) -> bool {
match bytes.get(at) {
Some(byte) if byte.is_ascii_digit() => digit_may_begin(bytes, at),
Some(b'.') => {
bytes.get(at + 1).is_some_and(u8::is_ascii_digit) && point_may_begin(bytes, at)
}
_ => false,
}
}
fn digit_may_begin(bytes: &[u8], at: usize) -> bool {
let Some(previous) = at.checked_sub(1).map(|before| bytes[before]) else {
return true;
};
if is_word(previous) {
return false;
}
if previous != b'.' {
return true;
}
at.checked_sub(2)
.map(|before| bytes[before])
.is_none_or(|before| !is_word(before) && !matches!(before, b')' | b']'))
}
fn point_may_begin(bytes: &[u8], at: usize) -> bool {
at.checked_sub(1)
.map(|before| bytes[before])
.is_none_or(|previous| !is_word(previous) && !matches!(previous, b')' | b']' | b'.'))
}
fn read_body(bytes: &[u8], at: usize, dialect: Dialect) -> Option<(Option<f64>, Notation, usize)> {
read_prefixed(bytes, at, dialect).or_else(|| read_decimal(bytes, at, dialect))
}
fn read_prefixed(
bytes: &[u8],
at: usize,
dialect: Dialect,
) -> Option<(Option<f64>, Notation, usize)> {
if *bytes.get(at)? != b'0' {
return None;
}
let (radix, notation) = match bytes.get(at + 1)? {
b'x' | b'X' => (16, Notation::Hex),
b'b' | b'B' => (2, Notation::Binary),
b'o' | b'O' => (8, Notation::Octal),
_ => return None,
};
let (digits, after) = read_digits(bytes, at + 2, radix, dialect);
if digits.is_empty() {
return None;
}
let (_, end) = read_suffix(bytes, after);
Some((from_radix(&digits, radix), notation, end))
}
fn read_decimal(
bytes: &[u8],
at: usize,
dialect: Dialect,
) -> Option<(Option<f64>, Notation, usize)> {
let (integer, mut end) = read_digits(bytes, at, 10, dialect);
let mut token = integer.clone();
let mut notation = if dialect.legacy_octal && is_legacy_octal(&integer) {
Notation::Octal
} else {
Notation::Decimal
};
if bytes.get(end) == Some(&b'.') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit) {
let (fraction, after) = read_digits(bytes, end + 1, 10, dialect);
token.push('.');
token.push_str(&fraction);
end = after;
notation = Notation::Decimal;
}
if token.is_empty() {
return None;
}
if let Some((exponent, after)) = read_exponent(bytes, end, dialect) {
token.push_str(&exponent);
end = after;
notation = Notation::Scientific;
}
let (suffix, after) = read_suffix(bytes, end);
if dialect.bigint && suffix == "n" && notation == Notation::Decimal {
notation = Notation::BigInt;
}
let value = match notation {
Notation::Octal => from_radix(&token, 8),
_ => token.parse::<f64>().ok(),
};
Some((value, notation, after))
}
fn is_legacy_octal(digits: &str) -> bool {
digits.len() > 1
&& digits.starts_with('0')
&& digits.bytes().all(|byte| (b'0'..=b'7').contains(&byte))
}
fn read_digits(bytes: &[u8], from: usize, radix: u32, dialect: Dialect) -> (String, usize) {
let mut digits = String::new();
let mut at = from;
while at < bytes.len() {
let byte = bytes[at];
if is_digit_of(byte, radix) {
digits.push(char::from(byte));
at += 1;
continue;
}
let separates = is_separator(byte, dialect)
&& bytes
.get(at + 1)
.is_some_and(|next| is_digit_of(*next, radix));
if !separates {
break;
}
at += 1;
}
(digits, at)
}
fn is_digit_of(byte: u8, radix: u32) -> bool {
char::from(byte).is_digit(radix)
}
fn is_separator(byte: u8, dialect: Dialect) -> bool {
dialect.separator == Some(byte)
}
fn read_exponent(bytes: &[u8], at: usize, dialect: Dialect) -> Option<(String, usize)> {
if !matches!(bytes.get(at), Some(b'e' | b'E')) {
return None;
}
let signed = matches!(bytes.get(at + 1), Some(b'+' | b'-'));
let from = if signed { at + 2 } else { at + 1 };
let (digits, end) = read_digits(bytes, from, 10, dialect);
if digits.is_empty() {
return None;
}
let sign = if signed {
char::from(bytes[at + 1]).to_string()
} else {
String::new()
};
Some((format!("e{sign}{digits}"), end))
}
fn read_suffix(bytes: &[u8], from: usize) -> (String, usize) {
let mut at = from;
while bytes
.get(at)
.is_some_and(|byte| is_word(*byte) && *byte < 0x80)
{
at += 1;
}
(String::from_utf8_lossy(&bytes[from..at]).into_owned(), at)
}
fn from_radix(digits: &str, radix: u32) -> Option<f64> {
u128::from_str_radix(digits, radix).ok().map(|n| n as f64)
}
#[cfg(test)]
mod tests {
use super::*;
fn values(text: &str, language: &str) -> Vec<f64> {
extract(text, language)
.into_iter()
.map(|literal| literal.value)
.collect()
}
fn notations(text: &str, language: &str) -> Vec<Notation> {
extract(text, language)
.into_iter()
.map(|literal| literal.notation)
.collect()
}
#[test]
fn a_type_name_is_not_a_number() {
for name in [
"u32", "i64", "f32", "usize", "int64", "sha256", "utf8", "x1",
] {
assert!(values(name, "rust").is_empty(), "{name}");
}
}
#[test]
fn a_type_suffix_leaves_one_number_behind_it() {
assert_eq!(values("10u32", "rust"), [10.0]);
assert_eq!(values("100L", "java"), [100.0]);
assert_eq!(values("1.5f", "cpp"), [1.5]);
assert_eq!(values("2.75_f64", "rust"), [2.75]);
assert_eq!(values("1.5e3f64", "rust"), [1500.0]);
}
#[test]
fn each_base_is_one_number_with_its_own_notation() {
assert_eq!(values("0xFF", "rust"), [255.0]);
assert_eq!(notations("0xFF", "rust"), [Notation::Hex]);
assert_eq!(values("0XFF", "c"), [255.0]);
assert_eq!(values("0b1010", "rust"), [10.0]);
assert_eq!(notations("0b1010", "rust"), [Notation::Binary]);
assert_eq!(values("0o755", "rust"), [493.0]);
assert_eq!(notations("0o755", "rust"), [Notation::Octal]);
}
#[test]
fn a_separator_does_not_split_a_number() {
assert_eq!(values("1_000_000", "rust"), [1_000_000.0]);
assert_eq!(values("1_000_000", "python"), [1_000_000.0]);
assert_eq!(values("1'000'000", "cpp"), [1_000_000.0]);
assert_eq!(values("0xFF_FF", "rust"), [65535.0]);
}
#[test]
fn a_separator_belongs_to_the_dialect_that_has_it() {
assert_eq!(values("1_000", "sql"), [1.0]);
assert_eq!(values("1'000'", "python"), [1.0, 0.0]);
}
#[test]
fn a_leading_zero_is_octal_only_where_the_language_says_so() {
for language in ["c", "cpp", "go", "java"] {
assert_eq!(values("0755", language), [493.0], "{language}");
assert_eq!(notations("0755", language), [Notation::Octal], "{language}");
}
for language in ["rust", "python", "csharp", "kotlin", "javascript", "sql"] {
assert_eq!(values("0755", language), [755.0], "{language}");
assert_eq!(
notations("0755", language),
[Notation::Decimal],
"{language}"
);
}
}
#[test]
fn a_leading_zero_that_is_not_octal_stays_decimal() {
assert_eq!(values("0", "go"), [0.0]);
assert_eq!(values("08", "go"), [8.0]);
assert_eq!(values("0.5", "go"), [0.5]);
}
#[test]
fn a_bigint_is_a_bigint_only_in_javascript() {
assert_eq!(values("123n", "javascript"), [123.0]);
assert_eq!(notations("123n", "javascript"), [Notation::BigInt]);
assert_eq!(notations("123n", "typescript"), [Notation::BigInt]);
assert_eq!(notations("123n", "python"), [Notation::Decimal]);
assert_eq!(notations("0xFFn", "javascript"), [Notation::Hex]);
}
#[test]
fn an_exponent_is_scientific() {
assert_eq!(values("1.5e3", "python"), [1500.0]);
assert_eq!(notations("1.5e3", "python"), [Notation::Scientific]);
assert_eq!(values("1e-7", "python"), [1e-7]);
assert_eq!(values("1E5", "python"), [100_000.0]);
}
#[test]
fn an_incomplete_exponent_is_part_of_the_suffix() {
assert_eq!(values("1exp", "python"), [1.0]);
assert_eq!(values("1e", "python"), [1.0]);
assert_eq!(values("1e+", "python"), [1.0]);
}
#[test]
fn a_sign_is_read_where_a_value_cannot_be() {
assert_eq!(values("x = -1", "rust"), [-1.0]);
assert_eq!(values("(-0.5)", "rust"), [-0.5]);
assert_eq!(values("+7", "rust"), [7.0]);
assert_eq!(values("a-1", "rust"), [1.0]);
assert_eq!(values("f()-1", "rust"), [1.0]);
assert_eq!(values("xs[0]-1", "rust"), [0.0, 1.0]);
}
#[test]
fn a_version_string_is_not_two_numbers() {
assert_eq!(values("v1.2.3", "python"), Vec::<f64>::new());
assert_eq!(values("\"1.2.3\"", "python"), [1.2]);
}
#[test]
fn a_field_access_is_not_a_number() {
assert_eq!(values("t.0", "rust"), Vec::<f64>::new());
assert_eq!(values("xs[1].0", "rust"), [1.0]);
}
#[test]
fn a_leading_point_is_a_number_where_nothing_precedes_it() {
assert_eq!(values("x = .5", "python"), [0.5]);
assert_eq!(values("[.5]", "javascript"), [0.5]);
}
#[test]
fn a_range_keeps_both_of_its_bounds() {
assert_eq!(values("0..10", "rust"), [0.0, 10.0]);
}
#[test]
fn a_run_is_consumed_whole_so_the_scan_cannot_re_enter_it() {
assert_eq!(values("let m: u32 = 0o755;", "rust"), [493.0]);
assert_eq!(values("const BIG: usize = 1_000_000;", "rust"), [1e6]);
}
#[test]
fn numbers_come_back_in_document_order() {
assert_eq!(
values("a = 1\nb = 0x10\nc = 2.5\n", "python"),
[1.0, 16.0, 2.5]
);
}
#[test]
fn a_span_points_at_the_start_of_the_literal() {
let text = "let mask: u64 = 0xFF;";
let (literal, offset) = spanned(text, "rust")[0];
assert_eq!(literal.value, 255.0);
assert_eq!(&text[offset..offset + 4], "0xFF");
}
#[test]
fn a_signed_span_starts_at_the_sign() {
let text = "x = -42";
let (literal, offset) = spanned(text, "python")[0];
assert_eq!(literal.value, -42.0);
assert_eq!(&text[offset..], "-42");
}
#[test]
fn an_overflowing_literal_is_consumed_and_not_reported() {
assert!(values("1e400", "python").is_empty());
assert!(values(&format!("0x{}", "F".repeat(40)), "rust").is_empty());
}
#[test]
fn a_bare_base_prefix_is_not_a_literal() {
assert_eq!(values("0x", "rust"), [0.0]);
assert_eq!(values("0xZZ", "rust"), [0.0]);
}
#[test]
fn text_without_numbers_yields_nothing() {
assert!(values("fn main() { println!(\"hello\"); }", "rust").is_empty());
}
#[test]
fn a_word_with_a_non_ascii_character_is_still_a_word() {
assert!(values("café1", "python").is_empty());
}
#[test]
fn an_unknown_language_reads_the_universal_shapes() {
assert_eq!(values("0xFF 1_000 0755", "wat"), [255.0, 1.0, 755.0]);
}
}