Skip to main content

try_rs/
shell.rs

1use crate::cli::Shell;
2use crate::config::{get_base_config_dir, get_config_dir};
3use anyhow::Result;
4use std::fs;
5use std::io::Write;
6use std::path::PathBuf;
7
8const FISH_PICKER_FUNCTION: &str = r#"function try-rs-picker
9    set -l picker_args --inline-picker
10
11    if set -q TRY_RS_PICKER_HEIGHT
12        if string match -qr '^[0-9]+$' -- "$TRY_RS_PICKER_HEIGHT"
13            set picker_args $picker_args --inline-height $TRY_RS_PICKER_HEIGHT
14        end
15    end
16
17    if status --is-interactive
18        printf "\n"
19    end
20
21    set command (command try-rs $picker_args | string collect)
22    set command_status $status
23
24    if test $command_status -eq 0; and test -n "$command"
25        eval $command
26    end
27
28    if status --is-interactive
29        printf "\033[A"
30        commandline -f repaint
31    end
32end
33"#;
34
35/// Returns the shell integration script content for the given shell type.
36/// This is used by --setup-stdout to print the content to stdout.
37pub fn get_shell_content(shell: &Shell) -> String {
38    let completions = get_completions_script(shell);
39    match shell {
40        Shell::Fish => {
41            format!(
42                r#"function try-rs
43    # Pass flags/options directly to stdout without capturing
44    for arg in $argv
45        if string match -q -- '-*' $arg
46            command try-rs $argv
47            return
48        end
49    end
50
51    # Captures the output of the binary (stdout) which is the "cd" command
52    # The TUI is rendered on stderr, so it doesn't interfere.
53    set command (command try-rs $argv | string collect)
54    set command_status $status
55
56    if test $command_status -eq 0; and test -n "$command"
57        eval $command
58    end
59end
60
61{picker_function}
62
63{completions}"#,
64                picker_function = FISH_PICKER_FUNCTION,
65            )
66        }
67        Shell::Zsh => {
68            format!(
69                r#"try-rs() {{
70    # Pass flags/options directly to stdout without capturing
71    for arg in "$@"; do
72        case "$arg" in
73            -*) command try-rs "$@"; return ;;
74        esac
75    done
76
77    # Captures the output of the binary (stdout) which is the "cd" command
78    # The TUI is rendered on stderr, so it doesn't interfere.
79    local output
80    output=$(command try-rs "$@")
81
82    if [ -n "$output" ]; then
83        eval "$output"
84    fi
85}}
86
87{completions}"#
88            )
89        }
90        Shell::Bash => {
91            format!(
92                r#"try-rs() {{
93    # Pass flags/options directly to stdout without capturing
94    for arg in "$@"; do
95        case "$arg" in
96            -*) command try-rs "$@"; return ;;
97        esac
98    done
99
100    # Captures the output of the binary (stdout) which is the "cd" command
101    # The TUI is rendered on stderr, so it doesn't interfere.
102    local output
103    output=$(command try-rs "$@")
104
105    if [ -n "$output" ]; then
106        eval "$output"
107    fi
108}}
109
110{completions}"#
111            )
112        }
113        Shell::PowerShell => {
114            format!(
115                r#"# try-rs integration for PowerShell
116function try-rs {{
117    # Pass flags/options directly to stdout without capturing
118    foreach ($a in $args) {{
119        if ($a -like '-*') {{
120            & try-rs.exe @args
121            return
122        }}
123    }}
124
125    # Captures the output of the binary (stdout) which is the "cd" or editor command
126    # The TUI is rendered on stderr, so it doesn't interfere.
127    $command = (try-rs.exe @args)
128
129    if ($command) {{
130        Invoke-Expression $command
131    }}
132}}
133
134{completions}"#
135            )
136        }
137        Shell::NuShell => {
138            format!(
139                r#"def --wrapped try-rs [...args] {{
140    # Pass flags/options directly to stdout without capturing
141    for arg in $args {{
142        if ($arg | str starts-with '-') {{
143            ^try-rs.exe ...$args
144            return
145        }}
146    }}
147
148    # Capture output. Stderr (TUI) goes directly to terminal.
149    let output = (try-rs.exe ...$args)
150
151    if ($output | is-not-empty) {{
152
153        # Grabs the path out of stdout returned by the binary and removes the single quotes
154        let $path = ($output | split row ' ').1 | str replace --all "'" ''
155        cd $path
156    }}
157}}
158
159{completions}"#
160            )
161        }
162    }
163}
164
165/// Returns the tab completion script for the given shell.
166/// This provides dynamic completion of directory names from the tries_path.
167pub fn get_completions_script(shell: &Shell) -> String {
168    match shell {
169        Shell::Fish => {
170            r#"# try-rs tab completion for directory names
171function __try_rs_get_tries_path
172    # Check TRY_PATH environment variable first
173    if set -q TRY_PATH
174        echo $TRY_PATH
175        return
176    end
177    
178    # Try to read from config file
179    set -l config_paths "$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml"
180    for config_path in $config_paths
181        if test -f $config_path
182            set -l tries_path (command grep -E '^\s*tries_path\s*=' $config_path 2>/dev/null | command sed 's/.*=\s*"\?\([^"]*\)"\?.*/\1/' | command sed "s|~|$HOME|" | string trim)
183            if test -n "$tries_path"
184                echo $tries_path
185                return
186            end
187        end
188    end
189    
190    # Default path
191    echo "$HOME/work/tries"
192end
193
194function __try_rs_complete_directories
195    set -l tries_path (__try_rs_get_tries_path)
196    
197    if test -d $tries_path
198        # List directories in tries_path, filtering by current token
199        command ls -1 $tries_path 2>/dev/null | while read -l dir
200            if test -d "$tries_path/$dir"
201                echo $dir
202            end
203        end
204    end
205end
206
207complete -f -c try-rs -n '__fish_use_subcommand' -a '(__try_rs_complete_directories)' -d 'Try directory'
208"#.to_string()
209        }
210        Shell::Zsh => {
211            r#"# try-rs tab completion for directory names
212_try_rs_get_tries_path() {
213    # Check TRY_PATH environment variable first
214    if [[ -n "${TRY_PATH}" ]]; then
215        echo "${TRY_PATH}"
216        return
217    fi
218    
219    # Try to read from config file
220    local config_paths=("$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml")
221    for config_path in "${config_paths[@]}"; do
222        if [[ -f "$config_path" ]]; then
223            local tries_path=$(grep -E '^\s*tries_path\s*=' "$config_path" 2>/dev/null | sed 's/.*=\s*"\?\([^"]*\)"\?.*/\1/' | sed "s|~|$HOME|" | tr -d '[:space:]')
224            if [[ -n "$tries_path" ]]; then
225                echo "$tries_path"
226                return
227            fi
228        fi
229    done
230    
231    # Default path
232    echo "$HOME/work/tries"
233}
234
235_try_rs_complete() {
236    local cur="${COMP_WORDS[COMP_CWORD]}"
237    local tries_path=$(_try_rs_get_tries_path)
238    local -a dirs=()
239    
240    if [[ -d "$tries_path" ]]; then
241        # Get list of directories
242        while IFS= read -r dir; do
243            dirs+=("$dir")
244        done < <(ls -1 "$tries_path" 2>/dev/null | while read -r dir; do
245            if [[ -d "$tries_path/$dir" ]]; then
246                echo "$dir"
247            fi
248        done)
249    fi
250    
251    COMPREPLY=($(compgen -W "${dirs[*]}" -- "$cur"))
252}
253
254complete -o default -F _try_rs_complete try-rs
255"#.to_string()
256        }
257        Shell::Bash => {
258            r#"# try-rs tab completion for directory names
259_try_rs_get_tries_path() {
260    # Check TRY_PATH environment variable first
261    if [[ -n "${TRY_PATH}" ]]; then
262        echo "${TRY_PATH}"
263        return
264    fi
265    
266    # Try to read from config file
267    local config_paths=("$HOME/.config/try-rs/config.toml" "$HOME/.try-rs/config.toml")
268    for config_path in "${config_paths[@]}"; do
269        if [[ -f "$config_path" ]]; then
270            local tries_path=$(grep -E '^[[:space:]]*tries_path[[:space:]]*=' "$config_path" 2>/dev/null | sed 's/.*=[[:space:]]*"\?\([^"]*\)"\?.*/\1/' | sed "s|~|$HOME|" | tr -d '[:space:]')
271            if [[ -n "$tries_path" ]]; then
272                echo "$tries_path"
273                return
274            fi
275        fi
276    done
277    
278    # Default path
279    echo "$HOME/work/tries"
280}
281
282_try_rs_complete() {
283    local cur="${COMP_WORDS[COMP_CWORD]}"
284    local tries_path=$(_try_rs_get_tries_path)
285    local dirs=""
286    
287    if [[ -d "$tries_path" ]]; then
288        # Get list of directories
289        while IFS= read -r dir; do
290            if [[ -d "$tries_path/$dir" ]]; then
291                dirs="$dirs $dir"
292            fi
293        done < <(ls -1 "$tries_path" 2>/dev/null)
294    fi
295    
296    COMPREPLY=($(compgen -W "$dirs" -- "$cur"))
297}
298
299complete -o default -F _try_rs_complete try-rs
300"#.to_string()
301        }
302        Shell::PowerShell => {
303            r#"# try-rs tab completion for directory names
304Register-ArgumentCompleter -CommandName try-rs -ScriptBlock {
305    param($wordToComplete, $commandAst, $cursorPosition)
306    
307    # Get tries path from environment variable or default
308    $triesPath = $env:TRY_PATH
309    if (-not $triesPath) {
310        # Try to read from config file
311        $configPaths = @(
312            "$env:USERPROFILE/.config/try-rs/config.toml",
313            "$env:USERPROFILE/.try-rs/config.toml"
314        )
315        foreach ($configPath in $configPaths) {
316            if (Test-Path $configPath) {
317                $content = Get-Content $configPath -Raw
318                if ($content -match 'tries_path\s*=\s*["'']?([^"'']+)["'']?') {
319                    $triesPath = $matches[1].Replace('~', $env:USERPROFILE).Trim()
320                    break
321                }
322            }
323        }
324    }
325    
326    # Default path
327    if (-not $triesPath) {
328        $triesPath = "$env:USERPROFILE/work/tries"
329    }
330    
331    # Get directories
332    if (Test-Path $triesPath) {
333        Get-ChildItem -Path $triesPath -Directory | 
334            Where-Object { $_.Name -like "$wordToComplete*" } |
335            ForEach-Object { 
336                [System.Management.Automation.CompletionResult]::new(
337                    $_.Name, 
338                    $_.Name, 
339                    'ParameterValue', 
340                    $_.Name
341                )
342            }
343    }
344}
345"#.to_string()
346        }
347        Shell::NuShell => {
348            r#"# try-rs tab completion for directory names
349# Add this to your Nushell config or env file
350
351export def __try_rs_get_tries_path [] {
352    # Check TRY_PATH environment variable first
353    if ($env.TRY_PATH? | is-not-empty) {
354        return $env.TRY_PATH
355    }
356    
357    # Try to read from config file
358    let config_paths = [
359        ($env.HOME | path join ".config" "try-rs" "config.toml"),
360        ($env.HOME | path join ".try-rs" "config.toml")
361    ]
362    
363    for config_path in $config_paths {
364        if ($config_path | path exists) {
365            let content = (open $config_path | str trim)
366            if ($content =~ 'tries_path\\s*=\\s*"?([^"]+)"?') {
367                let path = ($content | parse -r 'tries_path\\s*=\\s*"?([^"]+)"?' | get capture0.0? | default "")
368                if ($path | is-not-empty) {
369                    return ($path | str replace "~" $env.HOME)
370                }
371            }
372        }
373    }
374    
375    # Default path
376    ($env.HOME | path join "work" "tries")
377}
378
379export def __try_rs_complete [context: string] {
380    let tries_path = (__try_rs_get_tries_path)
381    
382    if ($tries_path | path exists) {
383        ls $tries_path | where type == "dir" | get name | path basename
384    } else {
385        []
386    }
387}
388
389# Add completion to the try-rs command
390export extern try-rs [
391    name_or_url?: string@__try_rs_complete
392    destination?: string
393    --setup: string
394    --setup-stdout: string
395    --completions: string
396    --shallow-clone(-s)
397    --worktree(-w): string
398]
399"#.to_string()
400        }
401    }
402}
403
404/// Returns only the completion script (for --completions flag)
405pub fn get_completion_script_only(shell: &Shell) -> String {
406    let completions = get_completions_script(shell);
407    match shell {
408        Shell::NuShell => {
409            // For NuShell, we need to provide a different format when used standalone
410            r#"# try-rs tab completion for directory names
411# Add this to your Nushell config
412
413def __try_rs_get_tries_path [] {
414    if ($env.TRY_PATH? | is-not-empty) {
415        return $env.TRY_PATH
416    }
417    
418    let config_paths = [
419        ($env.HOME | path join ".config" "try-rs" "config.toml"),
420        ($env.HOME | path join ".try-rs" "config.toml")
421    ]
422    
423    for config_path in $config_paths {
424        if ($config_path | path exists) {
425            let content = (open $config_path | str trim)
426            if ($content =~ 'tries_path\\s*=\\s*"?([^"]+)"?') {
427                let path = ($content | parse -r 'tries_path\\s*=\\s*"?([^"]+)"?' | get capture0.0? | default "")
428                if ($path | is-not-empty) {
429                    return ($path | str replace "~" $env.HOME)
430                }
431            }
432        }
433    }
434    
435    ($env.HOME | path join "work" "tries")
436}
437
438def __try_rs_complete [context: string] {
439    let tries_path = (__try_rs_get_tries_path)
440    
441    if ($tries_path | path exists) {
442        ls $tries_path | where type == "dir" | get name | path basename
443    } else {
444        []
445    }
446}
447
448# Register completion
449export extern try-rs [
450    name_or_url?: string@__try_rs_complete
451    destination?: string
452    --setup: string
453    --setup-stdout: string
454    --completions: string
455    --shallow-clone(-s)
456    --worktree(-w): string
457]
458"#.to_string()
459        }
460        _ => completions,
461    }
462}
463
464pub fn get_shell_integration_path(shell: &Shell) -> PathBuf {
465    let config_dir = match shell {
466        Shell::Fish => get_base_config_dir(),
467        _ => get_config_dir(),
468    };
469
470    match shell {
471        Shell::Fish => config_dir
472            .join("fish")
473            .join("functions")
474            .join("try-rs.fish"),
475        Shell::Zsh => config_dir.join("try-rs.zsh"),
476        Shell::Bash => config_dir.join("try-rs.bash"),
477        Shell::PowerShell => config_dir.join("try-rs.ps1"),
478        Shell::NuShell => config_dir.join("try-rs.nu"),
479    }
480}
481
482fn write_fish_picker_function() -> Result<PathBuf> {
483    let file_path = get_base_config_dir()
484        .join("fish")
485        .join("functions")
486        .join("try-rs-picker.fish");
487    if let Some(parent) = file_path.parent()
488        && !parent.exists()
489    {
490        fs::create_dir_all(parent)?;
491    }
492    fs::write(&file_path, FISH_PICKER_FUNCTION)?;
493    eprintln!("Fish picker function file created at: {}", file_path.display());
494    Ok(file_path)
495}
496
497pub fn is_shell_integration_configured(shell: &Shell) -> bool {
498    get_shell_integration_path(shell).exists()
499}
500
501/// Appends a source command to an RC file if not already present.
502fn append_source_to_rc(rc_path: &std::path::Path, source_cmd: &str) -> Result<()> {
503    if rc_path.exists() {
504        let content = fs::read_to_string(rc_path)?;
505        if !content.contains(source_cmd) {
506            let mut file = fs::OpenOptions::new().append(true).open(rc_path)?;
507            writeln!(file, "\n# try-rs integration")?;
508            writeln!(file, "{}", source_cmd)?;
509            eprintln!("Added configuration to {}", rc_path.display());
510        } else {
511            eprintln!("Configuration already present in {}", rc_path.display());
512        }
513    } else {
514        eprintln!(
515            "You need to add the following line to {}:",
516            rc_path.display()
517        );
518        eprintln!("{}", source_cmd);
519    }
520    Ok(())
521}
522
523/// Writes the shell integration file and returns its path.
524fn write_shell_integration(shell: &Shell) -> Result<std::path::PathBuf> {
525    let file_path = get_shell_integration_path(shell);
526    if let Some(parent) = file_path.parent()
527        && !parent.exists()
528    {
529        fs::create_dir_all(parent)?;
530    }
531    fs::write(&file_path, get_shell_content(shell))?;
532    eprintln!(
533        "{:?} function file created at: {}",
534        shell,
535        file_path.display()
536    );
537    Ok(file_path)
538}
539
540/// Sets up shell integration for the given shell.
541pub fn setup_shell(shell: &Shell) -> Result<()> {
542    let file_path = write_shell_integration(shell)?;
543    let home_dir = dirs::home_dir().expect("Could not find home directory");
544
545    match shell {
546        Shell::Fish => {
547            let _picker_path = write_fish_picker_function()?;
548            let fish_config_path = get_base_config_dir().join("fish").join("config.fish");
549            eprintln!(
550                "You may need to restart your shell or run 'source {}' to apply changes.",
551                file_path.display()
552            );
553            eprintln!(
554                "Optional: append the following to {} to bind Ctrl+T:",
555                fish_config_path.display()
556            );
557            eprintln!("bind \\ct try-rs-picker");
558            eprintln!("bind -M insert \\ct try-rs-picker");
559        }
560        Shell::Zsh => {
561            let source_cmd = format!("source '{}'", file_path.display());
562            append_source_to_rc(&home_dir.join(".zshrc"), &source_cmd)?;
563        }
564        Shell::Bash => {
565            let source_cmd = format!("source '{}'", file_path.display());
566            append_source_to_rc(&home_dir.join(".bashrc"), &source_cmd)?;
567        }
568        Shell::PowerShell => {
569            let profile_path_ps7 = home_dir
570                .join("Documents")
571                .join("PowerShell")
572                .join("Microsoft.PowerShell_profile.ps1");
573            let profile_path_ps5 = home_dir
574                .join("Documents")
575                .join("WindowsPowerShell")
576                .join("Microsoft.PowerShell_profile.ps1");
577            let profile_path = if profile_path_ps7.exists() {
578                profile_path_ps7
579            } else if profile_path_ps5.exists() {
580                profile_path_ps5
581            } else {
582                profile_path_ps7
583            };
584
585            if let Some(parent) = profile_path.parent()
586                && !parent.exists()
587            {
588                fs::create_dir_all(parent)?;
589            }
590
591            let source_cmd = format!(". '{}'", file_path.display());
592            if profile_path.exists() {
593                append_source_to_rc(&profile_path, &source_cmd)?;
594            } else {
595                let mut file = fs::File::create(&profile_path)?;
596                writeln!(file, "# try-rs integration")?;
597                writeln!(file, "{}", source_cmd)?;
598                eprintln!(
599                    "PowerShell profile created and configured at: {}",
600                    profile_path.display()
601                );
602            }
603
604            eprintln!(
605                "You may need to restart your shell or run '. {}' to apply changes.",
606                profile_path.display()
607            );
608            eprintln!(
609                "If you get an error about running scripts, you may need to run: Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned"
610            );
611        }
612        Shell::NuShell => {
613            let nu_config_path = dirs::config_dir()
614                .expect("Could not find config directory")
615                .join("nushell")
616                .join("config.nu");
617            let source_cmd = format!("source '{}'", file_path.display());
618            if nu_config_path.exists() {
619                append_source_to_rc(&nu_config_path, &source_cmd)?;
620            } else {
621                eprintln!("Could not find config.nu at {}", nu_config_path.display());
622                eprintln!("Please add the following line manually:");
623                eprintln!("{}", source_cmd);
624            }
625        }
626    }
627
628    Ok(())
629}
630
631/// Generates a standalone completion script for the given shell.
632pub fn generate_completions(shell: &Shell) -> Result<()> {
633    let script = get_completion_script_only(shell);
634    print!("{}", script);
635    Ok(())
636}