1use crate::logging::log_print;
2use crate::response::{Response, ResponseBodyType};
3use nu_engine::command_prelude::*;
4use nu_protocol::{
5 shell_error::generic::GenericError, ByteStream, ByteStreamType, Category, Config, CustomValue,
6 PipelineData, PipelineMetadata, 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, AutoEscape, 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_auto_escape_callback(|_| AutoEscape::Html);
61 env.set_loader(loader);
62 env.add_template_owned("template".to_string(), source.to_string())?;
63 cache.insert(hash, Arc::new(env));
64 Ok(hash)
65}
66
67fn get_compiled(hash: u128) -> Option<Arc<Environment<'static>>> {
69 get_cache().read().unwrap().get(&hash).map(Arc::clone)
70}
71
72#[cfg(feature = "cross-stream")]
74fn load_topic_content(store: &xs::store::Store, topic: &str) -> Option<String> {
75 let options = xs::store::ReadOptions::builder()
76 .follow(xs::store::FollowOption::Off)
77 .topic(topic.to_string())
78 .last(1_usize)
79 .build();
80 let frame = store.read_sync(options).last()?;
81 let hash = frame.hash?;
82 let bytes = store.cas_read_sync(&hash).ok()?;
83 String::from_utf8(bytes).ok()
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct CompiledTemplate {
90 hash: u128,
91}
92
93impl CompiledTemplate {
94 pub fn render(&self, context: &minijinja::Value) -> Result<String, minijinja::Error> {
96 let env = get_compiled(self.hash).expect("template not in cache");
97 let tmpl = env.get_template("template")?;
98 tmpl.render(context)
99 }
100}
101
102#[typetag::serde]
103impl CustomValue for CompiledTemplate {
104 fn clone_value(&self, span: Span) -> Value {
105 Value::custom(Box::new(self.clone()), span)
106 }
107
108 fn type_name(&self) -> String {
109 "CompiledTemplate".into()
110 }
111
112 fn to_base_value(&self, span: Span) -> Result<Value, ShellError> {
113 Ok(Value::string(format!("{:032x}", self.hash), span))
114 }
115
116 fn as_any(&self) -> &dyn std::any::Any {
117 self
118 }
119
120 fn as_mut_any(&mut self) -> &mut dyn std::any::Any {
121 self
122 }
123}
124
125thread_local! {
126 pub static RESPONSE_TX: RefCell<Option<oneshot::Sender<Response>>> = const { RefCell::new(None) };
127}
128
129#[derive(Clone)]
130pub struct StaticCommand;
131
132impl Default for StaticCommand {
133 fn default() -> Self {
134 Self::new()
135 }
136}
137
138impl StaticCommand {
139 pub fn new() -> Self {
140 Self
141 }
142}
143
144impl Command for StaticCommand {
145 fn name(&self) -> &str {
146 ".static"
147 }
148
149 fn description(&self) -> &str {
150 "Serve static files from a directory"
151 }
152
153 fn signature(&self) -> Signature {
154 Signature::build(".static")
155 .required("root", SyntaxShape::String, "root directory path")
156 .required("path", SyntaxShape::String, "request path")
157 .named(
158 "fallback",
159 SyntaxShape::String,
160 "fallback file when request missing",
161 None,
162 )
163 .input_output_types(vec![(Type::Nothing, Type::Nothing)])
164 .category(Category::Custom("http".into()))
165 }
166
167 fn run(
168 &self,
169 engine_state: &EngineState,
170 stack: &mut Stack,
171 call: &Call,
172 _input: PipelineData,
173 ) -> Result<PipelineData, ShellError> {
174 let root: String = call.req(engine_state, stack, 0)?;
175 let path: String = call.req(engine_state, stack, 1)?;
176
177 let fallback: Option<String> = call.get_flag(engine_state, stack, "fallback")?;
178
179 let response = Response {
180 status: 200,
181 headers: HashMap::new(),
182 body_type: ResponseBodyType::Static {
183 root: PathBuf::from(root),
184 path,
185 fallback,
186 },
187 };
188
189 RESPONSE_TX.with(|tx| -> Result<_, ShellError> {
190 if let Some(tx) = tx.borrow_mut().take() {
191 tx.send(response).map_err(|_| {
192 ShellError::Generic(GenericError::new(
193 "Failed to send response",
194 "Channel closed",
195 call.head,
196 ))
197 })?;
198 }
199 Ok(())
200 })?;
201
202 Ok(PipelineData::Empty)
203 }
204}
205
206const LINE_ENDING: &str = "\n";
207
208#[derive(Clone)]
209pub struct ToSse;
210
211impl Command for ToSse {
212 fn name(&self) -> &str {
213 "to sse"
214 }
215
216 fn signature(&self) -> Signature {
217 Signature::build("to sse")
218 .input_output_types(vec![
219 (Type::record(), Type::String),
220 (Type::List(Box::new(Type::record())), Type::String),
221 ])
222 .category(Category::Formats)
223 }
224
225 fn description(&self) -> &str {
226 "Convert records into text/event-stream format"
227 }
228
229 fn search_terms(&self) -> Vec<&str> {
230 vec!["sse", "server", "event"]
231 }
232
233 fn examples(&self) -> Vec<Example<'_>> {
234 vec![Example {
235 description: "Convert a record into a server-sent event",
236 example: "{data: 'hello'} | to sse",
237 result: Some(Value::test_string("data: hello\n\n")),
238 }]
239 }
240
241 fn run(
242 &self,
243 engine_state: &EngineState,
244 stack: &mut Stack,
245 call: &Call,
246 input: PipelineData,
247 ) -> Result<PipelineData, ShellError> {
248 let head = call.head;
249 let config = stack.get_config(engine_state);
250 match input {
251 PipelineData::ListStream(stream, meta) => {
252 let span = stream.span();
253 let cfg = config.clone();
254 let iter = stream
255 .into_iter()
256 .map(move |val| event_to_string(&cfg, val));
257 let stream = ByteStream::from_result_iter(
258 iter,
259 span,
260 engine_state.signals().clone(),
261 ByteStreamType::String,
262 );
263 Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
264 }
265 PipelineData::Value(Value::List { vals, .. }, meta) => {
266 let cfg = config.clone();
267 let iter = vals.into_iter().map(move |val| event_to_string(&cfg, val));
268 let span = head;
269 let stream = ByteStream::from_result_iter(
270 iter,
271 span,
272 engine_state.signals().clone(),
273 ByteStreamType::String,
274 );
275 Ok(PipelineData::ByteStream(stream, update_metadata(meta)))
276 }
277 PipelineData::Value(val, meta) => {
278 let out = event_to_string(&config, val)?;
279 Ok(
280 Value::string(out, head)
281 .into_pipeline_data_with_metadata(update_metadata(meta)),
282 )
283 }
284 PipelineData::Empty => Ok(PipelineData::Value(
285 Value::string(String::new(), head),
286 update_metadata(None),
287 )),
288 PipelineData::ByteStream(..) => Err(ShellError::TypeMismatch {
289 err_message: "expected record input".into(),
290 span: head,
291 }),
292 }
293 }
294}
295
296fn emit_data_lines(out: &mut String, s: &str) {
297 for line in s.lines() {
298 out.push_str("data: ");
299 out.push_str(line);
300 out.push_str(LINE_ENDING);
301 }
302}
303
304#[allow(clippy::result_large_err)]
305fn value_to_data_string(val: &Value, config: &Config) -> Result<String, ShellError> {
306 match val {
307 Value::String { val, .. } => Ok(val.clone()),
308 _ => {
309 let json_value = value_to_json(val, config).map_err(|err| {
310 ShellError::Generic(GenericError::new(
311 err.to_string(),
312 "failed to serialize json",
313 Span::unknown(),
314 ))
315 })?;
316 serde_json::to_string(&json_value).map_err(|err| {
317 ShellError::Generic(GenericError::new(
318 err.to_string(),
319 "failed to serialize json",
320 Span::unknown(),
321 ))
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(|_| {
574 ShellError::Generic(GenericError::new(
575 "Failed to send response",
576 "Channel closed",
577 call.head,
578 ))
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::Generic(GenericError::new(
657 "Cannot combine file, --inline, and --topic",
658 "use exactly one of: file path, --inline, or --topic",
659 head,
660 )));
661 }
662 if mode_count == 0 {
663 return Err(ShellError::Generic(GenericError::new(
664 "No template specified",
665 "provide a file path, --inline, or --topic",
666 head,
667 )));
668 }
669
670 let context = match input {
672 PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
673 PipelineData::Empty => minijinja::Value::from(()),
674 _ => {
675 return Err(ShellError::TypeMismatch {
676 err_message: "expected record input".into(),
677 span: head,
678 });
679 }
680 };
681
682 let mut env = Environment::new();
684 env.set_auto_escape_callback(|_| AutoEscape::Html);
685 let tmpl = if let Some(ref path) = file {
686 let path = std::path::Path::new(path);
688 let abs_path = if path.is_absolute() {
689 path.to_path_buf()
690 } else {
691 std::env::current_dir().unwrap_or_default().join(path)
692 };
693 if let Some(parent) = abs_path.parent() {
694 env.set_loader(path_loader(parent));
695 }
696 let name = abs_path
697 .file_name()
698 .and_then(|n| n.to_str())
699 .unwrap_or("template");
700 env.get_template(name).map_err(|e| {
701 ShellError::Generic(GenericError::new(
702 format!("Template error: {e}"),
703 e.to_string(),
704 head,
705 ))
706 })?
707 } else if let Some(ref topic_name) = topic {
708 #[cfg(feature = "cross-stream")]
710 {
711 let store = self.store.as_ref().ok_or_else(|| {
712 ShellError::Generic(GenericError::new(
713 "--topic requires --store",
714 "server must be started with --store to use --topic",
715 head,
716 ))
717 })?;
718 let source = load_topic_content(store, topic_name).ok_or_else(|| {
719 ShellError::Generic(GenericError::new(
720 format!("Topic not found: {topic_name}"),
721 "no content in store for this topic",
722 head,
723 ))
724 })?;
725 let topic_store = store.clone();
726 env.set_loader(move |name: &str| Ok(load_topic_content(&topic_store, name)));
727 env.add_template_owned(topic_name.clone(), source)
728 .map_err(|e| {
729 ShellError::Generic(GenericError::new(
730 format!("Template parse error: {e}"),
731 e.to_string(),
732 head,
733 ))
734 })?;
735 env.get_template(topic_name).map_err(|e| {
736 ShellError::Generic(GenericError::new(
737 format!("Template error: {e}"),
738 e.to_string(),
739 head,
740 ))
741 })?
742 }
743 #[cfg(not(feature = "cross-stream"))]
744 {
745 let _ = topic_name;
746 return Err(ShellError::Generic(GenericError::new(
747 "--topic requires cross-stream feature",
748 "built without store support",
749 head,
750 )));
751 }
752 } else {
753 let source = inline.unwrap();
755 env.add_template_owned("template".to_string(), source)
756 .map_err(|e| {
757 ShellError::Generic(GenericError::new(
758 format!("Template parse error: {e}"),
759 e.to_string(),
760 head,
761 ))
762 })?;
763 env.get_template("template").map_err(|e| {
764 ShellError::Generic(GenericError::new(
765 format!("Failed to get template: {e}"),
766 e.to_string(),
767 head,
768 ))
769 })?
770 };
771
772 let rendered = tmpl.render(&context).map_err(|e| {
773 ShellError::Generic(GenericError::new(
774 format!("Template render error: {e}"),
775 e.to_string(),
776 head,
777 ))
778 })?;
779
780 Ok(Value::string(rendered, head).into_pipeline_data())
781 }
782}
783
784fn nu_value_to_minijinja(val: &Value) -> minijinja::Value {
786 let json = value_to_json(val, &Config::default()).unwrap_or(serde_json::Value::Null);
787 minijinja::Value::from_serialize(&json)
788}
789
790#[derive(Clone)]
793pub struct MjCompileCommand {
794 #[cfg(feature = "cross-stream")]
795 store: Option<xs::store::Store>,
796}
797
798impl Default for MjCompileCommand {
799 fn default() -> Self {
800 Self::new()
801 }
802}
803
804impl MjCompileCommand {
805 pub fn new() -> Self {
806 Self {
807 #[cfg(feature = "cross-stream")]
808 store: None,
809 }
810 }
811
812 #[cfg(feature = "cross-stream")]
813 pub fn with_store(store: xs::store::Store) -> Self {
814 Self { store: Some(store) }
815 }
816}
817
818impl Command for MjCompileCommand {
819 fn name(&self) -> &str {
820 ".mj compile"
821 }
822
823 fn description(&self) -> &str {
824 "Compile a minijinja template, returning a reusable compiled template"
825 }
826
827 fn signature(&self) -> Signature {
828 Signature::build(".mj compile")
829 .optional("file", SyntaxShape::String, "template file path")
830 .named(
831 "inline",
832 SyntaxShape::Any,
833 "inline template (string or {__html: string})",
834 Some('i'),
835 )
836 .named(
837 "topic",
838 SyntaxShape::String,
839 "load template from a store topic",
840 Some('t'),
841 )
842 .input_output_types(vec![(
843 Type::Nothing,
844 Type::Custom("CompiledTemplate".into()),
845 )])
846 .category(Category::Custom("http".into()))
847 }
848
849 fn run(
850 &self,
851 engine_state: &EngineState,
852 stack: &mut Stack,
853 call: &Call,
854 _input: PipelineData,
855 ) -> Result<PipelineData, ShellError> {
856 let head = call.head;
857 let file: Option<String> = call.opt(engine_state, stack, 0)?;
858 let inline: Option<Value> = call.get_flag(engine_state, stack, "inline")?;
859 let topic: Option<String> = call.get_flag(engine_state, stack, "topic")?;
860
861 let inline_str: Option<String> = match &inline {
863 None => None,
864 Some(val) => match val {
865 Value::String { val, .. } => Some(val.clone()),
866 Value::Record { val, .. } => {
867 if let Some(html_val) = val.get("__html") {
868 match html_val {
869 Value::String { val, .. } => Some(val.clone()),
870 _ => {
871 return Err(ShellError::Generic(GenericError::new(
872 "__html must be a string",
873 "expected string value",
874 head,
875 )));
876 }
877 }
878 } else {
879 return Err(ShellError::Generic(GenericError::new(
880 "Record must have __html field",
881 "expected {__html: string}",
882 head,
883 )));
884 }
885 }
886 _ => {
887 return Err(ShellError::Generic(GenericError::new(
888 "--inline must be string or {__html: string}",
889 "invalid type",
890 head,
891 )));
892 }
893 },
894 };
895
896 let mode_count = file.is_some() as u8 + inline_str.is_some() as u8 + topic.is_some() as u8;
897 if mode_count > 1 {
898 return Err(ShellError::Generic(GenericError::new(
899 "Cannot combine file, --inline, and --topic",
900 "use exactly one of: file path, --inline, or --topic",
901 head,
902 )));
903 }
904 if mode_count == 0 {
905 return Err(ShellError::Generic(GenericError::new(
906 "No template specified",
907 "provide a file path, --inline, or --topic",
908 head,
909 )));
910 }
911
912 let hash = if let Some(ref path) = file {
913 let path = std::path::Path::new(path);
915 let abs_path = if path.is_absolute() {
916 path.to_path_buf()
917 } else {
918 std::env::current_dir().unwrap_or_default().join(path)
919 };
920 let base_dir = abs_path.parent().unwrap_or(&abs_path).to_path_buf();
921 let source = std::fs::read_to_string(&abs_path).map_err(|e| {
922 ShellError::Generic(GenericError::new(
923 format!("Failed to read template file: {e}"),
924 "could not read file",
925 head,
926 ))
927 })?;
928 compile_template(&source, &base_dir)
929 } else if let Some(ref topic_name) = topic {
930 #[cfg(feature = "cross-stream")]
932 {
933 let store = self.store.as_ref().ok_or_else(|| {
934 ShellError::Generic(GenericError::new(
935 "--topic requires --store",
936 "server must be started with --store to use --topic",
937 head,
938 ))
939 })?;
940 let source = load_topic_content(store, topic_name).ok_or_else(|| {
941 ShellError::Generic(GenericError::new(
942 format!("Topic not found: {topic_name}"),
943 "no content in store for this topic",
944 head,
945 ))
946 })?;
947 let topic_store = store.clone();
948 let base_dir = std::path::PathBuf::from(format!("__topic__/{topic_name}"));
950 compile_template_with_loader(&source, &base_dir, move |name: &str| {
951 Ok(load_topic_content(&topic_store, name))
952 })
953 }
954 #[cfg(not(feature = "cross-stream"))]
955 {
956 let _ = topic_name;
957 return Err(ShellError::Generic(GenericError::new(
958 "--topic requires cross-stream feature",
959 "built without store support",
960 head,
961 )));
962 }
963 } else {
964 let source = inline_str.unwrap();
966 let base_dir = std::path::PathBuf::from("__inline__");
967 compile_template_with_loader(&source, &base_dir, |_| Ok(None))
968 };
969
970 let hash = hash.map_err(|e| {
971 ShellError::Generic(GenericError::new(
972 format!("Template compile error: {e}"),
973 e.to_string(),
974 head,
975 ))
976 })?;
977
978 Ok(Value::custom(Box::new(CompiledTemplate { hash }), head).into_pipeline_data())
979 }
980}
981
982#[derive(Clone)]
985pub struct MjRenderCommand;
986
987impl Default for MjRenderCommand {
988 fn default() -> Self {
989 Self::new()
990 }
991}
992
993impl MjRenderCommand {
994 pub fn new() -> Self {
995 Self
996 }
997}
998
999impl Command for MjRenderCommand {
1000 fn name(&self) -> &str {
1001 ".mj render"
1002 }
1003
1004 fn description(&self) -> &str {
1005 "Render a compiled minijinja template with context from input"
1006 }
1007
1008 fn signature(&self) -> Signature {
1009 Signature::build(".mj render")
1010 .required(
1011 "template",
1012 SyntaxShape::Any,
1013 "compiled template from '.mj compile'",
1014 )
1015 .input_output_types(vec![(Type::Record(vec![].into()), Type::String)])
1016 .category(Category::Custom("http".into()))
1017 }
1018
1019 fn run(
1020 &self,
1021 engine_state: &EngineState,
1022 stack: &mut Stack,
1023 call: &Call,
1024 input: PipelineData,
1025 ) -> Result<PipelineData, ShellError> {
1026 let head = call.head;
1027 let template_val: Value = call.req(engine_state, stack, 0)?;
1028
1029 let compiled = match template_val {
1031 Value::Custom { val, .. } => val
1032 .as_any()
1033 .downcast_ref::<CompiledTemplate>()
1034 .ok_or_else(|| ShellError::TypeMismatch {
1035 err_message: "expected CompiledTemplate".into(),
1036 span: head,
1037 })?
1038 .clone(),
1039 _ => {
1040 return Err(ShellError::TypeMismatch {
1041 err_message: "expected CompiledTemplate from '.mj compile'".into(),
1042 span: head,
1043 });
1044 }
1045 };
1046
1047 let context = match input {
1049 PipelineData::Value(val, _) => nu_value_to_minijinja(&val),
1050 PipelineData::Empty => minijinja::Value::from(()),
1051 _ => {
1052 return Err(ShellError::TypeMismatch {
1053 err_message: "expected record input".into(),
1054 span: head,
1055 });
1056 }
1057 };
1058
1059 let rendered = compiled.render(&context).map_err(|e| {
1061 ShellError::Generic(GenericError::new(
1062 format!("Template render error: {e}"),
1063 e.to_string(),
1064 head,
1065 ))
1066 })?;
1067
1068 Ok(Value::string(rendered, head).into_pipeline_data())
1069 }
1070}
1071
1072struct SyntaxHighlighter {
1075 syntax_set: SyntaxSet,
1076}
1077
1078impl SyntaxHighlighter {
1079 fn new() -> Self {
1080 const SYNTAX_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/syntax_set.bin"));
1081 let syntax_set = syntect::dumps::from_binary(SYNTAX_SET);
1082 Self { syntax_set }
1083 }
1084
1085 fn highlight(&self, code: &str, lang: Option<&str>) -> String {
1086 let syntax = match lang {
1087 Some(lang) => self
1088 .syntax_set
1089 .find_syntax_by_token(lang)
1090 .or_else(|| self.syntax_set.find_syntax_by_extension(lang)),
1091 None => None,
1092 }
1093 .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
1094
1095 let mut html_generator = ClassedHTMLGenerator::new_with_class_style(
1096 syntax,
1097 &self.syntax_set,
1098 ClassStyle::Spaced,
1099 );
1100
1101 for line in LinesWithEndings::from(code) {
1102 let _ = html_generator.parse_html_for_line_which_includes_newline(line);
1103 }
1104
1105 html_generator.finalize()
1106 }
1107
1108 fn list_syntaxes(&self) -> Vec<(String, Vec<String>)> {
1109 self.syntax_set
1110 .syntaxes()
1111 .iter()
1112 .map(|s| (s.name.clone(), s.file_extensions.clone()))
1113 .collect()
1114 }
1115}
1116
1117static HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
1118
1119fn get_highlighter() -> &'static SyntaxHighlighter {
1120 HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
1121}
1122
1123#[derive(Clone)]
1126pub struct HighlightCommand;
1127
1128impl Default for HighlightCommand {
1129 fn default() -> Self {
1130 Self::new()
1131 }
1132}
1133
1134impl HighlightCommand {
1135 pub fn new() -> Self {
1136 Self
1137 }
1138}
1139
1140impl Command for HighlightCommand {
1141 fn name(&self) -> &str {
1142 ".highlight"
1143 }
1144
1145 fn description(&self) -> &str {
1146 "Syntax highlight code, outputting HTML with CSS classes"
1147 }
1148
1149 fn signature(&self) -> Signature {
1150 Signature::build(".highlight")
1151 .required("lang", SyntaxShape::String, "language for highlighting")
1152 .input_output_types(vec![(Type::String, Type::record())])
1153 .category(Category::Custom("http".into()))
1154 }
1155
1156 fn run(
1157 &self,
1158 engine_state: &EngineState,
1159 stack: &mut Stack,
1160 call: &Call,
1161 input: PipelineData,
1162 ) -> Result<PipelineData, ShellError> {
1163 let head = call.head;
1164 let lang: String = call.req(engine_state, stack, 0)?;
1165
1166 let code = match input {
1167 PipelineData::Value(Value::String { val, .. }, _) => val,
1168 PipelineData::ByteStream(stream, _) => stream.into_string()?,
1169 _ => {
1170 return Err(ShellError::TypeMismatch {
1171 err_message: "expected string input".into(),
1172 span: head,
1173 });
1174 }
1175 };
1176
1177 let highlighter = get_highlighter();
1178 let html = highlighter.highlight(&code, Some(&lang));
1179
1180 Ok(Value::record(
1181 nu_protocol::record! {
1182 "__html" => Value::string(html, head),
1183 },
1184 head,
1185 )
1186 .into_pipeline_data())
1187 }
1188}
1189
1190#[derive(Clone)]
1193pub struct HighlightThemeCommand;
1194
1195impl Default for HighlightThemeCommand {
1196 fn default() -> Self {
1197 Self::new()
1198 }
1199}
1200
1201impl HighlightThemeCommand {
1202 pub fn new() -> Self {
1203 Self
1204 }
1205}
1206
1207impl Command for HighlightThemeCommand {
1208 fn name(&self) -> &str {
1209 ".highlight theme"
1210 }
1211
1212 fn description(&self) -> &str {
1213 "List available themes or get CSS for a specific theme"
1214 }
1215
1216 fn signature(&self) -> Signature {
1217 Signature::build(".highlight theme")
1218 .optional("name", SyntaxShape::String, "theme name (omit to list all)")
1219 .input_output_types(vec![
1220 (Type::Nothing, Type::List(Box::new(Type::String))),
1221 (Type::Nothing, Type::String),
1222 ])
1223 .category(Category::Custom("http".into()))
1224 }
1225
1226 fn run(
1227 &self,
1228 engine_state: &EngineState,
1229 stack: &mut Stack,
1230 call: &Call,
1231 _input: PipelineData,
1232 ) -> Result<PipelineData, ShellError> {
1233 let head = call.head;
1234 let name: Option<String> = call.opt(engine_state, stack, 0)?;
1235
1236 let assets = syntect_assets::assets::HighlightingAssets::from_binary();
1237
1238 match name {
1239 None => {
1240 let themes: Vec<Value> = assets.themes().map(|t| Value::string(t, head)).collect();
1241 Ok(Value::list(themes, head).into_pipeline_data())
1242 }
1243 Some(theme_name) => {
1244 let theme = assets.get_theme(&theme_name);
1245 let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced)
1246 .map_err(|e| {
1247 ShellError::Generic(GenericError::new(
1248 format!("Failed to generate CSS: {e}"),
1249 e.to_string(),
1250 head,
1251 ))
1252 })?;
1253 Ok(Value::string(css, head).into_pipeline_data())
1254 }
1255 }
1256 }
1257}
1258
1259#[derive(Clone)]
1262pub struct HighlightLangCommand;
1263
1264impl Default for HighlightLangCommand {
1265 fn default() -> Self {
1266 Self::new()
1267 }
1268}
1269
1270impl HighlightLangCommand {
1271 pub fn new() -> Self {
1272 Self
1273 }
1274}
1275
1276impl Command for HighlightLangCommand {
1277 fn name(&self) -> &str {
1278 ".highlight lang"
1279 }
1280
1281 fn description(&self) -> &str {
1282 "List available languages for syntax highlighting"
1283 }
1284
1285 fn signature(&self) -> Signature {
1286 Signature::build(".highlight lang")
1287 .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
1288 .category(Category::Custom("http".into()))
1289 }
1290
1291 fn run(
1292 &self,
1293 _engine_state: &EngineState,
1294 _stack: &mut Stack,
1295 call: &Call,
1296 _input: PipelineData,
1297 ) -> Result<PipelineData, ShellError> {
1298 let head = call.head;
1299 let highlighter = get_highlighter();
1300 let langs: Vec<Value> = highlighter
1301 .list_syntaxes()
1302 .into_iter()
1303 .map(|(name, exts)| {
1304 Value::record(
1305 nu_protocol::record! {
1306 "name" => Value::string(name, head),
1307 "extensions" => Value::list(
1308 exts.into_iter().map(|e| Value::string(e, head)).collect(),
1309 head
1310 ),
1311 },
1312 head,
1313 )
1314 })
1315 .collect();
1316 Ok(Value::list(langs, head).into_pipeline_data())
1317 }
1318}
1319
1320use pulldown_cmark::{html, CodeBlockKind, Event, Parser as MarkdownParser, Tag, TagEnd};
1323
1324#[derive(Clone)]
1325pub struct MdCommand;
1326
1327impl Default for MdCommand {
1328 fn default() -> Self {
1329 Self::new()
1330 }
1331}
1332
1333impl MdCommand {
1334 pub fn new() -> Self {
1335 Self
1336 }
1337}
1338
1339impl Command for MdCommand {
1340 fn name(&self) -> &str {
1341 ".md"
1342 }
1343
1344 fn description(&self) -> &str {
1345 "Convert Markdown to HTML with syntax-highlighted code blocks"
1346 }
1347
1348 fn signature(&self) -> Signature {
1349 Signature::build(".md")
1350 .input_output_types(vec![
1351 (Type::String, Type::record()),
1352 (Type::record(), Type::record()),
1353 ])
1354 .category(Category::Custom("http".into()))
1355 }
1356
1357 fn run(
1358 &self,
1359 _engine_state: &EngineState,
1360 _stack: &mut Stack,
1361 call: &Call,
1362 input: PipelineData,
1363 ) -> Result<PipelineData, ShellError> {
1364 let head = call.head;
1365
1366 let (markdown, trusted) = match input.into_value(head)? {
1368 Value::String { val, .. } => (val, false),
1369 Value::Record { val, .. } => {
1370 if let Some(html_val) = val.get("__html") {
1371 (html_val.as_str()?.to_string(), true)
1372 } else {
1373 return Err(ShellError::TypeMismatch {
1374 err_message: "expected string or {__html: ...}".into(),
1375 span: head,
1376 });
1377 }
1378 }
1379 other => {
1380 return Err(ShellError::TypeMismatch {
1381 err_message: format!(
1382 "expected string or {{__html: ...}}, got {}",
1383 other.get_type()
1384 ),
1385 span: head,
1386 });
1387 }
1388 };
1389
1390 let highlighter = get_highlighter();
1391
1392 let mut in_code_block = false;
1393 let mut current_code = String::new();
1394 let mut current_lang: Option<String> = None;
1395
1396 let mut options = pulldown_cmark::Options::empty();
1397 options.insert(pulldown_cmark::Options::ENABLE_TABLES);
1398 options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
1399 options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
1400 options.insert(pulldown_cmark::Options::ENABLE_FOOTNOTES);
1401 options.insert(pulldown_cmark::Options::ENABLE_HEADING_ATTRIBUTES);
1402 options.insert(pulldown_cmark::Options::ENABLE_GFM);
1403 options.insert(pulldown_cmark::Options::ENABLE_DEFINITION_LIST);
1404 let parser = MarkdownParser::new_ext(&markdown, options).map(|event| match event {
1405 Event::Start(Tag::CodeBlock(kind)) => {
1406 in_code_block = true;
1407 current_code.clear();
1408 current_lang = match kind {
1409 CodeBlockKind::Fenced(info) => {
1410 let lang = info.split_whitespace().next().unwrap_or("");
1411 if lang.is_empty() {
1412 None
1413 } else {
1414 Some(lang.to_string())
1415 }
1416 }
1417 CodeBlockKind::Indented => None,
1418 };
1419 Event::Text("".into())
1420 }
1421 Event::End(TagEnd::CodeBlock) => {
1422 in_code_block = false;
1423 let highlighted = highlighter.highlight(¤t_code, current_lang.as_deref());
1424 let mut html_out = String::new();
1425 html_out.push_str("<pre><code");
1426 if let Some(lang) = ¤t_lang {
1427 let lang = v_htmlescape::escape(lang);
1428 html_out.push_str(&format!(" class=\"language-{lang}\""));
1429 }
1430 html_out.push('>');
1431 html_out.push_str(&highlighted);
1432 html_out.push_str("</code></pre>");
1433 Event::Html(html_out.into())
1434 }
1435 Event::Text(text) => {
1436 if in_code_block {
1437 current_code.push_str(&text);
1438 Event::Text("".into())
1439 } else {
1440 Event::Text(text)
1441 }
1442 }
1443 Event::Html(html) => {
1445 if trusted {
1446 Event::Html(html)
1447 } else {
1448 Event::Text(html) }
1450 }
1451 Event::InlineHtml(html) => {
1452 if trusted {
1453 Event::InlineHtml(html)
1454 } else {
1455 Event::Text(html)
1456 }
1457 }
1458 e => e,
1459 });
1460
1461 let mut html_output = String::new();
1462 html::push_html(&mut html_output, parser);
1463
1464 Ok(Value::record(
1465 nu_protocol::record! {
1466 "__html" => Value::string(html_output, head),
1467 },
1468 head,
1469 )
1470 .into_pipeline_data())
1471 }
1472}
1473
1474#[derive(Clone)]
1477pub struct PrintCommand;
1478
1479impl Default for PrintCommand {
1480 fn default() -> Self {
1481 Self::new()
1482 }
1483}
1484
1485impl PrintCommand {
1486 pub fn new() -> Self {
1487 Self
1488 }
1489}
1490
1491impl Command for PrintCommand {
1492 fn name(&self) -> &str {
1493 "print"
1494 }
1495
1496 fn description(&self) -> &str {
1497 "Print the given values to the http-nu logging system."
1498 }
1499
1500 fn extra_description(&self) -> &str {
1501 r#"This command outputs to http-nu's logging system rather than stdout/stderr.
1502Messages appear in both human-readable and JSONL output modes.
1503
1504`print` may be used inside blocks of code (e.g.: hooks) to display text during execution without interfering with the pipeline."#
1505 }
1506
1507 fn search_terms(&self) -> Vec<&str> {
1508 vec!["display"]
1509 }
1510
1511 fn signature(&self) -> Signature {
1512 Signature::build("print")
1513 .input_output_types(vec![
1514 (Type::Nothing, Type::Nothing),
1515 (Type::Any, Type::Nothing),
1516 ])
1517 .allow_variants_without_examples(true)
1518 .rest("rest", SyntaxShape::Any, "the values to print")
1519 .switch(
1520 "no-newline",
1521 "print without inserting a newline for the line ending",
1522 Some('n'),
1523 )
1524 .switch("stderr", "print to stderr instead of stdout", Some('e'))
1525 .switch(
1526 "raw",
1527 "print without formatting (including binary data)",
1528 Some('r'),
1529 )
1530 .category(Category::Strings)
1531 }
1532
1533 fn run(
1534 &self,
1535 engine_state: &EngineState,
1536 stack: &mut Stack,
1537 call: &Call,
1538 input: PipelineData,
1539 ) -> Result<PipelineData, ShellError> {
1540 let args: Vec<Value> = call.rest(engine_state, stack, 0)?;
1541 let no_newline = call.has_flag(engine_state, stack, "no-newline")?;
1542 let config = stack.get_config(engine_state);
1543
1544 let format_value = |val: &Value| -> String { val.to_expanded_string(" ", &config) };
1545
1546 if !args.is_empty() {
1547 let messages: Vec<String> = args.iter().map(format_value).collect();
1548 let message = if no_newline {
1549 messages.join("")
1550 } else {
1551 messages.join("\n")
1552 };
1553 log_print(&message);
1554 } else if !input.is_nothing() {
1555 let message = match input {
1556 PipelineData::Value(val, _) => format_value(&val),
1557 PipelineData::ListStream(stream, _) => {
1558 let vals: Vec<String> = stream.into_iter().map(|v| format_value(&v)).collect();
1559 vals.join("\n")
1560 }
1561 PipelineData::ByteStream(stream, _) => stream.into_string()?,
1562 PipelineData::Empty => String::new(),
1563 };
1564 if !message.is_empty() {
1565 log_print(&message);
1566 }
1567 }
1568
1569 Ok(PipelineData::empty())
1570 }
1571
1572 fn examples(&self) -> Vec<Example<'_>> {
1573 vec![
1574 Example {
1575 description: "Print 'hello world'",
1576 example: r#"print "hello world""#,
1577 result: None,
1578 },
1579 Example {
1580 description: "Print the sum of 2 and 3",
1581 example: r#"print (2 + 3)"#,
1582 result: None,
1583 },
1584 ]
1585 }
1586}