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        other => {
414            return Err(ShellError::TypeMismatch {
415                err_message: format!("expected record, got {}", other.get_type()),
416                span,
417            })
418        }
419    };
420    let mut out = String::new();
421    if let Some(id) = rec.get("id") {
422        if !matches!(id, Value::Nothing { .. }) {
423            out.push_str("id: ");
424            out.push_str(&id.to_expanded_string("", config));
425            out.push_str(LINE_ENDING);
426        }
427    }
428    if let Some(retry) = rec.get("retry") {
429        if !matches!(retry, Value::Nothing { .. }) {
430            out.push_str("retry: ");
431            out.push_str(&retry.to_expanded_string("", config));
432            out.push_str(LINE_ENDING);
433        }
434    }
435    if let Some(event) = rec.get("event") {
436        if !matches!(event, Value::Nothing { .. }) {
437            out.push_str("event: ");
438            out.push_str(&event.to_expanded_string("", config));
439            out.push_str(LINE_ENDING);
440        }
441    }
442    if let Some(data) = rec.get("data") {
443        if !matches!(data, Value::Nothing { .. }) {
444            match data {
445                Value::List { vals, .. } => {
446                    for item in vals {
447                        emit_data_lines(&mut out, &value_to_data_string(item, config)?);
448                    }
449                }
450                _ => {
451                    emit_data_lines(&mut out, &value_to_data_string(data, config)?);
452                }
453            }
454        }
455    }
456    out.push_str(LINE_ENDING);
457    Ok(out)
458}
459
460fn value_to_json(val: &Value, config: &Config) -> serde_json::Result<serde_json::Value> {
461    Ok(match val {
462        Value::Bool { val, .. } => serde_json::Value::Bool(*val),
463        Value::Int { val, .. } => serde_json::Value::from(*val),
464        Value::Float { val, .. } => serde_json::Number::from_f64(*val)
465            .map(serde_json::Value::Number)
466            .unwrap_or(serde_json::Value::Null),
467        Value::String { val, .. } => serde_json::Value::String(val.clone()),
468        Value::List { vals, .. } => serde_json::Value::Array(
469            vals.iter()
470                .map(|v| value_to_json(v, config))
471                .collect::<Result<Vec<_>, _>>()?,
472        ),
473        Value::Record { val, .. } => {
474            let mut map = serde_json::Map::new();
475            for (k, v) in val.iter() {
476                map.insert(k.clone(), value_to_json(v, config)?);
477            }
478            serde_json::Value::Object(map)
479        }
480        Value::Nothing { .. } => serde_json::Value::Null,
481        other => serde_json::Value::String(other.to_expanded_string("", config)),
482    })
483}
484
485fn update_metadata(metadata: Option<PipelineMetadata>) -> Option<PipelineMetadata> {
486    metadata
487        .map(|md| md.with_content_type(Some("text/event-stream".into())))
488        .or_else(|| {
489            Some(PipelineMetadata::default().with_content_type(Some("text/event-stream".into())))
490        })
491}
492
493#[derive(Clone)]
494pub struct ReverseProxyCommand;
495
496impl Default for ReverseProxyCommand {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502impl ReverseProxyCommand {
503    pub fn new() -> Self {
504        Self
505    }
506}
507
508impl Command for ReverseProxyCommand {
509    fn name(&self) -> &str {
510        ".reverse-proxy"
511    }
512
513    fn description(&self) -> &str {
514        "Forward HTTP requests to a backend server"
515    }
516
517    fn signature(&self) -> Signature {
518        Signature::build(".reverse-proxy")
519            .required("target_url", SyntaxShape::String, "backend URL to proxy to")
520            .optional(
521                "config",
522                SyntaxShape::Record(vec![]),
523                "optional configuration (headers, preserve_host, strip_prefix, query)",
524            )
525            .input_output_types(vec![(Type::Any, Type::Nothing)])
526            .category(Category::Custom("http".into()))
527    }
528
529    fn run(
530        &self,
531        engine_state: &EngineState,
532        stack: &mut Stack,
533        call: &Call,
534        input: PipelineData,
535    ) -> Result<PipelineData, ShellError> {
536        let target_url: String = call.req(engine_state, stack, 0)?;
537
538        // Convert input pipeline data to bytes for request body
539        let request_body = match input {
540            PipelineData::Empty => Vec::new(),
541            PipelineData::Value(value, _) => crate::response::value_to_bytes(value),
542            PipelineData::ByteStream(stream, _) => {
543                // Collect all bytes from the stream
544                let mut body_bytes = Vec::new();
545                if let Some(mut reader) = stream.reader() {
546                    loop {
547                        let mut buffer = vec![0; 8192];
548                        match reader.read(&mut buffer) {
549                            Ok(0) => break, // EOF
550                            Ok(n) => {
551                                buffer.truncate(n);
552                                body_bytes.extend_from_slice(&buffer);
553                            }
554                            Err(_) => break,
555                        }
556                    }
557                }
558                body_bytes
559            }
560            PipelineData::ListStream(stream, _) => {
561                // Convert list stream to JSON array
562                let items: Vec<_> = stream.into_iter().collect();
563                let json_value = serde_json::Value::Array(
564                    items
565                        .into_iter()
566                        .map(|v| crate::response::value_to_json(&v))
567                        .collect(),
568                );
569                serde_json::to_string(&json_value)
570                    .unwrap_or_default()
571                    .into_bytes()
572            }
573        };
574
575        // Parse optional config
576        let config = call.opt::<Value>(engine_state, stack, 1);
577
578        let mut headers = HashMap::new();
579        let mut preserve_host = true;
580        let mut strip_prefix: Option<String> = None;
581        let mut query: Option<HashMap<String, String>> = None;
582
583        if let Ok(Some(config_value)) = config {
584            if let Ok(record) = config_value.as_record() {
585                // Extract headers
586                if let Some(headers_value) = record.get("headers") {
587                    if let Ok(headers_record) = headers_value.as_record() {
588                        for (k, v) in headers_record.iter() {
589                            let header_value = match v {
590                                Value::String { val, .. } => {
591                                    crate::response::HeaderValue::Single(val.clone())
592                                }
593                                Value::List { vals, .. } => {
594                                    let strings: Vec<String> = vals
595                                        .iter()
596                                        .filter_map(|v| v.as_str().ok())
597                                        .map(|s| s.to_string())
598                                        .collect();
599                                    crate::response::HeaderValue::Multiple(strings)
600                                }
601                                _ => continue, // Skip non-string/non-list values
602                            };
603                            headers.insert(k.clone(), header_value);
604                        }
605                    }
606                }
607
608                // Extract preserve_host
609                if let Some(preserve_host_value) = record.get("preserve_host") {
610                    if let Ok(ph) = preserve_host_value.as_bool() {
611                        preserve_host = ph;
612                    }
613                }
614
615                // Extract strip_prefix
616                if let Some(strip_prefix_value) = record.get("strip_prefix") {
617                    if let Ok(prefix) = strip_prefix_value.as_str() {
618                        strip_prefix = Some(prefix.to_string());
619                    }
620                }
621
622                // Extract query
623                if let Some(query_value) = record.get("query") {
624                    if let Ok(query_record) = query_value.as_record() {
625                        let mut query_map = HashMap::new();
626                        for (k, v) in query_record.iter() {
627                            if let Ok(v_str) = v.as_str() {
628                                query_map.insert(k.clone(), v_str.to_string());
629                            }
630                        }
631                        query = Some(query_map);
632                    }
633                }
634            }
635        }
636
637        let response = Response {
638            status: 200,
639            headers: HashMap::new(),
640            body_type: ResponseBodyType::ReverseProxy {
641                target_url,
642                headers,
643                preserve_host,
644                strip_prefix,
645                request_body,
646                query,
647            },
648        };
649
650        RESPONSE_TX.with(|tx| -> Result<_, ShellError> {
651            if let Some(tx) = tx.borrow_mut().take() {
652                tx.send(response).map_err(|_| ShellError::GenericError {
653                    error: "Failed to send response".into(),
654                    msg: "Channel closed".into(),
655                    span: Some(call.head),
656                    help: None,
657                    inner: vec![],
658                })?;
659            }
660            Ok(())
661        })?;
662
663        Ok(PipelineData::Empty)
664    }
665}
666
667#[derive(Clone)]
668pub struct MjCommand;
669
670impl Default for MjCommand {
671    fn default() -> Self {
672        Self::new()
673    }
674}
675
676impl MjCommand {
677    pub fn new() -> Self {
678        Self
679    }
680}
681
682impl Command for MjCommand {
683    fn name(&self) -> &str {
684        ".mj"
685    }
686
687    fn description(&self) -> &str {
688        "Render a minijinja template with context from input"
689    }
690
691    fn signature(&self) -> Signature {
692        Signature::build(".mj")
693            .optional("file", SyntaxShape::String, "template file path")
694            .named(
695                "inline",
696                SyntaxShape::String,
697                "inline template string",
698                Some('i'),
699            )
700            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
701            .category(Category::Custom("http".into()))
702    }
703
704    fn run(
705        &self,
706        engine_state: &EngineState,
707        stack: &mut Stack,
708        call: &Call,
709        input: PipelineData,
710    ) -> Result<PipelineData, ShellError> {
711        let head = call.head;
712        let file: Option<String> = call.opt(engine_state, stack, 0)?;
713        let inline: Option<String> = call.get_flag(engine_state, stack, "inline")?;
714
715        // Get template source
716        let template_source = match (&file, &inline) {
717            (Some(_), Some(_)) => {
718                return Err(ShellError::GenericError {
719                    error: "Cannot specify both file and --inline".into(),
720                    msg: "use either a file path or --inline, not both".into(),
721                    span: Some(head),
722                    help: None,
723                    inner: vec![],
724                });
725            }
726            (None, None) => {
727                return Err(ShellError::GenericError {
728                    error: "No template specified".into(),
729                    msg: "provide a file path or use --inline".into(),
730                    span: Some(head),
731                    help: None,
732                    inner: vec![],
733                });
734            }
735            (Some(path), None) => {
736                std::fs::read_to_string(path).map_err(|e| ShellError::GenericError {
737                    error: format!("Failed to read template file: {e}"),
738                    msg: "could not read file".into(),
739                    span: Some(head),
740                    help: None,
741                    inner: vec![],
742                })?
743            }
744            (None, Some(tmpl)) => tmpl.clone(),
745        };
746
747        // Get context from input
748        let context = match input {
749            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
750            PipelineData::Empty => minijinja::Value::from(()),
751            _ => {
752                return Err(ShellError::TypeMismatch {
753                    err_message: "expected record input".into(),
754                    span: head,
755                });
756            }
757        };
758
759        // Render template
760        let mut env = Environment::new();
761        env.add_template("template", &template_source)
762            .map_err(|e| ShellError::GenericError {
763                error: format!("Template parse error: {e}"),
764                msg: e.to_string(),
765                span: Some(head),
766                help: None,
767                inner: vec![],
768            })?;
769
770        let tmpl = env
771            .get_template("template")
772            .map_err(|e| ShellError::GenericError {
773                error: format!("Failed to get template: {e}"),
774                msg: e.to_string(),
775                span: Some(head),
776                help: None,
777                inner: vec![],
778            })?;
779
780        let rendered = tmpl
781            .render(&context)
782            .map_err(|e| ShellError::GenericError {
783                error: format!("Template render error: {e}"),
784                msg: e.to_string(),
785                span: Some(head),
786                help: None,
787                inner: vec![],
788            })?;
789
790        Ok(Value::string(rendered, head).into_pipeline_data())
791    }
792}
793
794/// Convert a nu_protocol::Value to a minijinja::Value via serde_json
795fn nu_value_to_minijinja(val: &Value) -> minijinja::Value {
796    let json = value_to_json(val, &Config::default()).unwrap_or(serde_json::Value::Null);
797    minijinja::Value::from_serialize(&json)
798}
799
800// === .mj compile ===
801
802#[derive(Clone)]
803pub struct MjCompileCommand;
804
805impl Default for MjCompileCommand {
806    fn default() -> Self {
807        Self::new()
808    }
809}
810
811impl MjCompileCommand {
812    pub fn new() -> Self {
813        Self
814    }
815}
816
817impl Command for MjCompileCommand {
818    fn name(&self) -> &str {
819        ".mj compile"
820    }
821
822    fn description(&self) -> &str {
823        "Compile a minijinja template, returning a reusable compiled template"
824    }
825
826    fn signature(&self) -> Signature {
827        Signature::build(".mj compile")
828            .optional("file", SyntaxShape::String, "template file path")
829            .named(
830                "inline",
831                SyntaxShape::Any,
832                "inline template (string or {__html: string})",
833                Some('i'),
834            )
835            .input_output_types(vec![(
836                Type::Nothing,
837                Type::Custom("CompiledTemplate".into()),
838            )])
839            .category(Category::Custom("http".into()))
840    }
841
842    fn run(
843        &self,
844        engine_state: &EngineState,
845        stack: &mut Stack,
846        call: &Call,
847        _input: PipelineData,
848    ) -> Result<PipelineData, ShellError> {
849        let head = call.head;
850        let file: Option<String> = call.opt(engine_state, stack, 0)?;
851        let inline: Option<Value> = call.get_flag(engine_state, stack, "inline")?;
852
853        // Extract template string from --inline value (string or {__html: string})
854        let inline_str: Option<String> = match &inline {
855            None => None,
856            Some(val) => match val {
857                Value::String { val, .. } => Some(val.clone()),
858                Value::Record { val, .. } => {
859                    if let Some(html_val) = val.get("__html") {
860                        match html_val {
861                            Value::String { val, .. } => Some(val.clone()),
862                            _ => {
863                                return Err(ShellError::GenericError {
864                                    error: "__html must be a string".into(),
865                                    msg: "expected string value".into(),
866                                    span: Some(head),
867                                    help: None,
868                                    inner: vec![],
869                                });
870                            }
871                        }
872                    } else {
873                        return Err(ShellError::GenericError {
874                            error: "Record must have __html field".into(),
875                            msg: "expected {__html: string}".into(),
876                            span: Some(head),
877                            help: None,
878                            inner: vec![],
879                        });
880                    }
881                }
882                _ => {
883                    return Err(ShellError::GenericError {
884                        error: "--inline must be string or {__html: string}".into(),
885                        msg: "invalid type".into(),
886                        span: Some(head),
887                        help: None,
888                        inner: vec![],
889                    });
890                }
891            },
892        };
893
894        // Get template source
895        let template_source = match (&file, &inline_str) {
896            (Some(_), Some(_)) => {
897                return Err(ShellError::GenericError {
898                    error: "Cannot specify both file and --inline".into(),
899                    msg: "use either a file path or --inline, not both".into(),
900                    span: Some(head),
901                    help: None,
902                    inner: vec![],
903                });
904            }
905            (None, None) => {
906                return Err(ShellError::GenericError {
907                    error: "No template specified".into(),
908                    msg: "provide a file path or use --inline".into(),
909                    span: Some(head),
910                    help: None,
911                    inner: vec![],
912                });
913            }
914            (Some(path), None) => {
915                std::fs::read_to_string(path).map_err(|e| ShellError::GenericError {
916                    error: format!("Failed to read template file: {e}"),
917                    msg: "could not read file".into(),
918                    span: Some(head),
919                    help: None,
920                    inner: vec![],
921                })?
922            }
923            (None, Some(tmpl)) => tmpl.clone(),
924        };
925
926        // Compile and cache the template
927        let hash = compile_template(&template_source).map_err(|e| ShellError::GenericError {
928            error: format!("Template compile error: {e}"),
929            msg: e.to_string(),
930            span: Some(head),
931            help: None,
932            inner: vec![],
933        })?;
934
935        Ok(Value::custom(Box::new(CompiledTemplate { hash }), head).into_pipeline_data())
936    }
937}
938
939// === .mj render ===
940
941#[derive(Clone)]
942pub struct MjRenderCommand;
943
944impl Default for MjRenderCommand {
945    fn default() -> Self {
946        Self::new()
947    }
948}
949
950impl MjRenderCommand {
951    pub fn new() -> Self {
952        Self
953    }
954}
955
956impl Command for MjRenderCommand {
957    fn name(&self) -> &str {
958        ".mj render"
959    }
960
961    fn description(&self) -> &str {
962        "Render a compiled minijinja template with context from input"
963    }
964
965    fn signature(&self) -> Signature {
966        Signature::build(".mj render")
967            .required(
968                "template",
969                SyntaxShape::Any,
970                "compiled template from '.mj compile'",
971            )
972            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
973            .category(Category::Custom("http".into()))
974    }
975
976    fn run(
977        &self,
978        engine_state: &EngineState,
979        stack: &mut Stack,
980        call: &Call,
981        input: PipelineData,
982    ) -> Result<PipelineData, ShellError> {
983        let head = call.head;
984        let template_val: Value = call.req(engine_state, stack, 0)?;
985
986        // Extract CompiledTemplate from the value
987        let compiled = match template_val {
988            Value::Custom { val, .. } => val
989                .as_any()
990                .downcast_ref::<CompiledTemplate>()
991                .ok_or_else(|| ShellError::TypeMismatch {
992                    err_message: "expected CompiledTemplate".into(),
993                    span: head,
994                })?
995                .clone(),
996            _ => {
997                return Err(ShellError::TypeMismatch {
998                    err_message: "expected CompiledTemplate from '.mj compile'".into(),
999                    span: head,
1000                });
1001            }
1002        };
1003
1004        // Get context from input
1005        let context = match input {
1006            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
1007            PipelineData::Empty => minijinja::Value::from(()),
1008            _ => {
1009                return Err(ShellError::TypeMismatch {
1010                    err_message: "expected record input".into(),
1011                    span: head,
1012                });
1013            }
1014        };
1015
1016        // Render template
1017        let rendered = compiled
1018            .render(&context)
1019            .map_err(|e| ShellError::GenericError {
1020                error: format!("Template render error: {e}"),
1021                msg: e.to_string(),
1022                span: Some(head),
1023                help: None,
1024                inner: vec![],
1025            })?;
1026
1027        Ok(Value::string(rendered, head).into_pipeline_data())
1028    }
1029}
1030
1031// === Syntax Highlighting ===
1032
1033struct SyntaxHighlighter {
1034    syntax_set: SyntaxSet,
1035}
1036
1037impl SyntaxHighlighter {
1038    fn new() -> Self {
1039        const SYNTAX_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/syntax_set.bin"));
1040        let syntax_set = syntect::dumps::from_binary(SYNTAX_SET);
1041        Self { syntax_set }
1042    }
1043
1044    fn highlight(&self, code: &str, lang: Option<&str>) -> String {
1045        let syntax = match lang {
1046            Some(lang) => self
1047                .syntax_set
1048                .find_syntax_by_token(lang)
1049                .or_else(|| self.syntax_set.find_syntax_by_extension(lang)),
1050            None => None,
1051        }
1052        .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
1053
1054        let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
1055            syntax,
1056            &self.syntax_set,
1057            ClassStyle::Spaced,
1058        );
1059
1060        for line in LinesWithEndings::from(code) {
1061            let _ = html_generator.parse_html_for_line_which_includes_newline(line);
1062        }
1063
1064        html_generator.finalize()
1065    }
1066
1067    fn list_syntaxes(&self) -> Vec<(String, Vec<String>)> {
1068        self.syntax_set
1069            .syntaxes()
1070            .iter()
1071            .map(|s| (s.name.clone(), s.file_extensions.clone()))
1072            .collect()
1073    }
1074}
1075
1076static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
1077
1078fn get_highlighter() -> &'static SyntaxHighlighter {
1079    HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
1080}
1081
1082// === .highlight command ===
1083
1084#[derive(Clone)]
1085pub struct HighlightCommand;
1086
1087impl Default for HighlightCommand {
1088    fn default() -> Self {
1089        Self::new()
1090    }
1091}
1092
1093impl HighlightCommand {
1094    pub fn new() -> Self {
1095        Self
1096    }
1097}
1098
1099impl Command for HighlightCommand {
1100    fn name(&self) -> &str {
1101        ".highlight"
1102    }
1103
1104    fn description(&self) -> &str {
1105        "Syntax highlight code, outputting HTML with CSS classes"
1106    }
1107
1108    fn signature(&self) -> Signature {
1109        Signature::build(".highlight")
1110            .required("lang", SyntaxShape::String, "language for highlighting")
1111            .input_output_types(vec![(Type::String, Type::String)])
1112            .category(Category::Custom("http".into()))
1113    }
1114
1115    fn run(
1116        &self,
1117        engine_state: &EngineState,
1118        stack: &mut Stack,
1119        call: &Call,
1120        input: PipelineData,
1121    ) -> Result<PipelineData, ShellError> {
1122        let head = call.head;
1123        let lang: String = call.req(engine_state, stack, 0)?;
1124
1125        let code = match input {
1126            PipelineData::Value(Value::String { val, .. }, _) => val,
1127            PipelineData::ByteStream(stream, _) => stream.into_string()?,
1128            _ => {
1129                return Err(ShellError::TypeMismatch {
1130                    err_message: "expected string input".into(),
1131                    span: head,
1132                });
1133            }
1134        };
1135
1136        let highlighter = get_highlighter();
1137        let html = highlighter.highlight(&code, Some(&lang));
1138
1139        Ok(Value::string(html, head).into_pipeline_data())
1140    }
1141}
1142
1143// === .highlight theme command ===
1144
1145#[derive(Clone)]
1146pub struct HighlightThemeCommand;
1147
1148impl Default for HighlightThemeCommand {
1149    fn default() -> Self {
1150        Self::new()
1151    }
1152}
1153
1154impl HighlightThemeCommand {
1155    pub fn new() -> Self {
1156        Self
1157    }
1158}
1159
1160impl Command for HighlightThemeCommand {
1161    fn name(&self) -> &str {
1162        ".highlight theme"
1163    }
1164
1165    fn description(&self) -> &str {
1166        "List available themes or get CSS for a specific theme"
1167    }
1168
1169    fn signature(&self) -> Signature {
1170        Signature::build(".highlight theme")
1171            .optional("name", SyntaxShape::String, "theme name (omit to list all)")
1172            .input_output_types(vec![
1173                (Type::Nothing, Type::List(Box::new(Type::String))),
1174                (Type::Nothing, Type::String),
1175            ])
1176            .category(Category::Custom("http".into()))
1177    }
1178
1179    fn run(
1180        &self,
1181        engine_state: &EngineState,
1182        stack: &mut Stack,
1183        call: &Call,
1184        _input: PipelineData,
1185    ) -> Result<PipelineData, ShellError> {
1186        let head = call.head;
1187        let name: Option<String> = call.opt(engine_state, stack, 0)?;
1188
1189        let assets = syntect_assets::assets::HighlightingAssets::from_binary();
1190
1191        match name {
1192            None => {
1193                let themes: Vec<Value> = assets.themes().map(|t| Value::string(t, head)).collect();
1194                Ok(Value::list(themes, head).into_pipeline_data())
1195            }
1196            Some(theme_name) => {
1197                let theme = assets.get_theme(&theme_name);
1198                let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced)
1199                    .map_err(|e| ShellError::GenericError {
1200                        error: format!("Failed to generate CSS: {e}"),
1201                        msg: e.to_string(),
1202                        span: Some(head),
1203                        help: None,
1204                        inner: vec![],
1205                    })?;
1206                Ok(Value::string(css, head).into_pipeline_data())
1207            }
1208        }
1209    }
1210}
1211
1212// === .highlight lang command ===
1213
1214#[derive(Clone)]
1215pub struct HighlightLangCommand;
1216
1217impl Default for HighlightLangCommand {
1218    fn default() -> Self {
1219        Self::new()
1220    }
1221}
1222
1223impl HighlightLangCommand {
1224    pub fn new() -> Self {
1225        Self
1226    }
1227}
1228
1229impl Command for HighlightLangCommand {
1230    fn name(&self) -> &str {
1231        ".highlight lang"
1232    }
1233
1234    fn description(&self) -> &str {
1235        "List available languages for syntax highlighting"
1236    }
1237
1238    fn signature(&self) -> Signature {
1239        Signature::build(".highlight lang")
1240            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
1241            .category(Category::Custom("http".into()))
1242    }
1243
1244    fn run(
1245        &self,
1246        _engine_state: &EngineState,
1247        _stack: &mut Stack,
1248        call: &Call,
1249        _input: PipelineData,
1250    ) -> Result<PipelineData, ShellError> {
1251        let head = call.head;
1252        let highlighter = get_highlighter();
1253        let langs: Vec<Value> = highlighter
1254            .list_syntaxes()
1255            .into_iter()
1256            .map(|(name, exts)| {
1257                Value::record(
1258                    nu_protocol::record! {
1259                        "name" => Value::string(name, head),
1260                        "extensions" => Value::list(
1261                            exts.into_iter().map(|e| Value::string(e, head)).collect(),
1262                            head
1263                        ),
1264                    },
1265                    head,
1266                )
1267            })
1268            .collect();
1269        Ok(Value::list(langs, head).into_pipeline_data())
1270    }
1271}