From 9771c36c7bc2ffe77bd6a181a3f11a90929e7411 Mon Sep 17 00:00:00 2001 From: cyqsimon <28627918+cyqsimon@users.noreply.github.com> Date: Sun, 2 Jan 2022 03:48:34 +0800 Subject: [PATCH] More detailed error message in case of command parse error (#684) * Refactored `command::parse` * Removed unnecessary duplication in error msg * Renamed `NotEnoughArgs` -> `InsufficientArgs` * Inaccurate var name * Ditch wordy error prefix * Use `split_whitespace` instead of regex * Cleanup unused regex import * `insert` cmd fails fast * Refactor: use `and_then` instead of `unwrap` * Updated `Command::to_string` * Added `Command::basename` * Better err msg when running cmd in unsupported view, fully closes #597 * Sort `match` branches by their order in the enum --- src/command.rs | 760 +++++++++++++++++++++++++++++---------------- src/commands.rs | 49 ++- src/main.rs | 19 +- src/spotify.rs | 2 +- src/spotify_url.rs | 21 +- src/ui/listview.rs | 15 +- 6 files changed, 562 insertions(+), 304 deletions(-) diff --git a/src/command.rs b/src/command.rs index 7152e49..28f1819 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,4 +1,5 @@ use crate::queue::RepeatSetting; +use crate::spotify_url::SpotifyUrl; use std::collections::HashMap; use std::fmt; @@ -98,6 +99,24 @@ impl fmt::Display for SeekDirection { } } +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum InsertSource { + #[cfg(feature = "share_clipboard")] + Clipboard, + Input(SpotifyUrl), +} + +impl fmt::Display for InsertSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let repr = match self { + #[cfg(feature = "share_clipboard")] + InsertSource::Clipboard => "".into(), + InsertSource::Input(url) => url.to_string(), + }; + write!(f, "{}", repr) + } +} + #[derive(Clone, Serialize, Deserialize, Debug)] pub enum Command { Quit, @@ -130,7 +149,7 @@ pub enum Command { Help, ReloadConfig, Noop, - Insert(Option), + Insert(InsertSource), NewPlaylist(String), Sort(SortKey, SortDirection), Logout, @@ -141,72 +160,110 @@ pub enum Command { impl fmt::Display for Command { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let repr = match self { - Command::Noop => "noop".to_string(), - Command::Quit => "quit".to_string(), - Command::TogglePlay => "playpause".to_string(), - Command::Stop => "stop".to_string(), - Command::Previous => "previous".to_string(), - Command::Next => "next".to_string(), - Command::Clear => "clear".to_string(), - Command::Queue => "queue".to_string(), - Command::PlayNext => "playnext".to_string(), - Command::Play => "play".to_string(), - Command::UpdateLibrary => "update".to_string(), - Command::Save => "save".to_string(), - Command::SaveQueue => "save queue".to_string(), - Command::Delete => "delete".to_string(), - Command::Focus(tab) => format!("focus {}", tab), - Command::Seek(direction) => format!("seek {}", direction), - Command::VolumeUp(amount) => format!("volup {}", amount), - Command::VolumeDown(amount) => format!("voldown {}", amount), - Command::Repeat(mode) => { - let param = match mode { - Some(mode) => format!("{}", mode), - None => "".to_string(), - }; - format!("repeat {}", param) - } - Command::Shuffle(on) => { - let param = on.map(|x| if x { "on" } else { "off" }); - format!("shuffle {}", param.unwrap_or("")) - } - Command::Share(mode) => format!("share {}", mode), - Command::Back => "back".to_string(), - Command::Open(mode) => format!("open {}", mode), - Command::Goto(mode) => format!("goto {}", mode), - Command::Move(mode, MoveAmount::Extreme) => format!( - "move {}", - match mode { - MoveMode::Up => "top", - MoveMode::Down => "bottom", - MoveMode::Left => "leftmost", - MoveMode::Right => "rightmost", - _ => "", - } - ), - Command::Move(MoveMode::Playing, _) => "move playing".to_string(), - Command::Move(mode, MoveAmount::Integer(amount)) => format!("move {} {}", mode, amount), - Command::Shift(mode, amount) => format!("shift {} {}", mode, amount.unwrap_or(1)), - Command::Search(term) => format!("search {}", term), - Command::Jump(mode) => match mode { - JumpMode::Previous => "jumpprevious".to_string(), - JumpMode::Next => "jumpnext".to_string(), - JumpMode::Query(term) => String::from(format!("jump {}", term)), + let mut repr_tokens = vec![self.basename().to_owned()]; + let mut extras_args = match self { + Command::Focus(tab) => vec![tab.to_owned()], + Command::Seek(direction) => vec![direction.to_string()], + Command::VolumeUp(amount) => vec![amount.to_string()], + Command::VolumeDown(amount) => vec![amount.to_string()], + Command::Repeat(mode) => match mode { + Some(mode) => vec![mode.to_string()], + None => vec![], }, - Command::Help => "help".to_string(), - Command::ReloadConfig => "reload".to_string(), - Command::Insert(_) => "insert".to_string(), - Command::NewPlaylist(name) => format!("new playlist {}", name), - Command::Sort(key, direction) => format!("sort {} {}", key, direction), - Command::Logout => "logout".to_string(), - Command::ShowRecommendations(mode) => format!("similar {}", mode), - Command::Redraw => "redraw".to_string(), - Command::Execute(cmd) => format!("exec {}", cmd), + Command::Shuffle(on) => match on { + Some(b) => vec![(if *b { "on" } else { "off" }).into()], + None => vec![], + }, + Command::Share(mode) => vec![mode.to_string()], + Command::Open(mode) => vec![mode.to_string()], + Command::Goto(mode) => vec![mode.to_string()], + Command::Move(mode, amount) => match (mode, amount) { + (MoveMode::Playing, _) => vec!["playing".to_string()], + (MoveMode::Up, MoveAmount::Extreme) => vec!["top".to_string()], + (MoveMode::Down, MoveAmount::Extreme) => vec!["bottom".to_string()], + (MoveMode::Left, MoveAmount::Extreme) => vec!["leftmost".to_string()], + (MoveMode::Right, MoveAmount::Extreme) => vec!["rightmost".to_string()], + (mode, MoveAmount::Integer(amount)) => vec![mode.to_string(), amount.to_string()], + }, + Command::Shift(mode, amount) => vec![mode.to_string(), amount.unwrap_or(1).to_string()], + Command::Search(term) => vec![term.to_owned()], + Command::Jump(mode) => match mode { + JumpMode::Previous | JumpMode::Next => vec![], + JumpMode::Query(term) => vec![term.to_owned()], + }, + Command::Insert(source) => vec![source.to_string()], + Command::NewPlaylist(name) => vec![name.to_owned()], + Command::Sort(key, direction) => vec![key.to_string(), direction.to_string()], + Command::ShowRecommendations(mode) => vec![mode.to_string()], + Command::Execute(cmd) => vec![cmd.to_owned()], + Command::Quit + | Command::TogglePlay + | Command::Stop + | Command::Previous + | Command::Next + | Command::Clear + | Command::Queue + | Command::PlayNext + | Command::Play + | Command::UpdateLibrary + | Command::Save + | Command::SaveQueue + | Command::Delete + | Command::Back + | Command::Help + | Command::ReloadConfig + | Command::Noop + | Command::Logout + | Command::Redraw => vec![], }; - // escape the command separator - let repr = repr.replace(";", ";;"); - write!(f, "{}", repr) + repr_tokens.append(&mut extras_args); + write!(f, "{}", repr_tokens.join(" ")) + } +} + +impl Command { + pub fn basename(&self) -> &str { + match self { + Command::Quit => "quit", + Command::TogglePlay => "playpause", + Command::Stop => "stop", + Command::Previous => "previous", + Command::Next => "next", + Command::Clear => "clear", + Command::Queue => "queue", + Command::PlayNext => "playnext", + Command::Play => "play", + Command::UpdateLibrary => "update", + Command::Save => "save", + Command::SaveQueue => "save queue", + Command::Delete => "delete", + Command::Focus(_) => "focus", + Command::Seek(_) => "seek", + Command::VolumeUp(_) => "volup", + Command::VolumeDown(_) => "voldown", + Command::Repeat(_) => "repeat", + Command::Shuffle(_) => "shuffle", + Command::Share(_) => "share", + Command::Back => "back", + Command::Open(_) => "open", + Command::Goto(_) => "goto", + Command::Move(_, _) => "move", + Command::Shift(_, _) => "shift", + Command::Search(_) => "search", + Command::Jump(JumpMode::Previous) => "jumpprevious", + Command::Jump(JumpMode::Next) => "jumpnext", + Command::Jump(JumpMode::Query(_)) => "jump", + Command::Help => "help", + Command::ReloadConfig => "reload", + Command::Noop => "noop", + Command::Insert(_) => "insert", + Command::NewPlaylist(_) => "newplaylist", + Command::Sort(_, _) => "sort", + Command::Logout => "logout", + Command::ShowRecommendations(_) => "similar", + Command::Redraw => "redraw", + Command::Execute(_) => "exec", + } } } @@ -243,7 +300,40 @@ fn handle_aliases(input: &str) -> &str { } } -pub fn parse(input: &str) -> Option> { +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum CommandParseError { + NoSuchCommand { cmd: String }, + InsufficientArgs { cmd: String, hint: Option }, + BadEnumArg { arg: String, accept: Vec }, + ArgParseError { arg: String, err: String }, +} + +impl fmt::Display for CommandParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use CommandParseError::*; + let formatted = match self { + NoSuchCommand { cmd } => format!("No such command \"{}\"", cmd), + InsufficientArgs { cmd, hint } => { + if let Some(hint_str) = hint { + format!("\"{}\" requires additional arguments: {}", cmd, hint_str) + } else { + format!("\"{}\" requires additional arguments", cmd) + } + } + BadEnumArg { arg, accept } => { + format!( + "Illegal argument \"{}\": supported values are {}", + arg, + accept.join("|") + ) + } + ArgParseError { arg, err } => format!("Error with argument \"{}\": {}", arg, err), + }; + write!(f, "{}", formatted) + } +} + +pub fn parse(input: &str) -> Result, CommandParseError> { let mut command_inputs = vec!["".to_string()]; let mut command_idx = 0; enum ParseState { @@ -271,226 +361,362 @@ pub fn parse(input: &str) -> Option> { let mut commands = vec![]; for command_input in command_inputs { - let components: Vec<_> = command_input.trim().split(' ').collect(); + let components: Vec<_> = command_input.split_whitespace().collect(); let command = handle_aliases(components[0]); let args = components[1..].to_vec(); + use CommandParseError::*; let command = match command { - "quit" => Some(Command::Quit), - "playpause" => Some(Command::TogglePlay), - "stop" => Some(Command::Stop), - "previous" => Some(Command::Previous), - "next" => Some(Command::Next), - "clear" => Some(Command::Clear), - "playnext" => Some(Command::PlayNext), - "queue" => Some(Command::Queue), - "play" => Some(Command::Play), - "update" => Some(Command::UpdateLibrary), - "delete" => Some(Command::Delete), - "back" => Some(Command::Back), - "open" => args - .get(0) - .and_then(|target| match *target { - "selected" => Some(TargetMode::Selected), - "current" => Some(TargetMode::Current), - _ => None, - }) - .map(Command::Open), - "jump" => Some(Command::Jump(JumpMode::Query(args.join(" ")))), - "jumpnext" => Some(Command::Jump(JumpMode::Next)), - "jumpprevious" => Some(Command::Jump(JumpMode::Previous)), - "search" => Some(Command::Search(args.join(" "))), - "shift" => { - let amount = args.get(1).and_then(|amount| amount.parse().ok()); - - args.get(0) - .and_then(|direction| match *direction { - "up" => Some(ShiftMode::Up), - "down" => Some(ShiftMode::Down), - _ => None, - }) - .map(|mode| Command::Shift(mode, amount)) - } - "move" => { - let cmd: Option = { - args.get(0).and_then(|extreme| match *extreme { - "top" => Some(Command::Move(MoveMode::Up, MoveAmount::Extreme)), - "bottom" => Some(Command::Move(MoveMode::Down, MoveAmount::Extreme)), - "leftmost" => Some(Command::Move(MoveMode::Left, MoveAmount::Extreme)), - "rightmost" => Some(Command::Move(MoveMode::Right, MoveAmount::Extreme)), - "playing" => Some(Command::Move(MoveMode::Playing, MoveAmount::default())), - _ => None, - }) - }; - - cmd.or({ - let amount = args - .get(1) - .and_then(|amount| amount.parse().ok()) - .map(MoveAmount::Integer) - .unwrap_or_default(); - - args.get(0) - .and_then(|direction| match *direction { - "up" => Some(MoveMode::Up), - "down" => Some(MoveMode::Down), - "left" => Some(MoveMode::Left), - "right" => Some(MoveMode::Right), - _ => None, - }) - .map(|mode| Command::Move(mode, amount)) - }) - } - "goto" => args - .get(0) - .and_then(|mode| match *mode { - "album" => Some(GotoMode::Album), - "artist" => Some(GotoMode::Artist), - _ => None, - }) - .map(Command::Goto), - "share" => args - .get(0) - .and_then(|target| match *target { - "selected" => Some(TargetMode::Selected), - "current" => Some(TargetMode::Current), - _ => None, - }) - .map(Command::Share), - "shuffle" => { - let shuffle = args.get(0).and_then(|mode| match *mode { - "on" => Some(true), - "off" => Some(false), - _ => None, - }); - - Some(Command::Shuffle(shuffle)) - } - "repeat" => { - let mode = args.get(0).and_then(|mode| match *mode { - "list" | "playlist" | "queue" => Some(RepeatSetting::RepeatPlaylist), - "track" | "once" | "single" => Some(RepeatSetting::RepeatTrack), - "none" | "off" => Some(RepeatSetting::None), - _ => None, - }); - - Some(Command::Repeat(mode)) + "quit" => Command::Quit, + "playpause" => Command::TogglePlay, + "stop" => Command::Stop, + "previous" => Command::Previous, + "next" => Command::Next, + "clear" => Command::Clear, + "queue" => Command::Queue, + "playnext" => Command::PlayNext, + "play" => Command::Play, + "update" => Command::UpdateLibrary, + "save" => match args.get(0).cloned() { + Some("queue") => Ok(Command::SaveQueue), + Some(arg) => Err(BadEnumArg { + arg: arg.into(), + accept: vec!["**omit**".into(), "queue".into()], + }), + None => Ok(Command::Save), + }?, + "delete" => Command::Delete, + "focus" => { + let &target = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("queue|search|library".into()), + })?; + // TODO: this really should be strongly typed + Command::Focus(target.into()) } "seek" => { let arg = args.join(" "); let first_char = arg.chars().next(); let duration_raw = match first_char { - Some('+' | '-') => arg.chars().skip(1).collect(), - _ => arg.to_string(), + Some('+' | '-') => { + arg.chars().skip(1).collect::().trim().into() + // `trim` is necessary here, otherwise `+1000` -> 1 second, but `+ 1000` -> 1000 seconds + // this behaviour is inconsistent and could cause confusion + } + _ => arg, }; - duration_raw - .parse::() // accept raw milliseconds for backward compatibility - .ok() - .or_else(|| { - parse_duration::parse(&duration_raw) // accept fancy duration - .ok() - .and_then(|dur| dur.as_millis().try_into().ok()) - }) - .and_then(|unsigned_millis| { - match first_char { - // handle i32::MAX < unsigned_millis < u32::MAX gracefully - Some('+') => { - i32::try_from(unsigned_millis) - .ok() - .map(|unsigned_millis_i32| { - SeekDirection::Relative(unsigned_millis_i32) - }) - } - Some('-') => { - i32::try_from(unsigned_millis) - .ok() - .map(|unsigned_millis_i32| { - SeekDirection::Relative(-unsigned_millis_i32) - }) - } - _ => Some(SeekDirection::Absolute(unsigned_millis)), - } - .map(|direction| Command::Seek(direction)) - }) - } - "focus" => args - .get(0) - .map(|target| Command::Focus((*target).to_string())), - "save" => args - .get(0) - .map(|target| match *target { - "queue" => Command::SaveQueue, - _ => Command::Save, - }) - .or(Some(Command::Save)), - "volup" => Some(Command::VolumeUp( - args.get(0).and_then(|v| v.parse::().ok()).unwrap_or(1), - )), - "voldown" => Some(Command::VolumeDown( - args.get(0).and_then(|v| v.parse::().ok()).unwrap_or(1), - )), - "help" => Some(Command::Help), - "reload" => Some(Command::ReloadConfig), - "insert" => { - if args.is_empty() { - Some(Command::Insert(None)) - } else { - args.get(0) - .map(|url| Command::Insert(Some((*url).to_string()))) + let unsigned_millis = match duration_raw.parse() { + // accept raw milliseconds + Ok(millis) => millis, + Err(_) => parse_duration::parse(&duration_raw) // accept fancy duration + .map_err(|err| ArgParseError { + arg: duration_raw.clone(), + err: err.to_string(), + }) + .and_then(|dur| { + dur.as_millis().try_into().map_err(|_| ArgParseError { + arg: duration_raw.clone(), + err: "Duration value too large".into(), + }) + })?, + }; + let seek_direction = match first_char { + // handle i32::MAX < unsigned_millis < u32::MAX gracefully + Some('+') => { + i32::try_from(unsigned_millis).map(|millis| SeekDirection::Relative(millis)) + } + Some('-') => i32::try_from(unsigned_millis) + .map(|millis| SeekDirection::Relative(-millis)), + _ => Ok(SeekDirection::Absolute(unsigned_millis)), } + .map_err(|_| ArgParseError { + arg: duration_raw, + err: "Duration value too large".into(), + })?; + Command::Seek(seek_direction) + } + "volup" => { + let amount = match args.get(0) { + Some(&amount_raw) => { + amount_raw.parse::().map_err(|err| ArgParseError { + arg: amount_raw.into(), + err: err.to_string(), + })? + } + None => 1, + }; + Command::VolumeUp(amount) + } + "voldown" => { + let amount = match args.get(0) { + Some(&amount_raw) => { + amount_raw.parse::().map_err(|err| ArgParseError { + arg: amount_raw.into(), + err: err.to_string(), + })? + } + None => 1, + }; + Command::VolumeDown(amount) + } + "repeat" => { + let mode = match args.get(0).cloned() { + Some("list" | "playlist" | "queue") => Ok(Some(RepeatSetting::RepeatPlaylist)), + Some("track" | "once" | "single") => Ok(Some(RepeatSetting::RepeatTrack)), + Some("none" | "off") => Ok(Some(RepeatSetting::None)), + Some(arg) => Err(BadEnumArg { + arg: arg.into(), + accept: vec![ + "**omit**".into(), + "list".into(), + "playlist".into(), + "queue".into(), + "track".into(), + "once".into(), + "single".into(), + "none".into(), + "off".into(), + ], + }), + None => Ok(None), + }?; + Command::Repeat(mode) + } + "shuffle" => { + let switch = match args.get(0).cloned() { + Some("on") => Ok(Some(true)), + Some("off") => Ok(Some(false)), + Some(arg) => Err(BadEnumArg { + arg: arg.into(), + accept: vec!["**omit**".into(), "on".into(), "off".into()], + }), + None => Ok(None), + }?; + Command::Shuffle(switch) + } + "share" => { + let &target_mode_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("selected|current".into()), + })?; + let target_mode = match target_mode_raw { + "selected" => Ok(TargetMode::Selected), + "current" => Ok(TargetMode::Current), + _ => Err(BadEnumArg { + arg: target_mode_raw.into(), + accept: vec!["selected".into(), "current".into()], + }), + }?; + Command::Share(target_mode) + } + "back" => Command::Back, + "open" => { + let &target_mode_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("selected|current".into()), + })?; + let target_mode = match target_mode_raw { + "selected" => Ok(TargetMode::Selected), + "current" => Ok(TargetMode::Current), + _ => Err(BadEnumArg { + arg: target_mode_raw.into(), + accept: vec!["selected".into(), "current".into()], + }), + }?; + Command::Open(target_mode) + } + "goto" => { + let &goto_mode_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("album|artist".into()), + })?; + let goto_mode = match goto_mode_raw { + "album" => Ok(GotoMode::Album), + "artist" => Ok(GotoMode::Artist), + _ => Err(BadEnumArg { + arg: goto_mode_raw.into(), + accept: vec!["album".into(), "artist".into()], + }), + }?; + Command::Goto(goto_mode) + } + "move" => { + let &move_mode_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("a direction".into()), + })?; + let move_mode = { + use MoveMode::*; + match move_mode_raw { + "playing" => Ok(Playing), + "top" | "up" => Ok(Up), + "bottom" | "down" => Ok(Down), + "leftmost" | "left" => Ok(Left), + "rightmost" | "right" => Ok(Right), + _ => Err(BadEnumArg { + arg: move_mode_raw.into(), + accept: vec![ + "playing".into(), + "top".into(), + "bottom".into(), + "leftmost".into(), + "rightmost".into(), + "up".into(), + "down".into(), + "left".into(), + "right".into(), + ], + }), + }? + }; + let move_amount = match move_mode_raw { + "playing" => Ok(MoveAmount::default()), + "top" | "bottom" | "leftmost" | "rightmost" => Ok(MoveAmount::Extreme), + "up" | "down" | "left" | "right" => { + let amount = match args.get(1) { + Some(&amount_raw) => amount_raw + .parse::() + .map(MoveAmount::Integer) + .map_err(|err| ArgParseError { + arg: amount_raw.into(), + err: err.to_string(), + })?, + None => MoveAmount::default(), + }; + Ok(amount) + } + _ => unreachable!(), // already guarded when determining MoveMode + }?; + Command::Move(move_mode, move_amount) + } + "shift" => { + let &shift_dir_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("up|down".into()), + })?; + let shift_dir = match shift_dir_raw { + "up" => Ok(ShiftMode::Up), + "down" => Ok(ShiftMode::Down), + _ => Err(BadEnumArg { + arg: shift_dir_raw.into(), + accept: vec!["up".into(), "down".into()], + }), + }?; + let amount = match args.get(1) { + Some(&amount_raw) => { + let amount = amount_raw.parse::().map_err(|err| ArgParseError { + arg: amount_raw.into(), + err: err.to_string(), + })?; + Some(amount) + } + None => None, + }; + Command::Shift(shift_dir, amount) + } + "search" => Command::Search(args.join(" ")), + "jump" => Command::Jump(JumpMode::Query(args.join(" "))), + "jumpnext" => Command::Jump(JumpMode::Next), + "jumpprevious" => Command::Jump(JumpMode::Previous), + "help" => Command::Help, + "reload" => Command::ReloadConfig, + "noop" => Command::Noop, + "insert" => { + let insert_source = + match args.get(0).cloned() { + #[cfg(feature = "share_clipboard")] + Some("") | None => Ok(InsertSource::Clipboard), + Some(url) => SpotifyUrl::from_url(url).map(InsertSource::Input).ok_or( + ArgParseError { + arg: url.into(), + err: "Invalid Spotify URL".into(), + }, + ), + // if clipboard feature is disabled and args is empty + #[allow(unreachable_patterns)] + None => Err(InsufficientArgs { + cmd: command.into(), + hint: Some("a Spotify URL".into()), + }), + }?; + Command::Insert(insert_source) } "newplaylist" => { if !args.is_empty() { - Some(Command::NewPlaylist(args.join(" "))) + Ok(Command::NewPlaylist(args.join(" "))) } else { - None - } + Err(InsufficientArgs { + cmd: command.into(), + hint: Some("a name".into()), + }) + }? } "sort" => { - if !args.is_empty() { - let sort_key = args.get(0).and_then(|key| match *key { - "title" => Some(SortKey::Title), - "duration" => Some(SortKey::Duration), - "album" => Some(SortKey::Album), - "added" => Some(SortKey::Added), - "artist" => Some(SortKey::Artist), - _ => None, - })?; - - let sort_direction = args - .get(1) - .map(|direction| match *direction { - "a" => SortDirection::Ascending, - "asc" => SortDirection::Ascending, - "ascending" => SortDirection::Ascending, - "d" => SortDirection::Descending, - "desc" => SortDirection::Descending, - "descending" => SortDirection::Descending, - _ => SortDirection::Ascending, - }) - .unwrap_or(SortDirection::Ascending); - - Some(Command::Sort(sort_key, sort_direction)) - } else { - None - } + let &key_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("a sort key".into()), + })?; + let key = match key_raw { + "title" => Ok(SortKey::Title), + "duration" => Ok(SortKey::Duration), + "album" => Ok(SortKey::Album), + "added" => Ok(SortKey::Added), + "artist" => Ok(SortKey::Artist), + _ => Err(BadEnumArg { + arg: key_raw.into(), + accept: vec![ + "title".into(), + "duration".into(), + "album".into(), + "added".into(), + "artist".into(), + ], + }), + }?; + let direction = match args.get(1) { + Some(&direction_raw) => match direction_raw { + "a" | "asc" | "ascending" => Ok(SortDirection::Ascending), + "d" | "desc" | "descending" => Ok(SortDirection::Descending), + _ => Err(BadEnumArg { + arg: direction_raw.into(), + accept: vec![ + "a".into(), + "asc".into(), + "ascending".into(), + "d".into(), + "desc".into(), + "descending".into(), + ], + }), + }, + None => Ok(SortDirection::Ascending), + }?; + Command::Sort(key, direction) } - "logout" => Some(Command::Logout), - "similar" => args - .get(0) - .and_then(|target| match *target { - "selected" => Some(TargetMode::Selected), - "current" => Some(TargetMode::Current), - _ => None, - }) - .map(Command::ShowRecommendations), - "noop" => Some(Command::Noop), - "redraw" => Some(Command::Redraw), - "exec" => Some(Command::Execute(args.join(" "))), - _ => None, + "logout" => Command::Logout, + "similar" => { + let &target_mode_raw = args.get(0).ok_or(InsufficientArgs { + cmd: command.into(), + hint: Some("selected|current".into()), + })?; + let target_mode = match target_mode_raw { + "selected" => Ok(TargetMode::Selected), + "current" => Ok(TargetMode::Current), + _ => Err(BadEnumArg { + arg: target_mode_raw.into(), + accept: vec!["selected".into(), "current".into()], + }), + }?; + Command::ShowRecommendations(target_mode) + } + "redraw" => Command::Redraw, + "exec" => Command::Execute(args.join(" ")), + _ => Err(NoSuchCommand { + cmd: command.into(), + })?, // I'm surprised this compiles lol }; - commands.push(command?); + commands.push(command); } - Some(commands) + Ok(commands) } diff --git a/src/commands.rs b/src/commands.rs index 4997700..fe130c3 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use std::time::Duration; use crate::command::{ - parse, Command, GotoMode, JumpMode, MoveAmount, MoveMode, SeekDirection, ShiftMode, TargetMode, + parse, Command, GotoMode, InsertSource, JumpMode, MoveAmount, MoveMode, SeekDirection, + ShiftMode, TargetMode, }; use crate::config::Config; use crate::events::EventManager; @@ -71,11 +72,17 @@ impl CommandManager { let custom_bindings: Option> = config.keybindings.clone(); for (key, commands) in custom_bindings.unwrap_or_default() { - if let Some(commands) = parse(&commands) { - info!("Custom keybinding: {} -> {:?}", key, commands); - kb.insert(key, commands); - } else { - error!("Invalid command(s) for key {}: {}", key, commands); + match parse(&commands) { + Ok(cmds) => { + info!("Custom keybinding: {} -> {:?}", key, cmds); + kb.insert(key, cmds); + } + Err(err) => { + error!( + "Invalid command(s) for key {}-\"{}\": {}", + key, commands, err + ); + } } } @@ -260,20 +267,27 @@ impl CommandManager { log::info!("Exit code: {}", result); Ok(None) } - Command::Jump(_) - | Command::Move(_, _) - | Command::Shift(_, _) - | Command::Play + + Command::Queue | Command::PlayNext - | Command::Queue + | Command::Play | Command::Save + | Command::SaveQueue | Command::Delete + | Command::Focus(_) + | Command::Share(_) | Command::Back | Command::Open(_) - | Command::ShowRecommendations(_) + | Command::Goto(_) + | Command::Move(_, _) + | Command::Shift(_, _) + | Command::Jump(_) | Command::Insert(_) - | Command::Goto(_) => Ok(None), - _ => Err("Unknown Command".into()), + | Command::ShowRecommendations(_) + | Command::Sort(_, _) => Err(format!( + "The command \"{}\" is unsupported in this view", + cmd.basename() + )), } } @@ -508,7 +522,12 @@ impl CommandManager { "Shift+Down".into(), vec![Command::Shift(ShiftMode::Down, None)], ); - kb.insert("Ctrl+v".into(), vec![Command::Insert(None)]); + + #[cfg(feature = "share_clipboard")] + kb.insert( + "Ctrl+v".into(), + vec![Command::Insert(InsertSource::Clipboard)], + ); kb } diff --git a/src/main.rs b/src/main.rs index e3542c2..9d1de68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -295,17 +295,18 @@ async fn main() -> Result<(), String> { data.cmd.handle(s, command); } } else { - let parsed = command::parse(cmd_without_prefix); - if let Some(commands) = parsed { - if let Some(data) = s.user_data::().cloned() { - for cmd in commands { - data.cmd.handle(s, cmd); + match command::parse(cmd_without_prefix) { + Ok(commands) => { + if let Some(data) = s.user_data::().cloned() { + for cmd in commands { + data.cmd.handle(s, cmd); + } } } - } else { - let mut main = s.find_name::("main").unwrap(); - let err_msg = format!("Failed to parse command(s): \"{}\"", cmd_without_prefix); - main.set_result(Err(err_msg)); + Err(err) => { + let mut main = s.find_name::("main").unwrap(); + main.set_result(Err(err.to_string())); + } } } ev.trigger(); diff --git a/src/spotify.rs b/src/spotify.rs index 57b9148..196f3d2 100644 --- a/src/spotify.rs +++ b/src/spotify.rs @@ -358,7 +358,7 @@ impl Spotify { } } -#[derive(Debug, PartialEq)] +#[derive(Copy, Clone, Serialize, Deserialize, Debug, PartialEq)] pub enum UriType { Album, Artist, diff --git a/src/spotify_url.rs b/src/spotify_url.rs index 266ce39..2d2c7e3 100644 --- a/src/spotify_url.rs +++ b/src/spotify_url.rs @@ -1,12 +1,29 @@ +use std::fmt; + use crate::spotify::UriType; use url::{Host, Url}; +#[derive(Clone, Serialize, Deserialize, Debug)] pub struct SpotifyUrl { pub id: String, pub uri_type: UriType, } +impl fmt::Display for SpotifyUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let type_seg = match self.uri_type { + UriType::Album => "album", + UriType::Artist => "artist", + UriType::Episode => "episode", + UriType::Playlist => "playlist", + UriType::Show => "show", + UriType::Track => "track", + }; + write!(f, "https://open.spotify.com/{}/{}", type_seg, self.id) + } +} + impl SpotifyUrl { fn new(id: &str, uri_type: UriType) -> SpotifyUrl { SpotifyUrl { @@ -22,8 +39,8 @@ impl SpotifyUrl { /// assert_eq!(result.id, "4uLU6hMCjMI75M1A2tKUQC"); /// assert_eq!(result.uri_type, URIType::Track); /// ``` - pub fn from_url(s: &str) -> Option { - let url = Url::parse(s).ok()?; + pub fn from_url>(s: S) -> Option { + let url = Url::parse(s.as_ref()).ok()?; if url.host() != Some(Host::Domain("open.spotify.com")) { return None; } diff --git a/src/ui/listview.rs b/src/ui/listview.rs index 595d9ff..5173f41 100644 --- a/src/ui/listview.rs +++ b/src/ui/listview.rs @@ -10,7 +10,7 @@ use cursive::view::ScrollBase; use cursive::{Cursive, Printer, Rect, Vec2}; use unicode_width::UnicodeWidthStr; -use crate::command::{Command, GotoMode, JumpMode, MoveAmount, MoveMode, TargetMode}; +use crate::command::{Command, GotoMode, InsertSource, JumpMode, MoveAmount, MoveMode, TargetMode}; use crate::commands::CommandResult; use crate::library::Library; use crate::model::album::Album; @@ -520,20 +520,15 @@ impl ViewExt for ListView { } } } - Command::Insert(url) => { - let url = match url.as_ref().map(String::as_str) { + Command::Insert(source) => { + let url = match source { + InsertSource::Input(url) => Some(url.clone()), #[cfg(feature = "share_clipboard")] - Some("") | None => read_share().unwrap(), - Some(url) => url.to_owned(), - // do nothing if clipboard feature is disabled and there is no url provided - #[allow(unreachable_patterns)] - _ => return Ok(CommandResult::Consumed(None)), + InsertSource::Clipboard => read_share().and_then(SpotifyUrl::from_url), }; let spotify = self.queue.get_spotify(); - let url = SpotifyUrl::from_url(&url); - if let Some(url) = url { let target: Option> = match url.uri_type { UriType::Track => spotify