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