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::{path_loader, 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        // Validate arguments
718        if file.is_some() && inline.is_some() {
719            return Err(ShellError::GenericError {
720                error: "Cannot specify both file and --inline".into(),
721                msg: "use either a file path or --inline, not both".into(),
722                span: Some(head),
723                help: None,
724                inner: vec![],
725            });
726        }
727        if file.is_none() && inline.is_none() {
728            return Err(ShellError::GenericError {
729                error: "No template specified".into(),
730                msg: "provide a file path or use --inline".into(),
731                span: Some(head),
732                help: None,
733                inner: vec![],
734            });
735        }
736
737        // Get context from input
738        let context = match input {
739            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
740            PipelineData::Empty => minijinja::Value::from(()),
741            _ => {
742                return Err(ShellError::TypeMismatch {
743                    err_message: "expected record input".into(),
744                    span: head,
745                });
746            }
747        };
748
749        // Set up environment and get template
750        let mut env = Environment::new();
751        let tmpl = if let Some(ref path) = file {
752            let path = std::path::Path::new(path);
753            let abs_path = if path.is_absolute() {
754                path.to_path_buf()
755            } else {
756                std::env::current_dir().unwrap_or_default().join(path)
757            };
758            if let Some(parent) = abs_path.parent() {
759                env.set_loader(path_loader(parent));
760            }
761            let name = abs_path
762                .file_name()
763                .and_then(|n| n.to_str())
764                .unwrap_or("template");
765            env.get_template(name)
766                .map_err(|e| ShellError::GenericError {
767                    error: format!("Template error: {e}"),
768                    msg: e.to_string(),
769                    span: Some(head),
770                    help: None,
771                    inner: vec![],
772                })?
773        } else {
774            let source = inline.as_ref().unwrap();
775            env.add_template("template", source)
776                .map_err(|e| ShellError::GenericError {
777                    error: format!("Template parse error: {e}"),
778                    msg: e.to_string(),
779                    span: Some(head),
780                    help: None,
781                    inner: vec![],
782                })?;
783            env.get_template("template")
784                .map_err(|e| ShellError::GenericError {
785                    error: format!("Failed to get template: {e}"),
786                    msg: e.to_string(),
787                    span: Some(head),
788                    help: None,
789                    inner: vec![],
790                })?
791        };
792
793        let rendered = tmpl
794            .render(&context)
795            .map_err(|e| ShellError::GenericError {
796                error: format!("Template render error: {e}"),
797                msg: e.to_string(),
798                span: Some(head),
799                help: None,
800                inner: vec![],
801            })?;
802
803        Ok(Value::string(rendered, head).into_pipeline_data())
804    }
805}
806
807/// Convert a nu_protocol::Value to a minijinja::Value via serde_json
808fn nu_value_to_minijinja(val: &Value) -> minijinja::Value {
809    let json = value_to_json(val, &Config::default()).unwrap_or(serde_json::Value::Null);
810    minijinja::Value::from_serialize(&json)
811}
812
813// === .mj compile ===
814
815#[derive(Clone)]
816pub struct MjCompileCommand;
817
818impl Default for MjCompileCommand {
819    fn default() -> Self {
820        Self::new()
821    }
822}
823
824impl MjCompileCommand {
825    pub fn new() -> Self {
826        Self
827    }
828}
829
830impl Command for MjCompileCommand {
831    fn name(&self) -> &str {
832        ".mj compile"
833    }
834
835    fn description(&self) -> &str {
836        "Compile a minijinja template, returning a reusable compiled template"
837    }
838
839    fn signature(&self) -> Signature {
840        Signature::build(".mj compile")
841            .optional("file", SyntaxShape::String, "template file path")
842            .named(
843                "inline",
844                SyntaxShape::Any,
845                "inline template (string or {__html: string})",
846                Some('i'),
847            )
848            .input_output_types(vec![(
849                Type::Nothing,
850                Type::Custom("CompiledTemplate".into()),
851            )])
852            .category(Category::Custom("http".into()))
853    }
854
855    fn run(
856        &self,
857        engine_state: &EngineState,
858        stack: &mut Stack,
859        call: &Call,
860        _input: PipelineData,
861    ) -> Result<PipelineData, ShellError> {
862        let head = call.head;
863        let file: Option<String> = call.opt(engine_state, stack, 0)?;
864        let inline: Option<Value> = call.get_flag(engine_state, stack, "inline")?;
865
866        // Extract template string from --inline value (string or {__html: string})
867        let inline_str: Option<String> = match &inline {
868            None => None,
869            Some(val) => match val {
870                Value::String { val, .. } => Some(val.clone()),
871                Value::Record { val, .. } => {
872                    if let Some(html_val) = val.get("__html") {
873                        match html_val {
874                            Value::String { val, .. } => Some(val.clone()),
875                            _ => {
876                                return Err(ShellError::GenericError {
877                                    error: "__html must be a string".into(),
878                                    msg: "expected string value".into(),
879                                    span: Some(head),
880                                    help: None,
881                                    inner: vec![],
882                                });
883                            }
884                        }
885                    } else {
886                        return Err(ShellError::GenericError {
887                            error: "Record must have __html field".into(),
888                            msg: "expected {__html: string}".into(),
889                            span: Some(head),
890                            help: None,
891                            inner: vec![],
892                        });
893                    }
894                }
895                _ => {
896                    return Err(ShellError::GenericError {
897                        error: "--inline must be string or {__html: string}".into(),
898                        msg: "invalid type".into(),
899                        span: Some(head),
900                        help: None,
901                        inner: vec![],
902                    });
903                }
904            },
905        };
906
907        // Get template source
908        let template_source = match (&file, &inline_str) {
909            (Some(_), Some(_)) => {
910                return Err(ShellError::GenericError {
911                    error: "Cannot specify both file and --inline".into(),
912                    msg: "use either a file path or --inline, not both".into(),
913                    span: Some(head),
914                    help: None,
915                    inner: vec![],
916                });
917            }
918            (None, None) => {
919                return Err(ShellError::GenericError {
920                    error: "No template specified".into(),
921                    msg: "provide a file path or use --inline".into(),
922                    span: Some(head),
923                    help: None,
924                    inner: vec![],
925                });
926            }
927            (Some(path), None) => {
928                std::fs::read_to_string(path).map_err(|e| ShellError::GenericError {
929                    error: format!("Failed to read template file: {e}"),
930                    msg: "could not read file".into(),
931                    span: Some(head),
932                    help: None,
933                    inner: vec![],
934                })?
935            }
936            (None, Some(tmpl)) => tmpl.clone(),
937        };
938
939        // Compile and cache the template
940        let hash = compile_template(&template_source).map_err(|e| ShellError::GenericError {
941            error: format!("Template compile error: {e}"),
942            msg: e.to_string(),
943            span: Some(head),
944            help: None,
945            inner: vec![],
946        })?;
947
948        Ok(Value::custom(Box::new(CompiledTemplate { hash }), head).into_pipeline_data())
949    }
950}
951
952// === .mj render ===
953
954#[derive(Clone)]
955pub struct MjRenderCommand;
956
957impl Default for MjRenderCommand {
958    fn default() -> Self {
959        Self::new()
960    }
961}
962
963impl MjRenderCommand {
964    pub fn new() -> Self {
965        Self
966    }
967}
968
969impl Command for MjRenderCommand {
970    fn name(&self) -> &str {
971        ".mj render"
972    }
973
974    fn description(&self) -> &str {
975        "Render a compiled minijinja template with context from input"
976    }
977
978    fn signature(&self) -> Signature {
979        Signature::build(".mj render")
980            .required(
981                "template",
982                SyntaxShape::Any,
983                "compiled template from '.mj compile'",
984            )
985            .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
986            .category(Category::Custom("http".into()))
987    }
988
989    fn run(
990        &self,
991        engine_state: &EngineState,
992        stack: &mut Stack,
993        call: &Call,
994        input: PipelineData,
995    ) -> Result<PipelineData, ShellError> {
996        let head = call.head;
997        let template_val: Value = call.req(engine_state, stack, 0)?;
998
999        // Extract CompiledTemplate from the value
1000        let compiled = match template_val {
1001            Value::Custom { val, .. } => val
1002                .as_any()
1003                .downcast_ref::<CompiledTemplate>()
1004                .ok_or_else(|| ShellError::TypeMismatch {
1005                    err_message: "expected CompiledTemplate".into(),
1006                    span: head,
1007                })?
1008                .clone(),
1009            _ => {
1010                return Err(ShellError::TypeMismatch {
1011                    err_message: "expected CompiledTemplate from '.mj compile'".into(),
1012                    span: head,
1013                });
1014            }
1015        };
1016
1017        // Get context from input
1018        let context = match input {
1019            PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
1020            PipelineData::Empty => minijinja::Value::from(()),
1021            _ => {
1022                return Err(ShellError::TypeMismatch {
1023                    err_message: "expected record input".into(),
1024                    span: head,
1025                });
1026            }
1027        };
1028
1029        // Render template
1030        let rendered = compiled
1031            .render(&context)
1032            .map_err(|e| ShellError::GenericError {
1033                error: format!("Template render error: {e}"),
1034                msg: e.to_string(),
1035                span: Some(head),
1036                help: None,
1037                inner: vec![],
1038            })?;
1039
1040        Ok(Value::string(rendered, head).into_pipeline_data())
1041    }
1042}
1043
1044// === Syntax Highlighting ===
1045
1046struct SyntaxHighlighter {
1047    syntax_set: SyntaxSet,
1048}
1049
1050impl SyntaxHighlighter {
1051    fn new() -> Self {
1052        const SYNTAX_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/syntax_set.bin"));
1053        let syntax_set = syntect::dumps::from_binary(SYNTAX_SET);
1054        Self { syntax_set }
1055    }
1056
1057    fn highlight(&self, code: &str, lang: Option<&str>) -> String {
1058        let syntax = match lang {
1059            Some(lang) => self
1060                .syntax_set
1061                .find_syntax_by_token(lang)
1062                .or_else(|| self.syntax_set.find_syntax_by_extension(lang)),
1063            None => None,
1064        }
1065        .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
1066
1067        let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
1068            syntax,
1069            &self.syntax_set,
1070            ClassStyle::Spaced,
1071        );
1072
1073        for line in LinesWithEndings::from(code) {
1074            let _ = html_generator.parse_html_for_line_which_includes_newline(line);
1075        }
1076
1077        html_generator.finalize()
1078    }
1079
1080    fn list_syntaxes(&self) -> Vec<(String, Vec<String>)> {
1081        self.syntax_set
1082            .syntaxes()
1083            .iter()
1084            .map(|s| (s.name.clone(), s.file_extensions.clone()))
1085            .collect()
1086    }
1087}
1088
1089static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
1090
1091fn get_highlighter() -> &'static SyntaxHighlighter {
1092    HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
1093}
1094
1095// === .highlight command ===
1096
1097#[derive(Clone)]
1098pub struct HighlightCommand;
1099
1100impl Default for HighlightCommand {
1101    fn default() -> Self {
1102        Self::new()
1103    }
1104}
1105
1106impl HighlightCommand {
1107    pub fn new() -> Self {
1108        Self
1109    }
1110}
1111
1112impl Command for HighlightCommand {
1113    fn name(&self) -> &str {
1114        ".highlight"
1115    }
1116
1117    fn description(&self) -> &str {
1118        "Syntax highlight code, outputting HTML with CSS classes"
1119    }
1120
1121    fn signature(&self) -> Signature {
1122        Signature::build(".highlight")
1123            .required("lang", SyntaxShape::String, "language for highlighting")
1124            .input_output_types(vec![(Type::String, Type::record())])
1125            .category(Category::Custom("http".into()))
1126    }
1127
1128    fn run(
1129        &self,
1130        engine_state: &EngineState,
1131        stack: &mut Stack,
1132        call: &Call,
1133        input: PipelineData,
1134    ) -> Result<PipelineData, ShellError> {
1135        let head = call.head;
1136        let lang: String = call.req(engine_state, stack, 0)?;
1137
1138        let code = match input {
1139            PipelineData::Value(Value::String { val, .. }, _) => val,
1140            PipelineData::ByteStream(stream, _) => stream.into_string()?,
1141            _ => {
1142                return Err(ShellError::TypeMismatch {
1143                    err_message: "expected string input".into(),
1144                    span: head,
1145                });
1146            }
1147        };
1148
1149        let highlighter = get_highlighter();
1150        let html = highlighter.highlight(&code, Some(&lang));
1151
1152        Ok(Value::record(
1153            nu_protocol::record! {
1154                "__html" => Value::string(html, head),
1155            },
1156            head,
1157        )
1158        .into_pipeline_data())
1159    }
1160}
1161
1162// === .highlight theme command ===
1163
1164#[derive(Clone)]
1165pub struct HighlightThemeCommand;
1166
1167impl Default for HighlightThemeCommand {
1168    fn default() -> Self {
1169        Self::new()
1170    }
1171}
1172
1173impl HighlightThemeCommand {
1174    pub fn new() -> Self {
1175        Self
1176    }
1177}
1178
1179impl Command for HighlightThemeCommand {
1180    fn name(&self) -> &str {
1181        ".highlight theme"
1182    }
1183
1184    fn description(&self) -> &str {
1185        "List available themes or get CSS for a specific theme"
1186    }
1187
1188    fn signature(&self) -> Signature {
1189        Signature::build(".highlight theme")
1190            .optional("name", SyntaxShape::String, "theme name (omit to list all)")
1191            .input_output_types(vec![
1192                (Type::Nothing, Type::List(Box::new(Type::String))),
1193                (Type::Nothing, Type::String),
1194            ])
1195            .category(Category::Custom("http".into()))
1196    }
1197
1198    fn run(
1199        &self,
1200        engine_state: &EngineState,
1201        stack: &mut Stack,
1202        call: &Call,
1203        _input: PipelineData,
1204    ) -> Result<PipelineData, ShellError> {
1205        let head = call.head;
1206        let name: Option<String> = call.opt(engine_state, stack, 0)?;
1207
1208        let assets = syntect_assets::assets::HighlightingAssets::from_binary();
1209
1210        match name {
1211            None => {
1212                let themes: Vec<Value> = assets.themes().map(|t| Value::string(t, head)).collect();
1213                Ok(Value::list(themes, head).into_pipeline_data())
1214            }
1215            Some(theme_name) => {
1216                let theme = assets.get_theme(&theme_name);
1217                let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced)
1218                    .map_err(|e| ShellError::GenericError {
1219                        error: format!("Failed to generate CSS: {e}"),
1220                        msg: e.to_string(),
1221                        span: Some(head),
1222                        help: None,
1223                        inner: vec![],
1224                    })?;
1225                Ok(Value::string(css, head).into_pipeline_data())
1226            }
1227        }
1228    }
1229}
1230
1231// === .highlight lang command ===
1232
1233#[derive(Clone)]
1234pub struct HighlightLangCommand;
1235
1236impl Default for HighlightLangCommand {
1237    fn default() -> Self {
1238        Self::new()
1239    }
1240}
1241
1242impl HighlightLangCommand {
1243    pub fn new() -> Self {
1244        Self
1245    }
1246}
1247
1248impl Command for HighlightLangCommand {
1249    fn name(&self) -> &str {
1250        ".highlight lang"
1251    }
1252
1253    fn description(&self) -> &str {
1254        "List available languages for syntax highlighting"
1255    }
1256
1257    fn signature(&self) -> Signature {
1258        Signature::build(".highlight lang")
1259            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
1260            .category(Category::Custom("http".into()))
1261    }
1262
1263    fn run(
1264        &self,
1265        _engine_state: &EngineState,
1266        _stack: &mut Stack,
1267        call: &Call,
1268        _input: PipelineData,
1269    ) -> Result<PipelineData, ShellError> {
1270        let head = call.head;
1271        let highlighter = get_highlighter();
1272        let langs: Vec<Value> = highlighter
1273            .list_syntaxes()
1274            .into_iter()
1275            .map(|(name, exts)| {
1276                Value::record(
1277                    nu_protocol::record! {
1278                        "name" => Value::string(name, head),
1279                        "extensions" => Value::list(
1280                            exts.into_iter().map(|e| Value::string(e, head)).collect(),
1281                            head
1282                        ),
1283                    },
1284                    head,
1285                )
1286            })
1287            .collect();
1288        Ok(Value::list(langs, head).into_pipeline_data())
1289    }
1290}
1291
1292// === .md command ===
1293
1294use pulldown_cmark::{html, CodeBlockKind, Event, Parser as MarkdownParser, Tag, TagEnd};
1295
1296#[derive(Clone)]
1297pub struct MdCommand;
1298
1299impl Default for MdCommand {
1300    fn default() -> Self {
1301        Self::new()
1302    }
1303}
1304
1305impl MdCommand {
1306    pub fn new() -> Self {
1307        Self
1308    }
1309}
1310
1311impl Command for MdCommand {
1312    fn name(&self) -> &str {
1313        ".md"
1314    }
1315
1316    fn description(&self) -> &str {
1317        "Convert Markdown to HTML with syntax-highlighted code blocks"
1318    }
1319
1320    fn signature(&self) -> Signature {
1321        Signature::build(".md")
1322            .input_output_types(vec![
1323                (Type::String, Type::record()),
1324                (Type::record(), Type::record()),
1325            ])
1326            .category(Category::Custom("http".into()))
1327    }
1328
1329    fn run(
1330        &self,
1331        _engine_state: &EngineState,
1332        _stack: &mut Stack,
1333        call: &Call,
1334        input: PipelineData,
1335    ) -> Result<PipelineData, ShellError> {
1336        let head = call.head;
1337
1338        // Determine if input is trusted ({__html: ...}) or untrusted (plain string)
1339        let (markdown, trusted) = match input.into_value(head)? {
1340            Value::String { val, .. } => (val, false),
1341            Value::Record { val, .. } => {
1342                if let Some(html_val) = val.get("__html") {
1343                    (html_val.as_str()?.to_string(), true)
1344                } else {
1345                    return Err(ShellError::TypeMismatch {
1346                        err_message: "expected string or {__html: ...}".into(),
1347                        span: head,
1348                    });
1349                }
1350            }
1351            other => {
1352                return Err(ShellError::TypeMismatch {
1353                    err_message: format!(
1354                        "expected string or {{__html: ...}}, got {}",
1355                        other.get_type()
1356                    ),
1357                    span: head,
1358                });
1359            }
1360        };
1361
1362        let highlighter = get_highlighter();
1363
1364        let mut in_code_block = false;
1365        let mut current_code = String::new();
1366        let mut current_lang: Option<String> = None;
1367
1368        let parser = MarkdownParser::new(&markdown).map(|event| match event {
1369            Event::Start(Tag::CodeBlock(kind)) => {
1370                in_code_block = true;
1371                current_code.clear();
1372                current_lang = match kind {
1373                    CodeBlockKind::Fenced(info) => {
1374                        let lang = info.split_whitespace().next().unwrap_or("");
1375                        if lang.is_empty() {
1376                            None
1377                        } else {
1378                            Some(lang.to_string())
1379                        }
1380                    }
1381                    CodeBlockKind::Indented => None,
1382                };
1383                Event::Text("".into())
1384            }
1385            Event::End(TagEnd::CodeBlock) => {
1386                in_code_block = false;
1387                let highlighted = highlighter.highlight(&current_code, current_lang.as_deref());
1388                let mut html_out = String::new();
1389                html_out.push_str("<pre><code");
1390                if let Some(lang) = &current_lang {
1391                    html_out.push_str(&format!(" class=\"language-{lang}\""));
1392                }
1393                html_out.push('>');
1394                html_out.push_str(&highlighted);
1395                html_out.push_str("</code></pre>");
1396                Event::Html(html_out.into())
1397            }
1398            Event::Text(text) => {
1399                if in_code_block {
1400                    current_code.push_str(&text);
1401                    Event::Text("".into())
1402                } else {
1403                    Event::Text(text)
1404                }
1405            }
1406            // Escape raw HTML if input is untrusted
1407            Event::Html(html) => {
1408                if trusted {
1409                    Event::Html(html)
1410                } else {
1411                    Event::Text(html) // push_html escapes Text
1412                }
1413            }
1414            Event::InlineHtml(html) => {
1415                if trusted {
1416                    Event::InlineHtml(html)
1417                } else {
1418                    Event::Text(html)
1419                }
1420            }
1421            e => e,
1422        });
1423
1424        let mut html_output = String::new();
1425        html::push_html(&mut html_output, parser);
1426
1427        Ok(Value::record(
1428            nu_protocol::record! {
1429                "__html" => Value::string(html_output, head),
1430            },
1431            head,
1432        )
1433        .into_pipeline_data())
1434    }
1435}