refactor: avoid multiple cable channel parsing passes

This commit is contained in:
Alexandre Pasmantier 2025-04-24 01:32:46 +02:00
parent ba1c3fe8ee
commit 9b56e6687b
9 changed files with 165 additions and 67 deletions

View File

@ -15,8 +15,9 @@
# General settings # General settings
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
frame_rate = 60 # DEPRECATED: this option is no longer used frame_rate = 60 # DEPRECATED: this option is no longer used
tick_rate = 50 tick_rate = 50
default_channel = "files"
[ui] [ui]
# Whether to use nerd font icons in the UI # Whether to use nerd font icons in the UI

View File

@ -11,6 +11,8 @@ jobs:
test: test:
name: Test Suite name: Test Suite
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUST_BACKTRACE: 1
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -18,7 +20,7 @@ jobs:
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Install fd - name: Install fd
run: sudo apt-get install -y fd-find run: sudo apt install -y fd-find && sudo ln -s $(which fdfind) /usr/bin/fd
- name: Run tests - name: Run tests
run: cargo test --locked --all-features --workspace -- --nocapture run: cargo test --locked --all-features --workspace -- --nocapture

View File

@ -4,6 +4,13 @@ name = "files"
source_command = "fd -t f" source_command = "fd -t f"
preview_command = ":files:" preview_command = ":files:"
# Text
[[cable_channel]]
name = "text"
source_command = "rg . --no-heading --line-number"
preview_command = "bat -n --color=always {0} -H {1}"
preview_delimiter = ":"
# Directories # Directories
[[cable_channel]] [[cable_channel]]
name = "dirs" name = "dirs"

View File

@ -10,9 +10,9 @@ use crate::config::get_config_dir;
/// Just a proxy struct to deserialize prototypes /// Just a proxy struct to deserialize prototypes
#[derive(Debug, serde::Deserialize, Default)] #[derive(Debug, serde::Deserialize, Default)]
struct ChannelPrototypes { pub struct ChannelPrototypes {
#[serde(rename = "cable_channel")] #[serde(rename = "cable_channel")]
prototypes: Vec<CableChannelPrototype>, pub prototypes: Vec<CableChannelPrototype>,
} }
const CABLE_FILE_NAME_SUFFIX: &str = "channels"; const CABLE_FILE_NAME_SUFFIX: &str = "channels";
@ -85,6 +85,10 @@ pub fn load_cable_channels() -> Result<CableChannels> {
); );
debug!("Loaded cable channels: {:?}", prototypes); debug!("Loaded cable channels: {:?}", prototypes);
if prototypes.is_empty() {
error!("No cable channels found");
return Err(anyhow::anyhow!("No cable channels found"));
}
let mut cable_channels = FxHashMap::default(); let mut cable_channels = FxHashMap::default();
for prototype in prototypes { for prototype in prototypes {

View File

@ -13,11 +13,14 @@ use regex::Regex;
use rustc_hash::{FxBuildHasher, FxHashSet}; use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug; use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir; use crate::channels::OnAir;
use crate::matcher::Matcher; use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector}; use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command; use crate::utils::command::shell_command;
use crate::{
cable::ChannelPrototypes,
channels::entry::{Entry, PreviewCommand, PreviewType},
};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum PreviewKind { pub enum PreviewKind {
@ -39,10 +42,10 @@ pub struct Channel {
impl Default for Channel { impl Default for Channel {
fn default() -> Self { fn default() -> Self {
Self::new( Self::new(
"Files", "files",
"find . -type f", "find . -type f",
false, false,
Some(PreviewCommand::new("bat -n --color=always {}", ":")), Some(PreviewCommand::new("cat {}", ":")),
) )
} }
} }
@ -290,7 +293,7 @@ impl Display for CableChannelPrototype {
} }
} }
#[derive(Debug, serde::Deserialize, Default)] #[derive(Debug, serde::Deserialize)]
pub struct CableChannels(pub FxHashMap<String, CableChannelPrototype>); pub struct CableChannels(pub FxHashMap<String, CableChannelPrototype>);
impl Deref for CableChannels { impl Deref for CableChannels {
@ -300,3 +303,25 @@ impl Deref for CableChannels {
&self.0 &self.0
} }
} }
#[cfg(unix)]
const DEFAULT_CABLE_CHANNELS_FILE: &str =
include_str!("../../cable/unix-channels.toml");
#[cfg(not(unix))]
const DEFAULT_CABLE_CHANNELS_FILE: &str =
include_str!("../../cable/windows-channels.toml");
impl Default for CableChannels {
/// Fallback to the default cable channels specification (the template file
/// included in the repo).
fn default() -> Self {
let pts =
toml::from_str::<ChannelPrototypes>(DEFAULT_CABLE_CHANNELS_FILE)
.expect("Unable to parse default cable channels");
let mut channels = FxHashMap::default();
for prototype in pts.prototypes {
channels.insert(prototype.name.clone(), prototype);
}
CableChannels(channels)
}
}

