diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ dbus call - Call a method and get its response dbus get - Get a D-Bus property dbus get-all - Get all D-Bus property for the given objects dbus introspect - Introspect a D-Bus object + dbus list - List all available connection names on the bus dbus set - Get all D-Bus property for the given objects Flags: @@ -196,3 +197,44 @@ Examples: Set the volume of Spotify to 50% > dbus set --dest=org.mpris.MediaPlayer2.spotify /org/mpris/MediaPlayer2 org.mpris.MediaPlayer2.Player Volume 0.5 +## `dbus list` + + List all available connection names on the bus + + These can be used as arguments for --dest on any of the other commands. + + Search terms: dbus + + Usage: + > dbus list {flags} (pattern) + + Flags: + -h, --help - Display the help message for this command + --session - Send to the session message bus (default) + --system - Send to the system message bus + --started - Send to the bus that started this process, if applicable + --bus - Send to the bus server at the given address + --peer - Send to a non-bus D-Bus server at the given address. Will not call the Hello method on initialization. + --timeout - How long to wait for a response + + Parameters: + pattern : An optional glob-like pattern to filter the result by (optional) + + Examples: + List all names available on the bus + > dbus list + + List top-level freedesktop.org names on the bus (e.g. matches `org.freedesktop.PowerManagement`, but not `org.freedesktop.Management.Inhibit`) + > dbus list org.freedesktop.* + ╭───┬───────────────────────────────╮ + │ 0 │ org.freedesktop.DBus │ + │ 1 │ org.freedesktop.Flatpak │ + │ 2 │ org.freedesktop.Notifications │ + ╰───┴───────────────────────────────╯ + + List all MPRIS2 media players on the bus + > dbus list org.mpris.MediaPlayer2.** + ╭───┬────────────────────────────────────────────────╮ + │ 0 │ org.mpris.MediaPlayer2.spotify │ + │ 1 │ org.mpris.MediaPlayer2.kdeconnect.mpris_000001 │ + ╰───┴────────────────────────────────────────────────╯ diff --git a/src/client.rs b/src/client.rs --- a/src/client.rs +++ b/src/client.rs @@ -2,7 +2,7 @@ use dbus::{channel::{Channel, BusType}, Message, arg::messageitem::MessageItem}; use nu_plugin::LabeledError; use nu_protocol::{Spanned, Value}; -use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item, introspection::Node}; +use crate::{config::{DbusClientConfig, DbusBusChoice}, dbus_type::DbusType, convert::to_message_item, introspection::Node, pattern::Pattern}; /// Executes D-Bus actions on a connection, handling nushell types pub struct DbusClient { @@ -317,5 +317,31 @@ self.conn.send_with_reply_and_block(message, self.config.timeout.item) .map_err(|err| self.error(err, context))?; Ok(()) + } + + pub fn list(&self, pattern: Option<&Pattern>) + -> Result, LabeledError> + { + let context = "while listing D-Bus connection names"; + + let message = Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "ListNames" + ).map_err(|err| self.error(err, context))?; + + self.conn.send_with_reply_and_block(message, self.config.timeout.item) + .map_err(|err| self.error(err, context)) + .and_then(|reply| reply.read1().map_err(|err| self.error(err, context))) + .map(|names: Vec| { + // Filter the names by the pattern + if let Some(pattern) = pattern { + eprintln!("pattern: {:?}", pattern); + names.into_iter().filter(|name| pattern.is_match(name)).collect() + } else { + names + } + }) } } diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -6,9 +6,12 @@ mod client; mod convert; mod dbus_type; mod introspection; +mod pattern; use config::*; use client::*; + +use crate::pattern::Pattern; fn main() { serve_plugin(&mut NuPluginDbus, MsgPackSerializer) @@ -29,10 +32,10 @@ .usage("Commands for interacting with D-Bus"), PluginSignature::build("dbus introspect") .is_dbus_command() .accepts_dbus_client_options() + .accepts_timeout() .usage("Introspect a D-Bus object") .extra_usage("Returns information about available nodes, interfaces, methods, \ signals, and properties on the given object path") - .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .required_named("dest", SyntaxShape::String, "The name of the connection that owns the object", None) @@ -63,9 +66,9 @@ ]), PluginSignature::build("dbus call") .is_dbus_command() .accepts_dbus_client_options() + .accepts_timeout() .usage("Call a method and get its response") .extra_usage("Returns an array if the method call returns more than one value.") - .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .named("signature", SyntaxShape::String, "Signature of the arguments to send, in D-Bus format.\n \ If not provided, they will be determined from introspection.\n \ @@ -105,8 +108,8 @@ ]), PluginSignature::build("dbus get") .is_dbus_command() .accepts_dbus_client_options() + .accepts_timeout() .usage("Get a D-Bus property") - .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .required_named("dest", SyntaxShape::String, "The name of the connection to read the property from", None) @@ -135,8 +138,8 @@ ]), PluginSignature::build("dbus get-all") .is_dbus_command() .accepts_dbus_client_options() + .accepts_timeout() .usage("Get all D-Bus property for the given objects") - .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .required_named("dest", SyntaxShape::String, "The name of the connection to read the property from", None) @@ -160,8 +163,8 @@ ]), PluginSignature::build("dbus set") .is_dbus_command() .accepts_dbus_client_options() + .accepts_timeout() .usage("Get all D-Bus property for the given objects") - .named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) .named("signature", SyntaxShape::String, "Signature of the value to set, in D-Bus format.\n \ If not provided, it will be determined from introspection.\n \ @@ -187,6 +190,40 @@ description: "Set the volume of Spotify to 50%".into(), result: None, }, ]), + PluginSignature::build("dbus list") + .is_dbus_command() + .accepts_dbus_client_options() + .accepts_timeout() + .usage("List all available connection names on the bus") + .extra_usage("These can be used as arguments for --dest on any of the other commands.") + .optional("pattern", SyntaxShape::String, + "An optional glob-like pattern to filter the result by") + .plugin_examples(vec![ + PluginExample { + example: "dbus list".into(), + description: "List all names available on the bus".into(), + result: None, + }, + PluginExample { + example: "dbus list org.freedesktop.*".into(), + description: "List top-level freedesktop.org names on the bus \ + (e.g. matches `org.freedesktop.PowerManagement`, \ + but not `org.freedesktop.Management.Inhibit`)".into(), + result: Some(Value::list(vec![ + str!("org.freedesktop.DBus"), + str!("org.freedesktop.Flatpak"), + str!("org.freedesktop.Notifications"), + ], Span::unknown())), + }, + PluginExample { + example: "dbus list org.mpris.MediaPlayer2.**".into(), + description: "List all MPRIS2 media players on the bus".into(), + result: Some(Value::list(vec![ + str!("org.mpris.MediaPlayer2.spotify"), + str!("org.mpris.MediaPlayer2.kdeconnect.mpris_000001"), + ], Span::unknown())), + }, + ]) ] } @@ -208,6 +245,7 @@ "dbus call" => self.call(call), "dbus get" => self.get(call), "dbus get-all" => self.get_all(call), "dbus set" => self.set(call), + "dbus list" => self.list(call), _ => Err(LabeledError { label: "Plugin invoked with unknown command name".into(), @@ -222,6 +260,7 @@ /// For conveniently adding the base options to a dbus command trait DbusSignatureUtilExt { fn is_dbus_command(self) -> Self; fn accepts_dbus_client_options(self) -> Self; + fn accepts_timeout(self) -> Self; } impl DbusSignatureUtilExt for PluginSignature { @@ -239,6 +278,10 @@ .named("peer", SyntaxShape::String, "Send to a non-bus D-Bus server at the given address. \ Will not call the Hello method on initialization.", None) + } + + fn accepts_timeout(self) -> Self { + self.named("timeout", SyntaxShape::Duration, "How long to wait for a response", None) } } @@ -309,5 +352,15 @@ call.get_flag("signature")?.as_ref(), &call.req(3)?, )?; Ok(Value::nothing(call.head)) + } + + fn list(&self, call: &EvaluatedCall) -> Result { + let config = DbusClientConfig::try_from(call)?; + let dbus = DbusClient::new(config)?; + let pattern = call.opt::(0)?.map(|pat| Pattern::new(&pat, Some('.'))); + let result = dbus.list(pattern.as_ref())?; + Ok(Value::list( + result.into_iter().map(|s| Value::string(s, call.head)).collect(), + call.head)) } } diff --git a/src/pattern.rs b/src/pattern.rs new file mode 100644 --- /dev/null +++ b/src/pattern.rs @@ -0,0 +1,387 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pattern { + separator: Option, + tokens: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PatternToken { + Exact(String), + OneWildcard, + ManyWildcard, + AnyChar, +} + +impl Pattern { + pub fn new(pattern: &str, separator: Option) -> Pattern { + let mut tokens = vec![]; + for ch in pattern.chars() { + match ch { + '*' => + if tokens.last() == Some(&PatternToken::OneWildcard) { + *tokens.last_mut().unwrap() = PatternToken::ManyWildcard; + } else { + tokens.push(PatternToken::OneWildcard); + }, + '?' => + tokens.push(PatternToken::AnyChar), + _ => + match tokens.last_mut() { + Some(PatternToken::Exact(ref mut s)) => s.push(ch), + _ => tokens.push(PatternToken::Exact(ch.into())), + }, + } + } + Pattern { separator, tokens } + } + + pub fn is_match(&self, string: &str) -> bool { + #[derive(Debug)] + enum MatchState { + Precise, + ScanAhead { stop_at_separator: bool }, + } + let mut state = MatchState::Precise; + let mut tokens = &self.tokens[..]; + let mut search_str = string; + while !tokens.is_empty() { + match tokens.first().unwrap() { + PatternToken::Exact(s) => { + if search_str.starts_with(s) { + // Exact match passed. Consume the token and string and continue + tokens = &tokens[1..]; + search_str = &search_str[s.len()..]; + state = MatchState::Precise; + } else { + match state { + MatchState::Precise => { + // Can't possibly match + return false; + }, + MatchState::ScanAhead { stop_at_separator } => { + if search_str.is_empty() { + // End of input, can't match + return false; + } + if stop_at_separator && + self.separator.is_some_and(|sep| search_str.starts_with(sep)) { + // Found the separator. Consume a char and revert to precise + // mode + search_str = &search_str[1..]; + state = MatchState::Precise; + } else { + // Skip the non-matching char and continue + search_str = &search_str[1..]; + } + } + } + } + }, + PatternToken::OneWildcard => { + // Set the mode to ScanAhead, stopping at separator + state = MatchState::ScanAhead { stop_at_separator: true }; + tokens = &tokens[1..]; + }, + PatternToken::ManyWildcard => { + // Set the mode to ScanAhead, ignoring separator + state = MatchState::ScanAhead { stop_at_separator: false }; + tokens = &tokens[1..]; + }, + PatternToken::AnyChar => { + if !search_str.is_empty() { + // Take a char from the search str and continue + search_str = &search_str[1..]; + tokens = &tokens[1..]; + } else { + // End of input + return false; + } + }, + } + } + #[cfg(test)] { + println!("end, state={:?}, search_str={:?}, tokens={:?}", state, search_str, tokens); + } + if !search_str.is_empty() { + // If the search str is not empty at the end + match state { + // We didn't end with a wildcard, so this is a fail + MatchState::Precise => false, + // This could be a match as long as the separator isn't contained in the remainder + MatchState::ScanAhead { stop_at_separator: true } => + if let Some(separator) = self.separator { + !search_str.contains(separator) + } else { + // No separator specified, so this is a success + true + }, + // Always a success, no matter what remains + MatchState::ScanAhead { stop_at_separator: false } => true, + } + } else { + // The match has succeeded - there is nothing more to match + true + } + } +} + +#[test] +fn test_pattern_new() { + assert_eq!( + Pattern::new("", Some('/')), + Pattern { separator: Some('/'), tokens: vec![] } + ); + assert_eq!( + Pattern::new("", None), + Pattern { separator: None, tokens: vec![] } + ); + assert_eq!( + Pattern::new("org.freedesktop.DBus", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.freedesktop.DBus".into()), + ] } + ); + assert_eq!( + Pattern::new("*", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::OneWildcard, + ] } + ); + assert_eq!( + Pattern::new("**", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::ManyWildcard, + ] } + ); + assert_eq!( + Pattern::new("?", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::AnyChar, + ] } + ); + assert_eq!( + Pattern::new("org.freedesktop.*", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.freedesktop.".into()), + PatternToken::OneWildcard, + ] } + ); + assert_eq!( + Pattern::new("org.freedesktop.**", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.freedesktop.".into()), + PatternToken::ManyWildcard, + ] } + ); + assert_eq!( + Pattern::new("org.*.DBus", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.".into()), + PatternToken::OneWildcard, + PatternToken::Exact(".DBus".into()), + ] } + ); + assert_eq!( + Pattern::new("org.**.DBus", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.".into()), + PatternToken::ManyWildcard, + PatternToken::Exact(".DBus".into()), + ] } + ); + assert_eq!( + Pattern::new("org.**.?Bus", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.".into()), + PatternToken::ManyWildcard, + PatternToken::Exact(".".into()), + PatternToken::AnyChar, + PatternToken::Exact("Bus".into()), + ] } + ); + assert_eq!( + Pattern::new("org.free*top", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.free".into()), + PatternToken::OneWildcard, + PatternToken::Exact("top".into()), + ] } + ); + assert_eq!( + Pattern::new("org.free**top", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.free".into()), + PatternToken::ManyWildcard, + PatternToken::Exact("top".into()), + ] } + ); + assert_eq!( + Pattern::new("org.**top", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.".into()), + PatternToken::ManyWildcard, + PatternToken::Exact("top".into()), + ] } + ); + assert_eq!( + Pattern::new("**top", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::ManyWildcard, + PatternToken::Exact("top".into()), + ] } + ); + assert_eq!( + Pattern::new("org.free**", Some('.')), + Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("org.free".into()), + PatternToken::ManyWildcard, + ] } + ); +} + +#[test] +fn test_pattern_is_match_empty() { + let pat = Pattern { separator: Some('.'), tokens: vec![] }; + assert!(pat.is_match("")); + assert!(!pat.is_match("anystring")); + assert!(!pat.is_match("anystring.anyotherstring")); +} + +#[test] +fn test_pattern_is_match_exact() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("specific".into()), + ] }; + assert!(pat.is_match("specific")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica")); +} + +#[test] +fn test_pattern_is_match_one_wildcard() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("foo.".into()), + PatternToken::OneWildcard, + PatternToken::Exact(".baz".into()), + ] }; + assert!(pat.is_match("foo.bar.baz")); + assert!(pat.is_match("foo.grok.baz")); + assert!(pat.is_match("foo..baz")); + assert!(!pat.is_match("foo.ono.notmatch.baz")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); + assert!(!pat.is_match("foo.specifica")); +} + +#[test] +fn test_pattern_is_match_one_wildcard_at_end() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("foo.".into()), + PatternToken::OneWildcard, + ] }; + assert!(pat.is_match("foo.bar")); + assert!(pat.is_match("foo.grok")); + assert!(pat.is_match("foo.")); + assert!(!pat.is_match("foo.ono.notmatch.baz")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); +} + +#[test] +fn test_pattern_is_match_one_wildcard_at_start() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::OneWildcard, + PatternToken::Exact(".bar".into()), + ] }; + assert!(pat.is_match("foo.bar")); + assert!(pat.is_match("grok.bar")); + assert!(pat.is_match(".bar")); + assert!(!pat.is_match("foo.ono.notmatch.bar")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); +} + +#[test] +fn test_pattern_is_match_one_wildcard_no_separator() { + let pat = Pattern { separator: None, tokens: vec![ + PatternToken::Exact("foo.".into()), + PatternToken::OneWildcard, + PatternToken::Exact(".baz".into()), + ] }; + assert!(pat.is_match("foo.bar.baz")); + assert!(pat.is_match("foo.grok.baz")); + assert!(pat.is_match("foo..baz")); + assert!(pat.is_match("foo.this.shouldmatch.baz")); + assert!(pat.is_match("foo.this.should.match.baz")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); + assert!(!pat.is_match("foo.specifica")); +} + +#[test] +fn test_pattern_is_match_many_wildcard() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("foo.".into()), + PatternToken::ManyWildcard, + PatternToken::Exact(".baz".into()), + ] }; + assert!(pat.is_match("foo.bar.baz")); + assert!(pat.is_match("foo.grok.baz")); + assert!(pat.is_match("foo..baz")); + assert!(pat.is_match("foo.this.shouldmatch.baz")); + assert!(pat.is_match("foo.this.should.match.baz")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); + assert!(!pat.is_match("foo.specifica")); +} + +#[test] +fn test_pattern_is_match_many_wildcard_at_end() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("foo.".into()), + PatternToken::ManyWildcard, + ] }; + assert!(pat.is_match("foo.bar")); + assert!(pat.is_match("foo.grok")); + assert!(pat.is_match("foo.")); + assert!(pat.is_match("foo.this.should.match")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); +} + +#[test] +fn test_pattern_is_match_many_wildcard_at_start() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::ManyWildcard, + PatternToken::Exact(".bar".into()), + ] }; + assert!(pat.is_match("foo.bar")); + assert!(pat.is_match("grok.bar")); + assert!(pat.is_match("should.match.bar")); + assert!(pat.is_match(".bar")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("specifi")); + assert!(!pat.is_match("specifica.baz")); +} + +#[test] +fn test_pattern_is_match_any_char() { + let pat = Pattern { separator: Some('.'), tokens: vec![ + PatternToken::Exact("fo".into()), + PatternToken::AnyChar, + PatternToken::Exact(".baz".into()), + ] }; + assert!(pat.is_match("foo.baz")); + assert!(pat.is_match("foe.baz")); + assert!(pat.is_match("foi.baz")); + assert!(!pat.is_match("")); + assert!(!pat.is_match("fooo.baz")); + assert!(!pat.is_match("fo.baz")); +}