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