Showing a brief working example of how fish shell can be used to execute a script and provide autocompletions from the output of that script.
The Problem
Are you ever writing in your editor and importing a lot of files. If the language you are writing code in allows for something like typescript/react’s file based routing then navigating through your shell could sometimes feel like a pain.
Brief Background of use case
Currently I am using astro to build this site. Astro
provides for a file based routing system. This means that if I have a file
src/pages/blog/index.astro
then I can import it in another file like so:
import Blog from "@blog/index.astro";
In order for this to work I need to have a tsconfig.json
file that looks:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@blog/*": ["src/pages/blog/*"]
}
}
}
This is great when working in an editor that has typescript support, because I avoid the pain of having to write out relative paths for each new file I create. However, when outside of my editor, and in my shell, I still have to navigate through a lot of directories to get to the file I want to edit. This is where fish event handlers come in.
Using an external script to generate autocomplete options
In fish shell, I can write a script that will parse the json output of a file, and then output the keys of that json file. I can also use this script to display completions for the keys of the json file, which can be truncated for easier pager viewing.
In short, I am writing a script that will run everytime I change $PWD
(with
conditional logic on which directories I want to run it in). If the script is in
a directory where completions should be provided, then it handle their creation,
and output them to stdout. Finally, we will pipe this output provided to our
shell enviorment.
For the python script I wrote in this specific use case, I use arguments passed
to it to determine which mode I can run it in. We essentially will have three
possible inputs (passed as arguments to the script): enable
, disable
, and check
.,
Side Note: A cool thing you could do is provide a scripts directory in your fish config, and then not have to worry about the path of the script which I outline here. In this example, I will provide a working demo without using a scripts directory, but I’d recommend using this method if you plan to use this style of event handlers often in your own shell.
The Script
#!/usr/bin/env python3.10
import os
import sys
import json
def find_tsconfig_json(start_dir):
current_dir = start_dir
while current_dir != '/' and current_dir != os.environ['HOME']:
tsconfig_path = os.path.join(current_dir, 'tsconfig.json')
if os.path.isfile(tsconfig_path):
return tsconfig_path, os.path.join(current_dir, '')
# Move up one directory
current_dir = os.path.dirname(current_dir)
return None, None # If not found in any parent directory
def normalize_json_path_items(path: str):
"""removes the trailing "forward slash" and "star" from a path if it exists. Works for both keys and values
EXAMPLES:
- @component/* -> @component
- c/ -> c
- component/** -> component
- component/**/* -> component
Args:
path: a value from the tsconfig.json file for "paths" key
Returns: the path without a trailing star
"""
return path.rstrip('/*').lstrip('./') or ""
def normalize_dict_items_from_input(raw_key: str, raw_paths: list[str]) -> tuple[str, list[str]]:
"""fixes the raw output from the tsconfig.json file
Args:
raw_key: the raw key from the tsconfig.json file (i.e. '@assets/*')
raw_paths: the raw paths from the tsconfig.json file (i.e. ['assets/*'])
Returns:
tuple(fixed_key: str, fixed_paths: list[str])
fixed_key: the fixed key from the tsconfig.json file (i.e. '@assets')
fixed_paths: the fixed paths from the tsconfig.json file (i.e. ['assets/'])
"""
fixed_key = normalize_json_path_items(raw_key)
fixed_paths = [normalize_json_path_items(path) for path in raw_paths]
return fixed_key, fixed_paths
def dict_insert_normalized_input(input_dict: dict[str, list[str]], raw_key: str, raw_paths: list[str]):
"""inserts the normalized input into the input_dict
Args:
input_dict: the dict to insert the normalized input into
raw_key: the raw key from the tsconfig.json file (i.e. '@assets/*')
raw_paths: the raw paths from the tsconfig.json file (i.e. ['assets/*'])
Returns:
tuple(fixed_key: str, fixed_paths: list[str])
fixed_key: the fixed key from the tsconfig.json file (i.e. '@assets')
fixed_paths: the fixed paths from the tsconfig.json file (i.e. ['assets/'])
"""
fixed_key, fixed_paths = normalize_dict_items_from_input(
raw_key, raw_paths)
input_dict[fixed_key] = fixed_paths
return input_dict
def load_tsconfig_json_data(tsconfig_path):
def default_required_dict_keys():
default_required_keys= '{"compilerOptions": {"paths": {}, "baseUrl": "."}}'
# default_required_keys["compilerOptions"] = {}
# default_required_keys["compilerOptions"]["paths"] = {}
# default_required_keys["compilerOptions"]["baseUrl"] = "."
return json.loads(default_required_keys)
def has_required_keys(tsconfig_data):
if "compilerOptions" not in tsconfig_data:
return False
if "paths" not in tsconfig_data["compilerOptions"]:
return False
return True
def store_required_keys(loaded_json):
result_data = default_required_dict_keys().copy()
result_data["compilerOptions"]["paths"] = loaded_json["compilerOptions"]["paths"]
result_data["compilerOptions"]["baseUrl"] = loaded_json["compilerOptions"]["baseUrl"]
return result_data
def load_json_file(path):
try:
# tsconfig_data = load_json_file(tsconfig_path)
with open(tsconfig_path) as tsc_file:
tsconfig_data = json.loads(tsc_file.read())
default_tsc_data = default_required_dict_keys()
if not has_required_keys(tsconfig_data):
print_debug(3)
exit(1)
return store_required_keys(tsconfig_data)
except (FileNotFoundError, ValueError, KeyError) as e:
print(f"Error: {str(e)}")
exit(1)
tsc_data = load_json_file(tsconfig_path)
def get_json_compiler_opts_values():
tsc_copts_paths = tsc_data["compilerOptions"]['paths']
tsc_copts_base_url = tsc_data["compilerOptions"]['baseUrl'].lstrip('./') or ""
return tsc_copts_paths, tsc_copts_base_url
result_dict = dict()
proj_base = os.path.dirname(os.path.normpath(tsconfig_path))
paths_dict, tsc_base_url = get_json_compiler_opts_values()
for k, v in paths_dict.items():
result_dict = dict_insert_normalized_input(result_dict, k, v)
result_dict = dict_insert_normalized_input(result_dict, '@baseUrl', [calcualte_relative_path(os.path.join(proj_base, tsc_base_url, ''))])
return os.path.join(proj_base, tsc_base_url, ''), result_dict
def convert_path_to_output_cmd(path: str):
"""converts a path to a command that will open the path in the editor if it is a file or cd to the path if it is a directory
Args:
path: a value from the tsconfig.json file for "paths" key
Returns: a valid shell command
"""
if os.path.exists(path) and os.path.isfile(path):
return f"$EDITOR {path}"
else:
return f"cd {path}"
return path
def calcualte_relative_path(path: str):
"""calculates the relative path from the current directory to the path given
Args:
path: a value from the tsconfig.json file for "paths" key
Returns: a relative path from the current directory to the path given
"""
return os.path.relpath(path, os.getcwd())
def get_unique_suffixes(paths: list[str]) -> list[str]:
# Remove trailing '*' from directory paths
paths = [path.rstrip('*') for path in paths]
# Remove leading and trailing '.' and '/' characters from all paths
paths = [path.strip('./') for path in paths]
# Initialize a dictionary to store suffixes and their counts
suffix_count = {}
# Iterate through the paths and find unique suffixes
unique_suffixes = []
for path in paths:
parts = path.split('/')
for i in range(len(parts)):
suffix = '/'.join(parts[i:])
if suffix not in suffix_count:
suffix_count[suffix] = 1
else:
suffix_count[suffix] += 1
# Iterate through the paths again and append unique suffixes to the result
for path in paths:
parts = path.split('/')
for i in range(len(parts)):
suffix = '/'.join(parts[i:])
if suffix_count[suffix] == 1:
unique_suffixes.append(suffix)
break
return unique_suffixes
def create_output_dict(tsc_base_url: str, tsc_paths_dict: dict[str, list[str]]) -> dict[str, str]:
"""
creates a 1:1 mapping of the unique keys(truncated paths) to their values
Args:
tsc_base_url: the base url for the first parent directory containing tsconfig.json file
tsc_paths_dict: the paths dict from the tsconfig.json file
Returns: a dict with the unique keys(truncated paths) as the keys and the values as the relative paths from the current directory
"""
# create a 1:1 mapping of the unique keys(truncated paths) to their values
unique_map: dict[str, str] = dict()
for k, v in tsc_paths_dict.items():
# get the relative paths so completions fit to shell pager
rel_paths = [calcualte_relative_path(tsc_base_url+path) for path in v]
if len(rel_paths) == 1:
unique_map[k] = rel_paths[0]
continue
# create a unique key for each path
unique_suffixes = get_unique_suffixes(rel_paths)
for suffix in unique_suffixes:
unique_key = os.path.join(k, suffix)
unique_map[unique_key] = suffix
return unique_map
def create_abbr(key: str, value: str) -> str:
return f'abbr -a -g {key} "{convert_path_to_output_cmd(value)}"'
def remove_abbr(key: str) -> str:
return f'abbr -ge {key}'
def output_abbrs_list_from_map(unique_map: dict[str, str], output_type: str):
def get_output_list():
match output_type:
case 'enable':
return [create_abbr(k, v) for k, v in unique_map.items()]
case 'disable':
return [remove_abbr(k) for k, v in unique_map.items()]
case _:
return []
output = get_output_list()
if len(output) != 0:
for cmd in output:
sys.stdout.write(cmd+'\n')
def main():
cmd_current_dir = os.getcwd() # Get the current working directory
tsc_file, tsc_root = find_tsconfig_json(cmd_current_dir)
subcmd = sys.argv[1] if len(sys.argv) > 1 else ''
def normal_handling():
if tsc_file is None or tsc_root is None:
return
tsc_base_url, tsc_paths_dict = load_tsconfig_json_data(tsc_file)
unique_map = create_output_dict(tsc_base_url, tsc_paths_dict)
output_abbrs_list_from_map(unique_map, subcmd)
match subcmd:
case 'check':
if tsc_file is not None and tsc_root is not None:
sys.stdout.write('true')
else:
sys.stdout.write('false')
exit(0)
case 'enable':
normal_handling()
exit(0)
case 'disable':
normal_handling()
exit(0)
case _:
if tsc_file is None or tsc_root is None:
exit(1)
exit(0)
if __name__ == '__main__':
main()
Testing the output of the script
First we will need to make the tsc_jump.py script executable. Then, we can run it inside our shell enviornment. Once it is executable, we can run it by providing an alias to it seen below:
alias tsc_jump='scripts/tsc_jump.py' # this is the path to the script above
Now we can test running the script by running the following command:
# checks if we're in a working directory
tsc_jump check
# outputs the completions for the current directory
tsc_jump enable
# removes the completions for the current directory
tsc_jump disable
Adding the fish event handlers
Now that we have a working script, that properly outputs the completions for the relative to the current directory, we can add the event handlers to our fish config. We will need to add the following to our fish config:
function tsc_jump_handler --on-variable tsc_jump_active
if test $tsc_jump_active -ne 1
tsc_jump disable | source
else
tsc_jump enable | source
end
end
We also need a second event handler to handle the case where we change handle
when tsc_jump_handler
above is called. Here is the second event handler:
function tsc_activate_handler --on-variable PWD
set -l tsc_jump_check (tsc_jump check)
if test -n "$tsc_jump_check"; and test $tsc_jump_check = "true"
set -gx tsc_jump_active 1
else
set -gx tsc_jump_active 0
end
end
Finally, we can append these event handlers to our config.fish
file:
alias tsc_jump="$path_to_script/tsc-jump.py"
tsc_activate_handler
tsc_jump_handler
Using the script
Now, when inside our projects where a tsconfig.json
file exists, with ‘paths’
keys, we can use them to autocomplete our paths.
typescript_project> @_
@assets/ @blog/ @components/ @layouts/ @pages/ @styles/ @utils/
typescript_project> @assets/_
typescript_project> cd ./assets