Using fish event handlers to automate tsconfig.json paths

Posted on:October 27, 2023

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: I 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