Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: implement HELP #129

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions doc/command-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,23 @@ Handlers are identified by the `command_handler` attribute macro.

## The command_handler macro

The `command_handler` attribute macro has two possible forms, allowing for
multiple command dispatchers to exist.
The `command_handler` attribute macro can take several arguments:

The single-argument form - e.g. `#[command_handler("CAP")]` defines a handler
for the 'default' global dispatcher, which is used to look up client protocol
commands.
```rs
#[command_handler("PRIMARY", "ALIAS", "ALIAS2", in("PARENT"), restricted)]
```

The two-argument form - e.g. `#[command_handler("CERT", in("NS"))]` puts the
handler into a named secondary dispatcher, in this case `"NS"`. This form is
The first argument defines the primary name of the command. Any further strings
given will be used as aliases for the command.

If an argument is given of the form `in("PARENT")`, the command handler will be
put into a named secondary dispatcher, in this case `"PARENT"`. This form is
used to define handlers for services commands, and may have other uses in the
future.
future. If this argument is not given, the handler is added to the 'default'
global dispatcher, which is used to look up client protocol commands.

If the `restricted` keyword is added, the command will be marked as for operators
and will not be shown in `HELP` output to users.

## Async handlers

Expand Down
27 changes: 22 additions & 5 deletions sable_ircd/src/command/dispatcher.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::hash_map;

use super::{plumbing::Command, *};

/// Type alias for a boxed command context
Expand All @@ -7,17 +9,21 @@ pub type BoxCommand<'cmd> = Box<dyn Command + 'cmd>;
/// attribute macro
pub type CommandHandlerWrapper = for<'a> fn(BoxCommand<'a>) -> Option<AsyncHandler<'a>>;

#[derive(Clone)]
/// A command handler registration. Constructed by the `command_handler` attribute macro.
pub struct CommandRegistration {
pub(super) command: &'static str,
pub(super) aliases: &'static [&'static str],
pub(super) dispatcher: Option<&'static str>,
pub(super) handler: CommandHandlerWrapper,
pub(super) restricted: bool,
pub(super) docs: &'static [&'static str],
}

/// A command dispatcher. Collects registered command handlers and allows lookup by
/// command name.
pub struct CommandDispatcher {
handlers: HashMap<String, CommandHandlerWrapper>,
commands: HashMap<String, CommandRegistration>,
}

inventory::collect!(CommandRegistration);
Expand All @@ -42,11 +48,14 @@ impl CommandDispatcher {

for reg in inventory::iter::<CommandRegistration> {
if reg.dispatcher == category_name {
map.insert(reg.command.to_ascii_uppercase(), reg.handler);
map.insert(reg.command.to_ascii_uppercase(), reg.clone());
for alias in reg.aliases {
map.insert(alias.to_ascii_uppercase(), reg.clone());
}
}
}

Self { handlers: map }
Self { commands: map }
}

/// Look up and execute the handler function for to a given command.
Expand All @@ -59,12 +68,20 @@ impl CommandDispatcher {
) -> Option<AsyncHandler<'cmd>> {
let command: BoxCommand<'cmd> = Box::new(command);

match self.handlers.get(&command.command().to_ascii_uppercase()) {
Some(handler) => handler(command),
match self.commands.get(&command.command().to_ascii_uppercase()) {
Some(cmd) => (cmd.handler)(command),
None => {
command.notify_error(CommandError::CommandNotFound(command.command().to_owned()));
None
}
}
}

pub fn get_command(&self, command: &str) -> Option<&CommandRegistration> {
self.commands.get(&command.to_ascii_uppercase())
}

pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> {
self.commands.iter()
}
}
74 changes: 74 additions & 0 deletions sable_ircd/src/command/handlers/help.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use super::*;

use itertools::Itertools;

