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    shell_error::generic::GenericError, ByteStream, ByteStreamType, Category, Config, CustomValue,
6    PipelineData, PipelineMetadata, 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, AutoEscape, 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_auto_escape_callback(|_| AutoEscape::Html);
61    env.set_loader(loader);
62    env.add_template_owned("template".to_string(), source.to_string())?;
63    cache.insert(hash, Arc::new(env));
64    Ok(hash)
65}
66
67/// Get compiled template from cache by hash.
68fn get_compiled(hash: u128) -> Option<Arc<Environment<'static>>> {
69    get_cache().read().unwrap().get(&hash).map(Arc::clone)
70}
71
72/// Load the latest content for a store topic.
73#[cfg(feature = "cross-stream")]
74fn load_topic_content(store: &xs::store::Store, topic: &str) -> Option<String> {
75    let options = xs::store::ReadOptions::builder()
76        .follow(xs::store::FollowOption::Off)
77        .topic(topic.to_string())
78        .last(1_usize)
79        .build();
80    let frame = store.read_sync(options).last()?;
81    let hash = frame.hash?;
82    let bytes = store.cas_read_sync(&hash).ok()?;
83    String::from_utf8(bytes).ok()
84}
85
86// === CompiledTemplate CustomValue ===
87
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct CompiledTemplate {
90    hash: u128,
91}
92
93impl CompiledTemplate {
94    /// Render this template with the given context
95    pub fn render(&self, context: &minijinja::Value) -> Result<String, minijinja::Error> {
96        let env = get_compiled(self.hash).expect("template not in cache");
97        let tmpl = env.get_template("template")?;
98        tmpl.render(context)
99    }
100}
101
102#[typetag::serde]
103impl CustomValue for CompiledTemplate {
104    fn clone_value(&self, span: Span) -> Value {
105        Value::custom(Box::new(self.clone()), span)
106    }
107
108    fn type_name(&self) -> String {
109        "CompiledTemplate".into()
110    }
111
112    fn to_base_value(&self, span: Span) -> Result<Value, ShellError> {
113        Ok(Value::string(format!("{:032x}", self.hash), span))
114    }
115
116    fn as_any(&self) -> &dyn std::any::Any {
117        self
118    }
119
120    fn as_mut_any(&mut self) -> &mut dyn std::any::Any {
121        self
122    }
123}
124
125thread_local! {
126    pub static RESPONSE_TX: RefCell<Option<oneshot::Sender<Response>>> = const { RefCell::new(None) };
127}
128
129#[derive(Clone)]
130pub struct StaticCommand;
131
132impl Default for StaticCommand {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138impl StaticCommand {
139    pub fn new() -> Self {
140        Self
141    }
142}
143
144impl Command for StaticCommand {
145    fn name(&self) -> &str {
146        ".static"
147    }
148
149    fn description(&self) -> &str {
150        "Serve static files from a directory"
151    }
152
153    fn signature(&self) -> Signature {
154        Signature::build(".static")
155            .required("root", SyntaxShape::String, "root directory path")
156            .required("path", SyntaxShape::String, "request path")
157            .named(
158                "fallback",
159                SyntaxShape::String,
160                "fallback file when request missing",
161                None,
162            )
163            .input_output_types(vec![(Type::Nothing, Type::Nothing)])
164            .category(Category::Custom("http".into()))
165    }
166
167    fn run(
168        &self,
169        engine_state: &EngineState,
170        stack: &mut Stack,
171        call: &Call,
172        _input: PipelineData,
173    ) -> Result<PipelineData, ShellError> {
174        let root: String = call.req(engine_state, stack, 0)?;
175        let path: String = call.req(engine_state, stack, 1)?;
176
177        let fallback: Option<String> = call.get_flag(engine_state, stack, "fallback")?;
178
179        let response = Response {
180            status: 200,
181            headers: HashMap::new(),
182            body_type: ResponseBodyType::Static {
183                root: PathBuf::from(root),
184                path,
185                fallback,
186            },
187        };
188
189        RESPONSE_TX.with(|tx| -> Result<_, ShellError> {
190            if let Some(tx) = tx.borrow_mut().take() {
191                tx.send(response).map_err(|_| {
192                    ShellError::Generic(GenericError::new(
193                        "Failed to send response",
194                        "Channel closed",
195                        call.head,
196                    ))
197                })?;
198            }
199            Ok(())
200        })?;
201
202        Ok(PipelineData::Empty)
203    }
204}
205
206const LINE_ENDING: &str = "\n";
207
208#[derive(Clone)]
209pub struct ToSse;
210
211impl Command for ToSse {
212    fn name(&self) -> &str {
213        "to sse"
214    }
215
216    fn signature(&self) -> Signature {
217        Signature::build("to sse")
218            .input_output_types(vec![
219                (Type::record(), Type::String),
220                (Type::List(Box::new(Type::record())), Type::String),
221            ])
222            .category(Category::Formats)
223    }
224
225    fn description(&self) -> &str {
226        "Convert records into text/event-stream format"
227    }
228
229    fn search_terms(&self) -> Vec<&str> {
230        vec!["sse", "server", "event"]
231    }
232
233    fn examples(&self) -> Vec<Example<'_>> {
234        vec![Example {
235            description: "Convert a record into a server-sent event",
236            example: "{data: 'hello'} | to sse",
237            result: Some(Value::test_string("data: hello\n\n")),
238        }]
239    }
240
241    fn run(
242        &self,
243        engine_state: &EngineState,
244        stack: &mut Stack,
245        call: &Call,
246        input: PipelineData,
247    ) -> Result<PipelineData, ShellError> {
248        let head = call.head;
249        let config = stack.get_config(engine_state);
250        match input {
251            PipelineData::ListStream(stream, meta) => {
252                let span = stream.span();
253                let cfg = config.clone();
254                let iter = stream
255                    .into_iter()
256                    .map(move |val| event_to_string(&cfg, val));
257                let stream = ByteStream::from_result_iter(
258                    iter,
259                    span,
260                    engine_state.signals().clone(),
261                    ByteStreamType::String,
262                );
263                Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
264            }
265            PipelineData::Value(Value::List { vals, .. }, meta) => {
266                let cfg = config.clone();
267                let iter = vals.into_iter().map(move |val| event_to_string(&cfg, val));
268                let span = head;
269                let stream = ByteStream::from_result_iter(
270                    iter,
271                    span,
272                    engine_state.signals().clone(),
273                    ByteStreamType::String,
274                );
275                Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
276            }
277            PipelineData::Value(val, meta) => {
278                let out = event_to_string(&config, val)?;
279                Ok(
280                    Value::string(out, head)
281                        .into_pipeline_data_with_metadata(update_metadata(meta)),
282                )
283            }
284            PipelineData::Empty => Ok(PipelineData::Value(
285                Value::string(String::new(), head),
286                update_metadata(None),
287            )),
288            PipelineData::ByteStream(..) => Err(ShellError::TypeMismatch {
289                err_message: "expected record input".into(),
290                span: head,
291            }),
292        }
293    }
294}
295
296fn emit_data_lines(out: &mut String, s: &str) {
297    for line in s.lines() {
298        out.push_str("data: ");
299        out.push_str(line);
300        out.push_str(LINE_ENDING);
301    }
302}
303
304#[allow(clippy::result_large_err)]
305fn value_to_data_string(val: &Value, config: &Config) -> Result<String, ShellError> {
306    match val {
307        Value::String { val, .. } => Ok(val.clone()),
308        _ => {
309            let json_value = value_to_json(val, config).map_err(|err| {
310                ShellError::Generic(GenericError::new(
311                    err.to_string(),
312                    "failed to serialize json",
313                    Span::unknown(),
314                ))
315            })?;
316            serde_json::to_string(&json_value).map_err(|err| {
317                ShellError::Generic(GenericError::new(
318                    err.to_string(),
319                    "failed to serialize json",
320                    Span::unknown(),
321                ))
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(|_| {
574                    ShellError::Generic(GenericError::new(
575                        "Failed to send response",
576                        "Channel closed",
577                        call.head,
578                    ))
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::Generic(GenericError::new(
657                "Cannot combine file, --inline, and --topic",
658                "use exactly one of: file path, --inline, or --topic",
659                head,
660            )));
661        }
662        if mode_count == 0 {
663            return Err(ShellError::Generic(GenericError::new(
664                "No template specified",
665                "provide a file path, --inline, or --topic",
666                head,
667            )));
668        }
669
670        // Get context from input
671        let context = match input {
672            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
673            PipelineData::Empty => minijinja::Value::from(()),
674            _ => {
675                return Err(ShellError::TypeMismatch {
676                    err_message: "expected record input".into(),
677                    span: head,
678                });
679            }
680        };
681
682        // Set up environment and get template
683        let mut env = Environment::new();
684        env.set_auto_escape_callback(|_| AutoEscape::Html);
685        let tmpl = if let Some(ref path) = file {
686            // File mode: resolve from filesystem only
687            let path = std::path::Path::new(path);
688            let abs_path = if path.is_absolute() {
689                path.to_path_buf()
690            } else {
691                std::env::current_dir().unwrap_or_default().join(path)
692            };
693            if let Some(parent) = abs_path.parent() {
694                env.set_loader(path_loader(parent));
695            }
696            let name = abs_path
697                .file_name()
698                .and_then(|n| n.to_str())
699                .unwrap_or("template");
700            env.get_template(name).map_err(|e| {
701                ShellError::Generic(GenericError::new(
702                    format!("Template error: {e}"),
703                    e.to_string(),
704                    head,
705                ))
706            })?
707        } else if let Some(ref topic_name) = topic {
708            // Topic mode: resolve from store only
709            #[cfg(feature = "cross-stream")]
710            {
711                let store = self.store.as_ref().ok_or_else(|| {
712                    ShellError::Generic(GenericError::new(
713                        "--topic requires --store",
714                        "server must be started with --store to use --topic",
715                        head,
716                    ))
717                })?;
718                let source = load_topic_content(store, topic_name).ok_or_else(|| {
719                    ShellError::Generic(GenericError::new(
720                        format!("Topic not found: {topic_name}"),
721                        "no content in store for this topic",
722                        head,
723                    ))
724                })?;
725                let topic_store = store.clone();
726                env.set_loader(move |name: &str| Ok(load_topic_content(&topic_store, name)));
727                env.add_template_owned(topic_name.clone(), source)
728                    .map_err(|e| {
729                        ShellError::Generic(GenericError::new(
730                            format!("Template parse error: {e}"),
731                            e.to_string(),
732                            head,
733                        ))
734                    })?;
735                env.get_template(topic_name).map_err(|e| {
736                    ShellError::Generic(GenericError::new(
737                        format!("Template error: {e}"),
738                        e.to_string(),
739                        head,
740                    ))
741                })?
742            }
743            #[cfg(not(feature = "cross-stream"))]
744            {
745                let _ = topic_name;
746                return Err(ShellError::Generic(GenericError::new(
747                    "--topic requires cross-stream feature",
748                    "built without store support",
749                    head,
750                )));
751            }
752        } else {
753            // Inline mode: self-contained, no loader
754            let source = inline.unwrap();
755            env.add_template_owned("template".to_string(), source)
756                .map_err(|e| {
757                    ShellError::Generic(GenericError::new(
758                        format!("Template parse error: {e}"),
759                        e.to_string(),
760                        head,
761                    ))
762                })?;
763            env.get_template("template").map_err(|e| {
764                ShellError::Generic(GenericError::new(
765                    format!("Failed to get template: {e}"),
766                    e.to_string(),
767                    head,
768                ))
769            })?
770        };
771
772        let rendered = tmpl.render(&context).map_err(|e| {
773            ShellError::Generic(GenericError::new(
774                format!("Template render error: {e}"),
775                e.to_string(),
776                head,
777            ))
778        })?;
779
780        Ok(Value::string(rendered, head).into_pipeline_data())
781    }
782}
783
784/// Convert a nu_protocol::Value to a minijinja::Value via serde_json
785fn nu_value_to_minijinja(val: &Value) -> minijinja::Value {
786    let json = value_to_json(val, &Config::default()).unwrap_or(serde_json::Value::Null);
787    minijinja::Value::from_serialize(&json)
788}
789
790// === .mj compile ===
791
792#[derive(Clone)]
793pub struct MjCompileCommand {
794    #[cfg(feature = "cross-stream")]
795    store: Option<xs::store::Store>,
796}
797
798impl Default for MjCompileCommand {
799    fn default() -> Self {
800        Self::new()
801    }
802}
803
804impl MjCompileCommand {
805    pub fn new() -> Self {
806        Self {
807            #[cfg(feature = "cross-stream")]
808            store: None,
809        }
810    }
811
812    #[cfg(feature = "cross-stream")]
813    pub fn with_store(store: xs::store::Store) -> Self {
814        Self { store: Some(store) }
815    }
816}
817
818impl Command for MjCompileCommand {
819    fn name(&self) -> &str {
820        ".mj compile"
821    }
822
823    fn description(&self) -> &str {
824        "Compile a minijinja template, returning a reusable compiled template"
825    }
826
827    fn signature(&self) -> Signature {
828        Signature::build(".mj compile")
829            .optional("file", SyntaxShape::String, "template file path")
830            .named(
831                "inline",
832                SyntaxShape::Any,
833                "inline template (string or {__html: string})",
834                Some('i'),
835            )
836            .named(
837                "topic",
838                SyntaxShape::String,
839                "load template from a store topic",
840                Some('t'),
841            )
842            .input_output_types(vec![(
843                Type::Nothing,
844                Type::Custom("CompiledTemplate".into()),
845            )])
846            .category(Category::Custom("http".into()))
847    }
848
849    fn run(
850        &self,
851        engine_state: &EngineState,
852        stack: &mut Stack,
853        call: &Call,
854        _input: PipelineData,
855    ) -> Result<PipelineData, ShellError> {
856        let head = call.head;
857        let file: Option<String> = call.opt(engine_state, stack, 0)?;
858        let inline: Option<Value> = call.get_flag(engine_state, stack, "inline")?;
859        let topic: Option<String> = call.get_flag(engine_state, stack, "topic")?;
860
861        // Extract template string from --inline value (string or {__html: string})
862        let inline_str: Option<String> = match &inline {
863            None => None,
864            Some(val) => match val {
865                Value::String { val, .. } => Some(val.clone()),
866                Value::Record { val, .. } => {
867                    if let Some(html_val) = val.get("__html") {
868                        match html_val {
869                            Value::String { val, .. } => Some(val.clone()),
870                            _ => {
871                                return Err(ShellError::Generic(GenericError::new(
872                                    "__html must be a string",
873                                    "expected string value",
874                                    head,
875                                )));
876                            }
877                        }
878                    } else {
879                        return Err(ShellError::Generic(GenericError::new(
880                            "Record must have __html field",
881                            "expected {__html: string}",
882                            head,
883                        )));
884                    }
885                }
886                _ => {
887                    return Err(ShellError::Generic(GenericError::new(
888                        "--inline must be string or {__html: string}",
889                        "invalid type",
890                        head,
891                    )));
892                }
893            },
894        };
895
896        let mode_count = file.is_some() as u8 + inline_str.is_some() as u8 + topic.is_some() as u8;
897        if mode_count > 1 {
898            return Err(ShellError::Generic(GenericError::new(
899                "Cannot combine file, --inline, and --topic",
900                "use exactly one of: file path, --inline, or --topic",
901                head,
902            )));
903        }
904        if mode_count == 0 {
905            return Err(ShellError::Generic(GenericError::new(
906                "No template specified",
907                "provide a file path, --inline, or --topic",
908                head,
909            )));
910        }
911
912        let hash = if let Some(ref path) = file {
913            // File mode: filesystem only
914            let path = std::path::Path::new(path);
915            let abs_path = if path.is_absolute() {
916                path.to_path_buf()
917            } else {
918                std::env::current_dir().unwrap_or_default().join(path)
919            };
920            let base_dir = abs_path.parent().unwrap_or(&abs_path).to_path_buf();
921            let source = std::fs::read_to_string(&abs_path).map_err(|e| {
922                ShellError::Generic(GenericError::new(
923                    format!("Failed to read template file: {e}"),
924                    "could not read file",
925                    head,
926                ))
927            })?;
928            compile_template(&source, &base_dir)
929        } else if let Some(ref topic_name) = topic {
930            // Topic mode: store only
931            #[cfg(feature = "cross-stream")]
932            {
933                let store = self.store.as_ref().ok_or_else(|| {
934                    ShellError::Generic(GenericError::new(
935                        "--topic requires --store",
936                        "server must be started with --store to use --topic",
937                        head,
938                    ))
939                })?;
940                let source = load_topic_content(store, topic_name).ok_or_else(|| {
941                    ShellError::Generic(GenericError::new(
942                        format!("Topic not found: {topic_name}"),
943                        "no content in store for this topic",
944                        head,
945                    ))
946                })?;
947                let topic_store = store.clone();
948                // Use a synthetic base_dir for cache key uniqueness
949                let base_dir = std::path::PathBuf::from(format!("__topic__/{topic_name}"));
950                compile_template_with_loader(&source, &base_dir, move |name: &str| {
951                    Ok(load_topic_content(&topic_store, name))
952                })
953            }
954            #[cfg(not(feature = "cross-stream"))]
955            {
956                let _ = topic_name;
957                return Err(ShellError::Generic(GenericError::new(
958                    "--topic requires cross-stream feature",
959                    "built without store support",
960                    head,
961                )));
962            }
963        } else {
964            // Inline mode: self-contained, no loader
965            let source = inline_str.unwrap();
966            let base_dir = std::path::PathBuf::from("__inline__");
967            compile_template_with_loader(&source, &base_dir, |_| Ok(None))
968        };
969
970        let hash = hash.map_err(|e| {
971            ShellError::Generic(GenericError::new(
972                format!("Template compile error: {e}"),
973                e.to_string(),
974                head,
975            ))
976        })?;
977
978        Ok(Value::custom(Box::new(CompiledTemplate { hash }), head).into_pipeline_data())
979    }
980}
981
982// === .mj render ===
983
984#[derive(Clone)]
985pub struct MjRenderCommand;
986
987impl Default for MjRenderCommand {
988    fn default() -> Self {
989        Self::new()
990    }
991}
992
993impl MjRenderCommand {
994    pub fn new() -> Self {
995        Self
996    }
997}
998
999impl Command for MjRenderCommand {
1000    fn name(&self) -> &str {
1001        ".mj render"
1002    }
1003
1004    fn description(&self) -> &str {
1005        "Render a compiled minijinja template with context from input"
1006    }
1007
1008    fn signature(&self) -> Signature {
1009        Signature::build(".mj render")
1010            .required(
1011                "template",
1012                SyntaxShape::Any,
1013                "compiled template from '.mj compile'",
1014            )
1015            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
1016            .category(Category::Custom("http".into()))
1017    }
1018
1019    fn run(
1020        &self,
1021        engine_state: &EngineState,
1022        stack: &mut Stack,
1023        call: &Call,
1024        input: PipelineData,
1025    ) -> Result<PipelineData, ShellError> {
1026        let head = call.head;
1027        let template_val: Value = call.req(engine_state, stack, 0)?;
1028
1029        // Extract CompiledTemplate from the value
1030        let compiled = match template_val {
1031            Value::Custom { val, .. } => val
1032                .as_any()
1033                .downcast_ref::<CompiledTemplate>()
1034                .ok_or_else(|| ShellError::TypeMismatch {
1035                    err_message: "expected CompiledTemplate".into(),
1036                    span: head,
1037                })?
1038                .clone(),
1039            _ => {
1040                return Err(ShellError::TypeMismatch {
1041                    err_message: "expected CompiledTemplate from '.mj compile'".into(),
1042                    span: head,
1043                });
1044            }
1045        };
1046
1047        // Get context from input
1048        let context = match input {
1049            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
1050            PipelineData::Empty => minijinja::Value::from(()),
1051            _ => {
1052                return Err(ShellError::TypeMismatch {
1053                    err_message: "expected record input".into(),
1054                    span: head,
1055                });
1056            }
1057        };
1058
1059        // Render template
1060        let rendered = compiled.render(&context).map_err(|e| {
1061            ShellError::Generic(GenericError::new(
1062                format!("Template render error: {e}"),
1063                e.to_string(),
1064                head,
1065            ))
1066        })?;
1067
1068        Ok(Value::string(rendered, head).into_pipeline_data())
1069    }
1070}
1071
1072// === Syntax Highlighting ===
1073
1074struct SyntaxHighlighter {
1075    syntax_set: SyntaxSet,
1076}
1077
1078impl SyntaxHighlighter {
1079    fn new() -> Self {
1080        const SYNTAX_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/syntax_set.bin"));
1081        let syntax_set = syntect::dumps::from_binary(SYNTAX_SET);
1082        Self { syntax_set }
1083    }
1084
1085    fn highlight(&self, code: &str, lang: Option<&str>) -> String {
1086        let syntax = match lang {
1087            Some(lang) => self
1088                .syntax_set
1089                .find_syntax_by_token(lang)
1090                .or_else(|| self.syntax_set.find_syntax_by_extension(lang)),
1091            None => None,
1092        }
1093        .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
1094
1095        let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
1096            syntax,
1097            &self.syntax_set,
1098            ClassStyle::Spaced,
1099        );
1100
1101        for line in LinesWithEndings::from(code) {
1102            let _ = html_generator.parse_html_for_line_which_includes_newline(line);
1103        }
1104
1105        html_generator.finalize()
1106    }
1107
1108    fn list_syntaxes(&self) -> Vec<(String, Vec<String>)> {
1109        self.syntax_set
1110            .syntaxes()
1111            .iter()
1112            .map(|s| (s.name.clone(), s.file_extensions.clone()))
1113            .collect()
1114    }
1115}
1116
1117static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
1118
1119fn get_highlighter() -> &'static SyntaxHighlighter {
1120    HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
1121}
1122
1123// === .highlight command ===
1124
1125#[derive(Clone)]
1126pub struct HighlightCommand;
1127
1128impl Default for HighlightCommand {
1129    fn default() -> Self {
1130        Self::new()
1131    }
1132}
1133
1134impl HighlightCommand {
1135    pub fn new() -> Self {
1136        Self
1137    }
1138}
1139
1140impl Command for HighlightCommand {
1141    fn name(&self) -> &str {
1142        ".highlight"
1143    }
1144
1145    fn description(&self) -> &str {
1146        "Syntax highlight code, outputting HTML with CSS classes"
1147    }
1148
1149    fn signature(&self) -> Signature {
1150        Signature::build(".highlight")
1151            .required("lang", SyntaxShape::String, "language for highlighting")
1152            .input_output_types(vec![(Type::String, Type::record())])
1153            .category(Category::Custom("http".into()))
1154    }
1155
1156    fn run(
1157        &self,
1158        engine_state: &EngineState,
1159        stack: &mut Stack,
1160        call: &Call,
1161        input: PipelineData,
1162    ) -> Result<PipelineData, ShellError> {
1163        let head = call.head;
1164        let lang: String = call.req(engine_state, stack, 0)?;
1165
1166        let code = match input {
1167            PipelineData::Value(Value::String { val, .. }, _) => val,
1168            PipelineData::ByteStream(stream, _) => stream.into_string()?,
1169            _ => {
1170                return Err(ShellError::TypeMismatch {
1171                    err_message: "expected string input".into(),
1172                    span: head,
1173                });
1174            }
1175        };
1176
1177        let highlighter = get_highlighter();
1178        let html = highlighter.highlight(&code, Some(&lang));
1179
1180        Ok(Value::record(
1181            nu_protocol::record! {
1182                "__html" => Value::string(html, head),
1183            },
1184            head,
1185        )
1186        .into_pipeline_data())
1187    }
1188}
1189
1190// === .highlight theme command ===
1191
1192#[derive(Clone)]
1193pub struct HighlightThemeCommand;
1194
1195impl Default for HighlightThemeCommand {
1196    fn default() -> Self {
1197        Self::new()
1198    }
1199}
1200
1201impl HighlightThemeCommand {
1202    pub fn new() -> Self {
1203        Self
1204    }
1205}
1206
1207impl Command for HighlightThemeCommand {
1208    fn name(&self) -> &str {
1209        ".highlight theme"
1210    }
1211
1212    fn description(&self) -> &str {
1213        "List available themes or get CSS for a specific theme"
1214    }
1215
1216    fn signature(&self) -> Signature {
1217        Signature::build(".highlight theme")
1218            .optional("name", SyntaxShape::String, "theme name (omit to list all)")
1219            .input_output_types(vec![
1220                (Type::Nothing, Type::List(Box::new(Type::String))),
1221                (Type::Nothing, Type::String),
1222            ])
1223            .category(Category::Custom("http".into()))
1224    }
1225
1226    fn run(
1227        &self,
1228        engine_state: &EngineState,
1229        stack: &mut Stack,
1230        call: &Call,
1231        _input: PipelineData,
1232    ) -> Result<PipelineData, ShellError> {
1233        let head = call.head;
1234        let name: Option<String> = call.opt(engine_state, stack, 0)?;
1235
1236        let assets = syntect_assets::assets::HighlightingAssets::from_binary();
1237
1238        match name {
1239            None => {
1240                let themes: Vec<Value> = assets.themes().map(|t| Value::string(t, head)).collect();
1241                Ok(Value::list(themes, head).into_pipeline_data())
1242            }
1243            Some(theme_name) => {
1244                let theme = assets.get_theme(&theme_name);
1245                let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced)
1246                    .map_err(|e| {
1247                        ShellError::Generic(GenericError::new(
1248                            format!("Failed to generate CSS: {e}"),
1249                            e.to_string(),
1250                            head,
1251                        ))
1252                    })?;
1253                Ok(Value::string(css, head).into_pipeline_data())
1254            }
1255        }
1256    }
1257}
1258
1259// === .highlight lang command ===
1260
1261#[derive(Clone)]
1262pub struct HighlightLangCommand;
1263
1264impl Default for HighlightLangCommand {
1265    fn default() -> Self {
1266        Self::new()
1267    }
1268}
1269
1270impl HighlightLangCommand {
1271    pub fn new() -> Self {
1272        Self
1273    }
1274}
1275
1276impl Command for HighlightLangCommand {
1277    fn name(&self) -> &str {
1278        ".highlight lang"
1279    }
1280
1281    fn description(&self) -> &str {
1282        "List available languages for syntax highlighting"
1283    }
1284
1285    fn signature(&self) -> Signature {
1286        Signature::build(".highlight lang")
1287            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
1288            .category(Category::Custom("http".into()))
1289    }
1290
1291    fn run(
1292        &self,
1293        _engine_state: &EngineState,
1294        _stack: &mut Stack,
1295        call: &Call,
1296        _input: PipelineData,
1297    ) -> Result<PipelineData, ShellError> {
1298        let head = call.head;
1299        let highlighter = get_highlighter();
1300        let langs: Vec<Value> = highlighter
1301            .list_syntaxes()
1302            .into_iter()
1303            .map(|(name, exts)| {
1304                Value::record(
1305                    nu_protocol::record! {
1306                        "name" => Value::string(name, head),
1307                        "extensions" => Value::list(
1308                            exts.into_iter().map(|e| Value::string(e, head)).collect(),
1309                            head
1310                        ),
1311                    },
1312                    head,
1313                )
1314            })
1315            .collect();
1316        Ok(Value::list(langs, head).into_pipeline_data())
1317    }
1318}
1319
1320// === .md command ===
1321
1322use pulldown_cmark::{html, CodeBlockKind, Event, Parser as MarkdownParser, Tag, TagEnd};
1323
1324#[derive(Clone)]
1325pub struct MdCommand;
1326
1327impl Default for MdCommand {
1328    fn default() -> Self {
1329        Self::new()
1330    }
1331}
1332
1333impl MdCommand {
1334    pub fn new() -> Self {
1335        Self
1336    }
1337}
1338
1339impl Command for MdCommand {
1340    fn name(&self) -> &str {
1341        ".md"
1342    }
1343
1344    fn description(&self) -> &str {
1345        "Convert Markdown to HTML with syntax-highlighted code blocks"
1346    }
1347
1348    fn signature(&self) -> Signature {
1349        Signature::build(".md")
1350            .input_output_types(vec![
1351                (Type::String, Type::record()),
1352                (Type::record(), Type::record()),
1353            ])
1354            .category(Category::Custom("http".into()))
1355    }
1356
1357    fn run(
1358        &self,
1359        _engine_state: &EngineState,
1360        _stack: &mut Stack,
1361        call: &Call,
1362        input: PipelineData,
1363    ) -> Result<PipelineData, ShellError> {
1364        let head = call.head;
1365
1366        // Determine if input is trusted ({__html: ...}) or untrusted (plain string)
1367        let (markdown, trusted) = match input.into_value(head)? {
1368            Value::String { val, .. } => (val, false),
1369            Value::Record { val, .. } => {
1370                if let Some(html_val) = val.get("__html") {
1371                    (html_val.as_str()?.to_string(), true)
1372                } else {
1373                    return Err(ShellError::TypeMismatch {
1374                        err_message: "expected string or {__html: ...}".into(),
1375                        span: head,
1376                    });
1377                }
1378            }
1379            other => {
1380                return Err(ShellError::TypeMismatch {
1381                    err_message: format!(
1382                        "expected string or {{__html: ...}}, got {}",
1383                        other.get_type()
1384                    ),
1385                    span: head,
1386                });
1387            }
1388        };
1389
1390        let highlighter = get_highlighter();
1391
1392        let mut in_code_block = false;
1393        let mut current_code = String::new();
1394        let mut current_lang: Option<String> = None;
1395
1396        let mut options = pulldown_cmark::Options::empty();
1397        options.insert(pulldown_cmark::Options::ENABLE_TABLES);
1398        options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
1399        options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
1400        options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
1401        options.insert(pulldown_cmark::Options::ENABLE_HEADING_ATTRIBUTES);
1402        options.insert(pulldown_cmark::Options::ENABLE_GFM);
1403        options.insert(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
1404        let parser = MarkdownParser::new_ext(&markdown, options).map(|event| match event {
1405            Event::Start(Tag::CodeBlock(kind)) => {
1406                in_code_block = true;
1407                current_code.clear();
1408                current_lang = match kind {
1409                    CodeBlockKind::Fenced(info) => {
1410                        let lang = info.split_whitespace().next().unwrap_or("");
1411                        if lang.is_empty() {
1412                            None
1413                        } else {
1414                            Some(lang.to_string())
1415                        }
1416                    }
1417                    CodeBlockKind::Indented => None,
1418                };
1419                Event::Text("".into())
1420            }
1421            Event::End(TagEnd::CodeBlock) => {
1422                in_code_block = false;
1423                let highlighted = highlighter.highlight(&current_code, current_lang.as_deref());
1424                let mut html_out = String::new();
1425                html_out.push_str("<pre><code");
1426                if let Some(lang) = &current_lang {
1427                    let lang = v_htmlescape::escape(lang);
1428                    html_out.push_str(&format!(" class=\"language-{lang}\""));
1429                }
1430                html_out.push('>');
1431                html_out.push_str(&highlighted);
1432                html_out.push_str("</code></pre>");
1433                Event::Html(html_out.into())
1434            }
1435            Event::Text(text) => {
1436                if in_code_block {
1437                    current_code.push_str(&text);
1438                    Event::Text("".into())
1439                } else {
1440                    Event::Text(text)
1441                }
1442            }
1443            // Escape raw HTML if input is untrusted
1444            Event::Html(html) => {
1445                if trusted {
1446                    Event::Html(html)
1447                } else {
1448                    Event::Text(html) // push_html escapes Text
1449                }
1450            }
1451            Event::InlineHtml(html) => {
1452                if trusted {
1453                    Event::InlineHtml(html)
1454                } else {
1455                    Event::Text(html)
1456                }
1457            }
1458            e => e,
1459        });
1460
1461        let mut html_output = String::new();
1462        html::push_html(&mut html_output, parser);
1463
1464        Ok(Value::record(
1465            nu_protocol::record! {
1466                "__html" => Value::string(html_output, head),
1467            },
1468            head,
1469        )
1470        .into_pipeline_data())
1471    }
1472}
1473
1474// === .print command ===
1475
1476#[derive(Clone)]
1477pub struct PrintCommand;
1478
1479impl Default for PrintCommand {
1480    fn default() -> Self {
1481        Self::new()
1482    }
1483}
1484
1485impl PrintCommand {
1486    pub fn new() -> Self {
1487        Self
1488    }
1489}
1490
1491impl Command for PrintCommand {
1492    fn name(&self) -> &str {
1493        "print"
1494    }
1495
1496    fn description(&self) -> &str {
1497        "Print the given values to the http-nu logging system."
1498    }
1499
1500    fn extra_description(&self) -> &str {
1501        r#"This command outputs to http-nu's logging system rather than stdout/stderr.
1502Messages appear in both human-readable and JSONL output modes.
1503
1504`print` may be used inside blocks of code (e.g.: hooks) to display text during execution without interfering with the pipeline."#
1505    }
1506
1507    fn search_terms(&self) -> Vec<&str> {
1508        vec!["display"]
1509    }
1510
1511    fn signature(&self) -> Signature {
1512        Signature::build("print")
1513            .input_output_types(vec![
1514                (Type::Nothing, Type::Nothing),
1515                (Type::Any, Type::Nothing),
1516            ])
1517            .allow_variants_without_examples(true)
1518            .rest("rest", SyntaxShape::Any, "the values to print")
1519            .switch(
1520                "no-newline",
1521                "print without inserting a newline for the line ending",
1522                Some('n'),
1523            )
1524            .switch("stderr", "print to stderr instead of stdout", Some('e'))
1525            .switch(
1526                "raw",
1527                "print without formatting (including binary data)",
1528                Some('r'),
1529            )
1530            .category(Category::Strings)
1531    }
1532
1533    fn run(
1534        &self,
1535        engine_state: &EngineState,
1536        stack: &mut Stack,
1537        call: &Call,
1538        input: PipelineData,
1539    ) -> Result<PipelineData, ShellError> {
1540        let args: Vec<Value> = call.rest(engine_state, stack, 0)?;
1541        let no_newline = call.has_flag(engine_state, stack, "no-newline")?;
1542        let config = stack.get_config(engine_state);
1543
1544        let format_value = |val: &Value| -> String { val.to_expanded_string(" ", &config) };
1545
1546        if !args.is_empty() {
1547            let messages: Vec<String> = args.iter().map(format_value).collect();
1548            let message = if no_newline {
1549                messages.join("")
1550            } else {
1551                messages.join("\n")
1552            };
1553            log_print(&message);
1554        } else if !input.is_nothing() {
1555            let message = match input {
1556                PipelineData::Value(val, _) => format_value(&val),
1557                PipelineData::ListStream(stream, _) => {
1558                    let vals: Vec<String> = stream.into_iter().map(|v| format_value(&v)).collect();
1559                    vals.join("\n")
1560                }
1561                PipelineData::ByteStream(stream, _) => stream.into_string()?,
1562                PipelineData::Empty => String::new(),
1563            };
1564            if !message.is_empty() {
1565                log_print(&message);
1566            }
1567        }
1568
1569        Ok(PipelineData::empty())
1570    }
1571
1572    fn examples(&self) -> Vec<Example<'_>> {
1573        vec![
1574            Example {
1575                description: "Print 'hello world'",
1576                example: r#"print "hello world""#,
1577                result: None,
1578            },
1579            Example {
1580                description: "Print the sum of 2 and 3",
1581                example: r#"print (2 + 3)"#,
1582                result: None,
1583            },
1584        ]
1585    }
1586}