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