Skip to content

Commit 0bbde3a

Browse files
committed
feat: add modules support to commands, refactor config parsing
- Create CommonOptions for shared configuration between handlers and commands - Add modules support to commands (previously only in handlers) - Extract common config parsing into a shared implementation - Update tests to work with new implementation
1 parent 23b3e8c commit 0bbde3a

File tree

7 files changed

+276
-175
lines changed

7 files changed

+276
-175
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
store
33
.aider*
44
.env
5+
solid-ui

docs/src/content/docs/reference/commands.mdx

+31
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ To create a command, append a definition string with the topic
1919

2020
```nushell
2121
r#'{
22+
# Required: Command closure
2223
run: {|frame|
2324
# frame.topic - always <command>.call
2425
# frame.hash - contains input content if present
@@ -27,6 +28,17 @@ r#'{
2728
let n = $frame.meta.args.n
2829
1..($n) | each {$"($in): ($input)"}
2930
}
31+
32+
# Optional: Module definitions
33+
modules: {
34+
"my-util": "export def format [x] { $\"formatted: ($x)\" }"
35+
}
36+
37+
# Optional: Control output frame behavior
38+
return_options: {
39+
suffix: ".output" # Output topic suffix (default: ".recv")
40+
ttl: "head:1" # Keep only most recent frame
41+
}
3042
}'# | .append repeat.define
3143
```
3244

@@ -75,6 +87,24 @@ If a command encounters an error during execution, it will:
7587
Unlike generators, commands do not automatically restart on error - each
7688
invocation is independent.
7789

90+
## Modules
91+
92+
Commands can use custom Nushell modules:
93+
94+
```nushell
95+
r#'{
96+
run: {|frame|
97+
my-math double (frame.meta.args.number)
98+
}
99+
100+
modules: {
101+
"my-math": "export def double [x] { $x * 2 }"
102+
}
103+
}'# | .append calculator.define
104+
```
105+
106+
This allows you to modularize your commands and reuse code across different commands.
107+
78108
## Key Differences
79109

80110
| Feature | Commands | Handlers | Generators |
@@ -84,3 +114,4 @@ invocation is independent.
84114
| Results | Streamed immediately | Batched on completion | Streamed |
85115
| Parallelism | Multiple parallel calls | Sequential processing | Single instance |
86116
| Error Handling | Per-invocation | Unregisters handler | Auto-restarts |
117+
| Modules | Supported | Supported | Not supported |

src/commands/serve.rs

+9-70
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,11 @@ use scru128::Scru128Id;
22
use std::collections::HashMap;
33
use tracing::instrument;
44

5-
use serde::{Deserialize, Serialize};
6-
75
use crate::error::Error;
86
use crate::nu;
97
use crate::nu::commands;
10-
use crate::nu::util::value_to_json;
11-
use crate::store::{FollowOption, Frame, ReadOptions, Store, TTL};
12-
13-
// TODO: DRY with handlers
14-
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
15-
pub struct ReturnOptions {
16-
pub suffix: Option<String>,
17-
pub ttl: Option<TTL>,
18-
}
8+
use crate::nu::ReturnOptions;
9+
use crate::store::{FollowOption, Frame, ReadOptions, Store};
1910

2011
#[derive(Clone)]
2112
struct Command {
@@ -127,14 +118,14 @@ async fn register_command(
127118
)),
128119
])?;
129120

130-
// Parse the command configuration to extract return_options (ignore the run closure here)
131-
let (_closure, return_options) = parse_command_definition(&mut engine, &definition)?;
121+
// Parse the command configuration
122+
let common_options = nu::parse_config(&mut engine, &definition)?;
132123

133124
Ok(Command {
134125
id: frame.id,
135126
engine,
136127
definition,
137-
return_options,
128+
return_options: common_options.return_options,
138129
})
139130
}
140131

@@ -168,10 +159,11 @@ async fn execute_command(command: Command, frame: &Frame, store: &Store) -> Resu
168159
),
169160
)])?;
170161

171-
let (closure, _) = parse_command_definition(&mut engine, &command.definition)?;
162+
// Parse the command configuration to get the up-to-date closure with modules loaded
163+
let common_options = nu::parse_config(&mut engine, &command.definition)?;
172164

