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;
27pub(super) type 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)]
346pub(super) struct RangeStart {
347 pub(super) row: usize,
348 pub(super) 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)]
617pub(super) enum CellValue {
618 Empty,
619 Number(f64),
620 Boolean(bool),
621 Text(String),
622}
623
624pub(super) struct CellTable {
625 pub(super) rows: usize,
626 pub(super) cols: usize,
627 data: Vec<CellValue>,
628}
629
630impl CellTable {
631 pub(super) fn from_cells(
632 rows: usize,
633 cols: usize,
634 data: Vec<CellValue>,
635 ) -> BuiltinResult<Self> {
636 let expected = rows.checked_mul(cols).ok_or_else(|| {
637 writecell_error_with(&WRITECELL_ERROR_DATA, "writecell: cell table size overflow")
638 })?;
639 if data.len() != expected {
640 return Err(writecell_error_with(
641 &WRITECELL_ERROR_DATA,
642 format!(
643 "writecell: cell table has {} values for {rows}-by-{cols} shape",
644 data.len()
645 ),
646 ));
647 }
648 Ok(Self { rows, cols, data })
649 }
650
651 async fn from_value(value: Value) -> BuiltinResult<Self> {
652 let cell = match value {
653 Value::Cell(cell) => cell,
654 other => {
655 return Err(writecell_error_with(
656 &WRITECELL_ERROR_DATA,
657 format!("writecell: input must be a cell array, got {other:?}"),
658 ));
659 }
660 };
661 ensure_cell_shape(&cell)?;
662
663 let mut data = Vec::with_capacity(cell.data.len());
664 for row in 0..cell.rows {
665 for col in 0..cell.cols {
666 let value = cell.get(row, col).map_err(|message| {
667 writecell_error_with(&WRITECELL_ERROR_DATA, format!("writecell: {message}"))
668 })?;
669 let gathered = gather_if_needed_async(&value)
670 .await
671 .map_err(map_control_flow)?;
672 data.push(cell_value_from_value(gathered)?);
673 }
674 }
675 Ok(Self {
676 rows: cell.rows,
677 cols: cell.cols,
678 data,
679 })
680 }
681
682 pub(super) fn get(&self, row: usize, col: usize) -> &CellValue {
683 &self.data[row * self.cols + col]
684 }
685}
686
687fn ensure_cell_shape(cell: &CellArray) -> BuiltinResult<()> {
688 if cell.shape.len() <= 2 || cell.shape[2..].iter().all(|&dim| dim == 1) {
689 return Ok(());
690 }
691 Err(writecell_error_with(
692 &WRITECELL_ERROR_DATA_SHAPE,
693 "writecell: input cell array must be 2-D",
694 ))
695}
696
697fn cell_value_from_value(value: Value) -> BuiltinResult<CellValue> {
698 match value {
699 Value::Num(n) => Ok(CellValue::Number(n)),
700 Value::Int(i) => Ok(CellValue::Number(i.to_f64())),
701 Value::Bool(b) => Ok(CellValue::Boolean(b)),
702 Value::String(s) => Ok(CellValue::Text(s)),
703 Value::CharArray(ca) if ca.rows == 1 => Ok(CellValue::Text(ca.data.iter().collect())),
704 Value::StringArray(sa) if sa.data.len() == 1 => Ok(CellValue::Text(sa.data[0].clone())),
705 Value::StringArray(sa) if sa.data.is_empty() => Ok(CellValue::Empty),
706 Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(CellValue::Number(tensor.data[0])),
707 Value::Tensor(tensor) if tensor.data.is_empty() => Ok(CellValue::Empty),
708 Value::LogicalArray(logical) if logical.data.len() == 1 => {
709 Ok(CellValue::Boolean(logical.data[0] != 0))
710 }
711 Value::LogicalArray(logical) if logical.data.is_empty() => Ok(CellValue::Empty),
712 Value::Complex(_, _) | Value::ComplexTensor(_) => Err(writecell_error_with(
713 &WRITECELL_ERROR_DATA,
714 "writecell: complex values are not supported; split real and imaginary parts first",
715 )),
716 Value::Cell(_) => Err(writecell_error_with(
717 &WRITECELL_ERROR_DATA,
718 "writecell: nested cell arrays are not supported",
719 )),
720 other => Err(writecell_error_with(
721 &WRITECELL_ERROR_DATA,
722 format!("writecell: unsupported cell value {other:?}"),
723 )),
724 }
725}
726
727async fn write_delimited_cells(
728 path: &Path,
729 table: &CellTable,
730 options: &WriteCellOptions,
731) -> BuiltinResult<usize> {
732 let delimiter = options.resolve_delimiter(path);
733 let line_ending = options.line_ending.as_str();
734 let payload = build_delimited_payload(table, options, &delimiter, line_ending);
735 let write_lock = write_lock_for_path(path).await;
736 let _write_guard = write_lock.lock().await;
737
738 if options.write_mode == WriteMode::Overwrite {
739 safe_replace_file(path, &payload, "delimited text").await?;
740 return Ok(payload.len());
741 }
742
743 let mut open_options = OpenOptions::new();
744 open_options.create(true).write(true).append(true);
745
746 let mut file = open_options.open_async(path).await.map_err(|err| {
747 writecell_error_with_source(
748 &WRITECELL_ERROR_IO,
749 format!(
750 "writecell: unable to open \"{}\" for writing ({err})",
751 path.display()
752 ),
753 err,
754 )
755 })?;
756
757 let mut bytes_written = 0usize;
758 if append_needs_line_ending(path).await? {
759 file.write_all(line_ending.as_bytes()).map_err(|err| {
760 writecell_error_with_source(
761 &WRITECELL_ERROR_IO,
762 format!("writecell: failed to write append line ending ({err})"),
763 err,
764 )
765 })?;
766 bytes_written += line_ending.len();
767 }
768 file.write_all(&payload).map_err(|err| {
769 writecell_error_with_source(
770 &WRITECELL_ERROR_IO,
771 format!("writecell: failed to write delimited text ({err})"),
772 err,
773 )
774 })?;
775 bytes_written += payload.len();
776 file.flush_async().await.map_err(|err| {
777 writecell_error_with_source(
778 &WRITECELL_ERROR_IO,
779 format!("writecell: failed to flush output ({err})"),
780 err,
781 )
782 })?;
783 Ok(bytes_written)
784}
785
786pub(super) async fn write_lock_for_path(path: &Path) -> WriteLock {
787 let key = write_lock_key(path).await;
788 let locks = WRITE_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
789 let mut locks = locks
790 .lock()
791 .expect("writecell write lock registry poisoned");
792 if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
793 return lock;
794 }
795 locks.retain(|_, lock| lock.strong_count() > 0);
796 let lock = Arc::new(AsyncMutex::new(()));
797 locks.insert(key, Arc::downgrade(&lock));
798 lock
799}
800
801async fn write_lock_key(path: &Path) -> String {
802 if let Ok(canonical) = runmat_filesystem::canonicalize_async(path).await {
803 return canonical.to_string_lossy().into_owned();
804 }
805
806 let absolute = lexical_absolute_path(path);
807 let mut candidate = absolute.as_path();
808 let mut suffix = PathBuf::new();
809 loop {
810 if let Ok(canonical) = runmat_filesystem::canonicalize_async(candidate).await {
811 let keyed = if suffix.as_os_str().is_empty() {
812 canonical
813 } else {
814 canonical.join(&suffix)
815 };
816 return keyed.to_string_lossy().into_owned();
817 }
818 let Some(name) = candidate.file_name() else {
819 break;
820 };
821 let mut next_suffix = PathBuf::from(name);
822 if !suffix.as_os_str().is_empty() {
823 next_suffix.push(&suffix);
824 }
825 suffix = next_suffix;
826 let Some(parent) = candidate.parent() else {
827 break;
828 };
829 if parent == candidate {
830 break;
831 }
832 candidate = parent;
833 }
834
835 lexical_normalize_path(absolute)
836 .to_string_lossy()
837 .into_owned()
838}
839
840fn lexical_absolute_path(path: &Path) -> PathBuf {
841 let absolute = if path.is_absolute() {
842 path.to_path_buf()
843 } else {
844 runmat_filesystem::current_dir()
845 .map(|cwd| cwd.join(path))
846 .unwrap_or_else(|_| path.to_path_buf())
847 };
848 lexical_normalize_path(absolute)
849}
850
851fn lexical_normalize_path(path: PathBuf) -> PathBuf {
852 let mut normalized = PathBuf::new();
853 for component in path.components() {
854 match component {
855 Component::CurDir => {}
856 Component::ParentDir => {
857 normalized.pop();
858 }
859 other => normalized.push(other.as_os_str()),
860 }
861 }
862 normalized
863}
864
865fn build_delimited_payload(
866 table: &CellTable,
867 options: &WriteCellOptions,
868 delimiter: &str,
869 line_ending: &str,
870) -> Vec<u8> {
871 let mut payload = Vec::new();
872 for row in 0..table.rows {
873 for col in 0..table.cols {
874 if col > 0 {
875 payload.extend_from_slice(delimiter.as_bytes());
876 }
877 let rendered = format_cell_for_text(table.get(row, col), options, delimiter);
878 if !rendered.is_empty() {
879 payload.extend_from_slice(rendered.as_bytes());
880 }
881 }
882 payload.extend_from_slice(line_ending.as_bytes());
883 }
884 payload
885}
886
887async fn append_needs_line_ending(path: &Path) -> BuiltinResult<bool> {
888 let metadata = match runmat_filesystem::metadata_async(path).await {
889 Ok(metadata) => metadata,
890 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
891 Err(err) => {
892 return Err(writecell_error_with_source(
893 &WRITECELL_ERROR_IO,
894 format!(
895 "writecell: unable to inspect \"{}\" ({err})",
896 path.display()
897 ),
898 err,
899 ));
900 }
901 };
902 if metadata.is_empty() {
903 return Ok(false);
904 }
905 let mut file = File::open_async(path).await.map_err(|err| {
906 writecell_error_with_source(
907 &WRITECELL_ERROR_IO,
908 format!(
909 "writecell: unable to inspect \"{}\" ({err})",
910 path.display()
911 ),
912 err,
913 )
914 })?;
915 file.seek(SeekFrom::End(-1)).map_err(|err| {
916 writecell_error_with_source(
917 &WRITECELL_ERROR_IO,
918 format!("writecell: unable to inspect file ending ({err})"),
919 err,
920 )
921 })?;
922 let mut byte = [0u8; 1];
923 file.read_exact(&mut byte).map_err(|err| {
924 writecell_error_with_source(
925 &WRITECELL_ERROR_IO,
926 format!("writecell: unable to read file ending ({err})"),
927 err,
928 )
929 })?;
930 Ok(!matches!(byte[0], b'\n' | b'\r'))
931}
932
933async fn write_spreadsheet_cells(
934 path: &Path,
935 table: &CellTable,
936 options: &WriteCellOptions,
937) -> BuiltinResult<usize> {
938 if options.write_mode == WriteMode::Append {
939 return Err(writecell_error_with(
940 &WRITECELL_ERROR_OPTION,
941 "writecell: WriteMode 'append' is not supported for spreadsheet files",
942 ));
943 }
944 let range_start = options.range_start();
945 let end_row = range_start.row.checked_add(table.rows).ok_or_else(|| {
946 writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range row overflow")
947 })?;
948 let end_col = range_start.col.checked_add(table.cols).ok_or_else(|| {
949 writecell_error_with(&WRITECELL_ERROR_OPTION, "writecell: Range column overflow")
950 })?;
951 if end_row > MAX_EXCEL_ROW_INDEX + 1 || end_col > MAX_EXCEL_COLUMN_INDEX + 1 {
952 return Err(writecell_error_with(
953 &WRITECELL_ERROR_OPTION,
954 "writecell: Range exceeds Excel worksheet limits",
955 ));
956 }
957
958 let bytes = build_xlsx_workbook(table, &options.sheet_name(), range_start)?;
959 safe_replace_file(path, &bytes, "spreadsheet").await?;
960 Ok(bytes.len())
961}
962
963pub(super) async fn safe_replace_file(path: &Path, bytes: &[u8], label: &str) -> BuiltinResult<()> {
964 let temp_path = temporary_sibling_path(path);
965 let mut open_options = OpenOptions::new();
966 open_options.write(true).create_new(true);
967 let mut file = open_options.open_async(&temp_path).await.map_err(|err| {
968 writecell_error_with_source(
969 &WRITECELL_ERROR_IO,
970 format!(
971 "writecell: unable to create temporary {label} file \"{}\" ({err})",
972 temp_path.display()
973 ),
974 err,
975 )
976 })?;
977 file.write_all(bytes).map_err(|err| {
978 writecell_error_with_source(
979 &WRITECELL_ERROR_IO,
980 format!("writecell: failed to write spreadsheet ({err})"),
981 err,
982 )
983 })?;
984 file.flush_async().await.map_err(|err| {
985 writecell_error_with_source(
986 &WRITECELL_ERROR_IO,
987 format!("writecell: failed to flush temporary {label} file ({err})"),
988 err,
989 )
990 })?;
991 file.sync_all_async().await.map_err(|err| {
992 writecell_error_with_source(
993 &WRITECELL_ERROR_IO,
994 format!("writecell: failed to sync temporary {label} file ({err})"),
995 err,
996 )
997 })?;
998 drop(file);
999 if let Err(err) = runmat_filesystem::rename_async(&temp_path, path).await {
1000 let _ = runmat_filesystem::remove_file_async(&temp_path).await;
1001 return Err(writecell_error_with_source(
1002 &WRITECELL_ERROR_IO,
1003 format!(
1004 "writecell: failed to replace \"{}\" with temporary {label} file ({err})",
1005 path.display()
1006 ),
1007 err,
1008 ));
1009 }
1010 Ok(())
1011}
1012
1013fn temporary_sibling_path(path: &Path) -> PathBuf {
1014 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1015 let name = path
1016 .file_name()
1017 .and_then(|value| value.to_str())
1018 .unwrap_or("writecell");
1019 let nanos = std::time::SystemTime::now()
1020 .duration_since(std::time::UNIX_EPOCH)
1021 .map(|duration| duration.as_nanos())
1022 .unwrap_or_default();
1023 parent.join(format!(".{name}.runmat-tmp-{}-{nanos}", std::process::id()))
1024}
1025
1026pub(super) fn build_xlsx_workbook(
1027 table: &CellTable,
1028 sheet_name: &str,
1029 start: RangeStart,
1030) -> BuiltinResult<Vec<u8>> {
1031 let cursor = Cursor::new(Vec::new());
1032 let mut zip = zip::ZipWriter::new(cursor);
1033 write_xlsx_part(
1034 &mut zip,
1035 "[Content_Types].xml",
1036 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1037<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1038 <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1039 <Default Extension="xml" ContentType="application/xml"/>
1040 <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1041 <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1042 <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1043</Types>"#,
1044 )?;
1045 write_xlsx_part(
1046 &mut zip,
1047 "_rels/.rels",
1048 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1049<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1050 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1051</Relationships>"#,
1052 )?;
1053 write_xlsx_part(
1054 &mut zip,
1055 "xl/workbook.xml",
1056 &format!(
1057 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1058<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1059 <sheets>
1060 <sheet name="{}" sheetId="1" r:id="rId1"/>
1061 </sheets>
1062</workbook>"#,
1063 xml_attr_escape(sheet_name)
1064 ),
1065 )?;
1066 write_xlsx_part(
1067 &mut zip,
1068 "xl/_rels/workbook.xml.rels",
1069 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1070<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1071 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1072 <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
1073</Relationships>"#,
1074 )?;
1075 write_xlsx_part(
1076 &mut zip,
1077 "xl/styles.xml",
1078 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1079<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1080 <fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
1081 <fills count="1"><fill><patternFill patternType="none"/></fill></fills>
1082 <borders count="1"><border/></borders>
1083 <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
1084 <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellXfs>
1085</styleSheet>"#,
1086 )?;
1087 write_xlsx_part(
1088 &mut zip,
1089 "xl/worksheets/sheet1.xml",
1090 &build_sheet_xml(table, start),
1091 )?;
1092 let cursor = zip.finish().map_err(|err| {
1093 writecell_error_with_source(
1094 &WRITECELL_ERROR_IO,
1095 format!("writecell: failed to finish spreadsheet package ({err})"),
1096 err,
1097 )
1098 })?;
1099 Ok(cursor.into_inner())
1100}
1101
1102pub(super) fn write_xlsx_part(
1103 zip: &mut zip::ZipWriter<Cursor<Vec<u8>>>,
1104 name: &str,
1105 contents: &str,
1106) -> BuiltinResult<()> {
1107 let options =
1108 zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
1109 zip.start_file(name, options).map_err(|err| {
1110 writecell_error_with_source(
1111 &WRITECELL_ERROR_IO,
1112 format!("writecell: failed to start spreadsheet part {name} ({err})"),
1113 err,
1114 )
1115 })?;
1116 zip.write_all(contents.as_bytes()).map_err(|err| {
1117 writecell_error_with_source(
1118 &WRITECELL_ERROR_IO,
1119 format!("writecell: failed to write spreadsheet part {name} ({err})"),
1120 err,
1121 )
1122 })?;
1123 Ok(())
1124}
1125
1126pub(super) fn build_sheet_xml(table: &CellTable, start: RangeStart) -> String {
1127 let mut xml = String::from(
1128 r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1129<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1130 <sheetData>
1131"#,
1132 );
1133 for row in 0..table.rows {
1134 let excel_row = start.row + row + 1;
1135 xml.push_str(&format!(r#" <row r="{excel_row}">"#));
1136 xml.push('\n');
1137 for col in 0..table.cols {
1138 let cell = table.get(row, col);
1139 if *cell == CellValue::Empty {
1140 continue;
1141 }
1142 let reference = cell_reference(start.row + row, start.col + col);
1143 match cell {
1144 CellValue::Empty => {}
1145 CellValue::Number(value) => {
1146 xml.push_str(&format!(
1147 " <c r=\"{reference}\"><v>{}</v></c>\n",
1148 format_numeric(*value)
1149 ));
1150 }
1151 CellValue::Boolean(value) => {
1152 xml.push_str(&format!(
1153 " <c r=\"{reference}\" t=\"b\"><v>{}</v></c>\n",
1154 if *value { 1 } else { 0 }
1155 ));
1156 }
1157 CellValue::Text(text) => {
1158 xml.push_str(&format!(
1159 " <c r=\"{reference}\" t=\"inlineStr\"><is><t>{}</t></is></c>\n",
1160 xml_text_escape(text)
1161 ));
1162 }
1163 }
1164 }
1165 xml.push_str(" </row>\n");
1166 }
1167 xml.push_str(" </sheetData>\n</worksheet>");
1168 xml
1169}
1170
1171fn format_cell_for_text(cell: &CellValue, options: &WriteCellOptions, delimiter: &str) -> String {
1172 match cell {
1173 CellValue::Empty => String::new(),
1174 CellValue::Number(value) => format_numeric(*value),
1175 CellValue::Boolean(value) => {
1176 if *value {
1177 "1".to_string()
1178 } else {
1179 "0".to_string()
1180 }
1181 }
1182 CellValue::Text(text) => format_string(text, options.quote_strings, delimiter),
1183 }
1184}
1185
1186fn format_numeric(value: f64) -> String {
1187 if value.is_nan() {
1188 return "NaN".to_string();
1189 }
1190 if value.is_infinite() {
1191 return if value.is_sign_negative() {
1192 "-Inf".to_string()
1193 } else {
1194 "Inf".to_string()
1195 };
1196 }
1197
1198 let abs = value.abs();
1199 let scientific = abs != 0.0 && !(1e-4..1e15).contains(&abs);
1200 let raw = if scientific {
1201 format!("{:.15e}", value)
1202 } else {
1203 format!("{:.15}", value)
1204 };
1205 trim_trailing_zeros(raw)
1206}
1207
1208fn trim_trailing_zeros(mut value: String) -> String {
1209 if let Some(exp_pos) = value.find(['e', 'E']) {
1210 let exponent = value.split_off(exp_pos);
1211 while value.ends_with('0') {
1212 value.pop();
1213 }
1214 if value.ends_with('.') {
1215 value.pop();
1216 }
1217 value.push_str(&exponent);
1218 value
1219 } else {
1220 if value.contains('.') {
1221 while value.ends_with('0') {
1222 value.pop();
1223 }
1224 if value.ends_with('.') {
1225 value.pop();
1226 }
1227 }
1228 if value == "-0" || value.is_empty() {
1229 "0".to_string()
1230 } else {
1231 value
1232 }
1233 }
1234}
1235
1236fn format_string(value: &str, quote: bool, _delimiter: &str) -> String {
1237 if !quote {
1238 return value.to_string();
1239 }
1240 let mut escaped = String::with_capacity(value.len() + 2);
1241 escaped.push('"');
1242 for ch in value.chars() {
1243 if ch == '"' {
1244 escaped.push('"');
1245 escaped.push('"');
1246 } else {
1247 escaped.push(ch);
1248 }
1249 }
1250 escaped.push('"');
1251 escaped
1252}
1253
1254fn string_scalar_from_value(value: &Value, context: &str) -> Result<String, String> {
1255 match value {
1256 Value::String(s) => Ok(s.clone()),
1257 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
1258 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
1259 _ => Err(format!(
1260 "writecell: expected {context} as a string scalar or character vector"
1261 )),
1262 }
1263}
1264
1265fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
1266 match value {
1267 Value::String(s) => normalize_path(s),
1268 Value::CharArray(ca) if ca.rows == 1 => {
1269 let text: String = ca.data.iter().collect();
1270 normalize_path(&text)
1271 }
1272 Value::CharArray(_) => Err(writecell_error_with(
1273 &WRITECELL_ERROR_FILENAME,
1274 "writecell: expected a 1-by-N character vector for the filename",
1275 )),
1276 Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
1277 Value::StringArray(_) => Err(writecell_error_with(
1278 &WRITECELL_ERROR_FILENAME,
1279 "writecell: filename string array inputs must be scalar",
1280 )),
1281 other => Err(writecell_error_with(
1282 &WRITECELL_ERROR_FILENAME,
1283 format!(
1284 "writecell: expected filename as string scalar or character vector, got {other:?}"
1285 ),
1286 )),
1287 }
1288}
1289
1290fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
1291 if raw.trim().is_empty() {
1292 return Err(writecell_error_with(
1293 &WRITECELL_ERROR_FILENAME,
1294 "writecell: filename must not be empty",
1295 ));
1296 }
1297 let expanded = expand_user_path(raw, BUILTIN_NAME)
1298 .map_err(|msg| writecell_error_with(&WRITECELL_ERROR_FILENAME, msg))?;
1299 Ok(Path::new(&expanded).to_path_buf())
1300}
1301
1302fn default_delimiter_for_path(path: &Path) -> String {
1303 match path_extension_lower(path).as_deref() {
1304 Some("csv") => ",".to_string(),
1305 Some("tsv") | Some("tab") => "\t".to_string(),
1306 Some("txt") | Some("dat") | Some("dlm") => " ".to_string(),
1307 _ => ",".to_string(),
1308 }
1309}
1310
1311fn path_extension_lower(path: &Path) -> Option<String> {
1312 path.extension()
1313 .and_then(|s| s.to_str())
1314 .map(|s| s.to_ascii_lowercase())
1315}
1316
1317fn sanitize_sheet_name(value: &str) -> String {
1318 let mut name: String = value
1319 .chars()
1320 .map(|ch| match ch {
1321 ':' | '\\' | '/' | '?' | '*' | '[' | ']' => '_',
1322 _ => ch,
1323 })
1324 .take(31)
1325 .collect();
1326 if name.trim().is_empty() {
1327 name = "Sheet1".to_string();
1328 }
1329 name
1330}
1331
1332fn cell_reference(row: usize, col: usize) -> String {
1333 format!("{}{}", column_letters(col), row + 1)
1334}
1335
1336fn column_letters(mut col: usize) -> String {
1337 let mut letters = Vec::new();
1338 col += 1;
1339 while col > 0 {
1340 let rem = (col - 1) % 26;
1341 letters.push((b'A' + rem as u8) as char);
1342 col = (col - 1) / 26;
1343 }
1344 letters.iter().rev().collect()
1345}
1346
1347fn xml_text_escape(value: &str) -> String {
1348 value
1349 .chars()
1350 .map(|ch| match ch {
1351 '&' => "&".to_string(),
1352 '<' => "<".to_string(),
1353 '>' => ">".to_string(),
1354 _ => ch.to_string(),
1355 })
1356 .collect()
1357}
1358
1359pub(super) fn xml_attr_escape(value: &str) -> String {
1360 value
1361 .chars()
1362 .map(|ch| match ch {
1363 '&' => "&".to_string(),
1364 '<' => "<".to_string(),
1365 '>' => ">".to_string(),
1366 '"' => """.to_string(),
1367 '\'' => "'".to_string(),
1368 _ => ch.to_string(),
1369 })
1370 .collect()
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375 use super::*;
1376 use calamine::{open_workbook_auto, Data, Reader};
1377 use futures::executor::block_on;
1378 use runmat_time::unix_timestamp_ms;
1379 use std::fs;
1380 use std::sync::atomic::{AtomicU64, Ordering};
1381 #[cfg(not(target_arch = "wasm32"))]
1382 use std::sync::mpsc;
1383 #[cfg(not(target_arch = "wasm32"))]
1384 use std::sync::Barrier;
1385 #[cfg(not(target_arch = "wasm32"))]
1386 use std::thread;
1387 #[cfg(not(target_arch = "wasm32"))]
1388 use std::time::Duration;
1389
1390 use runmat_builtins::{CharArray, LogicalArray, Tensor};
1391
1392 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1393
1394 fn temp_path(ext: &str) -> PathBuf {
1395 let millis = unix_timestamp_ms();
1396 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1397 let mut path = std::env::temp_dir();
1398 path.push(format!(
1399 "runmat_writecell_{}_{}_{}.{}",
1400 std::process::id(),
1401 millis,
1402 unique,
1403 ext
1404 ));
1405 path
1406 }
1407
1408 fn cell(values: Vec<Value>, rows: usize, cols: usize) -> Value {
1409 Value::Cell(CellArray::new(values, rows, cols).expect("cell array"))
1410 }
1411
1412 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1413 #[test]
1414 fn writecell_descriptor_signatures_cover_core_forms() {
1415 let labels: Vec<&str> = WRITECELL_DESCRIPTOR
1416 .signatures
1417 .iter()
1418 .map(|sig| sig.label)
1419 .collect();
1420 assert!(labels.contains(&"bytesWritten = writecell(C, filename)"));
1421 assert!(labels.contains(&"bytesWritten = writecell(C, filename, name, optionValue)"));
1422 assert!(labels.contains(&"bytesWritten = writecell(C, filename, nameValuePairs...)"));
1423 }
1424
1425 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1426 #[test]
1427 fn writecell_writes_heterogeneous_csv() {
1428 let path = temp_path("csv");
1429 let filename = path.to_string_lossy().into_owned();
1430 let values = cell(
1431 vec![
1432 Value::Num(1.5),
1433 Value::from("alpha"),
1434 Value::Bool(true),
1435 Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor")),
1436 ],
1437 2,
1438 2,
1439 );
1440
1441 block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1442
1443 let contents = fs::read_to_string(&path).expect("read contents");
1444 assert_eq!(contents, "1.5,\"alpha\"\n1,\n");
1445 let _ = fs::remove_file(path);
1446 }
1447
1448 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1449 #[test]
1450 fn writecell_honours_delimiter_quote_strings_and_append() {
1451 let path = temp_path("txt");
1452 let filename = path.to_string_lossy().into_owned();
1453 let first = cell(vec![Value::from("a,b"), Value::Num(2.0)], 1, 2);
1454 let second = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1455
1456 block_on(writecell_builtin(
1457 first,
1458 vec![
1459 Value::from(filename.clone()),
1460 Value::from("Delimiter"),
1461 Value::from("|"),
1462 Value::from("QuoteStrings"),
1463 Value::Bool(false),
1464 ],
1465 ))
1466 .expect("initial write");
1467 block_on(writecell_builtin(
1468 second,
1469 vec![
1470 Value::from(filename.clone()),
1471 Value::from("Delimiter"),
1472 Value::from("|"),
1473 Value::from("QuoteStrings"),
1474 Value::Bool(false),
1475 Value::from("WriteMode"),
1476 Value::from("append"),
1477 ],
1478 ))
1479 .expect("append write");
1480
1481 let contents = fs::read_to_string(&path).expect("read contents");
1482 assert_eq!(contents, "a,b|2\ntail|3\n");
1483 let _ = fs::remove_file(path);
1484 }
1485
1486 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1487 #[test]
1488 fn writecell_append_inserts_missing_row_boundary() {
1489 let path = temp_path("txt");
1490 fs::write(&path, "existing").expect("seed");
1491 let filename = path.to_string_lossy().into_owned();
1492 let values = cell(vec![Value::from("tail"), Value::Num(3.0)], 1, 2);
1493
1494 block_on(writecell_builtin(
1495 values,
1496 vec![
1497 Value::from(filename),
1498 Value::from("Delimiter"),
1499 Value::from("|"),
1500 Value::from("QuoteStrings"),
1501 Value::Bool(false),
1502 Value::from("WriteMode"),
1503 Value::from("append"),
1504 ],
1505 ))
1506 .expect("append write");
1507
1508 let contents = fs::read_to_string(&path).expect("read contents");
1509 assert_eq!(contents, "existing\ntail|3\n");
1510 let _ = fs::remove_file(path);
1511 }
1512
1513 #[cfg(not(target_arch = "wasm32"))]
1514 #[test]
1515 fn writecell_concurrent_appends_share_one_boundary_insertion() {
1516 let path = temp_path("txt");
1517 fs::write(&path, "existing").expect("seed");
1518 let filename = path.to_string_lossy().into_owned();
1519 let writers = 8usize;
1520 let barrier = Arc::new(Barrier::new(writers));
1521 let mut handles = Vec::new();
1522 for idx in 0..writers {
1523 let barrier = Arc::clone(&barrier);
1524 let filename = filename.clone();
1525 handles.push(thread::spawn(move || {
1526 barrier.wait();
1527 let values = cell(
1528 vec![Value::from(format!("row{idx}")), Value::Num(idx as f64)],
1529 1,
1530 2,
1531 );
1532 block_on(writecell_builtin(
1533 values,
1534 vec![
1535 Value::from(filename),
1536 Value::from("Delimiter"),
1537 Value::from("|"),
1538 Value::from("QuoteStrings"),
1539 Value::Bool(false),
1540 Value::from("WriteMode"),
1541 Value::from("append"),
1542 ],
1543 ))
1544 .expect("append write");
1545 }));
1546 }
1547 for handle in handles {
1548 handle.join().expect("writer thread");
1549 }
1550
1551 let contents = fs::read_to_string(&path).expect("read contents");
1552 let lines = contents.lines().collect::<Vec<_>>();
1553 assert_eq!(lines.len(), writers + 1);
1554 assert_eq!(lines[0], "existing");
1555 assert!(lines.iter().all(|line| !line.is_empty()));
1556 for idx in 0..writers {
1557 let expected = format!("row{idx}|{idx}");
1558 assert!(lines.iter().any(|line| *line == expected));
1559 }
1560 let _ = fs::remove_file(path);
1561 }
1562
1563 #[cfg(not(target_arch = "wasm32"))]
1564 #[test]
1565 fn writecell_overwrite_uses_same_path_write_lock() {
1566 let path = temp_path("txt");
1567 fs::write(&path, "existing\n").expect("seed");
1568 let filename = path.to_string_lossy().into_owned();
1569 let lock = block_on(write_lock_for_path(&path));
1570 let guard = block_on(lock.lock());
1571 let (tx, rx) = mpsc::channel();
1572
1573 let handle = thread::spawn(move || {
1574 let values = cell(vec![Value::from("replacement")], 1, 1);
1575 block_on(writecell_builtin(values, vec![Value::from(filename)]))
1576 .expect("overwrite write");
1577 tx.send(()).expect("send completion");
1578 });
1579
1580 thread::sleep(Duration::from_millis(50));
1581 assert!(rx.try_recv().is_err());
1582 drop(guard);
1583 handle.join().expect("writer thread");
1584 rx.recv_timeout(Duration::from_secs(1))
1585 .expect("overwrite completion");
1586
1587 let contents = fs::read_to_string(&path).expect("read contents");
1588 assert_eq!(contents, "\"replacement\"\n");
1589 let _ = fs::remove_file(path);
1590 }
1591
1592 #[cfg(unix)]
1593 #[test]
1594 fn writecell_canonical_aliases_share_write_lock() {
1595 let path = temp_path("txt");
1596 fs::write(&path, "").expect("seed");
1597 let mut link = path.clone();
1598 link.set_extension("link.txt");
1599 std::os::unix::fs::symlink(&path, &link).expect("symlink");
1600
1601 let direct = block_on(write_lock_for_path(&path));
1602 let alias = block_on(write_lock_for_path(&link));
1603 assert!(Arc::ptr_eq(&direct, &alias));
1604
1605 let _ = fs::remove_file(link);
1606 let _ = fs::remove_file(path);
1607 }
1608
1609 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1610 #[test]
1611 fn writecell_accepts_scalar_char_tensor_and_logical_cells() {
1612 let path = temp_path("csv");
1613 let filename = path.to_string_lossy().into_owned();
1614 let values = cell(
1615 vec![
1616 Value::CharArray(CharArray::new_row("name")),
1617 Value::Tensor(Tensor::new(vec![42.0], vec![1, 1]).expect("scalar tensor")),
1618 Value::LogicalArray(LogicalArray::new(vec![0], vec![1, 1]).expect("logical")),
1619 ],
1620 1,
1621 3,
1622 );
1623
1624 block_on(writecell_builtin(values, vec![Value::from(filename)])).expect("writecell");
1625
1626 let contents = fs::read_to_string(&path).expect("read contents");
1627 assert_eq!(contents, "\"name\",42,0\n");
1628 let _ = fs::remove_file(path);
1629 }
1630
1631 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1632 #[test]
1633 fn writecell_rejects_nested_cells_and_nonscalar_arrays() {
1634 let path = temp_path("csv");
1635 let filename = path.to_string_lossy().into_owned();
1636 let nested = cell(vec![cell(vec![Value::Num(1.0)], 1, 1)], 1, 1);
1637 let err = block_on(writecell_builtin(
1638 nested,
1639 vec![Value::from(filename.clone())],
1640 ))
1641 .expect_err("nested cell error");
1642 assert!(err.message().contains("nested cell arrays"));
1643
1644 let nonscalar = cell(
1645 vec![Value::Tensor(
1646 Tensor::new(vec![1.0, 2.0], vec![1, 2]).expect("tensor"),
1647 )],
1648 1,
1649 1,
1650 );
1651 let err = block_on(writecell_builtin(nonscalar, vec![Value::from(filename)]))
1652 .expect_err("nonscalar error");
1653 assert!(err.message().contains("unsupported cell value"));
1654 }
1655
1656 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1657 #[test]
1658 fn writecell_writes_xlsx_with_sheet_and_range() {
1659 let path = temp_path("xlsx");
1660 let filename = path.to_string_lossy().into_owned();
1661 let values = cell(
1662 vec![Value::from("Voltage"), Value::Num(1.5), Value::Bool(true)],
1663 1,
1664 3,
1665 );
1666
1667 block_on(writecell_builtin(
1668 values,
1669 vec![
1670 Value::from(filename),
1671 Value::from("Sheet"),
1672 Value::from("Measurements"),
1673 Value::from("Range"),
1674 Value::from("B2"),
1675 ],
1676 ))
1677 .expect("writecell xlsx");
1678
1679 let mut workbook = open_workbook_auto(&path).expect("open workbook");
1680 assert_eq!(workbook.sheet_names()[0], "Measurements");
1681 let range = workbook
1682 .worksheet_range("Measurements")
1683 .expect("worksheet range");
1684 assert_eq!(
1685 range.get((0, 0)),
1686 Some(&Data::String("Voltage".to_string()))
1687 );
1688 assert_eq!(range.get((0, 1)), Some(&Data::Float(1.5)));
1689 assert_eq!(range.get((0, 2)), Some(&Data::Bool(true)));
1690 let _ = fs::remove_file(path);
1691 }
1692
1693 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1694 #[test]
1695 fn writecell_rejects_unsupported_spreadsheet_extension() {
1696 let path = temp_path("xls");
1697 let filename = path.to_string_lossy().into_owned();
1698 let values = cell(vec![Value::from("A"), Value::Num(1.0)], 1, 2);
1699 let err = block_on(writecell_builtin(values, vec![Value::from(filename)]))
1700 .expect_err("unsupported extension");
1701 assert!(err
1702 .message()
1703 .contains("unsupported spreadsheet file extension"));
1704 let _ = fs::remove_file(path);
1705 }
1706}