1use std::fs::OpenOptions;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use runmat_builtins::{Tensor, Value};
14use runmat_macros::runtime_builtin;
15
16use crate::builtins::common::fs::expand_user_path;
17use crate::builtins::common::spec::{
18 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19 ReductionNaN, ResidencyPolicy, ShapeRequirements,
20};
21use crate::builtins::common::tensor;
22#[cfg(feature = "doc_export")]
23use crate::register_builtin_doc_text;
24use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
25
26#[cfg(feature = "doc_export")]
27pub const DOC_MD: &str = r#"---
28title: "csvwrite"
29category: "io/tabular"
30keywords: ["csvwrite", "csv", "write", "comma-separated values", "numeric export", "row offset", "column offset"]
31summary: "Write numeric matrices to comma-separated text files using MATLAB-compatible offsets."
32references:
33 - https://www.mathworks.com/help/matlab/ref/csvwrite.html
34gpu_support:
35 elementwise: false
36 reduction: false
37 precisions: []
38 broadcasting: "none"
39 notes: "Runs entirely on the CPU. gpuArray inputs are gathered before serialisation."
40fusion:
41 elementwise: false
42 reduction: false
43 max_inputs: 2
44 constants: "inline"
45requires_feature: null
46tested:
47 unit: "builtins::io::tabular::csvwrite::tests"
48 integration:
49 - "builtins::io::tabular::csvwrite::tests::csvwrite_writes_basic_matrix"
50 - "builtins::io::tabular::csvwrite::tests::csvwrite_honours_offsets"
51 - "builtins::io::tabular::csvwrite::tests::csvwrite_handles_gpu_tensors"
52 - "builtins::io::tabular::csvwrite::tests::csvwrite_expands_home_directory"
53 - "builtins::io::tabular::csvwrite::tests::csvwrite_formats_with_short_g_precision"
54 - "builtins::io::tabular::csvwrite::tests::csvwrite_handles_wgpu_provider_gather"
55 - "builtins::io::tabular::csvwrite::tests::csvwrite_rejects_negative_offsets"
56---
57
58# What does the `csvwrite` function do in MATLAB / RunMat?
59`csvwrite(filename, M)` writes a numeric matrix to a comma-separated text file.
60The builtin honours MATLAB's historical zero-based row/column offset arguments so
61that existing scripts continue to behave identically in RunMat.
62
63## How does the `csvwrite` function behave in MATLAB / RunMat?
64- Only real numeric or logical inputs are accepted. Logical values are converted
65 to `0` and `1` before writing. Complex and textual inputs raise descriptive
66 errors.
67- `csvwrite(filename, M, row, col)` starts writing at zero-based row `row` and
68 column `col`, leaving earlier rows blank and earlier columns empty within each
69 row. Offsets must be non-negative integers.
70- Matrices must be 2-D (trailing singleton dimensions are ignored). Column-major
71 ordering is respected when serialising to text.
72- Numbers are emitted using MATLAB-compatible short `g` formatting (`%.5g`). `NaN`, `Inf`,
73 and `-Inf` tokens are written verbatim.
74- Existing files are overwritten. `csvwrite` does not support appending; switch
75 to `writematrix` with `'WriteMode','append'` when the behaviour is required.
76- Paths that begin with `~` expand to the user's home directory before writing.
77
78## `csvwrite` Function GPU Execution Behaviour
79`csvwrite` always executes on the host CPU. When the matrix resides on the GPU,
80RunMat gathers the data through the active acceleration provider before
81serialisation. No provider hooks are required, and the return value reports the
82number of bytes written after the gather completes.
83
84## Examples of using the `csvwrite` function in MATLAB / RunMat
85
86### Writing a numeric matrix to CSV
87```matlab
88A = [1 2 3; 4 5 6];
89csvwrite("scores.csv", A);
90```
91Expected contents of `scores.csv`:
92```matlab
931,2,3
944,5,6
95```
96
97### Starting output after a header row
98```matlab
99fid = fopen("with_header.csv", "w");
100fprintf(fid, "Name,Jan,Feb\nalpha,1,2\nbeta,3,4\n");
101fclose(fid);
102
103csvwrite("with_header.csv", [10 20; 30 40], 1, 0);
104```
105Expected contents of `with_header.csv`:
106```matlab
107Name,Jan,Feb
108
10910,20
11030,40
111```
112
113### Skipping leading columns before data
114```matlab
115B = magic(3);
116csvwrite("offset_columns.csv", B, 0, 2);
117```
118Expected contents of `offset_columns.csv`:
119```matlab
120,,8,1,6
121,,3,5,7
122,,4,9,2
123```
124
125### Exporting logical masks as numeric zeros and ones
126```matlab
127mask = [true false true; false true false];
128csvwrite("mask.csv", mask);
129```
130Expected contents of `mask.csv`:
131```matlab
1321,0,1
1330,1,0
134```
135
136### Writing GPU-resident data without manual gather
137```matlab
138G = gpuArray(single([0.1 0.2 0.3]));
139csvwrite("gpu_values.csv", G);
140```
141Expected behaviour:
142```matlab
143% Data is gathered automatically from the GPU and written to disk.
144```
145
146### Persisting a scalar value for downstream tools
147```matlab
148total = sum(rand(5));
149csvwrite("scalar.csv", total);
150```
151Expected contents of `scalar.csv`:
152```matlab
1532.5731
154```
155
156## GPU residency in RunMat (Do I need `gpuArray`?)
157No additional steps are necessary. `csvwrite` treats GPU arrays as residency
158sinks: data is gathered back to host memory prior to writing. This matches
159MATLAB's behaviour, where file I/O always operates on host-resident values.
160
161## FAQ
162
163### Why must the input be numeric or logical?
164`csvwrite` predates MATLAB's table and string support and only serialises numeric
165values. Provide numeric matrices or logical masks, or switch to `writematrix`
166when you need to mix text and numbers.
167
168### Are row and column offsets zero-based?
169Yes. `row = 1` skips one full line before writing, and `col = 2` inserts two
170empty comma-separated fields at the start of each written row.
171
172### Can I append to an existing CSV with `csvwrite`?
173No. `csvwrite` always overwrites the destination file. Use `writematrix` with
174`'WriteMode','append'` or manipulate the file with lower-level I/O functions.
175
176### How are `NaN` and `Inf` values written?
177They are emitted verbatim as `NaN`, `Inf`, or `-Inf`, matching MATLAB's text
178representation so that downstream tools can parse them consistently.
179
180### What line ending does `csvwrite` use?
181The builtin uses the platform default (`\r\n` on Windows, `\n` elsewhere). Most
182CSV consumers handle either convention transparently.
183
184## See Also
185[csvread](./csvread), [readmatrix](./readmatrix), [writematrix](./writematrix), [fprintf](../filetext/fprintf), [gpuArray](../../acceleration/gpu/gpuArray), [gather](../../acceleration/gpu/gather)
186
187## Source & Feedback
188- The full source code for `csvwrite` lives at: [`crates/runmat-runtime/src/builtins/io/tabular/csvwrite.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/tabular/csvwrite.rs)
189- Found a behavioural difference? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with details and a minimal reproduction.
190"#;
191
192pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
193 name: "csvwrite",
194 op_kind: GpuOpKind::Custom("io-csvwrite"),
195 supported_precisions: &[],
196 broadcast: BroadcastSemantics::None,
197 provider_hooks: &[],
198 constant_strategy: ConstantStrategy::InlineLiteral,
199 residency: ResidencyPolicy::GatherImmediately,
200 nan_mode: ReductionNaN::Include,
201 two_pass_threshold: None,
202 workgroup_size: None,
203 accepts_nan_mode: false,
204 notes: "Runs entirely on the host; gpuArray inputs are gathered before serialisation.",
205};
206
207register_builtin_gpu_spec!(GPU_SPEC);
208
209pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
210 name: "csvwrite",
211 shape: ShapeRequirements::Any,
212 constant_strategy: ConstantStrategy::InlineLiteral,
213 elementwise: None,
214 reduction: None,
215 emits_nan: false,
216 notes: "Not eligible for fusion; performs host-side file I/O.",
217};
218
219register_builtin_fusion_spec!(FUSION_SPEC);
220
221#[cfg(feature = "doc_export")]
222register_builtin_doc_text!("csvwrite", DOC_MD);
223
224#[runtime_builtin(
225 name = "csvwrite",
226 category = "io/tabular",
227 summary = "Write numeric matrices to comma-separated text files using MATLAB-compatible offsets.",
228 keywords = "csvwrite,csv,write,row offset,column offset",
229 accel = "cpu"
230)]
231fn csvwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> Result<Value, String> {
232 let filename_value = gather_if_needed(&filename).map_err(|e| format!("csvwrite: {e}"))?;
233 let path = resolve_path(&filename_value)?;
234
235 let (row_offset, col_offset) = parse_offsets(&rest)?;
236
237 let gathered_data = gather_if_needed(&data).map_err(|e| format!("csvwrite: {e}"))?;
238 let tensor = tensor::value_into_tensor_for("csvwrite", gathered_data)?;
239 ensure_matrix_shape(&tensor)?;
240
241 let bytes = write_csv(&path, &tensor, row_offset, col_offset)?;
242 Ok(Value::Num(bytes as f64))
243}
244
245fn resolve_path(value: &Value) -> Result<PathBuf, String> {
246 let raw = match value {
247 Value::String(s) => s.clone(),
248 Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
249 Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
250 _ => {
251 return Err(
252 "csvwrite: filename must be a string scalar or character vector".to_string(),
253 )
254 }
255 };
256
257 if raw.trim().is_empty() {
258 return Err("csvwrite: filename must not be empty".to_string());
259 }
260
261 let expanded = expand_user_path(&raw, "csvwrite").map_err(|e| format!("csvwrite: {e}"))?;
262 Ok(Path::new(&expanded).to_path_buf())
263}
264
265fn parse_offsets(args: &[Value]) -> Result<(usize, usize), String> {
266 match args.len() {
267 0 => Ok((0, 0)),
268 2 => {
269 let row = parse_offset(&args[0], "row offset")?;
270 let col = parse_offset(&args[1], "column offset")?;
271 Ok((row, col))
272 }
273 _ => Err(
274 "csvwrite: offsets must be provided as two numeric arguments (row, column)".to_string(),
275 ),
276 }
277}
278
279fn parse_offset(value: &Value, context: &str) -> Result<usize, String> {
280 match value {
281 Value::Int(i) => {
282 let raw = i.to_i64();
283 if raw < 0 {
284 return Err(format!("csvwrite: {context} must be >= 0"));
285 }
286 Ok(raw as usize)
287 }
288 Value::Num(n) => coerce_offset_from_float(*n, context),
289 Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
290 Value::Tensor(t) => {
291 if t.data.len() != 1 {
292 return Err(format!(
293 "csvwrite: {context} must be a scalar, got {} elements",
294 t.data.len()
295 ));
296 }
297 coerce_offset_from_float(t.data[0], context)
298 }
299 Value::LogicalArray(logical) => {
300 if logical.data.len() != 1 {
301 return Err(format!(
302 "csvwrite: {context} must be a scalar, got {} elements",
303 logical.data.len()
304 ));
305 }
306 Ok(if logical.data[0] != 0 { 1 } else { 0 })
307 }
308 other => Err(format!(
309 "csvwrite: {context} must be numeric, got {:?}",
310 other
311 )),
312 }
313}
314
315fn coerce_offset_from_float(value: f64, context: &str) -> Result<usize, String> {
316 if !value.is_finite() {
317 return Err(format!("csvwrite: {context} must be finite"));
318 }
319 let rounded = value.round();
320 if (rounded - value).abs() > 1e-9 {
321 return Err(format!("csvwrite: {context} must be an integer"));
322 }
323 if rounded < 0.0 {
324 return Err(format!("csvwrite: {context} must be >= 0"));
325 }
326 Ok(rounded as usize)
327}
328
329fn ensure_matrix_shape(tensor: &Tensor) -> Result<(), String> {
330 if tensor.shape.len() <= 2 {
331 return Ok(());
332 }
333 if tensor.shape[2..].iter().all(|&dim| dim == 1) {
334 return Ok(());
335 }
336 Err("csvwrite: input must be 2-D; reshape before writing".to_string())
337}
338
339fn write_csv(
340 path: &Path,
341 tensor: &Tensor,
342 row_offset: usize,
343 col_offset: usize,
344) -> Result<usize, String> {
345 let mut options = OpenOptions::new();
346 options.create(true).write(true).truncate(true);
347 let mut file = options.open(path).map_err(|err| {
348 format!(
349 "csvwrite: unable to open \"{}\" for writing ({err})",
350 path.display()
351 )
352 })?;
353
354 let line_ending = default_line_ending();
355 let rows = tensor.rows();
356 let cols = tensor.cols();
357
358 let mut bytes_written = 0usize;
359
360 for _ in 0..row_offset {
361 file.write_all(line_ending.as_bytes())
362 .map_err(|err| format!("csvwrite: failed to write line ending ({err})"))?;
363 bytes_written += line_ending.len();
364 }
365
366 if rows == 0 || cols == 0 {
367 file.flush()
368 .map_err(|err| format!("csvwrite: failed to flush output ({err})"))?;
369 return Ok(bytes_written);
370 }
371
372 for row in 0..rows {
373 let mut fields = Vec::with_capacity(col_offset + cols);
374 for _ in 0..col_offset {
375 fields.push(String::new());
376 }
377 for col in 0..cols {
378 let idx = row + col * rows;
379 let value = tensor.data[idx];
380 fields.push(format_numeric(value));
381 }
382 let line = fields.join(",");
383 if !line.is_empty() {
384 file.write_all(line.as_bytes())
385 .map_err(|err| format!("csvwrite: failed to write value ({err})"))?;
386 bytes_written += line.len();
387 }
388 file.write_all(line_ending.as_bytes())
389 .map_err(|err| format!("csvwrite: failed to write line ending ({err})"))?;
390 bytes_written += line_ending.len();
391 }
392
393 file.flush()
394 .map_err(|err| format!("csvwrite: failed to flush output ({err})"))?;
395
396 Ok(bytes_written)
397}
398
399fn default_line_ending() -> &'static str {
400 if cfg!(windows) {
401 "\r\n"
402 } else {
403 "\n"
404 }
405}
406
407fn format_numeric(value: f64) -> String {
408 if value.is_nan() {
409 return "NaN".to_string();
410 }
411 if value.is_infinite() {
412 return if value.is_sign_negative() {
413 "-Inf".to_string()
414 } else {
415 "Inf".to_string()
416 };
417 }
418 if value == 0.0 {
419 return "0".to_string();
420 }
421
422 let precision: i32 = 5;
423 let abs = value.abs();
424 let exp10 = abs.log10().floor() as i32;
425 let use_scientific = exp10 < -4 || exp10 >= precision;
426
427 let raw = if use_scientific {
428 let digits_after = (precision - 1).max(0) as usize;
429 format!("{:.*e}", digits_after, value)
430 } else {
431 let decimals = (precision - 1 - exp10).max(0) as usize;
432 format!("{:.*}", decimals, value)
433 };
434
435 let mut trimmed = trim_trailing_zeros(raw);
436 if trimmed == "-0" {
437 trimmed = "0".to_string();
438 }
439 trimmed
440}
441
442fn trim_trailing_zeros(mut value: String) -> String {
443 if let Some(exp_pos) = value.find(['e', 'E']) {
444 let exponent = value.split_off(exp_pos);
445 while value.ends_with('0') {
446 value.pop();
447 }
448 if value.ends_with('.') {
449 value.pop();
450 }
451 value.push_str(&normalize_exponent(&exponent));
452 value
453 } else {
454 if value.contains('.') {
455 while value.ends_with('0') {
456 value.pop();
457 }
458 if value.ends_with('.') {
459 value.pop();
460 }
461 }
462 if value.is_empty() {
463 "0".to_string()
464 } else {
465 value
466 }
467 }
468}
469
470fn normalize_exponent(exponent: &str) -> String {
471 if exponent.len() <= 1 {
472 return exponent.to_string();
473 }
474 let mut chars = exponent.chars();
475 let marker = chars.next().unwrap();
476 let rest: String = chars.collect();
477 match rest.parse::<i32>() {
478 Ok(parsed) => format!("{}{:+03}", marker, parsed),
479 Err(_) => exponent.to_string(),
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use std::fs;
487 use std::sync::atomic::{AtomicU64, Ordering};
488 use std::time::{SystemTime, UNIX_EPOCH};
489
490 use runmat_accelerate_api::HostTensorView;
491 use runmat_builtins::{IntValue, LogicalArray};
492
493 use crate::builtins::common::fs as fs_helpers;
494 use crate::builtins::common::test_support;
495
496 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
497
498 fn temp_path(ext: &str) -> PathBuf {
499 let millis = SystemTime::now()
500 .duration_since(UNIX_EPOCH)
501 .unwrap()
502 .as_millis();
503 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
504 let mut path = std::env::temp_dir();
505 path.push(format!(
506 "runmat_csvwrite_{}_{}_{}.{}",
507 std::process::id(),
508 millis,
509 unique,
510 ext
511 ));
512 path
513 }
514
515 fn line_ending() -> &'static str {
516 default_line_ending()
517 }
518
519 #[test]
520 fn csvwrite_writes_basic_matrix() {
521 let path = temp_path("csv");
522 let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
523 let filename = path.to_string_lossy().into_owned();
524
525 csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
526 .expect("csvwrite");
527
528 let contents = fs::read_to_string(&path).expect("read contents");
529 assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
530 let _ = fs::remove_file(path);
531 }
532
533 #[test]
534 fn csvwrite_honours_offsets() {
535 let path = temp_path("csv");
536 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
537 let filename = path.to_string_lossy().into_owned();
538
539 csvwrite_builtin(
540 Value::from(filename),
541 Value::Tensor(tensor),
542 vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
543 )
544 .expect("csvwrite");
545
546 let contents = fs::read_to_string(&path).expect("read contents");
547 assert_eq!(
548 contents,
549 format!("{le},,1,3{le},,2,4{le}", le = line_ending())
550 );
551 let _ = fs::remove_file(path);
552 }
553
554 #[test]
555 fn csvwrite_handles_gpu_tensors() {
556 test_support::with_test_provider(|provider| {
557 let path = temp_path("csv");
558 let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
559 let view = HostTensorView {
560 data: &tensor.data,
561 shape: &tensor.shape,
562 };
563 let handle = provider.upload(&view).expect("upload");
564 let filename = path.to_string_lossy().into_owned();
565
566 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
567 .expect("csvwrite");
568
569 let contents = fs::read_to_string(&path).expect("read contents");
570 assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
571 let _ = fs::remove_file(path);
572 });
573 }
574
575 #[test]
576 fn csvwrite_formats_with_short_g_precision() {
577 let path = temp_path("csv");
578 let values =
579 Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
580 let filename = path.to_string_lossy().into_owned();
581
582 csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
583 .expect("csvwrite");
584
585 let contents = fs::read_to_string(&path).expect("read contents");
586 assert_eq!(
587 contents,
588 format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
589 );
590 let _ = fs::remove_file(path);
591 }
592
593 #[test]
594 fn csvwrite_rejects_negative_offsets() {
595 let path = temp_path("csv");
596 let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
597 let filename = path.to_string_lossy().into_owned();
598 let err = csvwrite_builtin(
599 Value::from(filename),
600 Value::Tensor(tensor),
601 vec![Value::Num(-1.0), Value::Num(0.0)],
602 )
603 .expect_err("negative offsets should be rejected");
604 assert!(
605 err.contains("row offset"),
606 "unexpected error message: {err}"
607 );
608 }
609
610 #[cfg(feature = "wgpu")]
611 #[test]
612 fn csvwrite_handles_wgpu_provider_gather() {
613 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
614 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
615 );
616 let Some(provider) = runmat_accelerate_api::provider() else {
617 panic!("wgpu provider not registered");
618 };
619
620 let path = temp_path("csv");
621 let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
622 let view = HostTensorView {
623 data: &tensor.data,
624 shape: &tensor.shape,
625 };
626 let handle = provider.upload(&view).expect("upload");
627 let filename = path.to_string_lossy().into_owned();
628
629 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
630 .expect("csvwrite");
631
632 let contents = fs::read_to_string(&path).expect("read contents");
633 assert_eq!(contents, format!("2,4{le}", le = line_ending()));
634 let _ = fs::remove_file(path);
635 }
636
637 #[test]
638 fn csvwrite_expands_home_directory() {
639 let Some(mut home) = fs_helpers::home_directory() else {
640 return;
642 };
643 let filename = format!(
644 "runmat_csvwrite_home_{}_{}.csv",
645 std::process::id(),
646 NEXT_ID.fetch_add(1, Ordering::Relaxed)
647 );
648 home.push(&filename);
649
650 let tilde_path = format!("~/{}", filename);
651 let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
652
653 csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
654 .expect("csvwrite");
655
656 let contents = fs::read_to_string(&home).expect("read contents");
657 assert_eq!(contents, format!("42{le}", le = line_ending()));
658 let _ = fs::remove_file(home);
659 }
660
661 #[test]
662 fn csvwrite_rejects_non_numeric_inputs() {
663 let path = temp_path("csv");
664 let filename = path.to_string_lossy().into_owned();
665 let err = csvwrite_builtin(
666 Value::from(filename),
667 Value::String("abc".into()),
668 Vec::new(),
669 )
670 .expect_err("csvwrite should fail");
671 assert!(err.contains("csvwrite"), "unexpected error message: {err}");
672 }
673
674 #[test]
675 fn csvwrite_accepts_logical_arrays() {
676 let path = temp_path("csv");
677 let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
678 let filename = path.to_string_lossy().into_owned();
679
680 csvwrite_builtin(
681 Value::from(filename),
682 Value::LogicalArray(logical),
683 Vec::new(),
684 )
685 .expect("csvwrite");
686
687 let contents = fs::read_to_string(&path).expect("read contents");
688 assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
689 let _ = fs::remove_file(path);
690 }
691
692 #[test]
693 #[cfg(feature = "doc_export")]
694 fn doc_examples_present() {
695 let blocks = test_support::doc_examples(DOC_MD);
696 assert!(!blocks.is_empty());
697 }
698}