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