656 lines
28 KiB
Rust
656 lines
28 KiB
Rust
//! Fill: copy, AutoFill series (numbers, dates, weekdays, months, text with numbers, custom
|
||
//! lists), Series dialog and Flash Fill.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||
use gridcraft_model::{Cell, Formula};
|
||
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
||
|
||
use crate::Result;
|
||
use crate::cmd::Ctx;
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum FillMode {
|
||
Series,
|
||
/// The Ctrl+drag fill: the opposite of a plain drag. A lone number fills a
|
||
/// series stepping by 1; anything a plain drag would extend is copied.
|
||
Alternate,
|
||
Copy,
|
||
Formats,
|
||
ValuesOnly,
|
||
}
|
||
|
||
/// Built-in custom lists.
|
||
fn lists() -> Vec<Vec<String>> {
|
||
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
|
||
let short_months: Vec<String> = MONTHS.iter().map(|m| m.get(..3).unwrap_or(m).to_string()).collect();
|
||
vec![
|
||
short_days,
|
||
WEEKDAYS.iter().map(|s| s.to_string()).collect(),
|
||
short_months,
|
||
MONTHS.iter().map(|s| s.to_string()).collect(),
|
||
vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()],
|
||
]
|
||
}
|
||
|
||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||
let custom = cx.wb.custom_lists.clone();
|
||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||
// Direction: down/up if target extends rows, else right/left.
|
||
let vertical = target.height() != src.height() || target.width() == src.width();
|
||
let forward = if vertical {
|
||
target.end.row > src.end.row || target.start.row == src.start.row
|
||
} else {
|
||
target.end.col > src.end.col || target.start.col == src.start.col
|
||
};
|
||
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
|
||
let src_len = if vertical { src.height() } else { src.width() };
|
||
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
|
||
let mut pictures = Vec::new();
|
||
for lane in lanes {
|
||
let src_cells: Vec<(CellRef, Option<Cell>)> = (0..src_len)
|
||
.map(|i| {
|
||
let c = if vertical { CellRef::new(src.start.row + i, lane) } else { CellRef::new(lane, src.start.col + i) };
|
||
(c, sh.cell(c).cloned())
|
||
})
|
||
.collect();
|
||
let targets: Vec<CellRef> = if vertical {
|
||
let rows: Vec<u32> =
|
||
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
|
||
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
|
||
} else {
|
||
let cols: Vec<u32> =
|
||
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
|
||
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
|
||
};
|
||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
|
||
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
|
||
} else {
|
||
Pattern::Copy
|
||
};
|
||
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
|
||
// own series, one step per repeat of the source.
|
||
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
|
||
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
for (k, t) in targets.iter().enumerate() {
|
||
let step = k as i64 + 1;
|
||
let idx = k % src_cells.len().max(1);
|
||
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
|
||
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
|
||
let mut cell = scell.clone().unwrap_or_default();
|
||
match mode {
|
||
FillMode::Formats => {
|
||
let mut existing = sh.cell(*t).cloned().unwrap_or_default();
|
||
existing.style = cell.style;
|
||
writes.push((*t, Some(existing)));
|
||
continue;
|
||
}
|
||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
|
||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||
if let Some(f) = &cell.formula {
|
||
if let Some(e) = f.expr() {
|
||
let shifted = gridcraft_formula::adjust::shift_relative(e, t.row as i64 - sc.row as i64, t.col as i64 - sc.col as i64);
|
||
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||
cell.value = Value::Empty;
|
||
}
|
||
} else if let Some(v) = match per_cell.get(src_i) {
|
||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||
None => pattern.value(step, forward, src_cells.len()),
|
||
} {
|
||
cell.value = v;
|
||
}
|
||
cell.style = style;
|
||
}
|
||
}
|
||
if let Some(picture) = sh.cell_pictures.get(&sc) {
|
||
pictures.push((*t, picture.clone()));
|
||
}
|
||
writes.push((*t, if cell.is_blank() { None } else { Some(cell) }));
|
||
}
|
||
}
|
||
let sh = cx.sheet_mut(sheet)?;
|
||
for (c, cell) in &writes {
|
||
match cell {
|
||
Some(x) if mode == FillMode::Formats => sh.cells.set(*c, x.clone()),
|
||
Some(x) => sh.set_cell(*c, x.clone()),
|
||
None => {
|
||
sh.remove_cell(*c);
|
||
}
|
||
}
|
||
}
|
||
sh.cell_pictures.extend(pictures);
|
||
for (c, _) in writes {
|
||
cx.touch(sheet, c);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Pattern {
|
||
Copy,
|
||
/// Linear numeric series: last value and step.
|
||
Linear {
|
||
last: f64,
|
||
first: f64,
|
||
step: f64,
|
||
},
|
||
/// Text with a trailing (or leading) number: `Item 1`, `Q1`.
|
||
TextNum {
|
||
prefix: String,
|
||
suffix: String,
|
||
last: i64,
|
||
first: i64,
|
||
step: i64,
|
||
width: usize,
|
||
},
|
||
/// A position in a list (days, months, custom).
|
||
List {
|
||
list: Vec<String>,
|
||
last: usize,
|
||
first: usize,
|
||
step: i64,
|
||
upper: bool,
|
||
lower: bool,
|
||
},
|
||
/// Dates stepping by months (same day each month) or years.
|
||
Months {
|
||
last: f64,
|
||
first: f64,
|
||
months: i64,
|
||
},
|
||
}
|
||
|
||
impl Pattern {
|
||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||
return Pattern::Copy;
|
||
}
|
||
// Numbers.
|
||
if !vals.is_empty() && vals.iter().all(|v| v.is_number()) {
|
||
let nums: Vec<f64> = vals.iter().filter_map(Value::as_f64).collect();
|
||
let first = nums.first().copied().unwrap_or(0.0);
|
||
let last = nums.last().copied().unwrap_or(0.0);
|
||
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
|
||
if nums.len() == 1 {
|
||
// A single number copies; a single date increments by a day, a single time by an hour.
|
||
return if is_date {
|
||
Pattern::Linear { last, first, step: 1.0 }
|
||
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
|
||
Pattern::Linear { last, first, step: 1.0 / 24.0 }
|
||
} else {
|
||
Pattern::Copy
|
||
};
|
||
}
|
||
// Dates that differ by whole months.
|
||
if is_date && nums.len() >= 2 {
|
||
let d0 = datetime_from_serial(sys, first);
|
||
let d1 = datetime_from_serial(sys, nums.get(1).copied().unwrap_or(first));
|
||
if let (Some(a), Some(b)) = (d0, d1)
|
||
&& a.day == b.day
|
||
&& (a.month != b.month || a.year != b.year)
|
||
{
|
||
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
|
||
return Pattern::Months { last, first, months };
|
||
}
|
||
}
|
||
// Least-squares step for 2+ values (Excel uses a linear trend).
|
||
let n = nums.len() as f64;
|
||
let mean_x = (n - 1.0) / 2.0;
|
||
let mean_y = nums.iter().sum::<f64>() / n;
|
||
let mut num = 0.0;
|
||
let mut den = 0.0;
|
||
for (i, y) in nums.iter().enumerate() {
|
||
num += (i as f64 - mean_x) * (y - mean_y);
|
||
den += (i as f64 - mean_x).powi(2);
|
||
}
|
||
let step = if den == 0.0 { 0.0 } else { num / den };
|
||
let fitted_last = mean_y + step * (n - 1.0 - mean_x);
|
||
let fitted_first = mean_y - step * mean_x;
|
||
return Pattern::Linear { last: fitted_last, first: fitted_first, step };
|
||
}
|
||
// Text.
|
||
if !vals.is_empty() && vals.iter().all(|v| v.is_text()) {
|
||
let texts: Vec<String> = vals.iter().filter_map(|v| v.as_text().map(str::to_string)).collect();
|
||
let all_lists: Vec<Vec<String>> = custom.iter().cloned().chain(lists()).collect();
|
||
for list in &all_lists {
|
||
let idx: Vec<Option<usize>> = texts.iter().map(|t| list.iter().position(|l| l.eq_ignore_ascii_case(t))).collect();
|
||
if idx.iter().all(Option::is_some) {
|
||
let idx: Vec<usize> = idx.into_iter().flatten().collect();
|
||
let first = idx.first().copied().unwrap_or(0);
|
||
let last = idx.last().copied().unwrap_or(0);
|
||
let step = if idx.len() >= 2 { idx[1] as i64 - idx[0] as i64 } else { 1 };
|
||
let t0 = texts.first().cloned().unwrap_or_default();
|
||
let upper = t0.chars().all(|c| !c.is_lowercase()) && t0.chars().any(char::is_alphabetic) && t0.len() > 1;
|
||
let lower = t0.chars().all(|c| !c.is_uppercase());
|
||
return Pattern::List { list: list.clone(), last, first, step, upper, lower };
|
||
}
|
||
}
|
||
// Text with a number at the end (or start).
|
||
let split: Vec<Option<(String, i64, String, usize)>> = texts.iter().map(|t| split_num(t)).collect();
|
||
if split.iter().all(Option::is_some) {
|
||
let parts: Vec<(String, i64, String, usize)> = split.into_iter().flatten().collect();
|
||
if parts.iter().all(|p| p.0 == parts[0].0 && p.2 == parts[0].2) {
|
||
let first = parts.first().map(|p| p.1).unwrap_or(0);
|
||
let last = parts.last().map(|p| p.1).unwrap_or(0);
|
||
let step = if parts.len() >= 2 { parts[1].1 - parts[0].1 } else { 1 };
|
||
return Pattern::TextNum { prefix: parts[0].0.clone(), suffix: parts[0].2.clone(), last, first, step, width: parts[0].3 };
|
||
}
|
||
}
|
||
}
|
||
Pattern::Copy
|
||
}
|
||
|
||
/// The pattern of a Ctrl+drag, which swaps copying and extending: a single
|
||
/// number (which a plain drag copies) counts up by 1, and every series a
|
||
/// plain drag would extend (numbers, dates, lists, `Item 1`) is copied.
|
||
fn alternate(self, cells: &[(CellRef, Option<Cell>)]) -> Pattern {
|
||
match (self, cells) {
|
||
(Pattern::Copy, [(_, Some(cell))]) if cell.formula.is_none() => match cell.value.as_f64() {
|
||
Some(n) if cell.value.is_number() => Pattern::Linear { last: n, first: n, step: 1.0 },
|
||
_ => Pattern::Copy,
|
||
},
|
||
_ => Pattern::Copy,
|
||
}
|
||
}
|
||
|
||
/// The value `k` steps beyond the source (forward) or before it (backward).
|
||
fn value(&self, k: i64, forward: bool, _len: usize) -> Option<Value> {
|
||
let sign = if forward { 1 } else { -1 };
|
||
match self {
|
||
Pattern::Copy => None,
|
||
Pattern::Linear { last, first, step } => {
|
||
let base = if forward { *last } else { *first };
|
||
Some(Value::number(round15(base + step * (k * sign) as f64)))
|
||
}
|
||
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
|
||
let base = if forward { *last } else { *first };
|
||
let n = base + step * k * sign;
|
||
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
|
||
let quarter = suffix.is_empty()
|
||
&& (1..=4).contains(&base)
|
||
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
|
||
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
|
||
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
|
||
}
|
||
Pattern::List { list, last, first, step, upper, lower } => {
|
||
let len = list.len() as i64;
|
||
if len == 0 {
|
||
return None;
|
||
}
|
||
let base = if forward { *last } else { *first } as i64;
|
||
let i = (base + step * k * sign).rem_euclid(len) as usize;
|
||
let s = list.get(i)?.clone();
|
||
Some(Value::text(if *upper {
|
||
s.to_uppercase()
|
||
} else if *lower && !s.chars().next().is_some_and(char::is_uppercase) {
|
||
s.to_lowercase()
|
||
} else {
|
||
s
|
||
}))
|
||
}
|
||
Pattern::Months { last, first, months } => {
|
||
let base = if forward { *last } else { *first };
|
||
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
|
||
Some(Value::number(s))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
||
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
|
||
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
||
let d = datetime_from_serial(sys, serial)?;
|
||
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
||
let year = total.div_euclid(12);
|
||
let month = total.rem_euclid(12) + 1;
|
||
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
|
||
}
|
||
|
||
fn round15(x: f64) -> f64 {
|
||
if x == 0.0 || !x.is_finite() {
|
||
return x;
|
||
}
|
||
let mag = x.abs().log10().floor() as i32;
|
||
let p = 10f64.powi(14 - mag);
|
||
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
|
||
}
|
||
|
||
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
|
||
fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
||
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
|
||
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
|
||
return suffix.to_string();
|
||
}
|
||
let new = match (n % 10, n % 100) {
|
||
(_, 11..=13) => "th",
|
||
(1, _) => "st",
|
||
(2, _) => "nd",
|
||
(3, _) => "rd",
|
||
_ => "th",
|
||
};
|
||
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
|
||
format!("{new}{rest}")
|
||
}
|
||
|
||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||
let chars: Vec<char> = t.chars().collect();
|
||
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
|
||
let mut start = end;
|
||
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
|
||
start -= 1;
|
||
}
|
||
let digits: String = chars.get(start..=end)?.iter().collect();
|
||
let n: i64 = digits.parse().ok()?;
|
||
let prefix: String = chars.get(..start)?.iter().collect();
|
||
let suffix: String = chars.get(end + 1..)?.iter().collect();
|
||
let width = if digits.starts_with('0') && digits.len() > 1 { digits.len() } else { 1 };
|
||
Some((prefix, n, suffix, width))
|
||
}
|
||
|
||
/// Home › Fill › Series.
|
||
pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, step: f64, stop: Option<f64>, unit: &str) -> Result<()> {
|
||
let sys = cx.wb.date_system;
|
||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||
let lanes: Vec<u32> = if rows { (r.start.row..=r.end.row).collect() } else { (r.start.col..=r.end.col).collect() };
|
||
let len = if rows { r.width() } else { r.height() };
|
||
let mut writes = Vec::new();
|
||
for lane in lanes {
|
||
let first_cell = if rows { CellRef::new(lane, r.start.col) } else { CellRef::new(r.start.row, lane) };
|
||
let Some(start) = sh.value(first_cell).as_f64() else { continue };
|
||
let style = sh.style_id(first_cell);
|
||
let mut v = start;
|
||
for i in 1..len {
|
||
let prev = v;
|
||
v = match kind {
|
||
"growth" => v * step,
|
||
"date" => {
|
||
let d = datetime_from_serial(sys, v);
|
||
match (unit, d) {
|
||
("month", Some(_)) => (step as i64).checked_mul(i as i64).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||
("year", Some(_)) => (step as i64).checked_mul(i as i64 * 12).and_then(|m| add_months_clamped(sys, start, m)).unwrap_or(v),
|
||
("weekday", Some(_)) => {
|
||
let mut n = v;
|
||
let mut left = step.abs() as i64;
|
||
while left > 0 {
|
||
n += step.signum();
|
||
let Some(day) = datetime_from_serial(sys, n) else {
|
||
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
|
||
};
|
||
if day.weekday != 0 && day.weekday != 6 {
|
||
left -= 1;
|
||
}
|
||
}
|
||
n
|
||
}
|
||
_ => v + step,
|
||
}
|
||
}
|
||
_ => v + step,
|
||
};
|
||
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
|
||
if let Some(s) = stop
|
||
&& ((v >= prev && v > s) || (v < prev && v < s))
|
||
{
|
||
break;
|
||
}
|
||
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
|
||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
|
||
}
|
||
}
|
||
let sh = cx.sheet_mut(sheet)?;
|
||
for (c, cell) in &writes {
|
||
sh.set_cell(*c, cell.clone());
|
||
}
|
||
for (c, _) in writes {
|
||
cx.touch(sheet, c);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Flash Fill: learns a transformation from the examples typed in the column of `at` (rows
|
||
/// above it with values) from the column(s) to the left, and fills the remaining rows.
|
||
/// Supported transformations: a token (word) of the source by index, concatenations of tokens
|
||
/// with literal separators, upper/lower/proper case, and fixed-position substrings.
|
||
pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(0) };
|
||
if at.col == 0 {
|
||
return Ok(0);
|
||
}
|
||
let src_col = at.col - 1;
|
||
// Rows of the data region in the source column.
|
||
let mut top = at.row;
|
||
while top > 0 && !sh.value(CellRef::new(top - 1, src_col)).is_empty() {
|
||
top -= 1;
|
||
}
|
||
let mut bottom = at.row;
|
||
while bottom + 1 < gridcraft_core::MAX_ROWS && !sh.value(CellRef::new(bottom + 1, src_col)).is_empty() && bottom - top < 1_000_000 {
|
||
bottom += 1;
|
||
}
|
||
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
|
||
let mut examples = Vec::new();
|
||
let mut todo = Vec::new();
|
||
for r in top..=bottom {
|
||
let src = sh.value(CellRef::new(r, src_col)).display();
|
||
let dst = sh.value(CellRef::new(r, at.col));
|
||
if dst.is_empty() {
|
||
todo.push((r, src));
|
||
} else {
|
||
examples.push((src, dst.display()));
|
||
}
|
||
}
|
||
if examples.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
let Some(program) = learn(&examples) else { return Ok(0) };
|
||
let mut n = 0;
|
||
let style = sh.style_id(CellRef::new(top, at.col));
|
||
let mut writes = Vec::new();
|
||
for (r, src) in todo {
|
||
if let Some(out) = program.apply(&src) {
|
||
writes.push((CellRef::new(r, at.col), out));
|
||
n += 1;
|
||
}
|
||
}
|
||
let sh = cx.sheet_mut(sheet)?;
|
||
for (c, out) in &writes {
|
||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||
}
|
||
for (c, _) in writes {
|
||
cx.touch(sheet, c);
|
||
}
|
||
Ok(n)
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
enum Piece {
|
||
Lit(String),
|
||
/// Token index (negative from the end) with a case transform.
|
||
Tok(i32, Case),
|
||
/// First character of a token.
|
||
Initial(i32, Case),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
enum Case {
|
||
Same,
|
||
Upper,
|
||
Lower,
|
||
Proper,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Program(Vec<Piece>);
|
||
|
||
fn tokens(s: &str) -> Vec<String> {
|
||
s.split(|c: char| !c.is_alphanumeric() && c != '\'').filter(|t| !t.is_empty()).map(str::to_string).collect()
|
||
}
|
||
|
||
fn apply_case(s: &str, c: Case) -> String {
|
||
match c {
|
||
Case::Same => s.to_string(),
|
||
Case::Upper => s.to_uppercase(),
|
||
Case::Lower => s.to_lowercase(),
|
||
Case::Proper => {
|
||
let mut out = String::new();
|
||
for (i, ch) in s.chars().enumerate() {
|
||
if i == 0 {
|
||
out.extend(ch.to_uppercase());
|
||
} else {
|
||
out.extend(ch.to_lowercase());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Program {
|
||
fn apply(&self, src: &str) -> Option<String> {
|
||
let toks = tokens(src);
|
||
let get = |i: i32| -> Option<&String> {
|
||
if i >= 0 { toks.get(i as usize) } else { toks.len().checked_sub(i.unsigned_abs() as usize).and_then(|j| toks.get(j)) }
|
||
};
|
||
let mut out = String::new();
|
||
for p in &self.0 {
|
||
match p {
|
||
Piece::Lit(l) => out.push_str(l),
|
||
Piece::Tok(i, c) => out.push_str(&apply_case(get(*i)?, *c)),
|
||
Piece::Initial(i, c) => out.push_str(&apply_case(&get(*i)?.chars().next()?.to_string(), *c)),
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
}
|
||
|
||
/// Finds a program consistent with every example.
|
||
fn learn(examples: &[(String, String)]) -> Option<Program> {
|
||
let (src, dst) = examples.first()?;
|
||
let candidates = synthesize(src, dst);
|
||
candidates.into_iter().find(|p| examples.iter().all(|(s, d)| p.apply(s).as_deref() == Some(d.as_str())))
|
||
}
|
||
|
||
/// Candidate programs for one example, by greedy segmentation of the output into token matches
|
||
/// and literals (tries several index conventions).
|
||
fn synthesize(src: &str, dst: &str) -> Vec<Program> {
|
||
let toks = tokens(src);
|
||
let n = toks.len() as i32;
|
||
let mut out = Vec::new();
|
||
for prefer_neg in [false, true] {
|
||
let mut pieces = Vec::new();
|
||
let mut rest = dst;
|
||
let mut guard = 0;
|
||
while !rest.is_empty() && guard < 64 {
|
||
guard += 1;
|
||
// Longest token (with any case) that prefixes `rest`.
|
||
let mut best: Option<(usize, Piece)> = None;
|
||
for (i, t) in toks.iter().enumerate() {
|
||
let idx = if prefer_neg { i as i32 - n } else { i as i32 };
|
||
for c in [Case::Same, Case::Upper, Case::Lower, Case::Proper] {
|
||
let cand = apply_case(t, c);
|
||
if !cand.is_empty() && rest.starts_with(&cand) && best.as_ref().is_none_or(|(l, _)| cand.len() > *l) {
|
||
best = Some((cand.len(), Piece::Tok(idx, c)));
|
||
}
|
||
}
|
||
}
|
||
if best.as_ref().is_none_or(|(l, _)| *l <= 1) {
|
||
// Initial letters (e.g. "J. Smith").
|
||
for (i, t) in toks.iter().enumerate() {
|
||
let idx = if prefer_neg { i as i32 - n } else { i as i32 };
|
||
if let Some(ch) = t.chars().next() {
|
||
for c in [Case::Same, Case::Upper, Case::Lower] {
|
||
let cand = apply_case(&ch.to_string(), c);
|
||
if rest.starts_with(&cand) && best.is_none() {
|
||
best = Some((cand.len(), Piece::Initial(idx, c)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
match best {
|
||
Some((len, piece)) => {
|
||
pieces.push(piece);
|
||
rest = rest.get(len..).unwrap_or("");
|
||
}
|
||
None => {
|
||
let ch = rest.chars().next().unwrap_or(' ');
|
||
match pieces.last_mut() {
|
||
Some(Piece::Lit(l)) => l.push(ch),
|
||
_ => pieces.push(Piece::Lit(ch.to_string())),
|
||
}
|
||
rest = rest.get(ch.len_utf8()..).unwrap_or("");
|
||
}
|
||
}
|
||
}
|
||
out.push(Program(pieces));
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn flash_programs() {
|
||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||
assert_eq!(p.apply("Ada Lovelace").as_deref(), Some("Lovelace, Ada"));
|
||
let p = learn(&[("jane doe".into(), "JD".into())]).unwrap();
|
||
assert_eq!(p.apply("alan turing").as_deref(), Some("AT"));
|
||
let p = learn(&[("alice@example.com".into(), "alice".into())]).unwrap();
|
||
assert_eq!(p.apply("bob@example.com").as_deref(), Some("bob"));
|
||
}
|
||
|
||
#[test]
|
||
fn split_numbers() {
|
||
assert_eq!(split_num("Item 7"), Some(("Item ".into(), 7, "".into(), 1)));
|
||
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
|
||
assert_eq!(split_num("abc"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn times_quarters_ordinals_and_mixed_sources() {
|
||
let mut s = crate::Session::new();
|
||
s.new_workbook();
|
||
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
|
||
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
|
||
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
|
||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||
let r = RangeRef::parse(target).unwrap();
|
||
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
|
||
};
|
||
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
|
||
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
|
||
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
|
||
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
|
||
assert_eq!(
|
||
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
|
||
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn falling_growth_series_stops_at_its_stop_value() {
|
||
let mut s = crate::Session::new();
|
||
s.new_workbook();
|
||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
|
||
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
|
||
let d = s.doc().unwrap();
|
||
let sh = d.wb.active().unwrap();
|
||
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
|
||
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
|
||
}
|
||
}
|