#[command_handler("HELP", "UHELP")]
/// HELP [<topic>]
///
/// HELP displays information for topic requested.
/// If no topic is requested, it will list available
/// help topics.
fn help_handler(
command: &dyn Command,
response: &dyn CommandResponse,
server: &ClientServer,
source: UserSource,
topic: Option<&str>,
) -> CommandResult {
// TODO: oper help? (and if oper help is on the same command, UHELP like solanum?)
// TODO: non-command help topics
let is_oper = command.command().to_ascii_uppercase() != "UHELP" && source.is_oper();

match topic {
Some(s) => {
let topic = s.to_ascii_uppercase();
let topic = topic
.split_once(' ')
.map_or(topic.clone(), |(t, _)| t.to_string());

if let Some(cmd) = server.get_command(&topic) {
if cmd.docs.len() > 0 {
// TODO
if cmd.restricted && is_oper {
response.numeric(make_numeric!(HelpNotFound, &topic));
return Ok(());
}
let mut lines = cmd.docs.iter();
response.numeric(make_numeric!(
HelpStart,
&topic,
lines.next().unwrap_or(&topic.as_str())
));
for line in lines {
response.numeric(make_numeric!(HelpText, &topic, line));
}
response.numeric(make_numeric!(EndOfHelp, &topic));
return Ok(());
}
}
response.numeric(make_numeric!(HelpNotFound, &topic));
}
None => {
let topic = "*";
response.numeric(make_numeric!(HelpStart, topic, "Available help topics:"));
response.numeric(make_numeric!(HelpText, topic, ""));
for chunk in &server
.iter_commands()
.filter_map(|(k, v)| {
if !v.restricted || is_oper {
Some(k.to_ascii_uppercase())
} else {
None
}
})
.sorted()
.chunks(4)
{
let line = format!("{:16}", chunk.format(" "));
response.numeric(make_numeric!(HelpText, topic, &line));
}
response.numeric(make_numeric!(EndOfHelp, topic));
}
};
Ok(())
}
1 change: 1 addition & 0 deletions sable_ircd/src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
mod ban;
mod cap;
mod chathistory;
mod help;
mod info;
mod invite;
mod join;
Expand Down Expand Up @@ -77,8 +78,8 @@
mod services;

// Dev/test tools
#[cfg(debug)]

Check warning on line 81 in sable_ircd/src/command/mod.rs

View workflow job for this annotation

GitHub Actions / Test (nightly)

unexpected `cfg` condition name: `debug`
mod async_wait;
#[cfg(debug)]

Check warning on line 83 in sable_ircd/src/command/mod.rs

View workflow job for this annotation

GitHub Actions / Test (nightly)

unexpected `cfg` condition name: `debug`

Check warning on line 83 in sable_ircd/src/command/mod.rs

View workflow job for this annotation

GitHub Actions / Test (nightly)

