http_nu/
commands.rs

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