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