1use chrono::Datelike;
2use chrono_humanize::HumanTime;
3use nu_engine::command_prelude::*;
4use nu_protocol::{ByteStream, PipelineMetadata, format_duration, shell_error::io::IoError};
5use std::io::Write;
6
7const LINE_ENDING: &str = if cfg!(target_os = "windows") {
8 "\r\n"
9} else {
10 "\n"
11};
12
13#[derive(Clone)]
14pub struct ToText;
15
16impl Command for ToText {
17 fn name(&self) -> &str {
18 "to text"
19 }
20
21 fn signature(&self) -> Signature {
22 Signature::build("to text")
23 .input_output_types(vec![(Type::Any, Type::String)])
24 .switch(
25 "no-newline",
26 "Do not append a newline to the end of the text",
27 Some('n'),
28 )
29 .switch(
30 "serialize",
31 "serialize nushell types that cannot be deserialized",
32 Some('s'),
33 )
34 .category(Category::Formats)
35 }
36
37 fn description(&self) -> &str {
38 "Converts data into simple text."
39 }
40
41 fn run(
42 &self,
43 engine_state: &EngineState,
44 stack: &mut Stack,
45 call: &Call,
46 input: PipelineData,
47 ) -> Result<PipelineData, ShellError> {
48 let head = call.head;
49 let no_newline = call.has_flag(engine_state, stack, "no-newline")?;
50 let serialize_types = call.has_flag(engine_state, stack, "serialize")?;
51 let input = input.try_expand_range()?;
52
53 match input {
54 PipelineData::Empty => Ok(Value::string(String::new(), head)
55 .into_pipeline_data_with_metadata(update_metadata(None))),
56 PipelineData::Value(value, ..) => {
57 let add_trailing = !no_newline
58 && match &value {
59 Value::List { vals, .. } => !vals.is_empty(),
60 Value::Record { val, .. } => !val.is_empty(),
61 _ => false,
62 };
63 let mut str = local_into_string(engine_state, value, LINE_ENDING, serialize_types);
64 if add_trailing {
65 str.push_str(LINE_ENDING);
66 }
67 Ok(
68 Value::string(str, head)
69 .into_pipeline_data_with_metadata(update_metadata(None)),
70 )
71 }
72 PipelineData::ListStream(stream, meta) => {
73 let span = stream.span();
74 let from_io_error = IoError::factory(head, None);
75 let stream = if no_newline {
76 let mut first = true;
77 let mut iter = stream.into_inner();
78 let engine_state_clone = engine_state.clone();
79 ByteStream::from_fn(
80 span,
81 engine_state.signals().clone(),
82 ByteStreamType::String,
83 move |buf| {
84 let Some(val) = iter.next() else {
85 return Ok(false);
86 };
87 if first {
88 first = false;
89 } else {
90 write!(buf, "{LINE_ENDING}").map_err(&from_io_error)?;
91 }
92 let str = local_into_string(
95 &engine_state_clone,
96 val,
97 LINE_ENDING,
98 serialize_types,
99 );
100 write!(buf, "{str}").map_err(&from_io_error)?;
101 Ok(true)
102 },
103 )
104 } else {
105 let engine_state_clone = engine_state.clone();
106 ByteStream::from_iter(
107 stream.into_inner().map(move |val| {
108 let mut str = local_into_string(
109 &engine_state_clone,
110 val,
111 LINE_ENDING,
112 serialize_types,
113 );
114 str.push_str(LINE_ENDING);
115 str
116 }),
117 span,
118 engine_state.signals().clone(),
119 ByteStreamType::String,
120 )
121 };
122
123 Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
124 }
125 PipelineData::ByteStream(stream, meta) => {
126 Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
127 }
128 }
129 }
130
131 fn examples(&self) -> Vec<Example> {
132 vec![
133 Example {
134 description: "Outputs data as simple text with a trailing newline",
135 example: "[1] | to text",
136 result: Some(Value::test_string("1".to_string() + LINE_ENDING)),
137 },
138 Example {
139 description: "Outputs data as simple text without a trailing newline",
140 example: "[1] | to text --no-newline",
141 result: Some(Value::test_string("1")),
142 },
143 Example {
144 description: "Outputs external data as simple text",
145 example: "git help -a | lines | find -r '^ ' | to text",
146 result: None,
147 },
148 Example {
149 description: "Outputs records as simple text",
150 example: "ls | to text",
151 result: None,
152 },
153 ]
154 }
155}
156
157fn local_into_string(
158 engine_state: &EngineState,
159 value: Value,
160 separator: &str,
161 serialize_types: bool,
162) -> String {
163 let span = value.span();
164 match value {
165 Value::Bool { val, .. } => val.to_string(),
166 Value::Int { val, .. } => val.to_string(),
167 Value::Float { val, .. } => val.to_string(),
168 Value::Filesize { val, .. } => val.to_string(),
169 Value::Duration { val, .. } => format_duration(val),
170 Value::Date { val, .. } => {
171 format!(
172 "{} ({})",
173 {
174 if val.year() >= 0 {
175 val.to_rfc2822()
176 } else {
177 val.to_rfc3339()
178 }
179 },
180 HumanTime::from(val)
181 )
182 }
183 Value::Range { val, .. } => val.to_string(),
184 Value::String { val, .. } => val,
185 Value::Glob { val, .. } => val,
186 Value::List { vals: val, .. } => val
187 .into_iter()
188 .map(|x| local_into_string(engine_state, x, ", ", serialize_types))
189 .collect::<Vec<_>>()
190 .join(separator),
191 Value::Record { val, .. } => val
192 .into_owned()
193 .into_iter()
194 .map(|(x, y)| {
195 format!(
196 "{}: {}",
197 x,
198 local_into_string(engine_state, y, ", ", serialize_types)
199 )
200 })
201 .collect::<Vec<_>>()
202 .join(separator),
203 Value::Closure { val, .. } => {
204 if serialize_types {
205 let block = engine_state.get_block(val.block_id);
206 if let Some(span) = block.span {
207 let contents_bytes = engine_state.get_span_contents(span);
208 let contents_string = String::from_utf8_lossy(contents_bytes);
209 contents_string.to_string()
210 } else {
211 format!(
212 "unable to retrieve block contents for text block_id {}",
213 val.block_id.get()
214 )
215 }
216 } else {
217 format!("closure_{}", val.block_id.get())
218 }
219 }
220 Value::Nothing { .. } => String::new(),
221 Value::Error { error, .. } => format!("{error:?}"),
222 Value::Binary { val, .. } => format!("{val:?}"),
223 Value::CellPath { val, .. } => val.to_string(),
224 Value::Custom { val, .. } => val
227 .to_base_value(span)
228 .map(|val| local_into_string(engine_state, val, separator, serialize_types))
229 .unwrap_or_else(|_| format!("<{}>", val.type_name())),
230 }
231}
232
233fn update_metadata(metadata: Option<PipelineMetadata>) -> Option<PipelineMetadata> {
234 metadata
235 .map(|md| md.with_content_type(Some(mime::TEXT_PLAIN.to_string())))
236 .or_else(|| {
237 Some(PipelineMetadata::default().with_content_type(Some(mime::TEXT_PLAIN.to_string())))
238 })
239}
240
241#[cfg(test)]
242mod test {
243 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
244
245 use crate::{Get, Metadata};
246
247 use super::*;
248
249 #[test]
250 fn test_examples() {
251 use crate::test_examples;
252
253 test_examples(ToText {})
254 }
255
256 #[test]
257 fn test_content_type_metadata() {
258 let mut engine_state = Box::new(EngineState::new());
259 let delta = {
260 let mut working_set = StateWorkingSet::new(&engine_state);
263
264 working_set.add_decl(Box::new(ToText {}));
265 working_set.add_decl(Box::new(Metadata {}));
266 working_set.add_decl(Box::new(Get {}));
267
268 working_set.render()
269 };
270
271 engine_state
272 .merge_delta(delta)
273 .expect("Error merging delta");
274
275 let cmd = "{a: 1 b: 2} | to text | metadata | get content_type";
276 let result = eval_pipeline_without_terminal_expression(
277 cmd,
278 std::env::temp_dir().as_ref(),
279 &mut engine_state,
280 );
281 assert_eq!(
282 Value::test_record(record!("content_type" => Value::test_string("text/plain"))),
283 result.expect("There should be a result")
284 );
285 }
286}