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