_ssh.lua (5722B)
1 -- Converted into Lua from https://github.com/cyjake/ssh-config 2 -- TODO (siddhantdev): deal with include directives 3 4 local M = {} 5 6 local whitespace_pattern = '%s' 7 local line_break_pattern = '[\r\n]' 8 9 ---@param param string 10 local function is_multi_value_directive(param) 11 local multi_value_directives = { 12 'globalknownhostsfile', 13 'host', 14 'ipqos', 15 'sendenv', 16 'userknownhostsfile', 17 'proxycommand', 18 'match', 19 'canonicaldomains', 20 } 21 22 return vim.list_contains(multi_value_directives, param:lower()) 23 end 24 25 ---@param text string The ssh configuration which needs to be parsed 26 ---@return string[] The parsed host names in the configuration 27 function M.parse_ssh_config(text) 28 local i = 1 29 local line = 1 30 31 local function consume() 32 if i <= #text then 33 local char = text:sub(i, i) 34 i = i + 1 35 return char 36 end 37 return nil 38 end 39 40 local chr = consume() 41 42 local function parse_spaces() 43 local spaces = '' 44 while chr and chr:match(whitespace_pattern) do 45 spaces = spaces .. chr 46 chr = consume() 47 end 48 return spaces 49 end 50 51 local function parse_linebreaks() 52 local breaks = '' 53 while chr and chr:match(line_break_pattern) do 54 line = line + 1 55 breaks = breaks .. chr 56 chr = consume() 57 end 58 return breaks 59 end 60 61 local function parse_parameter_name() 62 local param = '' 63 while chr and not chr:match('[ \t=]') do 64 param = param .. chr 65 chr = consume() 66 end 67 return param 68 end 69 70 local function parse_separator() 71 local sep = parse_spaces() 72 if chr == '=' then 73 sep = sep .. chr 74 chr = consume() 75 end 76 return sep .. parse_spaces() 77 end 78 79 local function parse_value() 80 local val = {} 81 local quoted, escaped = false, false 82 83 while chr and not chr:match(line_break_pattern) do 84 if escaped then 85 table.insert(val, chr == '"' and chr or '\\' .. chr) 86 escaped = false 87 elseif chr == '"' and (val == {} or quoted) then 88 quoted = not quoted 89 elseif chr == '\\' then 90 escaped = true 91 elseif chr == '#' and not quoted then 92 break 93 else 94 table.insert(val, chr) 95 end 96 chr = consume() 97 end 98 99 if quoted or escaped then 100 error('Unexpected line break at line ' .. line) 101 end 102 103 return vim.trim(table.concat(val)) 104 end 105 106 local function parse_comment() 107 while chr and not chr:match(line_break_pattern) do 108 chr = consume() 109 end 110 end 111 112 ---@return string[] 113 local function parse_multiple_values() 114 local results = {} 115 local val = {} 116 local quoted = false 117 local escaped = false 118 119 while chr and not chr:match(line_break_pattern) do 120 if escaped then 121 table.insert(val, chr == '"' and chr or '\\' .. chr) 122 escaped = false 123 elseif chr == '"' then 124 quoted = not quoted 125 elseif chr == '\\' then 126 escaped = true 127 elseif quoted then 128 table.insert(val, chr) 129 elseif chr:match('[ \t=]') then 130 if val ~= {} then 131 table.insert(results, vim.trim(table.concat(val))) 132 val = {} 133 end 134 elseif chr == '#' and #results > 0 then 135 break 136 else 137 table.insert(val, chr) 138 end 139 chr = consume() 140 end 141 142 if quoted or escaped then 143 error('Unexpected line break at line ' .. line) 144 end 145 146 if val ~= {} then 147 table.insert(results, vim.trim(table.concat(val))) 148 end 149 150 return results 151 end 152 153 local function parse_directive() 154 local param = parse_parameter_name() 155 local multiple = is_multi_value_directive(param) 156 local _ = parse_separator() 157 local value = multiple and parse_multiple_values() or parse_value() 158 159 local result = { 160 param = param, 161 value = value, 162 } 163 164 return result 165 end 166 167 local function parse_line() 168 local _ = parse_spaces() 169 if chr == '#' then 170 parse_comment() 171 return nil 172 end 173 local node = parse_directive() 174 local _ = parse_linebreaks() 175 176 return node 177 end 178 179 local hostnames = {} 180 181 ---@param value string 182 local function is_valid(value) 183 return not (value:find('[?*!]') or vim.list_contains(hostnames, value)) 184 end 185 186 while chr do 187 local node = parse_line() 188 if node then 189 -- This is done just to assign the type 190 node.value = node.value ---@type string[] 191 if node.param:lower() == 'match' and node.value then 192 local current = nil 193 for ind, val in ipairs(node.value) do 194 if val:lower() == 'host' and ind + 1 <= #node.value and is_valid(node.value[ind + 1]) then 195 current = node.value[ind + 1] 196 end 197 end 198 if current then 199 table.insert(hostnames, current) 200 end 201 elseif node.param:lower() == 'host' and node.value then 202 for _, value in ipairs(node.value) do 203 if is_valid(value) then 204 table.insert(hostnames, value) 205 end 206 end 207 end 208 end 209 end 210 211 return hostnames 212 end 213 214 ---@param filename string 215 ---@return string[] The hostnames configured in the file located at filename 216 function M.parse_config(filename) 217 local file = io.open(filename, 'r') 218 if not file then 219 error('Cannot read ssh configuration file') 220 end 221 local config_string = file:read('*a') 222 file:close() 223 224 return M.parse_ssh_config(config_string) 225 end 226 227 ---@return string[] The hostnames configured in the ssh configuration file 228 --- located at "~/.ssh/config". 229 --- Note: This does not currently process `Include` directives in the 230 --- configuration file. 231 function M.get_hosts() 232 local config_path = vim.fs.normalize('~/.ssh/config') ---@type string 233 234 return M.parse_config(config_path) 235 end 236 237 return M