View File

@ -9,13 +9,8 @@ pub struct Cli {
/// A list of the available channels can be displayed using the /// A list of the available channels can be displayed using the
/// `list-channels` command. The channel can also be changed from within /// `list-channels` command. The channel can also be changed from within
/// the application. /// the application.
#[arg( #[arg(value_enum, index = 1, verbatim_doc_comment)]
value_enum, pub channel: Option<String>,
default_value = "files",
index = 1,
verbatim_doc_comment
)]
pub channel: String,
/// A preview command to use with the stdin channel. /// A preview command to use with the stdin channel.
/// ///

View File

@ -4,10 +4,10 @@ use std::path::Path;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use tracing::debug; use tracing::debug;
use crate::channels::cable::{parse_preview_kind, PreviewKind}; use crate::channels::cable::{parse_preview_kind, CableChannels, PreviewKind};
use crate::channels::{cable::CableChannelPrototype, entry::PreviewCommand}; use crate::channels::{cable::CableChannelPrototype, entry::PreviewCommand};
use crate::cli::args::{Cli, Command}; use crate::cli::args::{Cli, Command};
use crate::config::KeyBindings; use crate::config::{KeyBindings, DEFAULT_CHANNEL};
use crate::{ use crate::{
cable, cable,
config::{get_config_dir, get_data_dir}, config::{get_config_dir, get_data_dir},
@ -86,22 +86,37 @@ impl From<Cli> for PostProcessedCli {
let channel: CableChannelPrototype; let channel: CableChannelPrototype;
let working_directory: Option<String>; let working_directory: Option<String>;
match parse_channel(&cli.channel) { let cable_channels = cable::load_cable_channels().unwrap_or_default();
Ok(p) => { if cli.channel.is_none() {
channel = p; channel = cable_channels
working_directory = cli.working_directory; .get(DEFAULT_CHANNEL)
} .expect("Default channel not found in cable channels")
Err(_) => { .clone();
// if the path is provided as first argument and it exists, use it as the working working_directory = cli.working_directory;
// directory and default to the files channel } else {
if cli.working_directory.is_none() let cli_channel = cli.channel.as_ref().unwrap().to_owned();
&& Path::new(&cli.channel).exists() match parse_channel(&cli_channel, &cable_channels) {
{ Ok(p) => {
channel = CableChannelPrototype::default(); channel = p;
working_directory = Some(cli.channel.clone()); working_directory = cli.working_directory;
} else { }
unknown_channel_exit(&cli.channel); Err(_) => {
unreachable!(); // if the path is provided as first argument and it exists, use it as the working
// directory and default to the files channel
if cli.working_directory.is_none()
&& Path::new(&cli_channel).exists()
{
channel = cable_channels
.get(DEFAULT_CHANNEL)
.expect(
"Default channel not found in cable channels",
)
.clone();
working_directory = Some(cli.channel.unwrap().clone());
} else {
unknown_channel_exit(&cli.channel.unwrap());
unreachable!();
}
} }
} }
} }
@ -157,15 +172,17 @@ fn parse_keybindings_literal(
toml::from_str(&toml_definition).map_err(|e| anyhow!(e)) toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
} }
pub fn parse_channel(channel: &str) -> Result<CableChannelPrototype> { pub fn parse_channel(
let cable_channels = cable::load_cable_channels().unwrap_or_default(); channel: &str,
cable_channels: &CableChannels,
) -> Result<CableChannelPrototype> {
// try to parse the channel as a cable channel // try to parse the channel as a cable channel
match cable_channels match cable_channels
.iter() .iter()
.find(|(k, _)| k.to_lowercase() == channel) .find(|(k, _)| k.to_lowercase() == channel)
{ {
Some((_, v)) => Ok(v.clone()), Some((_, v)) => Ok(v.clone()),
None => Err(anyhow!("Unknown channel: {channel}")), None => Err(anyhow!("The following channel wasn't found among cable channels: {channel}")),
} }
} }
@ -201,13 +218,18 @@ pub fn list_channels() {
pub fn guess_channel_from_prompt( pub fn guess_channel_from_prompt(
prompt: &str, prompt: &str,
command_mapping: &FxHashMap<String, String>, command_mapping: &FxHashMap<String, String>,
fallback_channel: CableChannelPrototype, fallback_channel: &str,
cable_channels: &CableChannels,
) -> Result<CableChannelPrototype> { ) -> Result<CableChannelPrototype> {
debug!("Guessing channel from prompt: {}", prompt); debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf // git checkout -qf
// --- -------- --- <--------- // --- -------- --- <---------
let fallback = cable_channels
.get(fallback_channel)
.expect("Fallback channel not found in cable channels")
.clone();
if prompt.trim().is_empty() { if prompt.trim().is_empty() {
return Ok(fallback_channel); return Ok(fallback);
} }
let rev_prompt_words = prompt.split_whitespace().rev(); let rev_prompt_words = prompt.split_whitespace().rev();
let mut stack = Vec::new(); let mut stack = Vec::new();
@ -221,7 +243,7 @@ pub fn guess_channel_from_prompt(
for word in rev_prompt_words.clone() { for word in rev_prompt_words.clone() {
// if the stack is empty, we have a match // if the stack is empty, we have a match
if stack.is_empty() { if stack.is_empty() {
return parse_channel(channel); return parse_channel(channel, cable_channels);
} }
// if the word matches the top of the stack, pop it // if the word matches the top of the stack, pop it
if stack.last() == Some(&word) { if stack.last() == Some(&word) {
@ -230,14 +252,14 @@ pub fn guess_channel_from_prompt(
} }
// if the stack is empty, we have a match // if the stack is empty, we have a match
if stack.is_empty() { if stack.is_empty() {
return parse_channel(channel); return parse_channel(channel, cable_channels);
} }
// reset the stack // reset the stack
stack.clear(); stack.clear();
} }
debug!("No match found, falling back to default channel"); debug!("No match found, falling back to default channel");
Ok(fallback_channel) Ok(fallback)
} }
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION"); const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
@ -285,7 +307,7 @@ mod tests {
#[allow(clippy::float_cmp)] #[allow(clippy::float_cmp)]
fn test_from_cli() { fn test_from_cli() {
let cli = Cli { let cli = Cli {
channel: "files".to_string(), channel: Some("files".to_string()),
preview: Some("bat -n --color=always {}".to_string()), preview: Some("bat -n --color=always {}".to_string()),
delimiter: ":".to_string(), delimiter: ":".to_string(),
working_directory: Some("/home/user".to_string()), working_directory: Some("/home/user".to_string()),
@ -317,7 +339,7 @@ mod tests {
#[allow(clippy::float_cmp)] #[allow(clippy::float_cmp)]
fn test_from_cli_no_args() { fn test_from_cli_no_args() {
let cli = Cli { let cli = Cli {
channel: ".".to_string(), channel: Some(".".to_string()),
delimiter: ":".to_string(), delimiter: ":".to_string(),
..Default::default() ..Default::default()
}; };
@ -338,7 +360,7 @@ mod tests {
#[test] #[test]
fn test_builtin_previewer_files() { fn test_builtin_previewer_files() {
let cli = Cli { let cli = Cli {
channel: "files".to_string(), channel: Some("files".to_string()),
preview: Some(":files:".to_string()), preview: Some(":files:".to_string()),
delimiter: ":".to_string(), delimiter: ":".to_string(),
..Default::default() ..Default::default()
@ -355,7 +377,7 @@ mod tests {
#[test] #[test]
fn test_builtin_previewer_env() { fn test_builtin_previewer_env() {
let cli = Cli { let cli = Cli {
channel: "files".to_string(), channel: Some("files".to_string()),
preview: Some(":env_var:".to_string()), preview: Some(":env_var:".to_string()),
delimiter: ":".to_string(), delimiter: ":".to_string(),
..Default::default() ..Default::default()
@ -372,7 +394,7 @@ mod tests {
#[test] #[test]
fn test_custom_keybindings() { fn test_custom_keybindings() {
let cli = Cli { let cli = Cli {
channel: "files".to_string(), channel: Some("files".to_string()),
preview: Some(":env_var:".to_string()), preview: Some(":env_var:".to_string()),
delimiter: ":".to_string(), delimiter: ":".to_string(),
keybindings: Some( keybindings: Some(
@ -395,15 +417,16 @@ mod tests {
} }
/// Returns a tuple containing a command mapping and a fallback channel. /// Returns a tuple containing a command mapping and a fallback channel.
fn guess_channel_from_prompt_setup( fn guess_channel_from_prompt_setup<'a>(
) -> (FxHashMap<String, String>, CableChannelPrototype) { ) -> (FxHashMap<String, String>, &'a str, CableChannels) {
let mut command_mapping = FxHashMap::default(); let mut command_mapping = FxHashMap::default();
command_mapping.insert("vim".to_string(), "files".to_string()); command_mapping.insert("vim".to_string(), "files".to_string());
command_mapping.insert("export".to_string(), "env".to_string()); command_mapping.insert("export".to_string(), "env".to_string());
( (
command_mapping, command_mapping,
CableChannelPrototype::new("env", "", false, None, None), "env",
cable::load_cable_channels().unwrap_or_default(),
) )
} }
@ -411,11 +434,16 @@ mod tests {
fn test_guess_channel_from_prompt_present() { fn test_guess_channel_from_prompt_present() {
let prompt = "vim -d file1"; let prompt = "vim -d file1";
let (command_mapping, fallback) = guess_channel_from_prompt_setup(); let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel = let channel = guess_channel_from_prompt(
guess_channel_from_prompt(prompt, &command_mapping, fallback) prompt,
.unwrap(); &command_mapping,
fallback,
&channels,
)
.unwrap();
assert_eq!(channel.name, "files"); assert_eq!(channel.name, "files");
} }
@ -424,31 +452,35 @@ mod tests {
fn test_guess_channel_from_prompt_fallback() { fn test_guess_channel_from_prompt_fallback() {
let prompt = "git checkout "; let prompt = "git checkout ";
let (command_mapping, fallback) = guess_channel_from_prompt_setup(); let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt( let channel = guess_channel_from_prompt(
prompt, prompt,
&command_mapping, &command_mapping,
fallback.clone(), fallback,
&channels,
) )
.unwrap(); .unwrap();
assert_eq!(channel, fallback); assert_eq!(channel.name, fallback);
} }
#[test] #[test]
fn test_guess_channel_from_prompt_empty() { fn test_guess_channel_from_prompt_empty() {
let prompt = ""; let prompt = "";
let (command_mapping, fallback) = guess_channel_from_prompt_setup(); let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt( let channel = guess_channel_from_prompt(
prompt, prompt,
&command_mapping, &command_mapping,
fallback.clone(), fallback,
&channels,
) )
.unwrap(); .unwrap();
assert_eq!(channel, fallback); assert_eq!(channel.name, fallback);
} }
} }

View File

@ -36,6 +36,15 @@ pub struct AppConfig {
pub frame_rate: f64, pub frame_rate: f64,
#[serde(default = "default_tick_rate")] #[serde(default = "default_tick_rate")]
pub tick_rate: f64, pub tick_rate: f64,
/// The default channel to use when no channel is specified
#[serde(default = "default_channel")]
pub default_channel: String,
}
pub const DEFAULT_CHANNEL: &str = "files";
fn default_channel() -> String {
DEFAULT_CHANNEL.to_string()
} }
impl Hash for AppConfig { impl Hash for AppConfig {

View File

@ -5,8 +5,8 @@ use std::process::exit;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use television::channels::cable::PreviewKind; use television::cable;
use television::cli::parse_channel; use television::channels::cable::{CableChannels, PreviewKind};
use television::utils::clipboard::CLIPBOARD; use television::utils::clipboard::CLIPBOARD;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
@ -43,6 +43,9 @@ async fn main() -> Result<()> {
debug!("Loading configuration..."); debug!("Loading configuration...");
let mut config = Config::new(&ConfigEnv::init()?)?; let mut config = Config::new(&ConfigEnv::init()?)?;
debug!("Loading cable channels...");
let cable_channels = cable::load_cable_channels().unwrap_or_default();
// optionally handle subcommands // optionally handle subcommands
debug!("Handling subcommands..."); debug!("Handling subcommands...");
args.command args.command
@ -58,8 +61,12 @@ async fn main() -> Result<()> {
// determine the channel to use based on the CLI arguments and configuration // determine the channel to use based on the CLI arguments and configuration
debug!("Determining channel..."); debug!("Determining channel...");
let channel = let channel = determine_channel(
determine_channel(args.clone(), &config, is_readable_stdin())?; args.clone(),
&config,
is_readable_stdin(),
&cable_channels,
)?;
CLIPBOARD.with(<_>::default); CLIPBOARD.with(<_>::default);
@ -146,6 +153,7 @@ pub fn determine_channel(
args: PostProcessedCli, args: PostProcessedCli,
config: &Config, config: &Config,
readable_stdin: bool, readable_stdin: bool,
cable_channels: &CableChannels,
) -> Result<TelevisionChannel> { ) -> Result<TelevisionChannel> {
if readable_stdin { if readable_stdin {
debug!("Using stdin channel"); debug!("Using stdin channel");
@ -163,7 +171,8 @@ pub fn determine_channel(
let channel = guess_channel_from_prompt( let channel = guess_channel_from_prompt(
&prompt, &prompt,
&config.shell_integration.commands, &config.shell_integration.commands,
parse_channel(&config.shell_integration.fallback_channel)?, &config.shell_integration.fallback_channel,
cable_channels,
)?; )?;
debug!("Using guessed channel: {:?}", channel); debug!("Using guessed channel: {:?}", channel);
Ok(TelevisionChannel::Cable(channel.into())) Ok(TelevisionChannel::Cable(channel.into()))
@ -176,7 +185,9 @@ pub fn determine_channel(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use television::channels::cable::CableChannelPrototype; use television::{
cable::load_cable_channels, channels::cable::CableChannelPrototype,
};
use super::*; use super::*;
@ -185,9 +196,13 @@ mod tests {
config: &Config, config: &Config,
readable_stdin: bool, readable_stdin: bool,
expected_channel: &TelevisionChannel, expected_channel: &TelevisionChannel,
cable_channels: Option<CableChannels>,
) { ) {
let channels: CableChannels = cable_channels
.unwrap_or_else(|| load_cable_channels().unwrap_or_default());
let channel = let channel =
determine_channel(args.clone(), config, readable_stdin).unwrap(); determine_channel(args.clone(), config, readable_stdin, &channels)
.unwrap();
assert_eq!( assert_eq!(
channel.name(), channel.name(),
@ -212,6 +227,7 @@ mod tests {
&config, &config,
true, true,
&TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)), &TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
None,
); );
} }
@ -242,7 +258,13 @@ mod tests {
}; };
config.shell_integration.merge_triggers(); config.shell_integration.merge_triggers();
assert_is_correct_channel(&args, &config, false, &expected_channel); assert_is_correct_channel(
&args,
&config,
false,
&expected_channel,
None,
);
} }
#[tokio::test] #[tokio::test]
@ -262,6 +284,7 @@ mod tests {
CableChannelPrototype::new("dirs", "", false, None, None) CableChannelPrototype::new("dirs", "", false, None, None)
.into(), .into(),
), ),
None,
); );
} }