Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suffix Alias Script #13

Open
NikitaRevenco opened this issue Aug 28, 2024 · 31 comments
Open

Suffix Alias Script #13

NikitaRevenco opened this issue Aug 28, 2024 · 31 comments
Labels
enhancement New feature or request wontfix This will not be worked on

Comments

@NikitaRevenco
Copy link

NikitaRevenco commented Aug 28, 2024

Hey, I was looking for a while if there is some way I can have a "suffix alias" which are available in unix shell like zsh? For example, we can set an alias like below:

alias -s git="git clone"

And then all of these commands:

https://github.com/chrisant996/clink-gizmos.git
git@github.com:chrisant996/clink-gizmos.git

Would become expand to these when we press enter:

git clone https://github.com/chrisant996/clink-gizmos.git
git@github.com:chrisant996/clink-gizmos.git

This is useful for other scenarios, for example I could type "image.png" into my terminal and it'll open it with my image editor instead of having to type like "paintdotnet image.png"

If there is some function in clink that allows me get the contents of the prompt and then substitute it with a different command before it will be executed that would be amazing!

(This is possible in powershell but I'm wondering if it's possible in cmd.exe with clink)

@chrisant996
Copy link
Owner

chrisant996 commented Aug 28, 2024

Windows has "File Associations". That's how e.g. image.png opens in whatever app is registered to handle the .PNG extension. Read about File Associations in Windows for more info.

But I don't think File Associations apply to URLs the way you want in the two URL examples you gave.

In a Lua script loaded into Clink, you can do any arbitrary preprocessing of the input line before it gets passed to CMD via clink.onfilterinput(). You could do your own processing to give the effect you're looking for.

But watch out for edge cases. E.g. foo.git should NOT expand if there's a file foo.git.exe along the system %PATH%. (Or foo.git.bat, or any other extension listed in %PATHEXT%.)

@NikitaRevenco
Copy link
Author

NikitaRevenco commented Aug 30, 2024

Windows has "File Associations". That's how e.g. image.png opens in whatever app is registered to handle the .PNG extension. Read about File Associations in Windows for more info.

But I don't think File Associations apply to URLs the way you want in the two URL examples you gave.

In a Lua script loaded into Clink, you can do any arbitrary preprocessing of the input line before it gets passed to CMD via clink.onfilterinput(). You could do your own processing to give the effect you're looking for.

But watch out for edge cases. E.g. foo.git should NOT expand if there's a file foo.git.exe along the system %PATH%. (Or foo.git.bat, or any other extension listed in %PATHEXT%.)

Thank you! 😃
It didn't turn out to be very complicated to make, which is nice

local suffix_aliases = {
	[".git"] = "git clone",
}

local function file_exists_in_directory(directory, filename)
	local separator = "\\"
	local path = directory .. separator .. filename
	local file = io.open(path, "r")
	if file then
		file:close()
		return true
	end
	return false
end

local function is_in_path(program)
	local path_env = os.getenv("PATH")
	local path_ext = os.getenv("PATHEXT")

	if not path_ext then
		error("%PATHEXT% is not available")
	elseif not path_env then
		error("%PATH% is not available")
	end

	local extensions = {}

	for str in string.gmatch(path_ext, "([^;]+)") do
		table.insert(extensions, str)
	end

	local split_path = string.gmatch(path_env, "[^;]+")

	for dir in split_path do
		for _, ext in ipairs(extensions) do
			if file_exists_in_directory(dir, program .. ext) then
				return true
			end
		end
	end

	return false
end

