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                None,
89                &temp_working_set,
90                &shell_error,
91                None,
92            )));
93        }
94        temp_engine_state.merge_delta(temp_working_set.render())?;
95
96        let mut temp_stack = Stack::new();
97        let eval_result = eval_block_with_early_return::<WithoutDebug>(
98            &temp_engine_state,
99            &mut temp_stack,
100            &temp_block,
101            PipelineData::empty(),
102        )
103        .map_err(|err| {
104            let working_set = nu_protocol::engine::StateWorkingSet::new(&temp_engine_state);
105            Error::from(nu_protocol::format_cli_error(
106                None,
107                &working_set,
108                &err,
109                None,
110            ))
111        })?;
112        let val = eval_result.body.into_value(Span::unknown())?;
113
114        let modules = match val.get_data_by_key("modules") {
115            Some(mod_val) => {
116                let record = mod_val
117                    .as_record()
118                    .map_err(|_| -> Error { "modules field must be a record".into() })?;
119                record
120                    .iter()
121                    .map(|(name, content_val)| {
122                        let content_str = content_val
123                            .as_str()
124                            .map_err(|_| -> Error {
125                                format!(
126                                    "Module '{name}' content must be a string, got {typ:?}",
127                                    name = name,
128                                    typ = content_val.get_type()
129                                )
130                                .into()
131                            })?
132                            .to_string();
133                        Ok((name.to_string(), content_str))
134                    })
135                    .collect::<Result<HashMap<String, String>, Error>>()?
136            }
137            None => HashMap::new(),
138        };
139        temp_engine_state.merge_env(&mut temp_stack)?; // Merge env from first pass to temp_engine_state
140        (val, modules)
141    };
142
143    // --- Load modules into the main engine ---
144    if !modules_to_load.is_empty() {
145        for (name, content) in &modules_to_load {
146            tracing::debug!("Loading module '{}' into main engine", name);
147            engine
148                .add_module(name, content)
149                .map_err(|e| -> Error { format!("Failed to load module '{name}': {e}").into() })?;
150        }
151    }
152
153    // --- Pass 2: Parse and evaluate with modules loaded to get final closure and config ---
154    // Now, the main `engine` has the modules loaded.
155    // We re-parse and re-evaluate the script's main block using this module-aware engine.
156    // This ensures the 'run' closure correctly captures items from the loaded modules.
157    let mut working_set = StateWorkingSet::new(&engine.state);
158    let block = parse(&mut working_set, None, script.as_bytes(), false);
159
160    if let Some(err) = working_set.parse_errors.first() {
161        let shell_error = ShellError::GenericError {
162            error: "Parse error in script (final pass)".into(),
163            msg: format!("{err:?}"),
164            span: Some(err.span()),
165            help: None,
166            inner: vec![],
167        };
168        return Err(Error::from(nu_protocol::format_cli_error(
169            None,
170            &working_set,
171            &shell_error,
172            None,
173        )));
174    }
175
176    // Handle compile errors
177    if let Some(err) = working_set.compile_errors.first() {
178        let shell_error = ShellError::GenericError {
179            error: "Compile error in script".into(),
180            msg: format!("{err:?}"),
181            span: None,
182            help: None,
183            inner: vec![],
184        };
185        return Err(Error::from(nu_protocol::format_cli_error(
186            None,
187            &working_set,
188            &shell_error,
189            None,
190        )));
191    }
192
193    engine.state.merge_delta(working_set.render())?;
194
195    let mut stack = Stack::new();
196    let final_eval_result = eval_block_with_early_return::<WithoutDebug>(
197        &engine.state,
198        &mut stack,
199        &block,
200        PipelineData::empty(),
201    )
202    .map_err(|err| {
203        let working_set = nu_protocol::engine::StateWorkingSet::new(&engine.state); // Use main engine for error formatting
204        Error::from(nu_protocol::format_cli_error(
205            None,
206            &working_set,
207            &err,
208            None,
209        ))
210    })?;
211
212    let final_config_value = final_eval_result.body.into_value(Span::unknown())?;
213
214    let run_val = final_config_value
215        .get_data_by_key("run")
216        .ok_or_else(|| -> Error { "Script must define a 'run' closure.".into() })?;
217    let run_closure = run_val
218        .as_closure()
219        .map_err(|e| -> Error { format!("'run' field must be a closure: {e}").into() })?;
220
221    engine.state.merge_env(&mut stack)?; // Merge env from final pass to main engine
222
223    Ok(NuScriptConfig {
224        run_closure: run_closure.clone(),
225        full_config_value: final_config_value,
226    })
227}
228
229/// For backward compatibility
230/// @deprecated Use parse_config with NuScriptConfig instead
231pub fn parse_config_legacy(
232    engine: &mut crate::nu::Engine,
233    script: &str,
234) -> Result<CommonOptions, Error> {
235    // Use the new parsing function
236    let script_config = parse_config(engine, script)?;
237
238    // Extract values as needed for CommonOptions
239    let modules = match script_config.full_config_value.get_data_by_key("modules") {
240        Some(val) => {
241            let record = val
242                .as_record()
243                .map_err(|_| -> Error { "modules must be a record".into() })?;
244            record
245                .iter()
246                .map(|(name, content)| {
247                    let content = content
248                        .as_str()
249                        .map_err(|_| -> Error {
250                            format!("module '{name}' content must be a string").into()
251                        })?
252                        .to_string();
253                    Ok((name.to_string(), content))
254                })
255                .collect::<Result<HashMap<_, _>, Error>>()?
256        }
257        None => HashMap::new(),
258    };
259
260    // Parse return_options (optional)
261    let return_options = if let Some(return_config) = script_config
262        .full_config_value
263        .get_data_by_key("return_options")
264    {
265        let record = return_config
266            .as_record()
267            .map_err(|_| -> Error { "return_options must be a record".into() })?;
268
269        let suffix = record
270            .get("suffix")
271            .map(|v| {
272                v.as_str()
273                    .map_err(|_| -> Error { "suffix must be a string".into() })
274                    .map(|s| s.to_string())
275            })
276            .transpose()?;
277
278        let ttl = record
279            .get("ttl")
280            .map(|v| serde_json::from_str(&value_to_json(v).to_string()))
281            .transpose()
282            .map_err(|e| -> Error { format!("invalid TTL: {e}").into() })?;
283
284        Some(ReturnOptions { suffix, ttl })
285    } else {
286        None
287    };
288
289    Ok(CommonOptions {
290        run: script_config.run_closure,
291        modules,
292        return_options,
293    })
294}