chrisant996 / clink-gizmos

A library of Lua scripts for use with Clink https://github.com/chrisant996/clink.
MIT License
62 stars 5 forks source link

Suffix Alias Script #13

Open nikitarevenco opened 2 weeks ago

nikitarevenco commented 2 weeks ago

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 commented 2 weeks ago

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 commented 2 weeks ago

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 commented 2 weeks ago

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:

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 commented 2 weeks ago

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 commented 2 weeks ago

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 commented 1 week ago

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:

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 commented 1 week ago

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?

  • 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 commented 1 week ago

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 commented 1 week ago

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:

nikitarevenco commented 1 week ago

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 commented 1 week ago

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 commented 1 week ago

This fixes some subtle yet important issues, such as:

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 commented 1 week ago

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!