1use std::collections::HashMap;
4use std::io::{Cursor, Read, Seek, SeekFrom, Write};
5use std::path::{Component, Path, PathBuf};
6use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
7
8use futures::lock::Mutex as AsyncMutex;
9use runmat_builtins::{
10 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12 CellArray, Value,
13};
14use runmat_filesystem::{File, OpenOptions};
15use runmat_macros::runtime_builtin;
16
17use crate::builtins::common::fs::expand_user_path;
18use crate::builtins::common::spec::{
19 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
20 ReductionNaN, ResidencyPolicy, ShapeRequirements,
21};
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
23
24const BUILTIN_NAME: &str = "writecell";
25const MAX_EXCEL_ROW_INDEX: usize = 1_048_575;
26const MAX_EXCEL_COLUMN_INDEX: usize = 16_383;
27type WriteLock = Arc<AsyncMutex<()>>;
28type WeakWriteLock = Weak<AsyncMutex<()>>;
29static WRITE_LOCKS: OnceLock<StdMutex<HashMap<String, WeakWriteLock>>> = OnceLock::new();
30
31const WRITECELL_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
32 name: "bytesWritten",
33 ty: BuiltinParamType::NumericScalar,
34 arity: BuiltinParamArity::Required,
35 default: None,
36 description: "Number of bytes written to the destination file.",
37}];
38const WRITECELL_INPUTS_CELL_FILENAME: [BuiltinParamDescriptor; 2] = [
39 BuiltinParamDescriptor {
40 name: "C",
41 ty: BuiltinParamType::Any,
42 arity: BuiltinParamArity::Required,
43 default: None,
44 description: "Cell array to write.",
45 },
46 BuiltinParamDescriptor {
47 name: "filename",
48 ty: BuiltinParamType::StringScalar,
49 arity: BuiltinParamArity::Required,
50 default: None,
51 description: "Output file path.",
52 },
53];
54const WRITECELL_INPUTS_NAME_VALUE: [BuiltinParamDescriptor; 4] = [
55 BuiltinParamDescriptor {
56 name: "C",
57 ty: BuiltinParamType::Any,
58 arity: BuiltinParamArity::Required,
59 default: None,
60 description: "Cell array to write.",
61 },
62 BuiltinParamDescriptor {
63 name: "filename",
64 ty: BuiltinParamType::StringScalar,
65 arity: BuiltinParamArity::Required,
66 default: None,
67 description: "Output file path.",
68 },
69 BuiltinParamDescriptor {
70 name: "name",
71 ty: BuiltinParamType::StringScalar,
72 arity: BuiltinParamArity::Required,
73 default: None,
74 description: "Option name.",
75 },
76 BuiltinParamDescriptor {
77 name: "optionValue",
78 ty: BuiltinParamType::Any,
79 arity: BuiltinParamArity::Required,
80 default: None,
81 description: "Value for the preceding option name.",
82 },
83];
84const WRITECELL_INPUTS_NAME_VALUE_PAIRS: [BuiltinParamDescriptor; 3] = [
85 BuiltinParamDescriptor {
86 name: "C",
87 ty: BuiltinParamType::Any,
88 arity: BuiltinParamArity::Required,
89 default: None,
90 description: "Cell array to write.",
91 },
92 BuiltinParamDescriptor {
93 name: "filename",
94 ty: BuiltinParamType::StringScalar,
95 arity: BuiltinParamArity::Required,
96 default: None,
97 description: "Output file path.",
98 },
99 BuiltinParamDescriptor {
100 name: "nameValuePairs...",
101 ty: BuiltinParamType::Any,
102 arity: BuiltinParamArity::Variadic,
103 default: None,
104 description: "Name-value option pairs.",
105 },
106];
107const WRITECELL_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
108 BuiltinSignatureDescriptor {
109 label: "bytesWritten = writecell(C, filename)",
110 inputs: &WRITECELL_INPUTS_CELL_FILENAME,
111 outputs: &WRITECELL_OUTPUT,
112 },
113 BuiltinSignatureDescriptor {
114 label: "bytesWritten = writecell(C, filename, name, optionValue)",
115 inputs: &WRITECELL_INPUTS_NAME_VALUE,
116 outputs: &WRITECELL_OUTPUT,
117 },
118 BuiltinSignatureDescriptor {
119 label: "bytesWritten = writecell(C, filename, nameValuePairs...)",
120 inputs: &WRITECELL_INPUTS_NAME_VALUE_PAIRS,
121 outputs: &WRITECELL_OUTPUT,
122 },
123];
124
125const WRITECELL_ERROR_ARG_CONFIG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
126 code: "RM.WRITECELL.ARG_CONFIG",
127 identifier: None,
128 when: "Filename argument is missing or name-value options are malformed.",
129 message: "writecell: invalid argument configuration",
130};
131const WRITECELL_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
132 code: "RM.WRITECELL.FILENAME",
133 identifier: None,
134 when: "Filename is not a valid scalar path string.",
135 message: "writecell: invalid filename input",
136};
137const WRITECELL_ERROR_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
138 code: "RM.WRITECELL.OPTION",
139 identifier: None,
140 when: "A provided option value is invalid.",
141 message: "writecell: invalid option value",
142};
143const WRITECELL_ERROR_DATA: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
144 code: "RM.WRITECELL.DATA",
145 identifier: None,
146 when: "Input data cannot be converted into supported cell export rows.",
147 message: "writecell: invalid input data",
148};
149const WRITECELL_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
150 code: "RM.WRITECELL.DATA_SHAPE",
151 identifier: None,
152 when: "Input cell array has unsupported dimensionality.",
153 message: "writecell: input must be 2-D",
154};
155const WRITECELL_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
156 code: "RM.WRITECELL.IO",
157 identifier: None,
158 when: "The destination file cannot be opened or written.",
159 message: "writecell: file write failed",
160};
161const WRITECELL_ERRORS: [BuiltinErrorDescriptor; 6] = [
162 WRITECELL_ERROR_ARG_CONFIG,
163 WRITECELL_ERROR_FILENAME,
164 WRITECELL_ERROR_OPTION,
165 WRITECELL_ERROR_DATA,
166 WRITECELL_ERROR_DATA_SHAPE,
167 WRITECELL_ERROR_IO,
168];
169
170pub const WRITECELL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
171 signatures: &WRITECELL_SIGNATURES,
172 output_mode: BuiltinOutputMode::Fixed,
173 completion_policy: BuiltinCompletionPolicy::Public,
174 errors: &WRITECELL_ERRORS,
175};
176
177#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
178pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
179 name: "writecell",
180 op_kind: GpuOpKind::Custom("io-writecell"),
181 supported_precisions: &[],
182 broadcast: BroadcastSemantics::None,
183 provider_hooks: &[],
184 constant_strategy: ConstantStrategy::InlineLiteral,
185 residency: ResidencyPolicy::GatherImmediately,
186 nan_mode: ReductionNaN::Include,
187 two_pass_threshold: None,
188 workgroup_size: None,
189 accepts_nan_mode: false,
190 notes:
191 "Runs entirely on the host; gpuArray values inside cells are gathered before serialisation.",
192};
193
194#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::writecell")]
195pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
196 name: "writecell",
197 shape: ShapeRequirements::Any,
198 constant_strategy: ConstantStrategy::InlineLiteral,
199 elementwise: None,
200 reduction: None,
201 emits_nan: false,
202 notes: "Not eligible for fusion; performs host-side file I/O.",
203};
204
205fn writecell_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
206 writecell_error_with(error, error.message)
207}
208
209fn writecell_error_with(
210 error: &'static BuiltinErrorDescriptor,
211 message: impl Into<String>,
212) -> RuntimeError {
213 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
214 if let Some(identifier) = error.identifier {
215 builder = builder.with_identifier(identifier);
216 }
217 builder.build()
218}
219
220fn writecell_error_with_source<E>(
221 error: &'static BuiltinErrorDescriptor,
222 message: impl Into<String>,
223 source: E,
224) -> RuntimeError
225where
226 E: std::error::Error + Send + Sync + 'static,
227{
228 let mut builder = build_runtime_error(message)
229 .with_builtin(BUILTIN_NAME)
230 .with_source(source);
231 if let Some(identifier) = error.identifier {
232 builder = builder.with_identifier(identifier);
233 }
234 builder.build()
235}
236
237fn map_control_flow(err: RuntimeError) -> RuntimeError {
238 let identifier = err.identifier().map(|value| value.to_string());
239 let message = err.message().to_string();
240 let mut builder = build_runtime_error(message)
241 .with_builtin(BUILTIN_NAME)
242 .with_source(err);
243 if let Some(identifier) = identifier {
244 builder = builder.with_identifier(identifier);
245 }
246 builder.build()
247}
248
249#[runtime_builtin(
250 name = "writecell",
251 category = "io/tabular",
252 summary = "Write heterogeneous cell arrays to delimited text or spreadsheet files.",
253 keywords = "writecell,csv,xlsx,xls,cell array,delimited text,spreadsheet,append,quote strings",
254 accel = "cpu",
255 type_resolver(crate::builtins::io::type_resolvers::num_type),
256 descriptor(crate::builtins::io::tabular::writecell::WRITECELL_DESCRIPTOR),
257 builtin_path = "crate::builtins::io::tabular::writecell"
258)]
259async fn writecell_builtin(data: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
260 if rest.is_empty() {
261 return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
262 }
263
264 let filename_value = gather_if_needed_async(&rest[0])
265 .await
266 .map_err(map_control_flow)?;
267 let path = resolve_path(&filename_value)?;
268 let options = parse_options(&rest[1..]).await?;
269
270 let gathered = gather_if_needed_async(&data)
271 .await
272 .map_err(map_control_flow)?;
273 let table = CellTable::from_value(gathered).await?;
274
275 let bytes_written = match options.resolve_file_type(&path)? {
276 OutputFileType::DelimitedText => write_delimited_cells(&path, &table, &options).await?,
277 OutputFileType::Spreadsheet => write_spreadsheet_cells(&path, &table, &options).await?,
278 };
279
280 Ok(Value::Num(bytes_written as f64))
281}
282
283#[derive(Debug, Clone)]
284struct WriteCellOptions {
285 delimiter: Option<String>,
286 write_mode: WriteMode,
287 quote_strings: bool,
288 line_ending: LineEnding,
289 file_type: Option<OutputFileType>,
290 sheet: SheetSelector,
291 range: Option<RangeStart>,
292}
293
294impl Default for WriteCellOptions {
295 fn default() -> Self {
296 Self {
297 delimiter: None,
298 write_mode: WriteMode::Overwrite,
299 quote_strings: true,
300 line_ending: LineEnding::Auto,
301 file_type: None,
302 sheet: SheetSelector::Default,
303 range: None,
304 }
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309enum WriteMode {
310 Overwrite,
311 Append,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315enum LineEnding {
316 Auto,
317 Unix,
318 Windows,
319 Mac,
320}
321
322impl LineEnding {
323 fn as_str(self) -> &'static str {
324 match self {
325 LineEnding::Auto | LineEnding::Unix => "\n",
326 LineEnding::Windows => "\r\n",
327 LineEnding::Mac => "\r",
328 }
329 }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333enum OutputFileType {
334 DelimitedText,
335 Spreadsheet,
336}
337
338#[derive(Debug, Clone)]
339enum SheetSelector {
340 Default,
341 Name(String),
342 Index(usize),
343}
344
345#[derive(Debug, Clone, Copy, Default)]
346struct RangeStart {
347 row: usize,
348 col: usize,
349}
350
351impl WriteCellOptions {
352 fn resolve_file_type(&self, path: &Path) -> BuiltinResult<OutputFileType> {
353 if let Some(file_type) = self.file_type {
354 if file_type == OutputFileType::Spreadsheet {
355 ensure_supported_spreadsheet_extension(path)?;
356 }
357 return Ok(file_type);
358 }
359 match path_extension_lower(path).as_deref() {
360 Some("xlsx") | Some("xlsm") => Ok(OutputFileType::Spreadsheet),
361 Some(ext) if is_unsupported_spreadsheet_extension(ext) => Err(writecell_error_with(
362 &WRITECELL_ERROR_OPTION,
363 format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
364 )),
365 _ => Ok(OutputFileType::DelimitedText),
366 }
367 }
368
369 fn resolve_delimiter(&self, path: &Path) -> String {
370 self.delimiter
371 .clone()
372 .unwrap_or_else(|| default_delimiter_for_path(path))
373 }
374
375 fn sheet_name(&self) -> String {
376 match &self.sheet {
377 SheetSelector::Default => "Sheet1".to_string(),
378 SheetSelector::Name(name) => sanitize_sheet_name(name),
379 SheetSelector::Index(index) => format!("Sheet{index}"),
380 }
381 }
382
383 fn range_start(&self) -> RangeStart {
384 self.range.unwrap_or_default()
385 }
386}
387
388fn ensure_supported_spreadsheet_extension(path: &Path) -> BuiltinResult<()> {
389 match path_extension_lower(path).as_deref() {
390 Some("xlsx") | Some("xlsm") => Ok(()),
391 Some(ext) => Err(writecell_error_with(
392 &WRITECELL_ERROR_OPTION,
393 format!("writecell: unsupported spreadsheet file extension '.{ext}'"),
394 )),
395 None => Err(writecell_error_with(
396 &WRITECELL_ERROR_OPTION,
397 "writecell: spreadsheet output requires an .xlsx or .xlsm extension",
398 )),
399 }
400}
401
402fn is_unsupported_spreadsheet_extension(ext: &str) -> bool {
403 matches!(ext, "xls" | "xlsb" | "ods")
404}
405
406async fn parse_options(args: &[Value]) -> BuiltinResult<WriteCellOptions> {
407 if args.is_empty() {
408 return Ok(WriteCellOptions::default());
409 }
410 if !args.len().is_multiple_of(2) {
411 return Err(writecell_error(&WRITECELL_ERROR_ARG_CONFIG));
412 }
413
414 let mut options = WriteCellOptions::default();
415 let mut index = 0usize;
416 while index < args.len() {
417 let name_value = gather_if_needed_async(&args[index])
418 .await
419 .map_err(map_control_flow)?;
420 let name = string_scalar_from_value(&name_value, "option name")
421 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
422 let value = gather_if_needed_async(&args[index + 1])
423 .await
424 .map_err(map_control_flow)?;
425 apply_option(&mut options, &name, &value)?;
426 index += 2;
427 }
428 Ok(options)
429}
430
431fn apply_option(options: &mut WriteCellOptions, name: &str, value: &Value) -> BuiltinResult<()> {
432 if name.eq_ignore_ascii_case("Delimiter") {
433 options.delimiter = Some(parse_delimiter(value)?);
434 return Ok(());
435 }
436 if name.eq_ignore_ascii_case("WriteMode") {
437 options.write_mode = parse_write_mode(value)?;
438 return Ok(());
439 }
440 if name.eq_ignore_ascii_case("QuoteStrings") {
441 options.quote_strings = parse_bool_like(value, "QuoteStrings")?;
442 return Ok(());
443 }
444 if name.eq_ignore_ascii_case("LineEnding") {
445 options.line_ending = parse_line_ending(value)?;
446 return Ok(());
447 }
448 if name.eq_ignore_ascii_case("FileType") {
449 options.file_type = Some(parse_file_type(value)?);
450 return Ok(());
451 }
452 if name.eq_ignore_ascii_case("Sheet") {
453 options.sheet = parse_sheet(value)?;
454 return Ok(());
455 }
456 if name.eq_ignore_ascii_case("Range") {
457 options.range = Some(parse_range_start(value)?);
458 return Ok(());
459 }
460 Ok(())
461}
462
463fn parse_delimiter(value: &Value) -> BuiltinResult<String> {
464 let text = string_scalar_from_value(value, "Delimiter")
465 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
466 if text.is_empty() {
467 return Err(writecell_error_with(
468 &WRITECELL_ERROR_OPTION,
469 "writecell: Delimiter cannot be empty",
470 ));
471 }
472 let trimmed = text.trim();
473 match trimmed.to_ascii_lowercase().as_str() {
474 "tab" => Ok("\t".to_string()),
475 "space" | "whitespace" => Ok(" ".to_string()),
476 "comma" => Ok(",".to_string()),
477 "semicolon" => Ok(";".to_string()),
478 "pipe" => Ok("|".to_string()),
479 _ => Ok(trimmed.to_string()),
480 }
481}
482
483fn parse_write_mode(value: &Value) -> BuiltinResult<WriteMode> {
484 let text = string_scalar_from_value(value, "WriteMode")
485 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
486 match text.trim().to_ascii_lowercase().as_str() {
487 "overwrite" => Ok(WriteMode::Overwrite),
488 "append" => Ok(WriteMode::Append),
489 _ => Err(writecell_error_with(
490 &WRITECELL_ERROR_OPTION,
491 "writecell: WriteMode must be 'overwrite' or 'append'",
492 )),
493 }
494}
495
496fn parse_bool_like(value: &Value, context: &str) -> BuiltinResult<bool> {
497 match value {
498 Value::Bool(b) => Ok(*b),
499 Value::Int(i) => match i.to_i64() {
500 0 => Ok(false),
501 1 => Ok(true),
502 _ => Err(writecell_error_with(
503 &WRITECELL_ERROR_OPTION,
504 format!("writecell: {context} must be logical (0 or 1)"),
505 )),
506 },
507 Value::Num(n) if (*n - 0.0).abs() < f64::EPSILON => Ok(false),
508 Value::Num(n) if (*n - 1.0).abs() < f64::EPSILON => Ok(true),
509 _ => {
510 let text = string_scalar_from_value(value, context)
511 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
512 match text.trim().to_ascii_lowercase().as_str() {
513 "on" | "true" | "yes" | "1" => Ok(true),
514 "off" | "false" | "no" | "0" => Ok(false),
515 _ => Err(writecell_error_with(
516 &WRITECELL_ERROR_OPTION,
517 format!("writecell: {context} must be logical (true/on or false/off)"),
518 )),
519 }
520 }
521 }
522}
523
524fn parse_line_ending(value: &Value) -> BuiltinResult<LineEnding> {
525 let text = string_scalar_from_value(value, "LineEnding")
526 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
527 match text.trim().to_ascii_lowercase().as_str() {
528 "auto" => Ok(LineEnding::Auto),
529 "unix" => Ok(LineEnding::Unix),
530 "pc" | "windows" => Ok(LineEnding::Windows),
531 "mac" => Ok(LineEnding::Mac),
532 _ => Err(writecell_error_with(
533 &WRITECELL_ERROR_OPTION,
534 "writecell: LineEnding must be 'auto', 'unix', 'pc', or 'mac'",
535 )),
536 }
537}
538
539fn parse_file_type(value: &Value) -> BuiltinResult<OutputFileType> {
540 let text = string_scalar_from_value(value, "FileType")
541 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
542 match text.trim().to_ascii_lowercase().as_str() {
543 "text" | "delimitedtext" => Ok(OutputFileType::DelimitedText),
544 "spreadsheet" => Ok(OutputFileType::Spreadsheet),
545 _ => Err(writecell_error_with(
546 &WRITECELL_ERROR_OPTION,
547 "writecell: FileType must be 'text', 'delimitedtext', or 'spreadsheet'",
548 )),
549 }
550}
551
552fn parse_sheet(value: &Value) -> BuiltinResult<SheetSelector> {
553 match value {
554 Value::Num(n) if n.is_finite() && *n >= 1.0 && n.fract() == 0.0 => {
555 Ok(SheetSelector::Index(*n as usize))
556 }
557 Value::Int(i) if i.to_i64() >= 1 => Ok(SheetSelector::Index(i.to_i64() as usize)),
558 _ => {
559 let text = string_scalar_from_value(value, "Sheet")
560 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
561 if text.trim().is_empty() {
562 return Err(writecell_error_with(
563 &WRITECELL_ERROR_OPTION,
564 "writecell: Sheet name cannot be empty",
565 ));
566 }
567 Ok(SheetSelector::Name(text))
568 }
569 }
570}
571
572fn parse_range_start(value: &Value) -> BuiltinResult<RangeStart> {
573 let text = string_scalar_from_value(value, "Range")
574 .map_err(|message| writecell_error_with(&WRITECELL_ERROR_OPTION, message))?;
575 let start = text.split(':').next().unwrap_or("").trim();
576 parse_a1_cell(start).ok_or_else(|| {
577 writecell_error_with(
578 &WRITECELL_ERROR_OPTION,
579 "writecell: Range must start with an Excel A1 cell reference",
580 )
581 })
582}
583
584fn parse_a1_cell(value: &str) -> Option<RangeStart> {
585 if value.is_empty() {
586 return None;
587 }
588 let mut col = 0usize;
589 let mut letters = 0usize;
590 for ch in value.chars() {
591 if ch.is_ascii_alphabetic() {
592 if letters == 0 && col != 0 {
593 return None;
594 }
595 col = col.checked_mul(26)?;
596 col = col.checked_add((ch.to_ascii_uppercase() as u8 - b'A' + 1) as usize)?;
597 letters += 1;
598 } else {
599 break;
600 }
601 }
602 let row_text = &value[letters..];
603 if letters == 0 || row_text.is_empty() || !row_text.chars().all(|ch| ch.is_ascii_digit()) {
604 return None;
605 }
606 let row: usize = row_text.parse().ok()?;
607 if row == 0 || col == 0 {
608 return None;
609 }
610 Some(RangeStart {
611 row: row - 1,
612 col: col - 1,
613 })
614}
615
616#[derive(Debug, Clone, PartialEq)]
617enum CellValue {
618 Empty,
619 Number(f64),
620 Boolean(bool),
621 Text(String),
622}
623
624struct CellTable {
625 rows: usize,
626 cols: usize,
627 data: Vec<CellValue>,
628}
629
630impl CellTable {
631 async fn from_value(value: Value) -> BuiltinResult<Self> {
632 let cell = match value {
633 Value::Cell(cell) => cell,
634 other => {
635 return Err(writecell_error_with(
636 &WRITECELL_ERROR_DATA,
637 format!("writecell: input must be a cell array, got {other:?}"),
638 ));
639 }
640 };
641 ensure_cell_shape(&cell)?;
642
643 let mut data = Vec::with_capacity(cell.data.len());
644 for row in 0..cell.rows {
645 for col in 0..cell.cols {
646 let value = cell.get(row, col).map_err(|message| {
647 writecell_error_with(&WRITECELL_ERROR_DATA, format!("writecell: {message}"))
648 })?;
649 let gathered = gather_if_needed_async(&value)
650 .await
651 .map_err(map_control_flow)?;
652 data.push(cell_value_from_value(gathered)?);
653 }
654 }
655 Ok(Self {
656 rows: cell.rows,
657 cols: cell.cols,
658 data,
659 })
660 }
661
662 fn get(&self, row: usize, col: usize) -> &CellValue {
663 &self.data[row * self.cols + col]
664 }
665}
666
667fn ensure_cell_shape(cell: &CellArray) -> BuiltinResult<()> {
668 if cell.shape.len() <= 2 || cell.shape[2..].iter().all(|&dim| dim == 1) {
669 return Ok(());
670 }
671 Err(writecell_error_with(
672 &WRITECELL_ERROR_DATA_SHAPE,
673 "writecell: input cell array must be 2-D",
674 ))
675}
676
677fn cell_value_from_value(value: Value) -> BuiltinResult<CellValue> {
678 match value {
679 Value::Num(n) => Ok(CellValue::Number(n)),
680 Value::Int(i) => Ok(CellValue::Number(i.to_f64())),
681 Value::Bool(b) => Ok(CellValue::Boolean(b)),
682 Value::String(s) => Ok(CellValue::Text(s)),
683 Value::CharArray(ca) if ca.rows == 1 => Ok(CellValue::Text(ca.data.iter().collect())),
684 Value::StringArray(sa) if sa.data.len() == 1 => Ok(CellValue::Text(sa.data[0].clone())),
685 Value::StringArray(sa) if sa.data.is_empty() => Ok(CellValue::Empty),
686 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(CellValue::Number(tensor.data[0])),
687 Value::Tensor(tensor) if tensor.data.is_empty() => Ok(CellValue::Empty),
688 Value::LogicalArray(logical) if logical.data.len() == 1 => {
689 Ok(CellValue::Boolean(logical.data[0] != 0))
690 }
691 Value::LogicalArray(logical) if logical.data.is_empty() => Ok(CellValue::Empty),
692 Value::Complex(_, _) | Value::ComplexTensor(_) => Err(writecell_error_with(
693 &WRITECELL_ERROR_DATA,
694 "writecell: complex values are not supported; split real and imaginary parts first",
695 )),
696 Value::Cell(_) => Err(writecell_error_with(
697 &WRITECELL_ERROR_DATA,
698 "writecell: nested cell arrays are not supported",
699 )),
700 other => Err(writecell_error_with(
701 &WRITECELL_ERROR_DATA,
702 format!("writecell: unsupported cell value {other:?}"),
703 )),
704 }
705}
706
707async fn write_delimited_cells(
708 path: &Path,
709 table: &CellTable,
710 options: &WriteCellOptions,
711) -> BuiltinResult<usize> {
712 let delimiter = options.resolve_delimiter(path);
713 let line_ending = options.line_ending.as_str();
714 let payload = build_delimited_payload(table, options, &delimiter, line_ending);
715 let write_lock = write_lock_for_path(path).await;
716 let _write_guard = write_lock.lock().await;
717
718 if options.write_mode == WriteMode::Overwrite {
719 safe_replace_file(path, &payload, "delimited text").await?;
720 return Ok(payload.len());
721 }
722
723 let mut open_options = OpenOptions::new();
724 open_options.create(true).write(true).append(true);
725
726 let mut file = open_options.open_async(path).await.map_err(|err| {
727 writecell_error_with_source(
728 &WRITECELL_ERROR_IO,
729 format!(
730 "writecell: unable to open \"{}\" for writing ({err})",
731 path.display()
732 ),
733 err,
734 )
735 })?;
736
737 let mut bytes_written = 0usize;
738 if append_needs_line_ending(path).await? {
739 file.write_all(line_ending.as_bytes()).map_err(|err| {
740 writecell_error_with_source(
741 &WRITECELL_ERROR_IO,
742 format!("writecell: failed to write append line ending ({err})"),
743 err,
744 )
745 })?;
746 bytes_written += line_ending.len();
747 }
748 file.write_all(&payload).map_err(|err| {
749 writecell_error_with_source(
750 &WRITECELL_ERROR_IO,
751 format!("writecell: failed to write delimited text ({err})"),
752 err,
753 )
754 })?;
755 bytes_written += payload.len();
756 file.flush_async().await.map_err(|err| {
757 writecell_error_with_source(
758 &WRITECELL_ERROR_IO,
759 format!("writecell: failed to flush output ({err})"),
760 err,
761 )
762 })?;
763 Ok(bytes_written)
764}
765
766async fn write_lock_for_path(path: &Path) -> WriteLock {
767 let key = write_lock_key(path).await;
768 let locks = WRITE_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
769 let mut locks = locks
770 .lock()
771 .expect("writecell write lock registry poisoned");
772 if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
773 return lock;
774 }
775 locks.retain(|_, lock| lock.strong_count() > 0);
776 let lock = Arc::new(AsyncMutex::new(()));
777 locks.insert(key, Arc::downgrade(&lock));
778 lock
779}
780
781async fn write_lock_key(path: &Path) -> String {
782 if let Ok(canonical) = runmat_filesystem::canonicalize_async(path).await {
783 return canonical.to_string_lossy().into_owned();
784 }
785
786 let absolute = lexical_absolute_path(path);
787 let mut candidate = absolute.as_path();
788 let mut suffix = PathBuf::new();
789 loop {
790 if let Ok(canonical) = runmat_filesystem::canonicalize_async(candidate).await {
791 let keyed = if suffix.as_os_str().is_empty() {
792 canonical
793 } else {
794 canonical.join(&suffix)
795 };
796 return keyed.to_string_lossy().into_owned();
797 }
798 let Some(name) = candidate.file_name() else {
799 break;
800 };
801 let mut next_suffix = PathBuf::from(name);
802 if !suffix.as_os_str().is_empty() {
803 next_suffix.push(&suffix);
804 }
805 suffix = next_suffix;
806 let Some(parent) = candidate.parent() else {
807 break;
808 };
809 if parent == candidate {
810 break;
811 }
812 candidate = parent;
813 }
814
815 lexical_normalize_path(absolute)
816 .to_string_lossy()
817 .into_owned()
818}
819
820fn lexical_absolute_path(path: &Path) -> PathBuf {
821 let absolute = if path.is_absolute() {
822 path.to_path_buf()
823 } else {
824 runmat_filesystem::current_dir()
825 .map(|cwd| cwd.join(path))
826 .unwrap_or_else(|_| path.to_path_buf())
827 };
828 lexical_normalize_path(absolute)
829}
830
831fn lexical_normalize_path(path: PathBuf) -> PathBuf {
832 let mut normalized = PathBuf::new();
833 for component in path.components() {
834 match component {
835 Component::CurDir => {}
836 Component::ParentDir => {
837 normalized.pop();
838 }
839 other => normalized.push(other.as_os_str()),
840 }
841 }
842 normalized
843}
844
845fn build_delimited_payload(
846 table: &CellTable,
847 options: &WriteCellOptions,
848 delimiter: &str,
849 line_ending: &str,
850) -> Vec<u8> {
851 let mut payload = Vec::new();
852 for row in 0..table.rows {
853 for col in 0..table.cols {
854 if col > 0 {
855 payload.extend_from_slice(delimiter.as_bytes());
856 }
857 let rendered = format_cell_for_text(table.get(row, col), options, delimiter);
858 if !rendered.is_empty() {
859 payload.extend_from_slice(rendered.as_bytes());
860 }
861 }
862 payload.extend_from_slice(line_ending.as_bytes());
863 }
864 payload
865}
866
867async fn append_needs_line_ending(path: &Path) -> BuiltinResult<bool> {
868 let metadata = match runmat_filesystem::metadata_async(path).await {
869 Ok(metadata) => metadata,
870 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
871 Err(err) => {
872 return Err(writecell_error_with_source(
873 &WRITECELL_ERROR_IO,
874 format!(
875 "writecell: unable to inspect \"{}\" ({err})",
876 path.display()
877 ),
878 err,
879 ));
880 }
881 };
882 if metadata.is_empty() {
883 return Ok(false);
884 }
885 let mut file = File::open_async(path).await.map_err(|err| {
886 writecell_error_with_source(
887 &WRITECELL_ERROR_IO,
888 format!(
889 "writecell: unable to inspect \"{}\" ({err})",
890 path.display()
891 ),
892 err,
893 )
894 })?;
895 file.seek(SeekFrom::End(-1)).map_err(|err| {
896 writecell_error_with_source(
897 &WRITECELL_ERROR_IO,
898 format!("writecell: unable to inspect file ending ({err})"),
899 err,
900 )
901 })?;
902 let mut byte = [0u8; 1];
903 file.read_exact(&mut byte).map_err(|err| {
904 writecell_error_with_source(
905 &WRITECELL_ERROR_IO,
906 format!("writecell: unable to read file ending ({err})"),
907 err,
908 )
909 })?;
910 Ok(!matches!(byte[0], b'\n' | b'\r'))
911}
912
913async fn write_spreadsheet_cells(
914 path: &Path,
915 table: &CellTable,
916 options: &WriteCellOptions,
917) -> BuiltinResult<usize> {
918 if options.write_mode == WriteMode::Append {
919 return Err(writecell_error_with(
920 &WRITECELL_ERROR_OPTION,
921 "writecell: WriteMode 'append' is not supported for spreadsheet files",
922 ));
923 }
924 let range_start = options.range_start();
925 let end_row = range_start.row.checked_add(table.rows).ok_or_else(|| {
926 writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range row overflow")
927 })?;
928 let end_col = range_start.col.checked_add(table.cols).ok_or_else(|| {
929 writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range column overflow")
930 })?;
931 if end_row > MAX_EXCEL_ROW_INDEX + 1 || end_col > MAX_EXCEL_COLUMN_INDEX + 1 {
932 return Err(writecell_error_with(
933 &WRITECELL_ERROR_OPTION,
934 "writecell: Range exceeds Excel worksheet limits",
935 ));
936 }
937
938 let bytes = build_xlsx_workbook(table, &options.sheet_name(), range_start)?;
939 safe_replace_file(path, &bytes, "spreadsheet").await?;
940 Ok(bytes.len())
941}
942
943async fn safe_replace_file(path: &Path, bytes: &[u8], label: &str) -> BuiltinResult<()> {
944 let temp_path = temporary_sibling_path(path);
945 let mut open_options = OpenOptions::new();
946 open_options.write(true).create_new(true);
947 let mut file = open_options.open_async(&temp_path).await.map_err(|err| {
948 writecell_error_with_source(
949 &WRITECELL_ERROR_IO,
950 format!(
951 "writecell: unable to create temporary {label} file \"{}\" ({err})",
952 temp_path.display()
953 ),
954 err,
955 )
956 })?;
957 file.write_all(bytes).map_err(|err| {
958 writecell_error_with_source(
959 &WRITECELL_ERROR_IO,
960 format!("writecell: failed to write spreadsheet ({err})"),
961 err,
962 )
963 })?;
964 file.flush_async().await.map_err(|err| {
965 writecell_error_with_source(
966 &WRITECELL_ERROR_IO,
967 format!("writecell: failed to flush temporary {label} file ({err})"),
968 err,
969 )
970 })?;
971 file.sync_all_async().await.map_err(|err| {
972 writecell_error_with_source(
973 &WRITECELL_ERROR_IO,
974 format!("writecell: failed to sync temporary {label} file ({err})"),
975 err,
976 )
977 })?;
978 drop(file);
979 if let Err(err) = runmat_filesystem::rename_async(&temp_path, path).await {
980 let _ = runmat_filesystem::remove_file_async(&temp_path).await;
981 return Err(writecell_error_with_source(
982 &WRITECELL_ERROR_IO,
983 format!(
984 "writecell: failed to replace \"{}\" with temporary {label} file ({err})",
985 path.display()
986 ),
987 err,
988 ));
989 }
990 Ok(())
991}
992
993fn temporary_sibling_path(path: &Path) -> PathBuf {
994 let parent = path.parent().unwrap_or_else(|| Path::new("."));
995 let name = path
996 .file_name()
997 .and_then(|value| value.to_str())
998 .unwrap_or("writecell");
999 let nanos = std::time::SystemTime::now()
1000 .duration_since(std::time::UNIX_EPOCH)
1001 .map(|duration| duration.as_nanos())
1002 .unwrap_or_default();
1003 parent.join(format!(".{name}.runmat-tmp-{}-{nanos}", std::process::id()))
1004}
1005
1006fn build_xlsx_workbook(
1007 table: &CellTable,
1008 sheet_name: &str,
1009 start: RangeStart,
1010) -> BuiltinResult<Vec<u8>> {
1011 let cursor = Cursor::new(Vec::new());
1012 let mut zip = zip::ZipWriter::new(cursor);
1013 write_xlsx_part(
1014 &mut zip,
1015 "[Content_Types].xml",
1016 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1017<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1018 <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1019 <Default Extension="xml" ContentType="application/xml"/>
1020 <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1021 <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1022 <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1023</Types>"#,
1024 )?;
1025 write_xlsx_part(
1026 &mut zip,
1027 "_rels/.rels",
1028 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1029<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1030 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1031</Relationships>"#,
1032 )?;
1033 write_xlsx_part(
1034 &mut zip,
1035 "xl/workbook.xml",
1036 &format!(
1037 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1038<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1039 <sheets>
1040 <sheet name="{}" sheetId="1" r:id="rId1"/>
1041 </sheets>
1042</workbook>"#,
1043 xml_attr_escape(sheet_name)
1044 ),
1045 )?;
1046 write_xlsx_part(
1047 &mut zip,
1048 "xl/_rels/workbook.xml.rels",
1049 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1050<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1051 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1052 <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
1053</Relationships>"#,
1054 )?;
1055 write_xlsx_part(
1056 &mut zip,
1057 "xl/styles.xml",
1058 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1059<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1060 <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
1061 <fills count="1"><fill><patternFill patternType="none"/></fill></fills>
1062 <borders count="1"><border/></borders>
1063 <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
1064 <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
1065</styleSheet>"#,
1066 )?;
1067 write_xlsx_part(
1068 &mut zip,
1069 "xl/worksheets/sheet1.xml",
1070 &build_sheet_xml(table, start),
1071 )?;
1072 let cursor = zip.finish().map_err(|err| {
1073 writecell_error_with_source(
1074 &WRITECELL_ERROR_IO,
1075 format!("writecell: failed to finish spreadsheet package ({err})"),
1076 err,
1077 )
1078 })?;
1079 Ok(cursor.into_inner())
1080}
1081
1082fn write_xlsx_part(
1083 zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>,
1084 name: &str,
1085 contents: &str,
1086) -> BuiltinResult<()> {
1087 let options =
1088 zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
1089 zip.start_file(name, options).map_err(|err| {
1090 writecell_error_with_source(
1091 &WRITECELL_ERROR_IO,
1092 format!("writecell: failed to start spreadsheet part {name} ({err})"),
1093 err,
1094 )
1095 })?;
1096 zip.write_all(contents.as_bytes()).map_err(|err| {
1097 writecell_error_with_source(
1098 &WRITECELL_ERROR_IO,
1099 format!("writecell: failed to write spreadsheet part {name} ({err})"),
1100 err,
1101 )
1102 })?;
1103 Ok(())
1104}
1105
1106fn build_sheet_xml(table: &CellTable, start: RangeStart) -> String {
1107 let mut xml = String::from(
1108 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1109<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1110 <sheetData>
1111"#,
1112 );
1113 for row in 0..table.rows {
1114 let excel_row = start.row + row + 1;
1115 xml.push_str(&format!(r#" <row r="{excel_row}">"#));
1116 xml.push('\n');
1117 for col in 0..table.cols {
1118 let cell = table.get(row, col);
1119 if *cell == CellValue::Empty {
1120 continue;
1121 }
1122 let reference = cell_reference(start.row + row, start.col + col);
1123 match cell {
1124 CellValue::Empty => {}
1125 CellValue::Number(value) => {
1126 xml.push_str(&format!(
1127 " <c r=\"{reference}\"><v>{}</v></c>\n",
1128 format_numeric(*value)
1129 ));
1130 }
1131 CellValue::Boolean(value) => {
1132 xml.push_str(&format!(
1133 " <c r=\"{reference}\" t=\"b\"><v>{}</v></c>\n",
1134 if *value { 1 } else { 0 }
1135 ));
1136 }
1137 CellValue::Text(text) => {
1138 xml.push_str(&format!(
1139 " <c r=\"{reference}\" t=\"inlineStr\"><is><t>{}</t></is></c>\n",
1140 xml_text_escape(text)
1141 ));
1142 }
1143 }
1144 }
1145 xml.push_str(" </row>\n");
1146 }
1147 xml.push_str(" </sheetData>\n</worksheet>");
1148 xml
1149}
1150
1151fn format_cell_for_text(cell: &CellValue, options: &WriteCellOptions, delimiter: &str) -> String {
1152 match cell {
1153 CellValue::Empty => String::new(),
1154 CellValue::Number(value) => format_numeric(*value),
1155 CellValue::Boolean(value) => {
1156 if *value {
1157 "1".to_string()
1158 } else {
1159 "0".to_string()
1160 }
1161 }
1162 CellValue::Text(text) => format_string(text, options.quote_strings, delimiter),
1163 }
1164}
1165
1166fn format_numeric(value: f64) -> String {
1167 if value.is_nan() {
1168 return "NaN".to_string();
1169 }
1170 if value.is_infinite() {
1171 return if value.is_sign_negative() {
1172 "-Inf".to_string()
1173 } else {
1174 "Inf".to_string()
1175 };
1176 }
1177
1178 let abs = value.abs();
1179 let scientific = abs != 0.0 && !(1e-4..1e15).contains(&abs);
1180 let raw = if scientific {
1181 format!("{:.15e}", value)
1182 } else {
1183 format!("{:.15}", value)
1184 };
1185 trim_trailing_zeros(raw)
1186}
1187
1188fn trim_trailing_zeros(mut value: String) -> String {
1189 if let Some(exp_pos) = value.find(['e', 'E']) {
1190 let exponent = value.split_off(exp_pos);
1191 while value.ends_with('0') {
1192 value.pop();
1193 }
1194 if value.ends_with('.') {
1195 value.pop();
1196 }
1197 value.push_str(&exponent);
1198 value
1199 } else {
1200 if value.contains('.') {
1201 while value.ends_with('0') {
1202 value.pop();
1203 }
1204 if value.ends_with('.') {
1205 value.pop();
1206 }
1207 }
1208 if value == "-0" || value.is_empty() {
1209 "0".to_string()
1210 } else {
1211 value
1212 }
1213 }
1214}
1215
1216fn format_string(value: &str, quote: bool, _delimiter: &str) -> String {
1217 if !quote {
1218 return value.to_string();
1219 }
1220 let mut escaped = String::with_capacity(value.len() + 2);
1221 escaped.push('"');
1222 for ch in value.chars() {
1223 if ch == '"' {
1224 escaped.push('"');
1225 escaped.push('"');
1226 } else {
1227 escaped.push(ch);
1228 }
1229 }
1230 escaped.push('"');
1231 escaped
1232}
1233
1234fn string_scalar_from_value(value: &Value, context: &str) -> Result<String, String> {
1235 match value {
1236 Value::String(s) => Ok(s.clone()),
1237 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
1238 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
1239 _ => Err(format!(
1240 "writecell: expected {context} as a string scalar or character vector"
1241 )),
1242 }
1243}
1244
1245fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
1246 match value {
1247 Value::String(s) => normalize_path(s),
1248 Value::CharArray(ca) if ca.rows == 1 => {
1249 let text: String = ca.data.iter().collect();
1250 normalize_path(&text)
1251 }
1252 Value::CharArray(_) => Err(writecell_error_with(
1253 &WRITECELL_ERROR_FILENAME,
1254 "writecell: expected a 1-by-N character vector for the filename",
1255 )),
1256 Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
1257 Value::StringArray(_) => Err(writecell_error_with(
1258 &WRITECELL_ERROR_FILENAME,
1259 "writecell: filename string array inputs must be scalar",
1260 )),
1261 other => Err(writecell_error_with(
1262 &WRITECELL_ERROR_FILENAME,
1263 format!(
1264 "writecell: expected filename as string scalar or character vector, got {other:?}"
1265 ),
1266 )),
1267 }
1268}
1269
1270fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
1271 if raw.trim().is_empty() {
1272 return Err(writecell_error_with(
1273 &WRITECELL_ERROR_FILENAME,
1274 "writecell: filename must not be empty",
1275 ));
1276 }
1277 let expanded = expand_user_path(raw, BUILTIN_NAME)
1278 .map_err(|msg| writecell_error_with(&WRITECELL_ERROR_FILENAME, msg))?;
1279 Ok(Path::new(&expanded).to_path_buf())
1280}
1281
1282fn default_delimiter_for_path(path: &Path) -> String {
1283 match path_extension_lower(path).as_deref() {
1284 Some("csv") => ",".to_string(),
1285 Some("tsv") | Some("tab") => "\t".to_string(),
1286 Some("txt") | Some("dat") | Some("dlm") => " ".to_string(),
1287 _ => ",".to_string(),
1288 }
1289}
1290
1291fn path_extension_lower(path: &Path) -> Option<String> {
1292 path.extension()
1293 .and_then(|s| s.to_str())
1294 .map(|s| s.to_ascii_lowercase())
1295}
1296
1297fn sanitize_sheet_name(value: &str) -> String {
1298 let mut name: String = value
1299 .chars()
1300 .map(|ch| match ch {
1301 ':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
1302 _ => ch,
1303 })
1304 .take(31)
1305 .collect();
1306 if name.trim().is_empty() {
1307 name = "Sheet1".to_string();
1308 }
1309 name
1310}
1311
1312fn cell_reference(row: usize, col: usize) -> String {
1313 format!("{}{}", column_letters(col), row + 1)
1314}
1315
1316fn column_letters(mut col: usize) -> String {
1317 let mut letters = Vec::new();
1318 col += 1;
1319 while col > 0 {
1320 let rem = (col - 1) % 26;
1321 letters.push((b'A' + rem as u8) as char);
1322 col = (col - 1) / 26;
1323 }
1324 letters.iter().rev().collect()
1325}
1326
1327fn xml_text_escape(value: &str) -> String {
1328 value
1329 .chars()
1330 .map(|ch| match ch {
1331 '&' => "&".to_string(),
1332 '<' => "<".to_string(),
1333 '>' => ">".to_string(),
1334 _ => ch.to_string(),
1335 })
1336 .collect()
1337}
1338
1339fn xml_attr_escape(value: &str) -> String {
1340 value
1341 .chars()
1342 .map(|ch| match ch {
1343 '&' => "&".to_string(),
1344 '<' => "<".to_string(),
1345 '>' => ">".to_string(),
1346 '"' => """.to_string(),
1347 '\'' => "'".to_string(),
1348 _ => ch.to_string(),
1349 })
1350 .collect()
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355 use super::*;
1356 use calamine::{open_workbook_auto, Data, Reader};
1357 use futures::executor::block_on;
1358 use runmat_time::unix_timestamp_ms;
1359 use std::fs;
1360 use std::sync::atomic::{AtomicU64, Ordering};
1361 #[cfg(not(target_arch = "wasm32"))]
1362 use std::sync::mpsc;
1363 #[cfg(not(target_arch = "wasm32"))]
1364 use std::sync::Barrier;
1365 #[cfg(not(target_arch = "wasm32"))]
1366 use std::thread;
1367 #[cfg(not(target_arch = "wasm32"))]
1368 use std::time::Duration;
1369
1370 use runmat_builtins::{CharArray, LogicalArray, Tensor};
1371
1372 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1373
1374 fn temp_path(ext: &str) -> PathBuf {
1375 let millis = unix_timestamp_ms();
1376 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1377 let mut path = std::env::temp_dir();
1378 path.push(format!(
1379 "runmat_writecell_{}_{}_{}.{}",
1380 std::process::id(),
1381 millis,
1382 unique,
1383 ext
1384 ));
1385 path
1386 }
1387
1388 fn cell(values: Vec<Value>, rows: usize, cols: usize) -> Value {
1389 Value::Cell(CellArray::new(values, rows, cols).expect("cell array"))
1390 }
1391
1392 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1393 #[test]
1394 fn writecell_descriptor_signatures_cover_core_forms() {
1395 let labels: Vec<&str> = WRITECELL_DESCRIPTOR
1396 .signatures
1397 .iter()
1398 .map(|sig| sig.label)
1399 .collect();
1400 assert!(labels.contains(&"bytesWritten = writecell(C, filename)"));
1401 assert!(labels.contains(&"bytesWritten = writecell(C, filename, name, optionValue)"));
1402 assert!(labels.contains(&"bytesWritten = writecell(C, filename, nameValuePairs...)"));
1403 }
1404
1405 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1406 #[test]
1407 fn writecell_writes_heterogeneous_csv() {
1408 let path = temp_path("csv");
1409 let filename = path.to_string_lossy().into_owned();
1410 let values = cell(
1411 vec![
1412 Value::Num(1.5),
1413 Value::from("alpha"),
1414 Value::Bool(true),
1415 Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor")),
1416 ],
1417 2,
1418 2,
1419 );
1420
1421 block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1422
1423 let contents = fs::read_to_string(&path).expect("read contents");
1424 assert_eq!(contents, "1.5,\"alpha\"\n1,\n");
1425 let _ = fs::remove_file(path);
1426 }
1427
1428 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1429 #[test]
1430 fn writecell_honours_delimiter_quote_strings_and_append() {
1431 let path = temp_path("txt");
1432 let filename = path.to_string_lossy().into_owned();
1433 let first = cell(vec![Value::from("a,b"), Value::Num(2.0)], 1, 2);
1434 let second = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1435
1436 block_on(writecell_builtin(
1437 first,
1438 vec![
1439 Value::from(filename.clone()),
1440 Value::from("Delimiter"),
1441 Value::from("|"),
1442 Value::from("QuoteStrings"),
1443 Value::Bool(false),
1444 ],
1445 ))
1446 .expect("initial write");
1447 block_on(writecell_builtin(
1448 second,
1449 vec![
1450 Value::from(filename.clone()),
1451 Value::from("Delimiter"),
1452 Value::from("|"),
1453 Value::from("QuoteStrings"),
1454 Value::Bool(false),
1455 Value::from("WriteMode"),
1456 Value::from("append"),
1457 ],
1458 ))
1459 .expect("append write");
1460
1461 let contents = fs::read_to_string(&path).expect("read contents");
1462 assert_eq!(contents, "a,b|2\ntail|3\n");
1463 let _ = fs::remove_file(path);
1464 }
1465
1466 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1467 #[test]
1468 fn writecell_append_inserts_missing_row_boundary() {
1469 let path = temp_path("txt");
1470 fs::write(&path, "existing").expect("seed");
1471 let filename = path.to_string_lossy().into_owned();
1472 let values = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1473
1474 block_on(writecell_builtin(
1475 values,
1476 vec![
1477 Value::from(filename),
1478 Value::from("Delimiter"),
1479 Value::from("|"),
1480 Value::from("QuoteStrings"),
1481 Value::Bool(false),
1482 Value::from("WriteMode"),
1483 Value::from("append"),
1484 ],
1485 ))
1486 .expect("append write");
1487
1488 let contents = fs::read_to_string(&path).expect("read contents");
1489 assert_eq!(contents, "existing\ntail|3\n");
1490 let _ = fs::remove_file(path);
1491 }
1492
1493 #[cfg(not(target_arch = "wasm32"))]
1494 #[test]
1495 fn writecell_concurrent_appends_share_one_boundary_insertion() {
1496 let path = temp_path("txt");
1497 fs::write(&path, "existing").expect("seed");
1498 let filename = path.to_string_lossy().into_owned();
1499 let writers = 8usize;
1500 let barrier = Arc::new(Barrier::new(writers));
1501 let mut handles = Vec::new();
1502 for idx in 0..writers {
1503 let barrier = Arc::clone(&barrier);
1504 let filename = filename.clone();
1505 handles.push(thread::spawn(move || {
1506 barrier.wait();
1507 let values = cell(
1508 vec![Value::from(format!("row{idx}")), Value::Num(idx as f64)],
1509 1,
1510 2,
1511 );
1512 block_on(writecell_builtin(
1513 values,
1514 vec![
1515 Value::from(filename),
1516 Value::from("Delimiter"),
1517 Value::from("|"),
1518 Value::from("QuoteStrings"),
1519 Value::Bool(false),
1520 Value::from("WriteMode"),
1521 Value::from("append"),
1522 ],
1523 ))
1524 .expect("append write");
1525 }));
1526 }
1527 for handle in handles {
1528 handle.join().expect("writer thread");
1529 }
1530
1531 let contents = fs::read_to_string(&path).expect("read contents");
1532 let lines = contents.lines().collect::<Vec<_>>();
1533 assert_eq!(lines.len(), writers + 1);
1534 assert_eq!(lines[0], "existing");
1535 assert!(lines.iter().all(|line| !line.is_empty()));
1536 for idx in 0..writers {
1537 let expected = format!("row{idx}|{idx}");
1538 assert!(lines.iter().any(|line| *line == expected));
1539 }
1540 let _ = fs::remove_file(path);
1541 }
1542
1543 #[cfg(not(target_arch = "wasm32"))]
1544 #[test]
1545 fn writecell_overwrite_uses_same_path_write_lock() {
1546 let path = temp_path("txt");
1547 fs::write(&path, "existing\n").expect("seed");
1548 let filename = path.to_string_lossy().into_owned();
1549 let lock = block_on(write_lock_for_path(&path));
1550 let guard = block_on(lock.lock());
1551 let (tx, rx) = mpsc::channel();
1552
1553 let handle = thread::spawn(move || {
1554 let values = cell(vec![Value::from("replacement")], 1, 1);
1555 block_on(writecell_builtin(values, vec![Value::from(filename)]))
1556 .expect("overwrite write");
1557 tx.send(()).expect("send completion");
1558 });
1559
1560 thread::sleep(Duration::from_millis(50));
1561 assert!(rx.try_recv().is_err());
1562 drop(guard);
1563 handle.join().expect("writer thread");
1564 rx.recv_timeout(Duration::from_secs(1))
1565 .expect("overwrite completion");
1566
1567 let contents = fs::read_to_string(&path).expect("read contents");
1568 assert_eq!(contents, "\"replacement\"\n");
1569 let _ = fs::remove_file(path);
1570 }
1571
1572 #[cfg(unix)]
1573 #[test]
1574 fn writecell_canonical_aliases_share_write_lock() {
1575 let path = temp_path("txt");
1576 fs::write(&path, "").expect("seed");
1577 let mut link = path.clone();
1578 link.set_extension("link.txt");
1579 std::os::unix::fs::symlink(&path, &link).expect("symlink");
1580
1581 let direct = block_on(write_lock_for_path(&path));
1582 let alias = block_on(write_lock_for_path(&link));
1583 assert!(Arc::ptr_eq(&direct, &alias));
1584
1585 let _ = fs::remove_file(link);
1586 let _ = fs::remove_file(path);
1587 }
1588
1589 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1590 #[test]
1591 fn writecell_accepts_scalar_char_tensor_and_logical_cells() {
1592 let path = temp_path("csv");
1593 let filename = path.to_string_lossy().into_owned();
1594 let values = cell(
1595 vec![
1596 Value::CharArray(CharArray::new_row("name")),
1597 Value::Tensor(Tensor::new(vec![42.0], vec![1, 1]).expect("scalar tensor")),
1598 Value::LogicalArray(LogicalArray::new(vec![0], vec![1, 1]).expect("logical")),
1599 ],
1600 1,
1601 3,
1602 );
1603
1604 block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1605
1606 let contents = fs::read_to_string(&path).expect("read contents");
1607 assert_eq!(contents, "\"name\",42,0\n");
1608 let _ = fs::remove_file(path);
1609 }
1610
1611 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1612 #[test]
1613 fn writecell_rejects_nested_cells_and_nonscalar_arrays() {
1614 let path = temp_path("csv");
1615 let filename = path.to_string_lossy().into_owned();
1616 let nested = cell(vec![cell(vec![Value::Num(1.0)], 1, 1)], 1, 1);
1617 let err = block_on(writecell_builtin(
1618 nested,
1619 vec![Value::from(filename.clone())],
1620 ))
1621 .expect_err("nested cell error");
1622 assert!(err.message().contains("nested cell arrays"));
1623
1624 let nonscalar = cell(
1625 vec![Value::Tensor(
1626 Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor"),
1627 )],
1628 1,
1629 1,
1630 );
1631 let err = block_on(writecell_builtin(nonscalar, vec![Value::from(filename)]))
1632 .expect_err("nonscalar error");
1633 assert!(err.message().contains("unsupported cell value"));
1634 }
1635
1636 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1637 #[test]
1638 fn writecell_writes_xlsx_with_sheet_and_range() {
1639 let path = temp_path("xlsx");
1640 let filename = path.to_string_lossy().into_owned();
1641 let values = cell(
1642 vec![Value::from("Voltage"), Value::Num(1.5), Value::Bool(true)],
1643 1,
1644 3,
1645 );
1646
1647 block_on(writecell_builtin(
1648 values,
1649 vec![
1650 Value::from(filename),
1651 Value::from("Sheet"),
1652 Value::from("Measurements"),
1653 Value::from("Range"),
1654 Value::from("B2"),
1655 ],
1656 ))
1657 .expect("writecell xlsx");
1658
1659 let mut workbook = open_workbook_auto(&path).expect("open workbook");
1660 assert_eq!(workbook.sheet_names()[0], "Measurements");
1661 let range = workbook
1662 .worksheet_range("Measurements")
1663 .expect("worksheet range");
1664 assert_eq!(
1665 range.get((0, 0)),
1666 Some(&Data::String("Voltage".to_string()))
1667 );
1668 assert_eq!(range.get((0, 1)), Some(&Data::Float(1.5)));
1669 assert_eq!(range.get((0, 2)), Some(&Data::Bool(true)));
1670 let _ = fs::remove_file(path);
1671 }
1672
1673 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1674 #[test]
1675 fn writecell_rejects_unsupported_spreadsheet_extension() {
1676 let path = temp_path("xls");
1677 let filename = path.to_string_lossy().into_owned();
1678 let values = cell(vec![Value::from("A"), Value::Num(1.0)], 1, 2);
1679 let err = block_on(writecell_builtin(values, vec![Value::from(filename)]))
1680 .expect_err("unsupported extension");
1681 assert!(err
1682 .message()
1683 .contains("unsupported spreadsheet file extension"));
1684 let _ = fs::remove_file(path);
1685 }
1686}