local function string_endswith(string, suffix)
	return string:sub(-#suffix) == suffix
end

local function onfilterinput(text)
	for suffix, command in pairs(suffix_aliases) do
		if string_endswith(text, suffix) then
			local text_without_suffix = string.sub(text, 0, #text - #suffix)
			if not is_in_path(text_without_suffix) then
				return command .. " " .. text
			end
		end
	end
end

if clink.onfilterinput then
	clink.onfilterinput(onfilterinput)
else
	clink.onendedit(onfilterinput)
end

I can now create as many suffixes as I want!

I would like the suffix to also be highlighted with clink.argmatcher() when it's a valid suffix and i tried this:

local function onfilterinput(text)
	for suffix, command in pairs(suffix_aliases) do
		if string_endswith(text, suffix) then
+                       clink.argmatcher(text)
			local text_without_suffix = string.sub(text, 0, #text - #suffix)
			if not is_in_path(text_without_suffix) then
				return command .. " " .. text
			end
		end
	end
end

Since now the entire text is in argmatcher, I was expecting it to highlight everything with color.argmatcher but it didn't, do you have any advice?

also how about adding this to the repository?

@chrisant996
Copy link
Owner

chrisant996 commented Aug 30, 2024

I would like the suffix to also be highlighted with clink.argmatcher() when it's a valid suffix and i tried this:

local function onfilterinput(text)
	for suffix, command in pairs(suffix_aliases) do
		if string_endswith(text, suffix) then
+                       clink.argmatcher(text)
			local text_without_suffix = string.sub(text, 0, #text - #suffix)
			if not is_in_path(text_without_suffix) then
				return command .. " " .. text
			end
		end
	end
end

Since now the entire text is in argmatcher, I was expecting it to highlight everything with color.argmatcher but it didn't, do you have any advice?

There are some problems with that:

  1. The onfilterinput function is called after you press Enter. Nothing done in there could affect input line coloring before you press Enter.
  2. Calling simply clink.argmatcher(text) creates a blank argmatcher that does nothing at all.
  3. Creating a new argmatcher for each input line would consume a lot of memory over time.
  4. The input line coloring is merely a small side effect of an argmatcher. Argmatchers do tons more than that, but it looks like you want none of the actual behaviors or benefits of argmatchers.
  5. Using color.argmatcher would be misleading since this has nothing to do with argmatchers and is incompatible with any of their behaviors. Consider creating a new color setting instead (or maybe use color.doskey, but that may also be misleading).

If you want to apply custom coloring to the input line, then refer to Setting a classifier function for the whole input line.

Also, the text variable contains the entire command line. The way the code is written, when text is echo foo bar address.git the code turns it into git clone echo foo bar address.git. Was that intended? It seems undesirable.

also how about adding this to the repository?

This isn't ready to be included in a repo:

  • To configure suffixes, one must edit the Lua script directly, which requires creating a fork of the repo and doing manual operations to keep the forked repo up to date with the main repo. There would need to be a way to configure suffixes without modifying the script.
  • The echo foo bar address.git example might be an issue.
  • There isn't documentation.

Also, if it's included in the clink-gizmos repo, then that implies I would take over maintenance of it. I'm not sure I want to do that.

It seems like something that would be good to distribute separately, e.g. in separate repo that you maintain. If you create a repo, let me know and I'd be happy to include a link to it in the Clink documentation, along with the links to other script repos.

@chrisant996
Copy link
Owner

More thoughts:

  1. If you hook up a clink.classifier() to do input line coloring, then there's an edge case to consider: The call to is_in_path() is a blocking call and can take a while, depending on text (especially since it's looping over all suffixes). Classifiers need to be very fast, otherwise they'll introduce lag while typing and/or updating the display. To ensure high performance, it would need to use os.getdrivetype() and check for remote drives before calling file_exists_in_directory().

  2. Using io.open() in file_exists_in_directory() is an inefficient way to check for file existence, especially if the file is on a remote drive. Use os.isfile() instead.

  3. Is looping over the list of suffixes necessary? That will get slower the more suffixes are defined. It's already set up as an associative table with suffixes as keys. The loop could be eliminated by getting the suffix from the line and indexing into the table.

    local function onfilterinput(text)
        local suffix = text:match("(%.[^. /\\]+)$")
        if suffix then
            local command = suffix_aliases[suffix]
            if command then
                return command .. " " .. text
            end
        end
    end

Warning

The example above is not trying to fix the issue where echo foo bar repo.git become git clone echo foo bar repo.git. Fixing that requires more sophisticate parsing of text.

@chrisant996
Copy link
Owner

Also, what about an input line echo foo & repo.git, or an input line repo.git & echo done?

You can use clink.parseline(text) to get a table of line_state objects for each of the commands in the text input line, and then apply suffix alias expansion to each command.

@NikitaRevenco
Copy link
Author

Also, what about an input line echo foo & repo.git, or an input line repo.git & echo done?

You can use clink.parseline(text) to get a table of line_state objects for each of the commands in the text input line, and then apply suffix alias expansion to each command.

Hey, thank you for the insightful responses! I updated the color matching to use a custom user config theme.

After thinking about it for a bit, I'm not sure if the added complexity and performance costs are really worth command like cd.git not being expanded.

Since I was basing this suggestion off of zsh, I think it makes sense to implement it how they exactly have it. This has the added benefit that people can transfer their ZSH suffix aliases directly into clink

So the way it works in zsh is that it will iterate through every single word in the input line and expand them individually. For example echo foo bar.git will become echo foo bar git clone bar.git, and zsh doesn't account if something can be an executable. For example cd.git will just expand to git clone cd.git

Here's the current code:

settings.add("color.suffix", "", "Color for when suffix is matched")

local suffixes = {
	[".git"] = "git clone",
}

local function escape_pattern(str)
	return str:gsub("(%W)", "%%%1")
end

local pattern_parts = {}

for suffix, _ in pairs(suffixes) do
	table.insert(pattern_parts, escape_pattern(suffix) .. "$")
end

local combined_pattern = table.concat(pattern_parts, "|")

local suffix_classifier = clink.classifier(1)
function suffix_classifier:classify(commands)
	local line = commands[1].line_state:getline()
	local color = settings.get("color.suffix") or ""
	local classifications = commands[1].classifications
	local last_index = 1

	for word in line:gmatch("%S+") do
		local match = word:match(combined_pattern)
		local start_index, end_index = string.find(line, word, last_index, true)
		last_index = end_index + 1

		if match and suffixes[match] then
			classifications:applycolor(start_index, end_index - start_index + 1, color)
		end
	end
end

local function onfilterinput(line)
	local words = {}
	for word in line:gmatch("%S+") do
		local match = word:match(combined_pattern)
		if match and suffixes[match] then
			word = suffixes[match] .. " " .. word
		end
		table.insert(words, word)
	end
	return table.concat(words, " ")
end

if clink.onfilterinput then
	clink.onfilterinput(onfilterinput)
else
	clink.onendedit(onfilterinput)
end

I wanted to also allow the user to manually specify suffix aliases. I wasn't sure how to do it the way I really envisioned how its gonna work:

  • I define a new setting suffix
  • Users can set whatever properties they want, like:
suffix..git = git clone
suffix.!random123 = foo

And then I can iterate through the "suffix" setting in my code and check each property.

But I don't think this is possible, so instead I tried the following: (I replace the commented out part with the non-commented out part.)

settings.add("suffix.aliases", ".js=nvim;.git=git clone;", "Suffix aliases")

local suffixes = {}

for suffix_prepend_pair in string.gmatch(settings.get("suffix.aliases"), "([^;]+)") do
	local suffix = {}
	for suffix_or_prepend in string.gmatch(suffix_prepend_pair, "([^=]+)") do
		table.insert(suffix, suffix_or_prepend)
	end

	suffixes[suffix[1]] = suffix[2]
end

-- local suffixes = {
-- 	[".git"] = "git clone",
--     [".js"] = "nvim",
-- }

For some reason, the script no longer works at all. I'm not sure why this is happening. I used serializeTable function i found from this stack overflow post so that I can debug the suffixes table. In both cases (with the commented out parts switched around) the suffixes table had exactly the same structure:

{
  .git = git clone,
  .js = nvim,
}

However with the new approach, where I add the suffix.aliases setting, it just stops working. The code is not asynchronous (I don't think...?) so this doesn't make sense to me. The structure of suffixes is identical, so why it suddenly stops working?

If you don't want to add it to the repo I can understand that. i can add documentation and publish a repo once I'm able to figure out how I can let users define their suffix alias in clink_settings

(by the way I tried implementing your idea where we look at each word and then check its ending. The problem with that is users can define any suffix alias, it doesn't have to start with a period)

@chrisant996
Copy link
Owner

So the way it works in zsh is that it will iterate through every single word in the input line and expand them individually. For example echo foo bar.git will become echo foo bar git clone bar.git, and zsh doesn't account if something can be an executable. For example cd.git will just expand to git clone cd.git

Ah, ok, that makes it simpler and more consistent. This is sounding promising...

Here's the current code:

The zsh parity approach sounds interesting, and I'll load the script and share my observations after I've examined it a bit.

This has the added benefit that people can transfer their ZSH suffix aliases directly into clink
...
I wanted to also allow the user to manually specify suffix aliases. I wasn't sure how to do it the way I really envisioned how its gonna work:

How does one specify them in zsh?

  • Is there a file? Why not have a way to read the file format? Similar to how Clink uses Readline and therefore loads your existing .inputrc file, if you have one.
  • You could use clink.onfilterinput to implement a custom command like suffix-alias suffix=expansion, similar to how clink-flex-prompt implements a custom flexprompt configure command.
  • I define a new setting suffix

And then I can iterate through the "suffix" setting in my code and check each property.

But I don't think this is possible

I agree that using multiple settings wouldn't result in a nice user experience. Trying to use multiple suffix.whatever settings would be problematic because settings in Clink are not meant to be dynamically created by users. There's no way to dynamically delete a setting that's been created, for example.

so instead I tried the following: (I replace the commented out part with the non-commented out part.)

settings.add("suffix.aliases", ".js=nvim;.git=git clone;", "Suffix aliases")

local suffixes = {}

for suffix_prepend_pair in string.gmatch(settings.get("suffix.aliases"), "([^;]+)") do
	local suffix = {}
	for suffix_or_prepend in string.gmatch(suffix_prepend_pair, "([^=]+)") do
		table.insert(suffix, suffix_or_prepend)
	end

	suffixes[suffix[1]] = suffix[2]
end

-- local suffixes = {
-- 	[".git"] = "git clone",
--     [".js"] = "nvim",
-- }

For some reason, the script no longer works at all. I'm not sure why this is happening.

Re: "no longer works at all" -- But what does happen? I can see a few ways to improve the code, but I don't see anything that would make it "no longer work at all". I do see that if you try to use clink set suffix.aliases new_list_of_suffix_aliases then it won't update to use the new list, because the Lua script only parses the setting when the script is loaded, which only happens when starting Clink or when forcing a reload e.g. via Ctrl-X,Ctrl-R.

If you want to debug it, you could run clink set lua.debug true and then add a line pause() at the spot where you want to break into the debugger and start debugging. Type help at the debugger prompt for available commands.

I used serializeTable function i found from this stack overflow post so that I can debug the suffixes table.

You could use dumpvar() which is part of clink-gizmos (and is probably richer than what's in the stackoverflow post you found).

In the Lua debugger, you could use dump suffixes to show the table contents, or you could use dumpvar(suffixes) (since you have clink-gizmos loaded already and the Lua debugger lets you run any arbitrary Lua code at the debugger prompt).

If you don't want to add it to the repo I can understand that. i can add documentation and publish a repo once I'm able to figure out how I can let users define their suffix alias in clink_settings

With the additional info and simplification to match how zsh works, it's something I might be willing to add into clink-gizmos.

(by the way I tried implementing your idea where we look at each word and then check its ending. The problem with that is users can define any suffix alias, it doesn't have to start with a period)

Got it. Without optimizations, that will begin to perform poorly as the number of suffix definitions grows. To optimize for that, I would probably use a data structure like a Trie to enable fast matching in reverse starting from the end of a string (e.g. implement a sparse Trie using Lua associative tables).

@chrisant996
Copy link
Owner

I found the bug.

settings.add("suffix.aliases", ".js=nvim;.git=git clone;", "Suffix aliases")

local suffixes = {}

for suffix_prepend_pair in string.gmatch(settings.get("suffix.aliases"), "([^;]+)") do
	local suffix = {}
	for suffix_or_prepend in string.gmatch(suffix_prepend_pair, "([^=]+)") do
		table.insert(suffix, suffix_or_prepend)
	end

	suffixes[suffix[1]] = suffix[2]
end

-- local suffixes = {
-- 	[".git"] = "git clone",
--     [".js"] = "nvim",
-- }

For some reason, the script no longer works at all. I'm not sure why this is happening. I used serializeTable function i found from this stack overflow post so that I can debug the suffixes table. In both cases (with the commented out parts switched around) the suffixes table had exactly the same structure:

The bug

It has nothing to do with the table structure.

If you restore the commented-out code, then it will also not work.
Any time there is more than one suffix defined, the code won't work.
That's the difference that made it stop working.

Because the code tries to construct a regular expression string instead of a Lua pattern string.
Lua patterns do not have a | operator.

Performance

But if Lua did have regular expressions, then the code would be constructing a regular expression whose size and complexity is proportional to the number of suffixes defined. That could work (if Lua had regular expressions), but its performance wouldn't scale very well as the number of suffixes grows.

The performance almost might not matter much if it only occurs when pressing Enter.
But performance matters for input line coloring.

But, optimizations can come later

The first thing to do is to get the basics working, and configurability (e.g. reading a file of suffix alias definitions).

So for starters, looping over pairs(suffixes) would work and would be a good start. Maybe not ready for including in clink-gizmos, but probably plenty good enough for your own purposes.

@NikitaRevenco
Copy link
Author

I found the bug.

settings.add("suffix.aliases", ".js=nvim;.git=git clone;", "Suffix aliases")

local suffixes = {}

for suffix_prepend_pair in string.gmatch(settings.get("suffix.aliases"), "([^;]+)") do
	local suffix = {}
	for suffix_or_prepend in string.gmatch(suffix_prepend_pair, "([^=]+)") do
		table.insert(suffix, suffix_or_prepend)
	end

	suffixes[suffix[1]] = suffix[2]
end

-- local suffixes = {
-- 	[".git"] = "git clone",
--     [".js"] = "nvim",
-- }

For some reason, the script no longer works at all. I'm not sure why this is happening. I used serializeTable function i found from this stack overflow post so that I can debug the suffixes table. In both cases (with the commented out parts switched around) the suffixes table had exactly the same structure:

The bug

It has nothing to do with the table structure.

If you restore the commented-out code, then it will also not work. Any time there is more than one suffix defined, the code won't work. That's the difference that made it stop working.

Because the code tries to construct a regular expression string instead of a Lua pattern string. Lua patterns do not have a | operator.

Performance

But if Lua did have regular expressions, then the code would be constructing a regular expression whose size and complexity is proportional to the number of suffixes defined. That could work (if Lua had regular expressions), but its performance wouldn't scale very well as the number of suffixes grows.

The performance almost might not matter much if it only occurs when pressing Enter. But performance matters for input line coloring.

But, optimizations can come later

The first thing to do is to get the basics working, and configurability (e.g. reading a file of suffix alias definitions).

So for starters, looping over pairs(suffixes) would work and would be a good start. Maybe not ready for including in clink-gizmos, but probably plenty good enough for your own purposes.

thank you! I ended up replacing it with this instead:

local function get_suffix(word)
	for suffix in pairs(suffixes) do
		local pattern = escape_pattern(suffix) .. "$"
		if word:match(pattern) then
			return suffix
		end
	end
	return false
end

Not sure if this will be faster than the loop method, but we'll see.

The way zsh allows for suffix aliases is very simple, it just adds a -s flag to the alias command. alias is similar to doskey

For example we can have a file, my-alias.sh

alias -s .git="git clone"

And then when I make it executable and run it by typing its name, I'll have the .git suffix alias available to use. I can also just directly type the command, and I can put the line in any of my scripts which execute on startup

So to allow user customizability right now we may have two options:

  • using a single clink setting and then parsing it (not best for user experience)
  • storing it in a file and then reading the file (but maybe reading a file can be slow?, and maybe someone with 1 suffix alias wouldn't want to have to store it in a file, so maybe we can provide both options?)

@NikitaRevenco
Copy link
Author

NikitaRevenco commented Sep 1, 2024

Current script:

settings.add("color.suffix", "", "Color for when suffix is matched")

settings.add("suffix.aliases", ".git=git clone;", "Suffix aliases")

local suffixes = {}

for suffix_prepend_pair in string.gmatch(settings.get("suffix.aliases"), "([^;]+)") do
	local suffix = {}
	for suffix_or_prepend in string.gmatch(suffix_prepend_pair, "([^=]+)") do
		table.insert(suffix, suffix_or_prepend)
	end

	suffixes[suffix[1]] = suffix[2]
end

local function escape_pattern(str)
	return str:gsub("(%W)", "%%%1")
end

local function get_suffix(word)
	for suffix in pairs(suffixes) do
		local pattern = escape_pattern(suffix) .. "$"
		if word:match(pattern) then
			return suffix
		end
	end
	return false
end

local suffix_classifier = clink.classifier(1)
function suffix_classifier:classify(commands)
	local line = commands[1].line_state:getline()
	local color = settings.get("color.suffix") or ""
	local classifications = commands[1].classifications
	local last_index = 1

	for word in line:gmatch("%S+") do
		local match = get_suffix(word)
		local start_index, end_index = string.find(line, word, last_index, true)
		last_index = end_index + 1

		if match and suffixes[match] then
			classifications:applycolor(start_index, end_index - start_index + 1, color)
		end
	end
end

local function onfilterinput(line)
	local words = {}
	for word in line:gmatch("%S+") do
		local match = get_suffix(word)
		if match and suffixes[match] then
			word = suffixes[match] .. " " .. word
		end
		table.insert(words, word)
	end
	return table.concat(words, " ")
end

if clink.onfilterinput then
	clink.onfilterinput(onfilterinput)
else
	clink.onendedit(onfilterinput)
end

@chrisant996
Copy link
Owner

chrisant996 commented Sep 1, 2024

As written, the onfilterinput function is replacing all multi-space sequences with a single space. That makes it impossible to enter certain kinds of commands. That's one of the reasons it can be very dangerous to do onfilterinput processing.

It also doesn't respect quotes.

I have a version with several changes and bug fixes, which I'll share soon.

@chrisant996
Copy link
Owner

chrisant996 commented Sep 1, 2024

This fixes some subtle yet important issues, such as:

  • Didn't respect quotes.
  • Converted all runs of 2 or more spaces into a 1 space.
  • Didn't update when the suffix.aliases setting gets changed.

It also improves efficiency a little bit, but it doesn't implement a Trie yet.
I added some "FUTURE" comments for where/how further enhancements could be made.

It seems to work for me, but I haven't tested it extensively.

Note

I added a DEBUG_SUFFIX_ALIASES environment variable which can be set to only print the expansion without actually returning it to CMD. E.g. set DEBUG_SUFFIX_ALIASES=1.

if not clink.parseline then
    log.info("suffix_aliases.lua requires a newer version of Clink; please upgrade.")
    return
end

settings.add("color.suffix_alias", "bri mag", "Color when a suffix alias is matched")
settings.add("suffix.aliases", ".js=nvim;.git=git clone", "List of suffix aliases")

local suffixes = {}

--------------------------------------------------------------------------------
-- Ensure that suffixes are loaded.
--
-- For now this just loads from a setting, any time the setting changes.
--
-- Eventually it could reload from a file, for example whenever the file
-- timestamp changes.

local prev_suffixes

local function ensure_suffixes()
    -- FUTURE:  To load from a file, this could check the timestamp and compare
    -- it to a prev_timestamp value, and bail out if they match.
    local s = settings.get("suffix.aliases")
    if s == prev_suffixes then
        return
    end
    prev_suffixes = s

    -- FUTURE:  To load from a file, this could be replaced with f = io.open()
    -- and a for loop over f:lines().
    for _,entry in ipairs(string.explode(s, ";")) do
        local name,value = entry:match("^%s*([^=]-)%s*=%s*(.*)%s*$")
        if name then
            suffixes[name] = value
        elseif entry ~= "" and entry:gsub(" ", "") ~= "" then
            log.info(string.format("Unable to parse '%s' in setting suffix.aliases.", entry))
        end
    end

    -- FUTURE:  This is where to build a sparse Trie data structure (e.g. using
    -- Lua tables with string.byte() values as keys) that can optimize matching
    -- suffixes starting from the end of a word.  To reduce construction time
    -- and memory consumption, it could probably use a Trie for only the last 5
    -- to 10 characters, and then switch to looping over substrings.
    --
    -- E.g. this illustrates the a sparse Trie that switches to an array of
    -- substrings after the last 2 characters:
    --
    --  {
    --      [string.byte("t")] =
    --      {
    --          [string.byte("i")] =
    --          {
    --              [".g"] = "git clone",
    --          },
    --      },
    --      [string.byte("d")] =
    --      {
    --          [string.byte("l")] =
    --          {
    --              ["wor"] = "echo hello",
    --              ["fo"] = "echo poker",
    --          },
    --          [string.byte("e")] =
    --          {
    --              ["r"] = "echo color",
    --          },
    --      },
    --  }
end

clink.onbeginedit(ensure_suffixes)

--------------------------------------------------------------------------------
-- Look up a suffix alias for a word.

local function get_suffix_alias(word)
    local wordlen = #word
    if wordlen > 0 then
        -- FUTURE:  Performance scales poorly as the number of defined suffixes
        -- grows.  That could be improved with a sparse Trie data structure that
        -- matches characters in reverse order from the end of the word.
        for name,value in pairs(suffixes) do
            local namelen = #name
            if wordlen >= namelen and string.matchlen(word:sub(wordlen + 1 - namelen), name) == -1 then
                return value, name
            end
        end
    end
end

--------------------------------------------------------------------------------
-- Do input line coloring.

local suffix_classifier = clink.classifier(1)
function suffix_classifier:classify(commands)
    -- Do nothing if no color is set.
    local color = settings.get("color.suffix_alias") or ""
    if color == "" then
        return
    end

    -- Loop through the commands in the input line and apply coloring to each.
    for _,command in ipairs(commands) do
        local line_state = command.line_state
        local classifications = command.classifications
        for i = 1, line_state:getwordcount() do
            local word = line_state:getword(i)
            if get_suffix_alias(word) then
                local info = line_state:getwordinfo(i)
                classifications:applycolor(info.offset, info.length, color)
            end
        end
    end
end

--------------------------------------------------------------------------------
-- Provide an input hint (requires Clink v1.7.0 or newer).
-- The input hint is displayed below the input line, in the comment row (like
-- where history.show_preview displays history expansion previews).

local suffix_hinter = clink.hinter and clink.hinter(1) or {}
function suffix_hinter:gethint(line_state)
    local cursorpos = line_state:getcursor()
    for i = line_state:getwordcount(), 1, -1 do
        local info = line_state:getwordinfo(i)
        if cursorpos > info.offset + info.length then
            return
        elseif info.offset <= cursorpos and cursorpos <= info.offset + info.length then
            local word = line_state:getword(i)
            local value, suffix = get_suffix_alias(word)
            if value then
                local hint = string.format("%s=%s", suffix, value)
                if (settings.get("color.suffix_alias") or "") == "" then
                    hint = "Suffix alias "..hint
                end
                return hint, info.offset
            end
            return
        end
    end
end

--------------------------------------------------------------------------------
-- Insert suffix commands before any word that has a matching suffix alias.

local function onfilterinput(line)
    local last = 0
    local parts = {}
    local commands = clink.parseline(line)
    
    for _,command in ipairs(commands) do
        local line_state = command.line_state
        for i = 1, line_state:getwordcount() do
            local word = line_state:getword(i)
            local value = get_suffix_alias(word)
            if value then
                local info = line_state:getwordinfo(i)
                local next
                if info.quoted then
                    next = info.offset - 1
                else
                    next = info.offset
                end
                table.insert(parts, line:sub(last + 1, next - 1))
                table.insert(parts, value.." ")
                last = next - 1
            end
        end
    end

    if parts[1] then
        table.insert(parts, line:sub(last + 1, #line))

        local result = table.concat(parts, "")

        if os.getenv("DEBUG_SUFFIX_ALIASES") then
            print("SUFFIX ALIAS EXPANSION:")
            print(result)
        else
            line = result
        end
    end

    return line
end

clink.onfilterinput(onfilterinput)

@NikitaRevenco
Copy link
Author

This fixes some subtle yet important issues, such as:

* Didn't respect quotes.

* Converted all runs of 2 or more spaces into a 1 space.

* Didn't update when the suffix.aliases setting gets changed.

It also improves efficiency a little bit, but it doesn't implement a Trie yet. I added some "FUTURE" comments for where/how further enhancements could be made.

It seems to work for me, but I haven't tested it extensively.

Note

I added a DEBUG_SUFFIX_ALIASES environment variable which can be set to only print the expansion without actually returning it to CMD. E.g. set DEBUG_SUFFIX_ALIASES=1.

if not clink.parseline then
    log.info("suffix_aliases.lua requires a newer version of Clink; please upgrade.")
    return
end

settings.add("color.suffix_alias", "bri mag", "Color when a suffix alias is matched")
settings.add("suffix.aliases", ".js=nvim;.git=git clone", "List of suffix aliases")

local suffixes = {}

--------------------------------------------------------------------------------
-- Ensure that suffixes are loaded.
--
-- For now this just loads from a setting, any time the setting changes.
--
-- Eventually it could reload from a file, for example whenever the file
-- timestamp changes.

local prev_suffixes

local function ensure_suffixes()
    -- FUTURE:  To load from a file, this could check the timestamp and compare
    -- it to a prev_timestamp value, and bail out if they match.
    local s = settings.get("suffix.aliases")
    if s == prev_suffixes then
        return
    end
    prev_suffixes = s

    -- FUTURE:  To load from a file, this could be replaced with f = io.open()
    -- and a for loop over f:lines().
    for _,entry in ipairs(string.explode(s, ";")) do
        local name,value = entry:match("^%s*([^=]-)%s*=%s*(.*)%s*$")
        if name then
            suffixes[name] = value
        elseif entry ~= "" and entry:gsub(" ", "") ~= "" then
            log.info(string.format("Unable to parse '%s' in setting suffix.aliases.", entry))
        end
    end

    -- FUTURE:  This is where to build a sparse Trie data structure (e.g. using
    -- Lua tables with string.byte() values as keys) that can optimize matching
    -- suffixes starting from the end of a word.  To reduce construction time
    -- and memory consumption, it could probably use a Trie for only the last 5
    -- to 10 characters, and then switch to looping over substrings.
    --
    -- E.g. this illustrates the a sparse Trie that switches to an array of
    -- substrings after the last 2 characters:
    --
    --  {
    --      [string.byte("t")] =
    --      {
    --          [string.byte("i")] =
    --          {
    --              [".g"] = "git clone",
    --          },
    --      },
    --      [string.byte("d")] =
    --      {
    --          [string.byte("l")] =
    --          {
    --              ["wor"] = "echo hello",
    --              ["fo"] = "echo poker",
    --          },
    --          [string.byte("e")] =
    --          {
    --              ["r"] = "echo color",
    --          },
    --      },
    --  }
end

clink.onbeginedit(ensure_suffixes)

--------------------------------------------------------------------------------
-- Look up a suffix alias for a word.

local function get_suffix_alias(word)
    local wordlen = #word
    if wordlen > 0 then
        -- FUTURE:  Performance scales poorly as the number of defined suffixes
        -- grows.  That could be improved with a sparse Trie data structure that
        -- matches characters in reverse order from the end of the word.
        for name,value in pairs(suffixes) do
            local namelen = #name
            if wordlen >= namelen and string.matchlen(word:sub(wordlen + 1 - namelen), name) == -1 then
                return value, name
            end
        end
    end
end

--------------------------------------------------------------------------------
-- Do input line coloring.

local suffix_classifier = clink.classifier(1)
function suffix_classifier:classify(commands)
    -- Do nothing if no color is set.
    local color = settings.get("color.suffix_alias") or ""
    if color == "" then
        return
    end

    -- Loop through the commands in the input line and apply coloring to each.
    for _,command in ipairs(commands) do
        local line_state = command.line_state
        local classifications = command.classifications
        for i = 1, line_state:getwordcount() do
            local word = line_state:getword(i)
            if get_suffix_alias(word) then
                local info = line_state:getwordinfo(i)
                classifications:applycolor(info.offset, info.length, color)
            end
        end
    end
end

--------------------------------------------------------------------------------
-- Provide an input hint (requires Clink v1.7.0 or newer).
-- The input hint is displayed below the input line, in the comment row (like
-- where history.show_preview displays history expansion previews).

local suffix_hinter = clink.hinter and clink.hinter(1) or {}
function suffix_hinter:gethint(line_state)
    local cursorpos = line_state:getcursor()
    for i = line_state:getwordcount(), 1, -1 do
        local info = line_state:getwordinfo(i)
        if cursorpos > info.offset + info.length then
            return
        elseif info.offset <= cursorpos and cursorpos <= info.offset + info.length then
            local word = line_state:getword(i)
            local value, suffix = get_suffix_alias(word)
            if value then
                local hint = string.format("%s=%s", suffix, value)
                if (settings.get("color.suffix_alias") or "") == "" then
                    hint = "Suffix alias "..hint
                end
                return hint, info.offset
            end
            return
        end
    end
end

--------------------------------------------------------------------------------
-- Insert suffix commands before any word that has a matching suffix alias.

local function onfilterinput(line)
    local last = 0
    local parts = {}
    local commands = clink.parseline(line)
    
    for _,command in ipairs(commands) do
        local line_state = command.line_state
        for i = 1, line_state:getwordcount() do
            local word = line_state:getword(i)
            local value = get_suffix_alias(word)
            if value then
                local info = line_state:getwordinfo(i)
                local next
                if info.quoted then
                    next = info.offset - 1
                else
                    next = info.offset
                end
                table.insert(parts, line:sub(last + 1, next - 1))
                table.insert(parts, value.." ")
                last = next - 1
            end
        end
    end

    if parts[1] then
        table.insert(parts, line:sub(last + 1, #line))

        local result = table.concat(parts, "")

        if os.getenv("DEBUG_SUFFIX_ALIASES") then
            print("SUFFIX ALIAS EXPANSION:")
            print(result)
        else
            line = result
        end
    end

    return line
end

clink.onfilterinput(onfilterinput)

this is really awesome, thank you!

@chrisant996 chrisant996 added the enhancement New feature or request label Sep 7, 2024
@plutonium-239
Copy link
Contributor

Just chiming in that fish's abbr does something similar - expand input as you type (i.e. it doesn't wait for the command to be executed).

Would it be hard to modify this to serve that purpose as well? Possibly toggling between behaviors with a clink setting?

@chrisant996
Copy link
Owner

Just chiming in that fish's abbr does something similar - expand input as you type (i.e. it doesn't wait for the command to be executed).

Would it be hard to modify this to serve that purpose as well? Possibly toggling between behaviors with a clink setting?

Kind of?

It would be possible to create something similar to fish's abbr. But I wouldn't recommend using this script as a starting point for that -- there's very little similarity at an implementation level.

A script could use clink.onaftercommand() and look for available expansions for text preceding the cursor position. But it would have to also check rl.getlastcommand() to test what command was used -- for example, a cursor navigation command like forward-word shouldn't trigger expansions.

I don't plan to work on something like this. Also, I would personally not use something that automatically replaces what I type -- too often that kind of thing makes it impossible to type certain things. I think a key binding to perform expansion on demand would fit my own usage style better. But I don't plan to work on something like that, either.

(And FWIW, I can't use the suffix script because it makes it impossible to run certain kinds of commands, because it unconditionally expands things -- even the .git example for git clone made it impossible to run multiple commands I needed to run. I don't entirely understand why some people seem to have no problems with suffix expansion, but I accept it as a truth, albeit a mysterious truth.)

@plutonium-239
Copy link
Contributor

Yeah, I understand. Filtering real-time vs one-shot would have different priorities.

On my usage of fish abbreviations:
I mostly use it as a better alias (which is a bash feature fish doesn't support), since I can see immediately what will be run - which matters to me but would not to everyone.

For example, abbreviating CUDA_VISIBLE_DEVICES= to cvd= - you can see why. Another one that I use very often is cact/mact being conda activate and mamba activate.
Usually these are abbreviations that will not get in the way of anything else. (If I didn't explain it before, fish won't just replace every occurrence - will only do when the whole word matches, and you press a space afterward. You can ofc undo it.)

So I've never faced inconveniences with abbreviations - but maybe that is because of the abbreviations I set.

@chrisant996
Copy link
Owner

If in fish it only does something in response to Space then simply do that same thing in Clink.

  1. Make a Lua function that does expansions on the word before the cursor.
  2. Bind it to the Space key.

See Lua key bindings.

The suffix alias script is unrelated.

@chrisant996
Copy link
Owner

chrisant996 commented Dec 4, 2024

Windows has doskey which is similar to bash's alias.

Enabling Showing Input Hints in Clink lets doskey_hinter.lua show a preview of how an alias will expand.

image

@plutonium-239 I've written a prototype script abbr.lua which is in this gist at the moment. Eventually it may get added to clink-gizmos. (The clarification about binding to Space is what made it something I'd consider working on.)

@plutonium-239
Copy link
Contributor

I knew about the alias hints - I have it enabled, but usually the hint pops up ~0.75s after typing (is this an artificial delay because of debouncing the input?) so I don't wait for it.

Nice prototype!

I liked that you added 2 undogroups as well. It works with the semi-minimal testing I've done, just needs some color.
I've been working on that, taking reference from the envvar_highlighter. Left a comment on the gist.

@chrisant996
Copy link
Owner

I knew about the alias hints - I have it enabled, but usually the hint pops up ~0.75s after typing (is this an artificial delay because of debouncing the input?) so I don't wait for it.

See comment_row.hint_delay. Default is 500 ms.

@plutonium-239
Copy link
Contributor

Is there a reason for this delay? I couldn't find it in the doc

@chrisant996
Copy link
Owner

Is there a reason for this delay? I couldn't find it in the doc

Several reasons. Try it without a delay, they'll probably become clear. 🙃

@plutonium-239
Copy link
Contributor

I don't notice any lag/flashing/weird behaviour 😅

Maybe my list of macros is very small:

la=ls -A $*
ll=ls -lAh $*
gitbash="C:\Program Files\Git\usr\bin\bash.exe" $*
gnutar="C:\Program Files\Git\usr\bin\tar.exe" $*
smerge="C:\Program Files\Sublime Merge"\smerge.exe $*

What was supposed to happen?

@chrisant996
Copy link
Owner

chrisant996 commented Dec 5, 2024

I imagine you are probably only looking at what happens with the abbr.lua hints, and not any of the (many) other things that provide hints.

When hints show up instantly, then there's flicker while typing (hints appear for a fraction of a second and disappear again), and also it shifts the input line up a row if at the bottom of the screen. And a couple other things that I forget offhand.

I originally had no delay, and quickly realized that was not acceptable at all, in the big picture.

@plutonium-239
Copy link
Contributor

These are doskeys macros, not abbrs

Weirdly, I don't see any flicker, at all - I've even recorded it here
_the second half is empty as i looked for how to exit the recording

I could even screen-record if you want, but this should be more accurate as it captures everything sent to the terminal.

@chrisant996
Copy link
Owner

These are doskeys macros, not abbrs

Weirdly, I don't see any flicker, at all - I've even recorded it here _the second half is empty as i looked for how to exit the recording

I could even screen-record if you want, but this should be more accurate as it captures everything sent to the terminal.

I think the reason you don't experience it as "flicker" is because you're typing very slowly. It's impossible to have flicker when typing slowly. Sorry for not explaining that; I assumed it was clear.

@plutonium-239
Copy link
Contributor

Oh, understood, you mean to say the flicker caused by actually continuing typing. That doesn't bother me too much since I know it is because of my own actions and not something undesirable, and also serves as another way to check in passing if I entered the right thing.
But I guess the setting makes sense, I'm keeping it at 0 though 😆.

@chrisant996
Copy link
Owner

But I guess the setting makes sense, I'm keeping it at 0 though 😆.

As you like.

One kind of situation where flicker got really annoying was like this:

g = one command
gc = a second command
gco = a third command

As you type g then c then o, it flickers between three different hints. I have a bunch of cases like that, and I type quickly, which made immediate hints highly distracting. (Plus there's the issue of forced scrolling when at the bottom of the screen.)

@plutonium-239
Copy link
Contributor

Yeah that makes sense

@chrisant996
Copy link
Owner

The abbr discussion was off-topic from this issue; I wish I had moved it to a separate issue for proper tracking.

In any case, the abbr.lua script has been added to the clink-gizmos repo.

@chrisant996
Copy link
Owner

Back on topic...

The suffix alias script is still not added to clink-gizmos.

Because even the original "git clone" example causes problems for me: when a suffix alias is defined for ".git" then it becomes impossible to run a command like "dir .git" because it gets replaced. This completely blocked me from doing multiple different kinds of operations when a suffix alias was defined.

Suffix aliases seem problematic to me, so I haven't invested time finishing the TODO items in the prototype script.

@chrisant996 chrisant996 added the wontfix This will not be worked on label Dec 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request wontfix This will not be worked on
Projects
None yet
Development

No branches or pull requests

3 participants