aboutsummaryrefslogtreecommitdiff
path: root/lua/tasks/runner.lua
blob: bcd0bfa0ca410e6419f1ebe8ea5f96dc4a8b119b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
local config = require('tasks.config')
local Job = require('plenary.job')
local runner = {}

local last_job

---@param lines table
---@param errorformat string?
local function append_to_quickfix(lines, errorformat)
  vim.fn.setqflist({}, 'a', { efm = errorformat, lines = lines })
  -- Scrolls the quickfix buffer if not active
  if vim.bo.buftype ~= 'quickfix' then
    vim.api.nvim_command('cbottom')
  end
end

---@param errorformat? string
---@return function: A coroutine that reads job data into quickfix.
local function read_to_quickfix(errorformat)
  -- Modified from https://github.com/nvim-lua/plenary.nvim/blob/968a4b9afec0c633bc369662e78f8c5db0eba249/lua/plenary/job.lua#L287
  -- We use our own implementation to process data in chunks because
  -- default Plenary callback processes every line which is very slow for adding to quickfix.
  return coroutine.wrap(function(err, data, is_complete)
    -- We repeat forever as a coroutine so that we can keep calling this.
    local lines = {}
    local result_index = 1
    local result_line = nil
    local found_newline = nil

    while true do
      if data then
        data = data:gsub('\r', '')

        local processed_index = 1
        local data_length = #data + 1

        repeat
          local start = data:find('\n', processed_index, true) or data_length
          local line = data:sub(processed_index, start - 1)
          found_newline = start ~= data_length

          -- Concat to last line if there was something there already.
          --    This happens when "data" is broken into chunks and sometimes
          --    the content is sent without any newlines.
          if result_line then
            result_line = result_line .. line

            -- Only put in a new line when we actually have new data to split.
            --    This is generally only false when we do end with a new line.
            --    It prevents putting in a "" to the end of the results.
          elseif start ~= processed_index or found_newline then
            result_line = line

            -- Otherwise, we don't need to do anything.
          end

          if found_newline then
            if not result_line then
              return vim.api.nvim_err_writeln('Broken data thing due to: ' .. tostring(result_line) .. ' ' .. tostring(data))
            end

            table.insert(lines, err and err or result_line)

            result_index = result_index + 1
            result_line = nil
          end

          processed_index = start + 1
        until not found_newline
      end

      if is_complete and not found_newline then
        table.insert(lines, err and err or result_line)
      end

      if #lines ~= 0 then
        -- Move lines to another variable and send them to quickfix
        local processed_lines = lines
        lines = {}
        vim.schedule(function() append_to_quickfix(processed_lines, errorformat) end)
      end

      if data == nil or is_complete then
        return
      end

      err, data, is_complete = coroutine.yield()
    end
  end)
end

--- Run specified commands in chain.
---@param task_name string: Name of a task to read properties.
---@param commands table: Commands to chain.
---@param module_config table: Module configuration.
---@param addition_args table?: Additional arguments that will be applied to the last command.
---@param previous_job table?: Previous job to read data from, used by this function for recursion.
function runner.chain_commands(task_name, commands, module_config, addition_args, previous_job)
  local command = commands[1]
  if vim.is_callable(command) then
    command = command(module_config, previous_job)
    if not command then
      return
    end
  end

  local cwd = command.cwd or vim.loop.cwd()
  local args = command.args and command.args or {}
  local env = vim.tbl_extend('force', vim.loop.os_environ(), command.env and command.env or {})
  if #commands == 1 then
    -- Apply task parameters only to the last command
    vim.list_extend(args, addition_args)
    vim.list_extend(args, vim.tbl_get(module_config, 'args', task_name) or {})
    env = vim.tbl_extend('force', env, vim.tbl_get(module_config, 'env', task_name) or {})
  end

  if command.dap_name then
    vim.schedule(function()
      local dap = require('dap')
      local dap_config = dap.configurations[command.dap_name] -- Try to get an existing configuration
      dap.run(vim.tbl_extend('force', dap_config and dap_config or { type = command.dap_name }, {
        name = command.cmd,
        request = 'launch',
        program = command.cmd,
        args = args,
        cwd = cwd,
      }))
      if config.dap_open_command then
        vim.api.nvim_command('cclose')
        config.dap_open_command()
      end
      last_job = dap
    end)
    return
  end

  local quickfix_output = not command.ignore_stdout or not command.ignore_stderr
  local job = Job:new({
    command = command.cmd,
    args = args,
    cwd = cwd,
    env = env,
    enable_recording = #commands ~= 1,
    on_start = quickfix_output and vim.schedule_wrap(function()
      vim.fn.setqflist({}, ' ', { title = command.cmd .. ' ' .. table.concat(args, ' ') })
      vim.api.nvim_command(string.format('%s copen %d', config.quickfix.pos, config.quickfix.height))
      vim.api.nvim_command('wincmd p')
    end) or nil,
    on_exit = vim.schedule_wrap(function(_, code, signal)
      if quickfix_output then
        append_to_quickfix({ 'Exited with code ' .. (signal == 0 and code or 128 + signal) })
      end
      if code == 0 and signal == 0 and command.after_success then
        command.after_success()
      end
    end),
  })

  job:start()
  if not command.ignore_stdout then
    job.stdout:read_start(read_to_quickfix(command.errorformat))
  end
  if not command.ignore_stderr then
    job.stderr:read_start(read_to_quickfix(command.errorformat))
  end

  if #commands ~= 1 then
    job:after_success(function() runner.chain_commands(task_name, vim.list_slice(commands, 2), module_config, addition_args, job) end)
  end
  last_job = job
end

---@return string?
function runner.get_current_job_name()
  if not last_job then
    return nil
  end

  -- Check if this job was run through debugger.
  if last_job.session then
    local session = last_job.session()
    if not session then
      return nil
    end
    return session.config.program
  end

  if last_job.is_shutdown then
    return nil
  end

  return last_job.command
end

---@return boolean: `true` if a job was canceled or `false` if there is no active job.
function runner.cancel_job()
  if not last_job then
    return false
  end

  -- Check if this job was run through debugger.
  if last_job.session then
    if not last_job.session() then
      return false
    end
    last_job.terminate()
    return true
  end

  if last_job.is_shutdown then
    return false
  end

  last_job:shutdown(1, 9)

  if vim.fn.has('win32') == 1 then
    -- Kill all children.
    for _, pid in ipairs(vim.api.nvim_get_proc_children(last_job.pid)) do
      vim.loop.kill(pid, 9)
    end
  else
    vim.loop.kill(last_job.pid, 9)
  end
  return true
end

return runner