diff --git a/README.md b/README.md index fb7ee6e..8a46682 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,26 @@ $ pwd /home/user/Documents ``` +Quik is also smart enough to guess what you meant to type if you lost some +trailing characters: + +```shell +$ pwd +~ +$ quik --list +"docs" → "/home/user/Documents" +$ quik do +$ pwd +/ home/user/Documents +``` + +Finally, quik [likely](#why-quik) supports autocompletion in your shell: + +```shell +$ quik do +$ quik docs +``` + ## Quick Start ``` shell @@ -41,6 +61,20 @@ Windows, via the [batch port of that file](internals/quik_setup.bat), and as of - [x] Command Prompt - [x] PowerShell +### I Would Like to Contribute With a Port for my Shell + +Such pull requests are very welcome. + +If you are interested in doing so, all that you need to do is create a file in +the style of [`quik_setup.sh`](internals/quik_setup.sh) for your shell, and +indicate in your pull request how this file should be used in your shell (i.e., +whether it should be sourced at the start of each session, ran at the start of +each session, or something else). + +I have made an attempt to thoroughly comment +[`quik_setup.sh`](internals/quik_setup.sh), so it should be easy to follow and +write analogous code for your shell. + ## License This tool is licensed under an MIT license. diff --git a/internals/output_parse.py b/internals/output_parse.py index 2dc2214..724f671 100644 --- a/internals/output_parse.py +++ b/internals/output_parse.py @@ -2,12 +2,15 @@ """A helper file to parse output. You shouldn't be looking at this. Usage: - output_parse [options] + output_parse --cd + output_parse --output + output_parse --complete= [--alias=] Options: --cd Find the directory to change to. --output Find the regular output. --complete= Suggest completion for the currently typed text. + --alias= Hint that the completion is being called for a command alias. """ import re @@ -34,6 +37,7 @@ def __init__(self, root): self.root = root self.memoized_trees = {} self.connection_graph = {root: []} + self.aliases = {} def to_explicit(self, node): if type(node) is Graph.Node: @@ -46,18 +50,32 @@ def to_explicit(self, node): def memoize_tree(self, name, tree): self.memoized_trees[name] = tree + def alias(self, name, alias): + self.aliases[alias] = name + def connect(self, this, that): - if that in self.memoized_trees: - self.connection_graph.setdefault(this, []).append(Graph.MemoizedTree(that)) + if that in self.aliases: + self.connect(self, this, self.aliases[that]) else: - self.connection_graph.setdefault(this, []).append(Graph.Node(that)) - self.connection_graph.setdefault(that, []) + if that in self.memoized_trees: + self.connection_graph.setdefault(this, []).append(Graph.MemoizedTree(that)) + else: + self.connection_graph.setdefault(this, []).append(Graph.Node(that)) + self.connection_graph.setdefault(that, []) def get_connections(self, name): - return [y for x in self.connection_graph.get(name, []) for y in self.to_explicit(x)] + if name in self.aliases: + return self.get_connections(self.aliases[name]) + else: + return [y for x in self.connection_graph.get(name, []) + for y in self.to_explicit(x)] def contains(self, name): - return name in self.connection_graph or any(name in tree for tree in self.memoized_trees) + if name in self.aliases: + return self.contains(self.aliases[name]) + else: + return (name in self.connection_graph + or any(name in tree for tree in self.memoized_trees)) def suggest(suggestions): @@ -68,12 +86,17 @@ def suggest(suggestions): if __name__ == '__main__': - arguments = docopt(__doc__, version="output_parse 2.0") + arguments = docopt(__doc__, version="output_parse 3.0") cmd_regex = re.compile(r"^\s*\+(?:cd) \"?(.+?)\"?\s*$\n?", re.MULTILINE) if arguments['--cd']: - print(cmd_regex.search(get_input_text()).group(1).strip()) + match_obj = cmd_regex.search(get_input_text()) + if match_obj is None: + # Exit with no output + pass + else: + print(match_obj.group(1).strip()) elif arguments['--output']: print(cmd_regex.sub("", get_input_text()).strip()) elif arguments['--complete'] is not None: @@ -93,25 +116,23 @@ def aliases(): from quik import get_aliases, get_quik_json return list(get_aliases(get_quik_json(), warn=False).keys()) - grammar_graph = Graph("root") - grammar_graph.memoize_tree("aliases", [alias for alias in aliases()]) - grammar_graph.memoize_tree("cmds", - ["add", "--list", "get", "edit", "remove", - "--help", "-h"]) - grammar_graph.connect("root", "aliases") - grammar_graph.connect("quik", "aliases") - grammar_graph.connect("root", "cmds") - grammar_graph.connect("quik", "cmds") - grammar_graph.connect("add", "--force") - grammar_graph.connect("get", "aliases") - grammar_graph.connect("edit", "--force") - grammar_graph.connect("--force", "aliases") - grammar_graph.connect("edit", "aliases") - grammar_graph.connect("remove", "aliases") - - #if len(words) == 0 or words[0] != "quik": - # That's weird... how are you invoking this? - #exit(1) + grammar_graph = Graph('root') + grammar_graph.memoize_tree('aliases', [alias for alias in aliases()]) + grammar_graph.memoize_tree('cmds', + ['add', '--list', 'get', 'edit', 'remove', + '--help', '-h']) + grammar_graph.connect('root', 'aliases') + grammar_graph.connect('root', 'cmds') + grammar_graph.connect('add', '--force') + grammar_graph.connect('get', 'aliases') + grammar_graph.connect('edit', '--force') + grammar_graph.connect('--force', 'aliases') + grammar_graph.connect('edit', 'aliases') + grammar_graph.connect('remove', 'aliases') + + grammar_graph.alias('root', 'quik') + if arguments['--alias'] is not None: + grammar_graph.alias('root', arguments['--alias']) # "Edge" case; doing something like # quik add diff --git a/internals/quik_setup.bat b/internals/quik_setup.bat index f3e812f..5bbfe62 100644 --- a/internals/quik_setup.bat +++ b/internals/quik_setup.bat @@ -33,21 +33,17 @@ if %retcode% NEQ 0 ( goto :eof ) -(echo !out! | findstr /l "+cd ") >nul 2>&1 -if %errorlevel%==0 ( - for /F "delims=*" %%i in ('echo !out! ^| python %parse_py% --output 2^>^&1') do if "!userout!"=="" (set userout=%%i) else (set userout=!userout!!nl!%%i) - if "!userout!" NEQ "" (echo !userout!) - - for /F "delims=*" %%i in ('echo !out! ^| python %parse_py% --cd 2^>^&1') do set cdout=%%i - - REM HACK to survive endlocal - for /f "tokens=1 delims=" %%A in (""!cdout!"") do ( - endlocal - cd /D %%A - goto :eof - ) -) else ( - if "!out!" NEQ "" (echo !out!) +for /F "delims=*" %%i in ('echo !out! ^| python %parse_py% --output 2^>^&1') do if "!userout!"=="" (set userout=%%i) else (set userout=!userout!!nl!%%i) +if "!userout!" NEQ "" (echo !userout!) + +for /F "delims=*" %%i in ('echo !out! ^| python %parse_py% --cd 2^>^&1') do set cdout=%%i +if [%%i] == [] goto :eof + +REM HACK to survive endlocal +for /f "tokens=1 delims=" %%A in (""!cdout!"") do ( + endlocal + cd /D %%A + goto :eof ) endlocal diff --git a/internals/quik_setup.ps1 b/internals/quik_setup.ps1 index 96e7f8c..d72f583 100644 --- a/internals/quik_setup.ps1 +++ b/internals/quik_setup.ps1 @@ -2,39 +2,56 @@ # Its structure is closer to the bash file than to the command prompt file, # in terms of the approach taken. -# Get this file's directory -# While $PSScriptRoot should be available, this should be more backwards compatible. -# See https://stackoverflow.com/a/3667376 (Roman Kuzmin) -$scriptPath = Get-Item (Split-Path $MyInvocation.MyCommand.Path -Parent) +$scriptParent = Split-Path -Parent $PSScriptRoot # Variables we'll use for invocation -$pyQuik = Get-Item -Path (Join-Path -Path $scriptPath.parent -ChildPath "quik.py") -$pyParse = Get-Item -Path (Join-Path -Path $scriptPath -ChildPath "output_parse.py") +$pyQuik = Get-Item -Path (Join-Path -Path $ScriptParent -ChildPath "quik.py") +$pyParse = Get-Item -Path (Join-Path -Path $PSScriptRoot -ChildPath "output_parse.py") # Set the QUIK_JSON environment variable -$env:QUIK_JSON = Convert-Path -Path (Join-Path -Path $scriptPath.parent -ChildPath "quik.json") +$env:QUIK_JSON = Convert-Path -Path (Join-Path -Path $scriptParent -ChildPath "quik.json") function Invoke-Quik { $out = (&python $pyQuik @args) -join "`n" $exitcode=$LASTEXITCODE - if ($out | Select-String -Pattern '+cd' -SimpleMatch -Quiet) { - Write-Host ($out | &python $pyParse --output) - $dir = ($out | &python $pyParse --cd) + $userOut = ($out | &python $pyParse --output) -join "`n" + if (![String]::IsNullOrWhitespace($userOut)) { + Write-Host $userOut + } + $dir = ($out | &python $pyParse --cd) + if (![String]::IsNullOrWhitespace($dir)) { Set-Location -LiteralPath $dir - } else { - Write-Host $out } $LASTEXITCODE=$exitcode } -Set-Alias -Name quik -Value Invoke-Quik +function Register-QuikAlias { + param( + [String]$Name + ) -Register-ArgumentCompleter -Native -CommandName quik -ScriptBlock { - param($commandName, $wordToComplete, $cursorPosition) - $completion = &python $pyParse --complete="$wordToComplete" - if ($LASTEXITCODE -eq 0) { - $completion - } else { - Get-ChildItem ".\$wordToComplete*" | ForEach-Object { Resolve-Path -Relative "$_" } - } + $completionScript = { + param($commandName, $wordToComplete, $cursorPosition) + # The trailing space is important to let the autocomplete script know if we are + # looking for the next word or the completion of the current word. + if ($cursorPosition -gt $wordToComplete.Length) { + $wordToComplete = "$wordToComplete " + } + [array]$completion = (&python $pyParse --complete="$wordToComplete" --alias="$Name") + if ($LASTEXITCODE -eq 0) { + $completion + } else { + Get-ChildItem ".\$wordToComplete*" -Directory | + ForEach-Object { Resolve-Path -Relative "$_" } + } + }.GetNewClosure() + # GetNewClosure is necessary to ensure that $Name preserves its value after going out of scope. + # See [https://techstronghold.com/scripting/@rudolfvesely/how-to-copy-values-of-variables-into-powershell-script-block-and-keep-it-intact-remember-it/] + + Set-Alias -Name "$Name" -Value Invoke-Quik -Scope Global + Register-ArgumentCompleter -Native -CommandName $Name -ScriptBlock $completionScript } + +Register-QuikAlias -Name quik +# To register other aliases, you may call +# Register-QuikAlias -Name alias diff --git a/internals/quik_setup.sh b/internals/quik_setup.sh index 52f5984..17464ea 100644 --- a/internals/quik_setup.sh +++ b/internals/quik_setup.sh @@ -1,40 +1,114 @@ #!/bin/bash +# The glue between the core contents of quik and the shell you are using. +# +# If you want to parse quik, what you need is to parse this file to your +# favorite shell. Likely, you should expect that this file is sourced (say, at +# shell startup) and not ran, because it provides functionality within the +# user's session, and requires the ability to change the user's working +# directory. This is achieved, in this case, by defining a function. See below. +# Get this file's directory, so that we can reference other quik files +# as relative paths to it. +# (It is an invariant that this file, or ports of it, live in +# `/internal`. ) DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +# Get the path for the main quik (python) script. +# This file is responsible for taking in the users input, and producing output +# that can signal the directory should be changed or just report some +# information to the user. PY_QUIK="$DIR/../quik.py" + +# Get the path for the output parsing (python) script. +# This script is just responsible for taking the raw output of the PY_QUIK +# process and doing the parsing for us, both into the directory we should `cd` +# into, or just into the output that the user should see. PY_PARSE="$DIR/output_parse.py" + +# Quik expects this environment variable to be set to the JSON file where +# the user's bookmarked directories are saved. export QUIK_JSON="$DIR/../quik.json" -function _quik_autocomplete { - COMPL_OUT="$(${PY_PARSE} --complete="$COMP_LINE")" - SUCCESS=$? - COMPREPLY=() - if [ $SUCCESS == 0 ] - then - readarray -t COMPREPLY <<<"$COMPL_OUT" - else - local cur - case "$2" in - \~*) eval cur="$2" ;; - *) cur=$2 ;; - esac - COMPREPLY=( $(compgen -d -- "$cur") ) - fi +# The function that the user will call when they type `quik` into the terminal. +# +# This may have to be an alias or some other kind of construction if you are +# porting this file to other shells. In any case, the user never directly calls +# the PY_QUIK python file, because that wouldn't be able to change the user's +# working directory as an external process (for every shell I'm aware of). +# +# See also the PowerShell port, where `quik` is an alias to a function, or the +# Windows (Batch) port, which switches mode based on an environment variable. +function quik { + # Run the PY_QUIK python script, and capture the raw output. + # PY_QUIK takes whatever arguments the user called this function with. + # We save also the return code of this main script, so that we can exit the + # function with that same return code. + OUTPUT="$(${PY_QUIK} "$@")" + RET=$? + + # We can use the PY_PARSE auxiliary script to split the raw output into + # what the output that should be shown to the user, and the directory + # that we should change into. + # + # These two modes of operation can be achieved with the `--output` and + # `--cd` flags. + # + # If the `--cd` mode output is empty, that means that there is no directory + # to change to. + DIR="$(echo -e -n "${OUTPUT}" | ${PY_PARSE} --cd)" + echo -e -n "$(echo -n "${OUTPUT}" | ${PY_PARSE} --output)" + if [[ $param = *[!\ ]* ]] # output is anything other than whitespace + then + cd "${DIR}" + fi + + return ${RET} } -function quik { - OUTPUT="$(${PY_QUIK} "$@")" - RET=$? - if [[ "${OUTPUT}" == *"+cd "* ]] - then - echo -e -n "$(echo -n "${OUTPUT}" | ${PY_PARSE} --output)" - DIR="$(echo -e -n "${OUTPUT}" | ${PY_PARSE} --cd)" - cd "${DIR}" - else - echo -n "${OUTPUT}" - fi - - return ${RET} +# Since quik is a "speed" tool, it is fairly important to have autocomplete +# functionality for the aliases. (There are only so many terse aliases you can +# define and remember.) +# +# While the autocomplete mechanisms are very specific to the shell, PY_QUIK +# helps with this task via the `--complete` mode of operation, where, when given +# a stub for a command, like +# +# quik -- +# +# passing this as a string to `PY_QUIK --complete` returns a list of possible +# completions to the last word, separated by newlines. So, in this case, we +# would get as an output to `PY_QUIK --complete 'quik --'` something like +# +# --list +# --help +# +# The main convenience for this is that autocompletion for the user's defined +# aliases is also provided. +# If there are no suggestions to the provided stub, the process exits with +# non-zero return code. In this case, your code should attempt to autocomplete +# the stub as a path. This is due to the case where the user does something like +# +# quik add my_alias ./dire +# +# and expects an expansion to `./directory`. +function _quik_autocomplete { + COMPL_OUT="$(${PY_PARSE} --complete="$COMP_LINE")" + SUCCESS=$? + COMPREPLY=() + if [ $SUCCESS == 0 ] + then + # We have an autocompletion suggestion from PY_PARSE + readarray -t COMPREPLY <<<"$COMPL_OUT" + else + # There are no autocompletion suggestions; we suggest elements from the + # current directory. + local cur + case "$2" in + \~*) eval cur="$2" ;; + *) cur=$2 ;; + esac + COMPREPLY=( $(compgen -d -- "$cur") ) + fi } complete -F _quik_autocomplete -o dirnames quik diff --git a/internals/version.py b/internals/version.py new file mode 100644 index 0000000..f010548 --- /dev/null +++ b/internals/version.py @@ -0,0 +1,2 @@ +QUIK_VERSION = 2.6 +JSON_VERSION = 1.0 diff --git a/quik.py b/quik.py index 298131c..94c794a 100644 --- a/quik.py +++ b/quik.py @@ -23,6 +23,7 @@ import json import sys from internals.docopt import docopt +from internals.version import JSON_VERSION, QUIK_VERSION class NoPrint: def __enter__(self): @@ -33,7 +34,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): sys.stdout.close() sys.stdout = self._original_stdout -EXPECTED_JSON_VERSION = 1.0 NO_JSON_ENV_VAR = """Could not find quik.json. Please make sure your QUIK_JSON environment variable is set up, and that the file exists.""" NO_JSON_VER = """Warning: quik.json does not have a version. @@ -83,6 +83,10 @@ def ALIAS_ASSIGN(alias, path): {ALIAS_ASSIGN(alias, old)}""" CD_NO_ALIAS = lambda alias: f"""{alias} is not defined. Use `quik add` to add a new alias.""" +CD_ALIAS_SUGGEST = lambda typo, suggestions: f"""{typo} is not defined. +Did you mean one of +{suggestions} +?""" def err_print(msg): @@ -123,8 +127,8 @@ def get_aliases(quik_json, warn=True): # Check the version if "version" not in quik_json: err_print(NO_JSON_VER) - elif quik_json["version"] != EXPECTED_JSON_VERSION: - err_print(WRONG_JSON_VER(quik_json["version"], EXPECTED_JSON_VERSION)) + elif quik_json["version"] != JSON_VERSION: + err_print(WRONG_JSON_VER(quik_json["version"], JSON_VERSION)) # Perform the parsing alias = {} @@ -157,7 +161,7 @@ def get_aliases(quik_json, warn=True): if __name__ == '__main__': - arguments = docopt(__doc__, version="quik 2.4") + arguments = docopt(__doc__, version=f"quik {QUIK_VERSION}") quik_json_loc = get_quik_json_loc() quik_json = get_quik_json() @@ -261,8 +265,20 @@ def get_aliases(quik_json, warn=True): cd_alias = arguments[''] if cd_alias not in alias: - err_print(CD_NO_ALIAS(cd_alias)) - exit(1) + # Maybe the user missed a key + # If it's unambiguous, cd to that directory anyway + compatible = [candidate for candidate in alias + if (candidate.startswith(cd_alias) or + candidate.endswith(cd_alias))] + if len(compatible) == 1: + cd_alias = compatible[0] + else: + if len(compatible) > 0: + suggestions = '\n'.join(' - ' + suggestion for suggestion in compatible) + err_print(CD_ALIAS_SUGGEST(cd_alias, suggestions)) + else: + err_print(CD_NO_ALIAS(cd_alias)) + exit(1) # The bash extension will handle it from here. print(f"+cd \"{alias[cd_alias]}\"")