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

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

View File

@ -11,6 +11,8 @@ jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
env:
RUST_BACKTRACE: 1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@ -18,7 +20,7 @@ jobs:
uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- 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
run: cargo test --locked --all-features --workspace -- --nocapture

View File

@ -4,6 +4,13 @@ name = "files"
source_command = "fd -t f"
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
[[cable_channel]]
name = "dirs"

View File

@ -10,9 +10,9 @@ use crate::config::get_config_dir;
/// Just a proxy struct to deserialize prototypes
#[derive(Debug, serde::Deserialize, Default)]
struct ChannelPrototypes {
pub struct ChannelPrototypes {
#[serde(rename = "cable_channel")]
prototypes: Vec<CableChannelPrototype>,
pub prototypes: Vec<CableChannelPrototype>,
}
const CABLE_FILE_NAME_SUFFIX: &str = "channels";
@ -85,6 +85,10 @@ pub fn load_cable_channels() -> Result<CableChannels> {
);
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();
for prototype in prototypes {

View File

@ -13,11 +13,14 @@ use regex::Regex;
use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir;
use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command;
use crate::{
cable::ChannelPrototypes,
channels::entry::{Entry, PreviewCommand, PreviewType},
};
#[derive(Debug, Clone, PartialEq)]
pub enum PreviewKind {
@ -39,10 +42,10 @@ pub struct Channel {
impl Default for Channel {
fn default() -> Self {
Self::new(
"Files",
"files",
"find . -type f",
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>);
impl Deref for CableChannels {
@ -300,3 +303,25 @@ impl Deref for CableChannels {
&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
/// `list-channels` command. The channel can also be changed from within
/// the application.
#[arg(
value_enum,
default_value = "files",
index = 1,
verbatim_doc_comment
)]
pub channel: String,
#[arg(value_enum, index = 1, verbatim_doc_comment)]
pub channel: Option<String>,
/// A preview command to use with the stdin channel.
///

View File

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

View File

@ -36,6 +36,15 @@ pub struct AppConfig {
pub frame_rate: f64,
#[serde(default = "default_tick_rate")]
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 {

View File

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