Skip to main content

http_nu/
commands.rs

1use crate::logging::log_print;
2use crate::response::{Response, ResponseBodyType};
3use nu_engine::command_prelude::*;
4use nu_protocol::{
5    ByteStream, ByteStreamType, Category, Config, CustomValue, PipelineData, PipelineMetadata,
6    ShellError, Signature, Span, SyntaxShape, Type, Value,
7};
8use serde::{Deserialize, Serialize};
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::io::Read;
12use std::path::PathBuf;
13use tokio::sync::oneshot;
14
15use minijinja::{path_loader, Environment};
16use std::sync::{Arc, OnceLock, RwLock};
17
18use syntect::html::{ClassStyle, ClassedHTMLGenerator};
19use syntect::parsing::SyntaxSet;
20use syntect::util::LinesWithEndings;
21
22// === Template Cache ===
23
24type TemplateCache = RwLock<HashMap<u128, Arc<Environment<'static>>>>;
25
26static TEMPLATE_CACHE: OnceLock<TemplateCache> = OnceLock::new();
27
28fn get_cache() -> &'static TemplateCache {
29    TEMPLATE_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
30}
31
32fn hash_source_and_path(source: &str, base_dir: &std::path::Path) -> u128 {
33    let mut data = source.as_bytes().to_vec();
34    data.extend_from_slice(base_dir.to_string_lossy().as_bytes());
35    xxhash_rust::xxh3::xxh3_128(&data)
36}
37
38/// Compile template and insert into cache. Returns hash.
39fn compile_template(source: &str, base_dir: &std::path::Path) -> Result<u128, minijinja::Error> {
40    compile_template_with_loader(source, base_dir, path_loader(base_dir))
41}
42
43/// Compile template with a custom loader and insert into cache. Returns hash.
44fn compile_template_with_loader<F>(
45    source: &str,
46    base_dir: &std::path::Path,
47    loader: F,
48) -> Result<u128, minijinja::Error>
49where
50    F: Fn(&str) -> Result<Option<String>, minijinja::Error> + Send + Sync + 'static,
51{
52    let hash = hash_source_and_path(source, base_dir);
53
54    let mut cache = get_cache().write().unwrap();
55    if cache.contains_key(&hash) {
56        return Ok(hash);
57    }
58
59    let mut env = Environment::new();
60    env.set_loader(loader);
61    env.add_template_owned("template".to_string(), source.to_string())?;
62    cache.insert(hash, Arc::new(env));
63    Ok(hash)
64}
65
66/// Get compiled template from cache by hash.
67fn get_compiled(hash: u128) -> Option<Arc<Environment<'static>>> {
68    get_cache().read().unwrap().get(&hash).map(Arc::clone)
69}
70
71/// Load the latest content for a store topic.
72#[cfg(feature = "cross-stream")]
73fn load_topic_content(store: &xs::store::Store, topic: &str) -> Option<String> {
74    let options = xs::store::ReadOptions::builder()
75        .follow(xs::store::FollowOption::Off)
76        .topic(topic.to_string())
77        .last(1_usize)
78        .build();
79    let frame = store.read_sync(options).last()?;
80    let hash = frame.hash?;
81    let bytes = store.cas_read_sync(&hash).ok()?;
82    String::from_utf8(bytes).ok()
83}
84
85// === CompiledTemplate CustomValue ===
86
87#[derive(Clone, Debug, Serialize, Deserialize)]
88pub struct CompiledTemplate {
89    hash: u128,
90}
91
92impl CompiledTemplate {
93    /// Render this template with the given context
94    pub fn render(&self, context: &minijinja::Value) -> Result<String, minijinja::Error> {
95        let env = get_compiled(self.hash).expect("template not in cache");
96        let tmpl = env.get_template("template")?;
97        tmpl.render(context)
98    }
99}
100
101#[typetag::serde]
102impl CustomValue for CompiledTemplate {
103    fn clone_value(&self, span: Span) -> Value {
104        Value::custom(Box::new(self.clone()), span)
105    }
106
107    fn type_name(&self) -> String {
108        "CompiledTemplate".into()
109    }
110
111    fn to_base_value(&self, span: Span) -> Result<Value, ShellError> {
112        Ok(Value::string(format!("{:032x}", self.hash), span))
113    }
114
115    fn as_any(&self) -> &dyn std::any::Any {
116        self
117    }
118
119    fn as_mut_any(&mut self) -> &mut dyn std::any::Any {
120        self
121    }
122}
123
124thread_local! {
125    pub static RESPONSE_TX: RefCell<Option<oneshot::Sender<Response>>> = const { RefCell::new(None) };
126}
127
128#[derive(Clone)]
129pub struct StaticCommand;
130
131impl Default for StaticCommand {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl StaticCommand {
138    pub fn new() -> Self {
139        Self
140    }
141}
142
143impl Command for StaticCommand {
144    fn name(&self) -> &str {
145        ".static"
146    }
147
148    fn description(&self) -> &str {
149        "Serve static files from a directory"
150    }
151
152    fn signature(&self) -> Signature {
153        Signature::build(".static")
154            .required("root", SyntaxShape::String, "root directory path")
155            .required("path", SyntaxShape::String, "request path")
156            .named(
157                "fallback",
158                SyntaxShape::String,
159                "fallback file when request missing",
160                None,
161            )
162            .input_output_types(vec![(Type::Nothing, Type::Nothing)])
163            .category(Category::Custom("http".into()))
164    }
165
166    fn run(
167        &self,
168        engine_state: &EngineState,
169        stack: &mut Stack,
170        call: &Call,
171        _input: PipelineData,
172    ) -> Result<PipelineData, ShellError> {
173        let root: String = call.req(engine_state, stack, 0)?;
174        let path: String = call.req(engine_state, stack, 1)?;
175
176        let fallback: Option<String> = call.get_flag(engine_state, stack, "fallback")?;
177
178        let response = Response {
179            status: 200,
180            headers: HashMap::new(),
181            body_type: ResponseBodyType::Static {
182                root: PathBuf::from(root),
183                path,
184                fallback,
185            },
186        };
187
188        RESPONSE_TX.with(|tx| -> Result<_, ShellError> {
189            if let Some(tx) = tx.borrow_mut().take() {
190                tx.send(response).map_err(|_| ShellError::GenericError {
191                    error: "Failed to send response".into(),
192                    msg: "Channel closed".into(),
193                    span: Some(call.head),
194                    help: None,
195                    inner: vec![],
196                })?;
197            }
198            Ok(())
199        })?;
200
201        Ok(PipelineData::Empty)
202    }
203}
204
205const LINE_ENDING: &str = "\n";
206
207#[derive(Clone)]
208pub struct ToSse;
209
210impl Command for ToSse {
211    fn name(&self) -> &str {
212        "to sse"
213    }
214
215    fn signature(&self) -> Signature {
216        Signature::build("to sse")
217            .input_output_types(vec![
218                (Type::record(), Type::String),
219                (Type::List(Box::new(Type::record())), Type::String),
220            ])
221            .category(Category::Formats)
222    }
223
224    fn description(&self) -> &str {
225        "Convert records into text/event-stream format"
226    }
227
228    fn search_terms(&self) -> Vec<&str> {
229        vec!["sse", "server", "event"]
230    }
231
232    fn examples(&self) -> Vec<Example<'_>> {
233        vec![Example {
234            description: "Convert a record into a server-sent event",
235            example: "{data: 'hello'} | to sse",
236            result: Some(Value::test_string("data: hello\n\n")),
237        }]
238    }
239
240    fn run(
241        &self,
242        engine_state: &EngineState,
243        stack: &mut Stack,
244        call: &Call,
245        input: PipelineData,
246    ) -> Result<PipelineData, ShellError> {
247        let head = call.head;
248        let config = stack.get_config(engine_state);
249        match input {
250            PipelineData::ListStream(stream, meta) => {
251                let span = stream.span();
252                let cfg = config.clone();
253                let iter = stream
254                    .into_iter()
255                    .map(move |val| event_to_string(&cfg, val));
256                let stream = ByteStream::from_result_iter(
257                    iter,
258                    span,
259                    engine_state.signals().clone(),
260                    ByteStreamType::String,
261                );
262                Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
263            }
264            PipelineData::Value(Value::List { vals, .. }, meta) => {
265                let cfg = config.clone();
266                let iter = vals.into_iter().map(move |val| event_to_string(&cfg, val));
267                let span = head;
268                let stream = ByteStream::from_result_iter(
269                    iter,
270                    span,
271                    engine_state.signals().clone(),
272                    ByteStreamType::String,
273                );
274                Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
275            }
276            PipelineData::Value(val, meta) => {
277                let out = event_to_string(&config, val)?;
278                Ok(
279                    Value::string(out, head)
280                        .into_pipeline_data_with_metadata(update_metadata(meta)),
281                )
282            }
283            PipelineData::Empty => Ok(PipelineData::Value(
284                Value::string(String::new(), head),
285                update_metadata(None),
286            )),
287            PipelineData::ByteStream(..) => Err(ShellError::TypeMismatch {
288                err_message: "expected record input".into(),
289                span: head,
290            }),
291        }
292    }
293}
294
295fn emit_data_lines(out: &mut String, s: &str) {
296    for line in s.lines() {
297        out.push_str("data: ");
298        out.push_str(line);
299        out.push_str(LINE_ENDING);
300    }
301}
302
303#[allow(clippy::result_large_err)]
304fn value_to_data_string(val: &Value, config: &Config) -> Result<String, ShellError> {
305    match val {
306        Value::String { val, .. } => Ok(val.clone()),
307        _ => {
308            let json_value =
309                value_to_json(val, config).map_err(|err| ShellError::GenericError {
310                    error: err.to_string(),
311                    msg: "failed to serialize json".into(),
312                    span: Some(Span::unknown()),
313                    help: None,
314                    inner: vec![],
315                })?;
316            serde_json::to_string(&json_value).map_err(|err| ShellError::GenericError {
317                error: err.to_string(),
318                msg: "failed to serialize json".into(),
319                span: Some(Span::unknown()),
320                help: None,
321                inner: vec![],
322            })
323        }
324    }
325}
326
327#[allow(clippy::result_large_err)]
328fn event_to_string(config: &Config, val: Value) -> Result<String, ShellError> {
329    let span = val.span();
330    let rec = match val {
331        Value::Record { val, .. } => val,
332        // Propagate the original error instead of creating a new "expected record" error
333        Value::Error { error, .. } => return Err(*error),
334        other => {
335            return Err(ShellError::TypeMismatch {
336                err_message: format!("expected record, got {}", other.get_type()),
337                span,
338            })
339        }
340    };
341    let mut out = String::new();
342    if let Some(event) = rec.get("event") {
343        if !matches!(event, Value::Nothing { .. }) {
344            out.push_str("event: ");
345            out.push_str(&event.to_expanded_string("", config));
346            out.push_str(LINE_ENDING);
347        }
348    }
349    if let Some(id) = rec.get("id") {
350        if !matches!(id, Value::Nothing { .. }) {
351            out.push_str("id: ");
352            out.push_str(&id.to_expanded_string("", config));
353            out.push_str(LINE_ENDING);
354        }
355    }
356    if let Some(retry) = rec.get("retry") {
357        if !matches!(retry, Value::Nothing { .. }) {
358            out.push_str("retry: ");
359            out.push_str(&retry.to_expanded_string("", config));
360            out.push_str(LINE_ENDING);
361        }
362    }
363    if let Some(data) = rec.get("data") {
364        if !matches!(data, Value::Nothing { .. }) {
365            match data {
366                Value::List { vals, .. } => {
367                    for item in vals {
368                        emit_data_lines(&mut out, &value_to_data_string(item, config)?);
369                    }
370                }
371                _ => {
372                    emit_data_lines(&mut out, &value_to_data_string(data, config)?);
373                }
374            }
375        }
376    }
377    out.push_str(LINE_ENDING);
378    Ok(out)
379}
380
381fn value_to_json(val: &Value, config: &Config) -> serde_json::Result<serde_json::Value> {
382    Ok(match val {
383        Value::Bool { val, .. } => serde_json::Value::Bool(*val),
384        Value::Int { val, .. } => serde_json::Value::from(*val),
385        Value::Float { val, .. } => serde_json::Number::from_f64(*val)
386            .map(serde_json::Value::Number)
387            .unwrap_or(serde_json::Value::Null),
388        Value::String { val, .. } => serde_json::Value::String(val.clone()),
389        Value::List { vals, .. } => serde_json::Value::Array(
390            vals.iter()
391                .map(|v| value_to_json(v, config))
392                .collect::<Result<Vec<_>, _>>()?,
393        ),
394        Value::Record { val, .. } => {
395            let mut map = serde_json::Map::new();
396            for (k, v) in val.iter() {
397                map.insert(k.clone(), value_to_json(v, config)?);
398            }
399            serde_json::Value::Object(map)
400        }
401        Value::Nothing { .. } => serde_json::Value::Null,
402        other => serde_json::Value::String(other.to_expanded_string("", config)),
403    })
404}
405
406fn update_metadata(metadata: Option<PipelineMetadata>) -> Option<PipelineMetadata> {
407    metadata
408        .map(|md| md.with_content_type(Some("text/event-stream".into())))
409        .or_else(|| {
410            Some(PipelineMetadata::default().with_content_type(Some("text/event-stream".into())))
411        })
412}
413
414#[derive(Clone)]
415pub struct ReverseProxyCommand;
416
417impl Default for ReverseProxyCommand {
418    fn default() -> Self {
419        Self::new()
420    }
421}
422
423impl ReverseProxyCommand {
424    pub fn new() -> Self {
425        Self
426    }
427}
428
429impl Command for ReverseProxyCommand {
430    fn name(&self) -> &str {
431        ".reverse-proxy"
432    }
433
434    fn description(&self) -> &str {
435        "Forward HTTP requests to a backend server"
436    }
437
438    fn signature(&self) -> Signature {
439        Signature::build(".reverse-proxy")
440            .required("target_url", SyntaxShape::String, "backend URL to proxy to")
441            .optional(
442                "config",
443                SyntaxShape::Record(vec![]),
444                "optional configuration (headers, preserve_host, strip_prefix, query)",
445            )
446            .input_output_types(vec![(Type::Any, Type::Nothing)])
447            .category(Category::Custom("http".into()))
448    }
449
450    fn run(
451        &self,
452        engine_state: &EngineState,
453        stack: &mut Stack,
454        call: &Call,
455        input: PipelineData,
456    ) -> Result<PipelineData, ShellError> {
457        let target_url: String = call.req(engine_state, stack, 0)?;
458
459        // Convert input pipeline data to bytes for request body
460        let request_body = match input {
461            PipelineData::Empty => Vec::new(),
462            PipelineData::Value(value, _) => crate::response::value_to_bytes(value),
463            PipelineData::ByteStream(stream, _) => {
464                // Collect all bytes from the stream
465                let mut body_bytes = Vec::new();
466                if let Some(mut reader) = stream.reader() {
467                    loop {
468                        let mut buffer = vec![0; 8192];
469                        match reader.read(&mut buffer) {
470                            Ok(0) => break, // EOF
471                            Ok(n) => {
472                                buffer.truncate(n);
473                                body_bytes.extend_from_slice(&buffer);
474                            }
475                            Err(_) => break,
476                        }
477                    }
478                }
479                body_bytes
480            }
481            PipelineData::ListStream(stream, _) => {
482                // Convert list stream to JSON array
483                let items: Vec<_> = stream.into_iter().collect();
484                let json_value = serde_json::Value::Array(
485                    items
486                        .into_iter()
487                        .map(|v| crate::response::value_to_json(&v))
488                        .collect(),
489                );
490                serde_json::to_string(&json_value)
491                    .unwrap_or_default()
492                    .into_bytes()
493            }
494        };
495
496        // Parse optional config
497        let config = call.opt::<Value>(engine_state, stack, 1);
498
499        let mut headers = HashMap::new();
500        let mut preserve_host = true;
501        let mut strip_prefix: Option<String> = None;
502        let mut query: Option<HashMap<String, String>> = None;
503
504        if let Ok(Some(config_value)) = config {
505            if let Ok(record) = config_value.as_record() {
506                // Extract headers
507                if let Some(headers_value) = record.get("headers") {
508                    if let Ok(headers_record) = headers_value.as_record() {
509                        for (k, v) in headers_record.iter() {
510                            let header_value = match v {
511                                Value::String { val, .. } => {
512                                    crate::response::HeaderValue::Single(val.clone())
513                                }
514                                Value::List { vals, .. } => {
515                                    let strings: Vec<String> = vals
516                                        .iter()
517                                        .filter_map(|v| v.as_str().ok())
518                                        .map(|s| s.to_string())
519                                        .collect();
520                                    crate::response::HeaderValue::Multiple(strings)
521                                }
522                                _ => continue, // Skip non-string/non-list values
523                            };
524                            headers.insert(k.clone(), header_value);
525                        }
526                    }
527                }
528
529                // Extract preserve_host
530                if let Some(preserve_host_value) = record.get("preserve_host") {
531                    if let Ok(ph) = preserve_host_value.as_bool() {
532                        preserve_host = ph;
533                    }
534                }
535
536                // Extract strip_prefix
537                if let Some(strip_prefix_value) = record.get("strip_prefix") {
538                    if let Ok(prefix) = strip_prefix_value.as_str() {
539                        strip_prefix = Some(prefix.to_string());
540                    }
541                }
542
543                // Extract query
544                if let Some(query_value) = record.get("query") {
545                    if let Ok(query_record) = query_value.as_record() {
546                        let mut query_map = HashMap::new();
547                        for (k, v) in query_record.iter() {
548                            if let Ok(v_str) = v.as_str() {
549                                query_map.insert(k.clone(), v_str.to_string());
550                            }
551                        }
552                        query = Some(query_map);
553                    }
554                }
555            }
556        }
557
558        let response = Response {
559            status: 200,
560            headers: HashMap::new(),
561            body_type: ResponseBodyType::ReverseProxy {
562                target_url,
563                headers,
564                preserve_host,
565                strip_prefix,
566                request_body,
567                query,
568            },
569        };
570
571        RESPONSE_TX.with(|tx| -> Result<_, ShellError> {
572            if let Some(tx) = tx.borrow_mut().take() {
573                tx.send(response).map_err(|_| ShellError::GenericError {
574                    error: "Failed to send response".into(),
575                    msg: "Channel closed".into(),
576                    span: Some(call.head),
577                    help: None,
578                    inner: vec![],
579                })?;
580            }
581            Ok(())
582        })?;
583
584        Ok(PipelineData::Empty)
585    }
586}
587
588#[derive(Clone)]
589pub struct MjCommand {
590    #[cfg(feature = "cross-stream")]
591    store: Option<xs::store::Store>,
592}
593
594impl Default for MjCommand {
595    fn default() -> Self {
596        Self::new()
597    }
598}
599
600impl MjCommand {
601    pub fn new() -> Self {
602        Self {
603            #[cfg(feature = "cross-stream")]
604            store: None,
605        }
606    }
607
608    #[cfg(feature = "cross-stream")]
609    pub fn with_store(store: xs::store::Store) -> Self {
610        Self { store: Some(store) }
611    }
612}
613
614impl Command for MjCommand {
615    fn name(&self) -> &str {
616        ".mj"
617    }
618
619    fn description(&self) -> &str {
620        "Render a minijinja template with context from input"
621    }
622
623    fn signature(&self) -> Signature {
624        Signature::build(".mj")
625            .optional("file", SyntaxShape::String, "template file path")
626            .named(
627                "inline",
628                SyntaxShape::String,
629                "inline template string",
630                Some('i'),
631            )
632            .named(
633                "topic",
634                SyntaxShape::String,
635                "load template from a store topic",
636                Some('t'),
637            )
638            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
639            .category(Category::Custom("http".into()))
640    }
641
642    fn run(
643        &self,
644        engine_state: &EngineState,
645        stack: &mut Stack,
646        call: &Call,
647        input: PipelineData,
648    ) -> Result<PipelineData, ShellError> {
649        let head = call.head;
650        let file: Option<String> = call.opt(engine_state, stack, 0)?;
651        let inline: Option<String> = call.get_flag(engine_state, stack, "inline")?;
652        let topic: Option<String> = call.get_flag(engine_state, stack, "topic")?;
653
654        let mode_count = file.is_some() as u8 + inline.is_some() as u8 + topic.is_some() as u8;
655        if mode_count > 1 {
656            return Err(ShellError::GenericError {
657                error: "Cannot combine file, --inline, and --topic".into(),
658                msg: "use exactly one of: file path, --inline, or --topic".into(),
659                span: Some(head),
660                help: None,
661                inner: vec![],
662            });
663        }
664        if mode_count == 0 {
665            return Err(ShellError::GenericError {
666                error: "No template specified".into(),
667                msg: "provide a file path, --inline, or --topic".into(),
668                span: Some(head),
669                help: None,
670                inner: vec![],
671            });
672        }
673
674        // Get context from input
675        let context = match input {
676            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
677            PipelineData::Empty => minijinja::Value::from(()),
678            _ => {
679                return Err(ShellError::TypeMismatch {
680                    err_message: "expected record input".into(),
681                    span: head,
682                });
683            }
684        };
685
686        // Set up environment and get template
687        let mut env = Environment::new();
688        let tmpl = if let Some(ref path) = file {
689            // File mode: resolve from filesystem only
690            let path = std::path::Path::new(path);
691            let abs_path = if path.is_absolute() {
692                path.to_path_buf()
693            } else {
694                std::env::current_dir().unwrap_or_default().join(path)
695            };
696            if let Some(parent) = abs_path.parent() {
697                env.set_loader(path_loader(parent));
698            }
699            let name = abs_path
700                .file_name()
701                .and_then(|n| n.to_str())
702                .unwrap_or("template");
703            env.get_template(name)
704                .map_err(|e| ShellError::GenericError {
705                    error: format!("Template error: {e}"),
706                    msg: e.to_string(),
707                    span: Some(head),
708                    help: None,
709                    inner: vec![],
710                })?
711        } else if let Some(ref topic_name) = topic {
712            // Topic mode: resolve from store only
713            #[cfg(feature = "cross-stream")]
714            {
715                let store = self
716                    .store
717                    .as_ref()
718                    .ok_or_else(|| ShellError::GenericError {
719                        error: "--topic requires --store".into(),
720                        msg: "server must be started with --store to use --topic".into(),
721                        span: Some(head),
722                        help: None,
723                        inner: vec![],
724                    })?;
725                let source = load_topic_content(store, topic_name).ok_or_else(|| {
726                    ShellError::GenericError {
727                        error: format!("Topic not found: {topic_name}"),
728                        msg: "no content in store for this topic".into(),
729                        span: Some(head),
730                        help: None,
731                        inner: vec![],
732                    }
733                })?;
734                let topic_store = store.clone();
735                env.set_loader(move |name: &str| Ok(load_topic_content(&topic_store, name)));
736                env.add_template_owned(topic_name.clone(), source)
737                    .map_err(|e| ShellError::GenericError {
738                        error: format!("Template parse error: {e}"),
739                        msg: e.to_string(),
740                        span: Some(head),
741                        help: None,
742                        inner: vec![],
743                    })?;
744                env.get_template(topic_name)
745                    .map_err(|e| ShellError::GenericError {
746                        error: format!("Template error: {e}"),
747                        msg: e.to_string(),
748                        span: Some(head),
749                        help: None,
750                        inner: vec![],
751                    })?
752            }
753            #[cfg(not(feature = "cross-stream"))]
754            {
755                let _ = topic_name;
756                return Err(ShellError::GenericError {
757                    error: "--topic requires cross-stream feature".into(),
758                    msg: "built without store support".into(),
759                    span: Some(head),
760                    help: None,
761                    inner: vec![],
762                });
763            }
764        } else {
765            // Inline mode: self-contained, no loader
766            let source = inline.unwrap();
767            env.add_template_owned("template".to_string(), source)
768                .map_err(|e| ShellError::GenericError {
769                    error: format!("Template parse error: {e}"),
770                    msg: e.to_string(),
771                    span: Some(head),
772                    help: None,
773                    inner: vec![],
774                })?;
775            env.get_template("template")
776                .map_err(|e| ShellError::GenericError {
777                    error: format!("Failed to get template: {e}"),
778                    msg: e.to_string(),
779                    span: Some(head),
780                    help: None,
781                    inner: vec![],
782                })?
783        };
784
785        let rendered = tmpl
786            .render(&context)
787            .map_err(|e| ShellError::GenericError {
788                error: format!("Template render error: {e}"),
789                msg: e.to_string(),
790                span: Some(head),
791                help: None,
792                inner: vec![],
793            })?;
794
795        Ok(Value::string(rendered, head).into_pipeline_data())
796    }
797}
798
799/// Convert a nu_protocol::Value to a minijinja::Value via serde_json
800fn nu_value_to_minijinja(val: &Value) -> minijinja::Value {
801    let json = value_to_json(val, &Config::default()).unwrap_or(serde_json::Value::Null);
802    minijinja::Value::from_serialize(&json)
803}
804
805// === .mj compile ===
806
807#[derive(Clone)]
808pub struct MjCompileCommand {
809    #[cfg(feature = "cross-stream")]
810    store: Option<xs::store::Store>,
811}
812
813impl Default for MjCompileCommand {
814    fn default() -> Self {
815        Self::new()
816    }
817}
818
819impl MjCompileCommand {
820    pub fn new() -> Self {
821        Self {
822            #[cfg(feature = "cross-stream")]
823            store: None,
824        }
825    }
826
827    #[cfg(feature = "cross-stream")]
828    pub fn with_store(store: xs::store::Store) -> Self {
829        Self { store: Some(store) }
830    }
831}
832
833impl Command for MjCompileCommand {
834    fn name(&self) -> &str {
835        ".mj compile"
836    }
837
838    fn description(&self) -> &str {
839        "Compile a minijinja template, returning a reusable compiled template"
840    }
841
842    fn signature(&self) -> Signature {
843        Signature::build(".mj compile")
844            .optional("file", SyntaxShape::String, "template file path")
845            .named(
846                "inline",
847                SyntaxShape::Any,
848                "inline template (string or {__html: string})",
849                Some('i'),
850            )
851            .named(
852                "topic",
853                SyntaxShape::String,
854                "load template from a store topic",
855                Some('t'),
856            )
857            .input_output_types(vec![(
858                Type::Nothing,
859                Type::Custom("CompiledTemplate".into()),
860            )])
861            .category(Category::Custom("http".into()))
862    }
863
864    fn run(
865        &self,
866        engine_state: &EngineState,
867        stack: &mut Stack,
868        call: &Call,
869        _input: PipelineData,
870    ) -> Result<PipelineData, ShellError> {
871        let head = call.head;
872        let file: Option<String> = call.opt(engine_state, stack, 0)?;
873        let inline: Option<Value> = call.get_flag(engine_state, stack, "inline")?;
874        let topic: Option<String> = call.get_flag(engine_state, stack, "topic")?;
875
876        // Extract template string from --inline value (string or {__html: string})
877        let inline_str: Option<String> = match &inline {
878            None => None,
879            Some(val) => match val {
880                Value::String { val, .. } => Some(val.clone()),
881                Value::Record { val, .. } => {
882                    if let Some(html_val) = val.get("__html") {
883                        match html_val {
884                            Value::String { val, .. } => Some(val.clone()),
885                            _ => {
886                                return Err(ShellError::GenericError {
887                                    error: "__html must be a string".into(),
888                                    msg: "expected string value".into(),
889                                    span: Some(head),
890                                    help: None,
891                                    inner: vec![],
892                                });
893                            }
894                        }
895                    } else {
896                        return Err(ShellError::GenericError {
897                            error: "Record must have __html field".into(),
898                            msg: "expected {__html: string}".into(),
899                            span: Some(head),
900                            help: None,
901                            inner: vec![],
902                        });
903                    }
904                }
905                _ => {
906                    return Err(ShellError::GenericError {
907                        error: "--inline must be string or {__html: string}".into(),
908                        msg: "invalid type".into(),
909                        span: Some(head),
910                        help: None,
911                        inner: vec![],
912                    });
913                }
914            },
915        };
916
917        let mode_count = file.is_some() as u8 + inline_str.is_some() as u8 + topic.is_some() as u8;
918        if mode_count > 1 {
919            return Err(ShellError::GenericError {
920                error: "Cannot combine file, --inline, and --topic".into(),
921                msg: "use exactly one of: file path, --inline, or --topic".into(),
922                span: Some(head),
923                help: None,
924                inner: vec![],
925            });
926        }
927        if mode_count == 0 {
928            return Err(ShellError::GenericError {
929                error: "No template specified".into(),
930                msg: "provide a file path, --inline, or --topic".into(),
931                span: Some(head),
932                help: None,
933                inner: vec![],
934            });
935        }
936
937        let hash = if let Some(ref path) = file {
938            // File mode: filesystem only
939            let path = std::path::Path::new(path);
940            let abs_path = if path.is_absolute() {
941                path.to_path_buf()
942            } else {
943                std::env::current_dir().unwrap_or_default().join(path)
944            };
945            let base_dir = abs_path.parent().unwrap_or(&abs_path).to_path_buf();
946            let source =
947                std::fs::read_to_string(&abs_path).map_err(|e| ShellError::GenericError {
948                    error: format!("Failed to read template file: {e}"),
949                    msg: "could not read file".into(),
950                    span: Some(head),
951                    help: None,
952                    inner: vec![],
953                })?;
954            compile_template(&source, &base_dir)
955        } else if let Some(ref topic_name) = topic {
956            // Topic mode: store only
957            #[cfg(feature = "cross-stream")]
958            {
959                let store = self
960                    .store
961                    .as_ref()
962                    .ok_or_else(|| ShellError::GenericError {
963                        error: "--topic requires --store".into(),
964                        msg: "server must be started with --store to use --topic".into(),
965                        span: Some(head),
966                        help: None,
967                        inner: vec![],
968                    })?;
969                let source = load_topic_content(store, topic_name).ok_or_else(|| {
970                    ShellError::GenericError {
971                        error: format!("Topic not found: {topic_name}"),
972                        msg: "no content in store for this topic".into(),
973                        span: Some(head),
974                        help: None,
975                        inner: vec![],
976                    }
977                })?;
978                let topic_store = store.clone();
979                // Use a synthetic base_dir for cache key uniqueness
980                let base_dir = std::path::PathBuf::from(format!("__topic__/{topic_name}"));
981                compile_template_with_loader(&source, &base_dir, move |name: &str| {
982                    Ok(load_topic_content(&topic_store, name))
983                })
984            }
985            #[cfg(not(feature = "cross-stream"))]
986            {
987                let _ = topic_name;
988                return Err(ShellError::GenericError {
989                    error: "--topic requires cross-stream feature".into(),
990                    msg: "built without store support".into(),
991                    span: Some(head),
992                    help: None,
993                    inner: vec![],
994                });
995            }
996        } else {
997            // Inline mode: self-contained, no loader
998            let source = inline_str.unwrap();
999            let base_dir = std::path::PathBuf::from("__inline__");
1000            compile_template_with_loader(&source, &base_dir, |_| Ok(None))
1001        };
1002
1003        let hash = hash.map_err(|e| ShellError::GenericError {
1004            error: format!("Template compile error: {e}"),
1005            msg: e.to_string(),
1006            span: Some(head),
1007            help: None,
1008            inner: vec![],
1009        })?;
1010
1011        Ok(Value::custom(Box::new(CompiledTemplate { hash }), head).into_pipeline_data())
1012    }
1013}
1014
1015// === .mj render ===
1016
1017#[derive(Clone)]
1018pub struct MjRenderCommand;
1019
1020impl Default for MjRenderCommand {
1021    fn default() -> Self {
1022        Self::new()
1023    }
1024}
1025
1026impl MjRenderCommand {
1027    pub fn new() -> Self {
1028        Self
1029    }
1030}
1031
1032impl Command for MjRenderCommand {
1033    fn name(&self) -> &str {
1034        ".mj render"
1035    }
1036
1037    fn description(&self) -> &str {
1038        "Render a compiled minijinja template with context from input"
1039    }
1040
1041    fn signature(&self) -> Signature {
1042        Signature::build(".mj render")
1043            .required(
1044                "template",
1045                SyntaxShape::Any,
1046                "compiled template from '.mj compile'",
1047            )
1048            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
1049            .category(Category::Custom("http".into()))
1050    }
1051
1052    fn run(
1053        &self,
1054        engine_state: &EngineState,
1055        stack: &mut Stack,
1056        call: &Call,
1057        input: PipelineData,
1058    ) -> Result<PipelineData, ShellError> {
1059        let head = call.head;
1060        let template_val: Value = call.req(engine_state, stack, 0)?;
1061
1062        // Extract CompiledTemplate from the value
1063        let compiled = match template_val {
1064            Value::Custom { val, .. } => val
1065                .as_any()
1066                .downcast_ref::<CompiledTemplate>()
1067                .ok_or_else(|| ShellError::TypeMismatch {
1068                    err_message: "expected CompiledTemplate".into(),
1069                    span: head,
1070                })?
1071                .clone(),
1072            _ => {
1073                return Err(ShellError::TypeMismatch {
1074                    err_message: "expected CompiledTemplate from '.mj compile'".into(),
1075                    span: head,
1076                });
1077            }
1078        };
1079
1080        // Get context from input
1081        let context = match input {
1082            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
1083            PipelineData::Empty => minijinja::Value::from(()),
1084            _ => {
1085                return Err(ShellError::TypeMismatch {
1086                    err_message: "expected record input".into(),
1087                    span: head,
1088                });
1089            }
1090        };
1091
1092        // Render template
1093        let rendered = compiled
1094            .render(&context)
1095            .map_err(|e| ShellError::GenericError {
1096                error: format!("Template render error: {e}"),
1097                msg: e.to_string(),
1098                span: Some(head),
1099                help: None,
1100                inner: vec![],
1101            })?;
1102
1103        Ok(Value::string(rendered, head).into_pipeline_data())
1104    }
1105}
1106
1107// === Syntax Highlighting ===
1108
1109struct SyntaxHighlighter {
1110    syntax_set: SyntaxSet,
1111}
1112
1113impl SyntaxHighlighter {
1114    fn new() -> Self {
1115        const SYNTAX_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/syntax_set.bin"));
1116        let syntax_set = syntect::dumps::from_binary(SYNTAX_SET);
1117        Self { syntax_set }
1118    }
1119
1120    fn highlight(&self, code: &str, lang: Option<&str>) -> String {
1121        let syntax = match lang {
1122            Some(lang) => self
1123                .syntax_set
1124                .find_syntax_by_token(lang)
1125                .or_else(|| self.syntax_set.find_syntax_by_extension(lang)),
1126            None => None,
1127        }
1128        .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
1129
1130        let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
1131            syntax,
1132            &self.syntax_set,
1133            ClassStyle::Spaced,
1134        );
1135
1136        for line in LinesWithEndings::from(code) {
1137            let _ = html_generator.parse_html_for_line_which_includes_newline(line);
1138        }
1139
1140        html_generator.finalize()
1141    }
1142
1143    fn list_syntaxes(&self) -> Vec<(String, Vec<String>)> {
1144        self.syntax_set
1145            .syntaxes()
1146            .iter()
1147            .map(|s| (s.name.clone(), s.file_extensions.clone()))
1148            .collect()
1149    }
1150}
1151
1152static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
1153
1154fn get_highlighter() -> &'static SyntaxHighlighter {
1155    HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
1156}
1157
1158// === .highlight command ===
1159
1160#[derive(Clone)]
1161pub struct HighlightCommand;
1162
1163impl Default for HighlightCommand {
1164    fn default() -> Self {
1165        Self::new()
1166    }
1167}
1168
1169impl HighlightCommand {
1170    pub fn new() -> Self {
1171        Self
1172    }
1173}
1174
1175impl Command for HighlightCommand {
1176    fn name(&self) -> &str {
1177        ".highlight"
1178    }
1179
1180    fn description(&self) -> &str {
1181        "Syntax highlight code, outputting HTML with CSS classes"
1182    }
1183
1184    fn signature(&self) -> Signature {
1185        Signature::build(".highlight")
1186            .required("lang", SyntaxShape::String, "language for highlighting")
1187            .input_output_types(vec![(Type::String, Type::record())])
1188            .category(Category::Custom("http".into()))
1189    }
1190
1191    fn run(
1192        &self,
1193        engine_state: &EngineState,
1194        stack: &mut Stack,
1195        call: &Call,
1196        input: PipelineData,
1197    ) -> Result<PipelineData, ShellError> {
1198        let head = call.head;
1199        let lang: String = call.req(engine_state, stack, 0)?;
1200
1201        let code = match input {
1202            PipelineData::Value(Value::String { val, .. }, _) => val,
1203            PipelineData::ByteStream(stream, _) => stream.into_string()?,
1204            _ => {
1205                return Err(ShellError::TypeMismatch {
1206                    err_message: "expected string input".into(),
1207                    span: head,
1208                });
1209            }
1210        };
1211
1212        let highlighter = get_highlighter();
1213        let html = highlighter.highlight(&code, Some(&lang));
1214
1215        Ok(Value::record(
1216            nu_protocol::record! {
1217                "__html" => Value::string(html, head),
1218            },
1219            head,
1220        )
1221        .into_pipeline_data())
1222    }
1223}
1224
1225// === .highlight theme command ===
1226
1227#[derive(Clone)]
1228pub struct HighlightThemeCommand;
1229
1230impl Default for HighlightThemeCommand {
1231    fn default() -> Self {
1232        Self::new()
1233    }
1234}
1235
1236impl HighlightThemeCommand {
1237    pub fn new() -> Self {
1238        Self
1239    }
1240}
1241
1242impl Command for HighlightThemeCommand {
1243    fn name(&self) -> &str {
1244        ".highlight theme"
1245    }
1246
1247    fn description(&self) -> &str {
1248        "List available themes or get CSS for a specific theme"
1249    }
1250
1251    fn signature(&self) -> Signature {
1252        Signature::build(".highlight theme")
1253            .optional("name", SyntaxShape::String, "theme name (omit to list all)")
1254            .input_output_types(vec![
1255                (Type::Nothing, Type::List(Box::new(Type::String))),
1256                (Type::Nothing, Type::String),
1257            ])
1258            .category(Category::Custom("http".into()))
1259    }
1260
1261    fn run(
1262        &self,
1263        engine_state: &EngineState,
1264        stack: &mut Stack,
1265        call: &Call,
1266        _input: PipelineData,
1267    ) -> Result<PipelineData, ShellError> {
1268        let head = call.head;
1269        let name: Option<String> = call.opt(engine_state, stack, 0)?;
1270
1271        let assets = syntect_assets::assets::HighlightingAssets::from_binary();
1272
1273        match name {
1274            None => {
1275                let themes: Vec<Value> = assets.themes().map(|t| Value::string(t, head)).collect();
1276                Ok(Value::list(themes, head).into_pipeline_data())
1277            }
1278            Some(theme_name) => {
1279                let theme = assets.get_theme(&theme_name);
1280                let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced)
1281                    .map_err(|e| ShellError::GenericError {
1282                        error: format!("Failed to generate CSS: {e}"),
1283                        msg: e.to_string(),
1284                        span: Some(head),
1285                        help: None,
1286                        inner: vec![],
1287                    })?;
1288                Ok(Value::string(css, head).into_pipeline_data())
1289            }
1290        }
1291    }
1292}
1293
1294// === .highlight lang command ===
1295
1296#[derive(Clone)]
1297pub struct HighlightLangCommand;
1298
1299impl Default for HighlightLangCommand {
1300    fn default() -> Self {
1301        Self::new()
1302    }
1303}
1304
1305impl HighlightLangCommand {
1306    pub fn new() -> Self {
1307        Self
1308    }
1309}
1310
1311impl Command for HighlightLangCommand {
1312    fn name(&self) -> &str {
1313        ".highlight lang"
1314    }
1315
1316    fn description(&self) -> &str {
1317        "List available languages for syntax highlighting"
1318    }
1319
1320    fn signature(&self) -> Signature {
1321        Signature::build(".highlight lang")
1322            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
1323            .category(Category::Custom("http".into()))
1324    }
1325
1326    fn run(
1327        &self,
1328        _engine_state: &EngineState,
1329        _stack: &mut Stack,
1330        call: &Call,
1331        _input: PipelineData,
1332    ) -> Result<PipelineData, ShellError> {
1333        let head = call.head;
1334        let highlighter = get_highlighter();
1335        let langs: Vec<Value> = highlighter
1336            .list_syntaxes()
1337            .into_iter()
1338            .map(|(name, exts)| {
1339                Value::record(
1340                    nu_protocol::record! {
1341                        "name" => Value::string(name, head),
1342                        "extensions" => Value::list(
1343                            exts.into_iter().map(|e| Value::string(e, head)).collect(),
1344                            head
1345                        ),
1346                    },
1347                    head,
1348                )
1349            })
1350            .collect();
1351        Ok(Value::list(langs, head).into_pipeline_data())
1352    }
1353}
1354
1355// === .md command ===
1356
1357use pulldown_cmark::{html, CodeBlockKind, Event, Parser as MarkdownParser, Tag, TagEnd};
1358
1359#[derive(Clone)]
1360pub struct MdCommand;
1361
1362impl Default for MdCommand {
1363    fn default() -> Self {
1364        Self::new()
1365    }
1366}
1367
1368impl MdCommand {
1369    pub fn new() -> Self {
1370        Self
1371    }
1372}
1373
1374impl Command for MdCommand {
1375    fn name(&self) -> &str {
1376        ".md"
1377    }
1378
1379    fn description(&self) -> &str {
1380        "Convert Markdown to HTML with syntax-highlighted code blocks"
1381    }
1382
1383    fn signature(&self) -> Signature {
1384        Signature::build(".md")
1385            .input_output_types(vec![
1386                (Type::String, Type::record()),
1387                (Type::record(), Type::record()),
1388            ])
1389            .category(Category::Custom("http".into()))
1390    }
1391
1392    fn run(
1393        &self,
1394        _engine_state: &EngineState,
1395        _stack: &mut Stack,
1396        call: &Call,
1397        input: PipelineData,
1398    ) -> Result<PipelineData, ShellError> {
1399        let head = call.head;
1400
1401        // Determine if input is trusted ({__html: ...}) or untrusted (plain string)
1402        let (markdown, trusted) = match input.into_value(head)? {
1403            Value::String { val, .. } => (val, false),
1404            Value::Record { val, .. } => {
1405                if let Some(html_val) = val.get("__html") {
1406                    (html_val.as_str()?.to_string(), true)
1407                } else {
1408                    return Err(ShellError::TypeMismatch {
1409                        err_message: "expected string or {__html: ...}".into(),
1410                        span: head,
1411                    });
1412                }
1413            }
1414            other => {
1415                return Err(ShellError::TypeMismatch {
1416                    err_message: format!(
1417                        "expected string or {{__html: ...}}, got {}",
1418                        other.get_type()
1419                    ),
1420                    span: head,
1421                });
1422            }
1423        };
1424
1425        let highlighter = get_highlighter();
1426
1427        let mut in_code_block = false;
1428        let mut current_code = String::new();
1429        let mut current_lang: Option<String> = None;
1430
1431        let mut options = pulldown_cmark::Options::empty();
1432        options.insert(pulldown_cmark::Options::ENABLE_TABLES);
1433        options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
1434        options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
1435        options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
1436        options.insert(pulldown_cmark::Options::ENABLE_HEADING_ATTRIBUTES);
1437        options.insert(pulldown_cmark::Options::ENABLE_GFM);
1438        options.insert(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
1439        let parser = MarkdownParser::new_ext(&markdown, options).map(|event| match event {
1440            Event::Start(Tag::CodeBlock(kind)) => {
1441                in_code_block = true;
1442                current_code.clear();
1443                current_lang = match kind {
1444                    CodeBlockKind::Fenced(info) => {
1445                        let lang = info.split_whitespace().next().unwrap_or("");
1446                        if lang.is_empty() {
1447                            None
1448                        } else {
1449                            Some(lang.to_string())
1450                        }
1451                    }
1452                    CodeBlockKind::Indented => None,
1453                };
1454                Event::Text("".into())
1455            }
1456            Event::End(TagEnd::CodeBlock) => {
1457                in_code_block = false;
1458                let highlighted = highlighter.highlight(&current_code, current_lang.as_deref());
1459                let mut html_out = String::new();
1460                html_out.push_str("<pre><code");
1461                if let Some(lang) = &current_lang {
1462                    html_out.push_str(&format!(" class=\"language-{lang}\""));
1463                }
1464                html_out.push('>');
1465                html_out.push_str(&highlighted);
1466                html_out.push_str("</code></pre>");
1467                Event::Html(html_out.into())
1468            }
1469            Event::Text(text) => {
1470                if in_code_block {
1471                    current_code.push_str(&text);
1472                    Event::Text("".into())
1473                } else {
1474                    Event::Text(text)
1475                }
1476            }
1477            // Escape raw HTML if input is untrusted
1478            Event::Html(html) => {
1479                if trusted {
1480                    Event::Html(html)
1481                } else {
1482                    Event::Text(html) // push_html escapes Text
1483                }
1484            }
1485            Event::InlineHtml(html) => {
1486                if trusted {
1487                    Event::InlineHtml(html)
1488                } else {
1489                    Event::Text(html)
1490                }
1491            }
1492            e => e,
1493        });
1494
1495        let mut html_output = String::new();
1496        html::push_html(&mut html_output, parser);
1497
1498        Ok(Value::record(
1499            nu_protocol::record! {
1500                "__html" => Value::string(html_output, head),
1501            },
1502            head,
1503        )
1504        .into_pipeline_data())
1505    }
1506}
1507
1508// === .print command ===
1509
1510#[derive(Clone)]
1511pub struct PrintCommand;
1512
1513impl Default for PrintCommand {
1514    fn default() -> Self {
1515        Self::new()
1516    }
1517}
1518
1519impl PrintCommand {
1520    pub fn new() -> Self {
1521        Self
1522    }
1523}
1524
1525impl Command for PrintCommand {
1526    fn name(&self) -> &str {
1527        "print"
1528    }
1529
1530    fn description(&self) -> &str {
1531        "Print the given values to the http-nu logging system."
1532    }
1533
1534    fn extra_description(&self) -> &str {
1535        r#"This command outputs to http-nu's logging system rather than stdout/stderr.
1536Messages appear in both human-readable and JSONL output modes.
1537
1538`print` may be used inside blocks of code (e.g.: hooks) to display text during execution without interfering with the pipeline."#
1539    }
1540
1541    fn search_terms(&self) -> Vec<&str> {
1542        vec!["display"]
1543    }
1544
1545    fn signature(&self) -> Signature {
1546        Signature::build("print")
1547            .input_output_types(vec![
1548                (Type::Nothing, Type::Nothing),
1549                (Type::Any, Type::Nothing),
1550            ])
1551            .allow_variants_without_examples(true)
1552            .rest("rest", SyntaxShape::Any, "the values to print")
1553            .switch(
1554                "no-newline",
1555                "print without inserting a newline for the line ending",
1556                Some('n'),
1557            )
1558            .switch("stderr", "print to stderr instead of stdout", Some('e'))
1559            .switch(
1560                "raw",
1561                "print without formatting (including binary data)",
1562                Some('r'),
1563            )
1564            .category(Category::Strings)
1565    }
1566
1567    fn run(
1568        &self,
1569        engine_state: &EngineState,
1570        stack: &mut Stack,
1571        call: &Call,
1572        input: PipelineData,
1573    ) -> Result<PipelineData, ShellError> {
1574        let args: Vec<Value> = call.rest(engine_state, stack, 0)?;
1575        let no_newline = call.has_flag(engine_state, stack, "no-newline")?;
1576        let config = stack.get_config(engine_state);
1577
1578        let format_value = |val: &Value| -> String { val.to_expanded_string(" ", &config) };
1579
1580        if !args.is_empty() {
1581            let messages: Vec<String> = args.iter().map(format_value).collect();
1582            let message = if no_newline {
1583                messages.join("")
1584            } else {
1585                messages.join("\n")
1586            };
1587            log_print(&message);
1588        } else if !input.is_nothing() {
1589            let message = match input {
1590                PipelineData::Value(val, _) => format_value(&val),
1591                PipelineData::ListStream(stream, _) => {
1592                    let vals: Vec<String> = stream.into_iter().map(|v| format_value(&v)).collect();
1593                    vals.join("\n")
1594                }
1595                PipelineData::ByteStream(stream, _) => stream.into_string()?,
1596                PipelineData::Empty => String::new(),
1597            };
1598            if !message.is_empty() {
1599                log_print(&message);
1600            }
1601        }
1602
1603        Ok(PipelineData::empty())
1604    }
1605
1606    fn examples(&self) -> Vec<Example<'_>> {
1607        vec![
1608            Example {
1609                description: "Print 'hello world'",
1610                example: r#"print "hello world""#,
1611                result: None,
1612            },
1613            Example {
1614                description: "Print the sum of 2 and 3",
1615                example: r#"print (2 + 3)"#,
1616                result: None,
1617            },
1618        ]
1619    }
1620}