unexpected `cfg` condition name: `debug`
mod sping;
}
6 changes: 6 additions & 0 deletions sable_ircd/src/messages/numeric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ define_messages! {
371(Info) => { (line: &str) => ":{line}" },
374(EndOfInfo) => { () => ":End of /INFO list" },

704(HelpStart) => { (subj: &str, line: &str) => "{subj} :{line}" },
705(HelpText) => { (subj: &str, line: &str) => "{subj} :{line}" },
706(EndOfHelp) => { (subj: &str) => "{subj} :End of /HELP" },


401(NoSuchTarget) => { (unknown: &str) => "{unknown} :No such nick/channel" },
402(NoSuchServer) => { (server_name: &ServerName) => "{server_name} :No such server" },
Expand Down Expand Up @@ -125,6 +129,8 @@ define_messages! {

440(ServicesNotAvailable) => { () => ":Services are not available"},

524(HelpNotFound) => { (subj: &str) => "{subj} :No help available on this topic" },

// https://ircv3.net/specs/extensions/monitor
730(MonOnline) => { (content: &str ) => ":{content}" },
731(MonOffline) => { (content: &str ) => ":{content}" },
Expand Down
12 changes: 11 additions & 1 deletion sable_ircd/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use tokio::{
};

use std::{
collections::VecDeque,
collections::{hash_map, VecDeque},
sync::{Arc, Weak},
time::Duration,
};
Expand Down Expand Up @@ -116,6 +116,16 @@ impl ClientServer {
self.node.name()
}

/// Get a command from the server's dispatcher
pub fn get_command(&self, cmd: &str) -> Option<&CommandRegistration> {
self.command_dispatcher.get_command(cmd)
}

/// Get a command from the server's dispatcher
pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> {
self.command_dispatcher.iter_commands()
}

/// Submit a command action to process in the next loop iteration.
#[tracing::instrument(skip(self))]
pub fn add_action(&self, act: CommandAction) {
Expand Down
71 changes: 61 additions & 10 deletions sable_macros/src/command_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,67 @@ use super::*;

use quote::{quote, quote_spanned};
use syn::{
parenthesized, parse::Parse, parse_macro_input, token::In, Ident, ItemFn, LitStr, Token,
parenthesized, parse::Parse, parse_macro_input, token::In, Attribute, Ident, ItemFn, LitStr,
Meta, MetaNameValue, Token,
};

struct CommandHandlerAttr {
command_name: LitStr,
aliases: Vec<LitStr>,
dispatcher: Option<LitStr>,
restricted: bool,
}

impl Parse for CommandHandlerAttr {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let command_name = input.parse()?;
let dispatcher = if input.parse::<Token![,]>().is_ok() {
let content;
input.parse::<In>()?;
let _paren = parenthesized!(content in input);
Some(content.parse()?)
} else {
None
};
let mut aliases = vec![];
let mut dispatcher = None;
let mut restricted = false;
while input.peek(Token![,]) {
if !input.peek2(LitStr) {
break;
}
let _ = input.parse::<Token![,]>();
aliases.push(input.parse()?);
}
while input.peek(Token![,]) {
let _ = input.parse::<Token![,]>()?;
if input.peek(In) {
let content;
input.parse::<In>()?;
let _paren = parenthesized!(content in input);
dispatcher = Some(content.parse()?);
} else if input.peek(Ident) {
if input.parse::<Ident>()? == "restricted" {
restricted = true;
}
}
}
Ok(Self {
command_name,
aliases,
dispatcher,
restricted,
})
}
}

pub fn command_docs(attrs: &[Attribute]) -> Vec<String> {
attrs
.iter()
.filter(|a| a.path.is_ident("doc"))
.filter_map(|a| match a.parse_meta() {
Ok(Meta::NameValue(MetaNameValue {
lit: syn::Lit::Str(s),
..
})) => Some(s.value()),
_ => None,
})
.map(|s| s.strip_prefix(' ').unwrap_or(&s).trim_end().to_owned())
.collect()
}

pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let input = parse_macro_input!(attr as CommandHandlerAttr);
let item = parse_macro_input!(item as ItemFn);
Expand All @@ -43,11 +78,24 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream {
}
}

let aliases = input.aliases;
for alias in &aliases {
for c in alias.value().chars() {
if !c.is_ascii_uppercase() {
return quote_spanned!(command_name.span()=> compile_error!("Command aliases should be uppercase")).into();
}
}
}

let dispatcher = match input.dispatcher {
Some(name) => quote!( Some( #name ) ),
None => quote!(None),
};

let restricted = input.restricted;

let docs = command_docs(&item.attrs);

let body = if asyncness.is_none() {
quote!(
if let Err(e) = crate::command::plumbing::call_handler(ctx.as_ref(), &super::#name, ctx.args())
Expand Down Expand Up @@ -90,8 +138,11 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream {

inventory::submit!(crate::command::CommandRegistration {
command: #command_name,
aliases: &[ #(#aliases),* ],
dispatcher: #dispatcher,
handler: call_proxy
handler: call_proxy,
restricted: #restricted,
docs: &[ #(#docs),* ],
});
}
).into()
Expand Down
Loading