xs/nu/
config.rs

1use std::collections::HashMap;
2
3use nu_engine::eval_block_with_early_return;
4use nu_parser::parse;
5use nu_protocol::debugger::WithoutDebug;
6use nu_protocol::engine::{Closure, Stack, StateWorkingSet};
7use nu_protocol::{PipelineData, ShellError, Span, Value};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::Error;
12use crate::nu::util::value_to_json;
13use crate::store::TTL;
14
15/// Configuration parsed from a Nushell script.
16pub struct NuScriptConfig {
17    /// The main executable closure defined by the `run:` field in the script.
18    pub run_closure: Closure,
19    /// The full Nushell Value (typically a record) that the script evaluated to.
20    /// Callers can use this to extract other script-defined options.
21    pub full_config_value: Value,
22}
23
24impl NuScriptConfig {
25    /// Deserializes specific options from the `full_config_value`.
26    ///
27    /// The type `T` must implement `serde::Deserialize`.
28    /// This is a convenience for callers to extract custom fields from the script's
29    /// configuration record after `run` and `modules` have been processed.
30    pub fn deserialize_options<T>(&self) -> Result<T, Error>
31    where
32        T: for<'de> serde::Deserialize<'de>,
33    {
34        let json_value = value_to_json(&self.full_config_value);
35        serde_json::from_value(json_value)
36            .map_err(|e| format!("Failed to deserialize script options: {e}").into())
37    }
38}
39
40/// Options for customizing the output frames
41#[derive(Clone, Debug, Serialize, Deserialize, Default)]
42pub struct ReturnOptions {
43    /// Custom suffix for output frames (default is ".out" for handlers, ".recv" for commands)
44    pub suffix: Option<String>,
45    /// Optional time-to-live for the output frames
46    pub ttl: Option<TTL>,
47}
48
49/// For backward compatibility
50/// @deprecated Use NuScriptConfig instead
51pub struct CommonOptions {
52    /// The run closure that will be executed
53    pub run: Closure,
54    /// Map of module names to module content
55    pub modules: HashMap<String, String>,
56    /// Optional customization for return frame format
57    pub return_options: Option<ReturnOptions>,
58}
59
60/// Parse a script into a NuScriptConfig struct.
61///
62/// This function parses a Nushell script, loads its defined modules, and extracts the `run` closure
63/// and the full configuration value.
64///
65/// The process involves:
66/// 1. A first pass evaluation of the script to extract `modules` definitions.
67/// 2. Loading these modules into the provided `engine`.
68/// 3. A second pass evaluation (of the main script block) to obtain the `run` closure
69///    (which can now reference the loaded modules) and the script's full output Value.
70pub fn parse_config(engine: &mut crate::nu::Engine, script: &str) -> Result<NuScriptConfig, Error> {
71    // --- Pass 1: Extract modules and initial config value ---
72    // We need to evaluate the script once to see what modules it *wants* to define.
73    let (_initial_config_value, modules_to_load) = {
74        let mut temp_engine_state = engine.state.clone(); // Use a temporary state for the first pass
75        let mut temp_working_set = StateWorkingSet::new(&temp_engine_state);
76        let temp_block = parse(&mut temp_working_set, None, script.as_bytes(), false);
77
78        // Handle parse errors from first pass
79        if let Some(err) = temp_working_set.parse_errors.first() {
80            let shell_error = ShellError::GenericError {
81                error: "Parse error in script (initial pass)".into(),
82                msg: format!("{err:?}"),
83                span: Some(err.span()),
84                help: None,
85                inner: vec![],
86            };
87            return Err(Error::from(nu_protocol::format_cli_error(
88                &temp_working_set,
89                &shell_error,
90                None,
91            )));
92        }
93        temp_engine_state.merge_delta(temp_working_set.render())?;
94
95        let mut temp_stack = Stack::new();
96        let eval_result = eval_block_with_early_return::<WithoutDebug>(
97            &temp_engine_state,
98            &mut temp_stack,
99            &temp_block,
100            PipelineData::empty(),
101        )
102        .map_err(|err| {
103            let working_set = nu_protocol::engine::StateWorkingSet::new(&temp_engine_state);
104            Error::from(nu_protocol::format_cli_error(&working_set, &err, None))
105        })?;
106        let val = eval_result.body.into_value(Span::unknown())?;
107
108        let modules = match val.get_data_by_key("modules") {
109            Some(mod_val) => {
110                let record = mod_val
111                    .as_record()
112                    .map_err(|_| -> Error { "modules field must be a record".into() })?;
113                record
114                    .iter()
115                    .map(|(name, content_val)| {
116                        let content_str = content_val
117                            .as_str()
118                            .map_err(|_| -> Error {
119                                format!(
120                                    "Module '{name}' content must be a string, got {typ:?}",
121                                    name = name,
122                                    typ = content_val.get_type()
123                                )
124                                .into()
125                            })?
126                            .to_string();
127                        Ok((name.to_string(), content_str))
128                    })
129                    .collect::<Result<HashMap<String, String>, Error>>()?
130            }
131            None => HashMap::new(),
132        };
133        temp_engine_state.merge_env(&mut temp_stack)?; // Merge env from first pass to temp_engine_state
134        (val, modules)
135    };
136
137    // --- Load modules into the main engine ---
138    if !modules_to_load.is_empty() {
139        for (name, content) in &modules_to_load {
140            tracing::debug!("Loading module '{}' into main engine", name);
141            engine
142                .add_module(name, content)
143                .map_err(|e| -> Error { format!("Failed to load module '{name}': {e}").into() })?;
144        }
145    }
146
147    // --- Pass 2: Parse and evaluate with modules loaded to get final closure and config ---
148    // Now, the main `engine` has the modules loaded.
149    // We re-parse and re-evaluate the script's main block using this module-aware engine.
150    // This ensures the 'run' closure correctly captures items from the loaded modules.
151    let mut working_set = StateWorkingSet::new(&engine.state);
152    let block = parse(&mut working_set, None, script.as_bytes(), false);
153
154    if let Some(err) = working_set.parse_errors.first() {
155        let shell_error = ShellError::GenericError {
156            error: "Parse error in script (final pass)".into(),
157            msg: format!("{err:?}"),
158            span: Some(err.span()),
159            help: None,
160            inner: vec![],
161        };
162        return Err(Error::from(nu_protocol::format_cli_error(
163            &working_set,
164            &shell_error,
165            None,
166        )));
167    }
168
169    // Handle compile errors
170    if let Some(err) = working_set.compile_errors.first() {
171        let shell_error = ShellError::GenericError {
172            error: "Compile error in script".into(),
173            msg: format!("{err:?}"),
174            span: None,
175            help: None,
176            inner: vec![],
177        };
178        return Err(Error::from(nu_protocol::format_cli_error(
179            &working_set,
180            &shell_error,
181            None,
182        )));
183    }
184
185    engine.state.merge_delta(working_set.render())?;
186
187    let mut stack = Stack::new();
188    let final_eval_result = eval_block_with_early_return::<WithoutDebug>(
189        &engine.state,
190        &mut stack,
191        &block,
192        PipelineData::empty(),
193    )
194    .map_err(|err| {
195        let working_set = nu_protocol::engine::StateWorkingSet::new(&engine.state); // Use main engine for error formatting
196        Error::from(nu_protocol::format_cli_error(&working_set, &err, None))
197    })?;
198
199    let final_config_value = final_eval_result.body.into_value(Span::unknown())?;
200
201    let run_val = final_config_value
202        .get_data_by_key("run")
203        .ok_or_else(|| -> Error { "Script must define a 'run' closure.".into() })?;
204    let run_closure = run_val
205        .as_closure()
206        .map_err(|e| -> Error { format!("'run' field must be a closure: {e}").into() })?;
207
208    engine.state.merge_env(&mut stack)?; // Merge env from final pass to main engine
209
210    Ok(NuScriptConfig {
211        run_closure: run_closure.clone(),
212        full_config_value: final_config_value,
213    })
214}
215
216/// For backward compatibility
217/// @deprecated Use parse_config with NuScriptConfig instead
218pub fn parse_config_legacy(
219    engine: &mut crate::nu::Engine,
220    script: &str,
221) -> Result<CommonOptions, Error> {
222    // Use the new parsing function
223    let script_config = parse_config(engine, script)?;
224
225    // Extract values as needed for CommonOptions
226    let modules = match script_config.full_config_value.get_data_by_key("modules") {
227        Some(val) => {
228            let record = val
229                .as_record()
230                .map_err(|_| -> Error { "modules must be a record".into() })?;
231            record
232                .iter()
233                .map(|(name, content)| {
234                    let content = content
235                        .as_str()
236                        .map_err(|_| -> Error {
237                            format!("module '{name}' content must be a string").into()
238                        })?
239                        .to_string();
240                    Ok((name.to_string(), content))
241                })
242                .collect::<Result<HashMap<_, _>, Error>>()?
243        }
244        None => HashMap::new(),
245    };
246
247    // Parse return_options (optional)
248    let return_options = if let Some(return_config) = script_config
249        .full_config_value
250        .get_data_by_key("return_options")
251    {
252        let record = return_config
253            .as_record()
254            .map_err(|_| -> Error { "return_options must be a record".into() })?;
255
256        let suffix = record
257            .get("suffix")
258            .map(|v| {
259                v.as_str()
260                    .map_err(|_| -> Error { "suffix must be a string".into() })
261                    .map(|s| s.to_string())
262            })
263            .transpose()?;
264
265        let ttl = record
266            .get("ttl")
267            .map(|v| serde_json::from_str(&value_to_json(v).to_string()))
268            .transpose()
269            .map_err(|e| -> Error { format!("invalid TTL: {e}").into() })?;
270
271        Some(ReturnOptions { suffix, ttl })
272    } else {
273        None
274    };
275
276    Ok(CommonOptions {
277        run: script_config.run_closure,
278        modules,
279        return_options,
280    })
281}