173165
// Run command and process pipeline
174-
match run_command(&engine, closure, &frame) {
166+
match run_command(&engine, common_options.run, &frame) {
175167
Ok(pipeline_data) => {
176168
let recv_suffix = command
177169
.return_options
@@ -185,7 +177,7 @@ async fn execute_command(command: Command, frame: &Frame, store: &Store) -> Resu
185177

186178
// Process each value as a .recv event
187179
for value in pipeline_data {
188-
let hash = store.cas_insert_sync(value_to_json(&value).to_string())?;
180+
let hash = store.cas_insert_sync(nu::value_to_json(&value).to_string())?;
189181
let _ = store.append(
190182
Frame::builder(
191183
format!(
@@ -264,56 +256,3 @@ fn run_command(
264256
nu_protocol::PipelineData::empty(),
265257
)
266258
}
267-
268-
fn parse_command_definition(
269-
engine: &mut nu::Engine,
270-
script: &str,
271-
) -> Result<(nu_protocol::engine::Closure, Option<ReturnOptions>), Error> {
272-
let mut working_set = nu_protocol::engine::StateWorkingSet::new(&engine.state);
273-
let block = nu_parser::parse(&mut working_set, None, script.as_bytes(), false);
274-
275-
engine.state.merge_delta(working_set.render())?;
276-
277-
let mut stack = nu_protocol::engine::Stack::new();
278-
let result = nu_engine::eval_block_with_early_return::<nu_protocol::debugger::WithoutDebug>(
279-
&engine.state,
280-
&mut stack,
281-
&block,
282-
nu_protocol::PipelineData::empty(),
283-
)?;
284-
285-
let config = result.into_value(nu_protocol::Span::unknown())?;
286-
287-
// Get the run closure (required)
288-
let run = config
289-
.get_data_by_key("run")
290-
.ok_or("No 'run' field found in command configuration")?
291-
.into_closure()?;
292-
293-
// Optionally parse return_options (using the same approach as in handlers)
294-
let return_options = if let Some(return_config) = config.get_data_by_key("return_options") {
295-
let record = return_config
296-
.as_record()
297-
.map_err(|_| "return must be a record")?;
298-
299-
let suffix = record
300-
.get("suffix")
301-
.map(|v| v.as_str().map_err(|_| "suffix must be a string"))
302-
.transpose()?
303-
.map(String::from);
304-
305-
let ttl = record
306-
.get("ttl")
307-
.map(|v| serde_json::from_str(&value_to_json(v).to_string()))
308-
.transpose()
309-
.map_err(|e| format!("invalid TTL: {}", e))?;
310-
311-
Some(ReturnOptions { suffix, ttl })
312-
} else {
313-
None
314-
};
315-
316-
engine.state.merge_env(&mut stack)?;
317-
318-
Ok((run, return_options))
319-
}

src/handlers/handler.rs

+15-104
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use std::collections::HashMap;
21
use std::str::FromStr;
32
use std::sync::{Arc, Mutex};
43
use std::time::Duration;
@@ -16,14 +15,9 @@ use scru128::Scru128Id;
1615
use crate::error::Error;
1716
use crate::nu;
1817
use crate::nu::commands;
19-
use crate::nu::util::value_to_json;
20-
use crate::store::{FollowOption, Frame, ReadOptions, Store, TTL};
21-
22-
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
23-
pub struct ReturnOptions {
24-
pub suffix: Option<String>,
25-
pub ttl: Option<TTL>,
26-
}
18+
use crate::nu::value_to_json;
19+
use crate::nu::ReturnOptions;
20+
use crate::store::{FollowOption, Frame, ReadOptions, Store};
2721

2822
#[derive(Clone)]
2923
pub struct Handler {
@@ -38,7 +32,6 @@ pub struct Handler {
3832
#[derive(Clone, Debug)]
3933
struct HandlerConfig {
4034
resume_from: ResumeFrom,
41-
modules: HashMap<String, String>,
4235
pulse: Option<u64>,
4336
return_options: Option<ReturnOptions>,
4437
}
@@ -82,22 +75,10 @@ impl Handler {
8275
)),
8376
])?;
8477

85-
let (mut run, mut config) = parse_handler_configuration_script(&mut engine, &expression)?;
86-
87-
// Load modules and reparse if needed
88-
if !config.modules.is_empty() {
89-
for (name, content) in &config.modules {
90-
tracing::debug!("Loading module '{}'", name);
91-
engine
92-
.add_module(name, content)
93-
.map_err(|e| format!("Failed to load module '{}': {}", name, e))?;
94-
}
95-
96-
// we need to re-parse the expression after loading modules, so that the closure has access
97-
// to the additional modules: not the best, but I can't see a better way
98-
(run, config) = parse_handler_configuration_script(&mut engine, &expression)?;
99-
}
78+
// Parse configuration (this will handle module loading internally)
79+
let (run, config) = parse_handler_configuration_script(&mut engine, &expression)?;
10080

81+
// Validate the closure signature
10182
let block = engine.state.get_block(run.block_id);
10283
if block.signature.required_positional.len() != 1 {
10384
return Err(format!(
@@ -423,41 +404,17 @@ use nu_protocol::debugger::WithoutDebug;
423404
use nu_protocol::engine::{Closure, Stack, StateWorkingSet};
424405
use nu_protocol::PipelineData;
425406

426-
use nu_protocol::format_shell_error;
427-
use nu_protocol::ShellError;
428-
429407
fn parse_handler_configuration_script(
430408
engine: &mut nu::Engine,
431409
script: &str,
432410
) -> Result<(Closure, HandlerConfig), Error> {
433-
let mut working_set = StateWorkingSet::new(&engine.state);
411+
// Parse common options first (includes run, modules, return_options)
412+
let common_options = nu::parse_config(engine, script)?;
434413

414+
// Parse handler-specific options
415+
let mut working_set = StateWorkingSet::new(&engine.state);
435416
let block = parse(&mut working_set, None, script.as_bytes(), false);
436417

437-
// Handle parse errors
438-
if let Some(err) = working_set.parse_errors.first() {
439-
let shell_error = ShellError::GenericError {
440-
error: "Parse error".into(),
441-
msg: format!("{:?}", err),
442-
span: Some(err.span()),
443-
help: None,
444-
inner: vec![],
445-
};
446-
return Err(Error::from(format_shell_error(&working_set, &shell_error)));
447-
}
448-
449-
// Handle compile errors
450-
if let Some(err) = working_set.compile_errors.first() {
451-
let shell_error = ShellError::GenericError {
452-
error: "Compile error".into(),
453-
msg: format!("{:?}", err),
454-
span: None,
455-
help: None,
456-
inner: vec![],
457-
};
458-
return Err(Error::from(format_shell_error(&working_set, &shell_error)));
459-
}
460-
461418
engine.state.merge_delta(working_set.render())?;
462419

463420
let mut stack = Stack::new();
@@ -466,19 +423,11 @@ fn parse_handler_configuration_script(
466423
&mut stack,
467424
&block,
468425
PipelineData::empty(),
469-
)
470-
.map_err(|err| {
471-
let working_set = nu_protocol::engine::StateWorkingSet::new(&engine.state);
472-
Error::from(nu_protocol::format_shell_error(&working_set, &err))
473-
})?;
426+
)?;
474427

475428
let config = result.into_value(nu_protocol::Span::unknown())?;
476429

477-
let run = config
478-
.get_data_by_key("run")
479-
.ok_or("No 'run' field found in handler configuration")?
480-
.into_closure()?;
481-
430+
// Parse resume_from (specific to handlers)
482431
let resume_from = match config.get_data_by_key("resume_from") {
483432
Some(val) => {
484433
let resume_str = val.as_str().map_err(|_| "resume_from must be a string")?;
@@ -494,59 +443,21 @@ fn parse_handler_configuration_script(
494443
None => ResumeFrom::default(),
495444
};
496445

497-
let modules = match config.get_data_by_key("modules") {
498-
Some(val) => {
499-
let record = val.as_record().map_err(|_| "modules must be a record")?;
500-
record
501-
.iter()
502-
.map(|(name, content)| {
503-
let content = content
504-
.as_str()
505-
.map_err(|_| format!("module '{}' content must be a string", name))?;
506-
Ok((name.to_string(), content.to_string()))
507-
})
508-
.collect::<Result<HashMap<_, _>, Error>>()?
509-
}
510-
None => HashMap::new(),
511-
};
512-
446+
// Parse pulse (specific to handlers)
513447
let pulse = config
514448
.get_data_by_key("pulse")
515449
.map(|v| v.as_int().map_err(|_| "pulse must be an integer"))
516450
.transpose()?
517451
.map(|n| n as u64);
518452

519-
let return_options = if let Some(return_config) = config.get_data_by_key("return_options") {
520-
let record = return_config
521-
.as_record()
522-
.map_err(|_| "return must be a record")?;
523-
524-
let suffix = record
525-
.get("suffix")
526-
.map(|v| v.as_str().map_err(|_| "suffix must be a string"))
527-
.transpose()?
528-
.map(String::from);
529-
530-
let ttl = record
531-
.get("ttl")
532-
.map(|v| serde_json::from_str(&value_to_json(v).to_string()))
533-
.transpose()
534-
.map_err(|e| format!("invalid TTL: {}", e))?;
535-
536-
Some(ReturnOptions { suffix, ttl })
537-
} else {
538-
None
539-
};
540-
541453
engine.state.merge_env(&mut stack)?;
542454

543455
Ok((
544-
run,
456+
common_options.run,
545457
HandlerConfig {
546458
resume_from,
547-
modules,
548459
pulse,
549-
return_options,
460+
return_options: common_options.return_options,
550461
},
551462
))
552463
}

src/handlers/tests.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ async fn test_register_parse_error() {
186186
recver.recv().await.unwrap(), {
187187
topic: "invalid.unregistered",
188188
handler: frame_handler,
189-
error: "MissingPositional",
189+
error: "Parse error", // Expecting parse error details
190190
});
191191

192192
assert_no_more_frames(&mut recver).await;

0 commit comments

Comments
 (0)