]> git.lizzy.rs Git - micro.git/blob - runtime/plugins/linter/linter.lua
Merge
[micro.git] / runtime / plugins / linter / linter.lua
1 VERSION = "1.0.0"
2
3 local micro = import("micro")
4 local runtime = import("runtime")
5 local filepath = import("path/filepath")
6 local shell = import("micro/shell")
7 local buffer = import("micro/buffer")
8 local config = import("micro/config")
9
10 local linters = {}
11
12 -- creates a linter entry, call from within an initialization function, not
13 -- directly at initial load time
14 --
15 -- name: name of the linter
16 -- filetype: filetype to check for to use linter
17 -- cmd: main linter process that is executed
18 -- args: arguments to pass to the linter process
19 --     use %f to refer to the current file name
20 --     use %d to refer to the current directory name
21 -- errorformat: how to parse the linter/compiler process output
22 --     %f: file, %l: line number, %m: error/warning message
23 -- os: list of OSs this linter is supported or unsupported on
24 --     optional param, default: {}
25 -- whitelist: should the OS list be a blacklist (do not run the linter for these OSs)
26 --            or a whitelist (only run the linter for these OSs)
27 --     optional param, default: false (should blacklist)
28 -- domatch: should the filetype be interpreted as a lua pattern to match with
29 --          the actual filetype, or should the linter only activate on an exact match
30 --     optional param, default: false (require exact match)
31 -- loffset: line offset will be added to the line number returned by the linter
32 --          useful if the linter returns 0-indexed lines
33 --     optional param, default: 0
34 -- coffset: column offset will be added to the col number returned by the linter
35 --          useful if the linter returns 0-indexed columns
36 --     optional param, default: 0
37 -- callback: function to call before executing the linter, if it returns
38 --           false the lint is canceled. The callback is passed the buf.
39 --     optional param, default: nil
40 function makeLinter(name, filetype, cmd, args, errorformat, os, whitelist, domatch, loffset, coffset, callback)
41     if linters[name] == nil then
42         linters[name] = {}
43         linters[name].filetype = filetype
44         linters[name].cmd = cmd
45         linters[name].args = args
46         linters[name].errorformat = errorformat
47         linters[name].os = os or {}
48         linters[name].whitelist = whitelist or false
49         linters[name].domatch = domatch or false
50         linters[name].loffset = loffset or 0
51         linters[name].coffset = coffset or 0
52         linters[name].callback = callback or nil
53     end
54 end
55
56 function removeLinter(name)
57     linters[name] = nil
58 end
59
60 function init()
61     local devnull = "/dev/null"
62     if runtime.GOOS == "windows" then
63         devnull = "NUL"
64     end
65
66     makeLinter("gcc", "c", "gcc", {"-fsyntax-only", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
67     makeLinter("gcc", "c++", "gcc", {"-fsyntax-only","-std=c++14", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
68     makeLinter("dmd", "d", "dmd", {"-color=off", "-o-", "-w", "-wi", "-c", "%f"}, "%f%(%l%):.+: %m")
69     makeLinter("gobuild", "go", "go", {"build", "-o", devnull}, "%f:%l:%c:? %m")
70     -- makeLinter("golint", "go", "golint", {"%f"}, "%f:%l:%c: %m")
71     makeLinter("javac", "java", "javac", {"-d", "%d", "%f"}, "%f:%l: error: %m")
72     makeLinter("jshint", "javascript", "jshint", {"%f"}, "%f: line %l,.+, %m")
73     makeLinter("literate", "literate", "lit", {"-c", "%f"}, "%f:%l:%m", {}, false, true)
74     makeLinter("luacheck", "lua", "luacheck", {"--no-color", "%f"}, "%f:%l:%c: %m")
75     makeLinter("nim", "nim", "nim", {"check", "--listFullPaths", "--stdout", "--hints:off", "%f"}, "%f.%l, %c. %m")
76     makeLinter("clang", "objective-c", "xcrun", {"clang", "-fsyntax-only", "-Wall", "-Wextra", "%f"}, "%f:%l:%c:.+: %m")
77     makeLinter("pyflakes", "python", "pyflakes", {"%f"}, "%f:%l:.-:? %m")
78     makeLinter("mypy", "python", "mypy", {"%f"}, "%f:%l: %m")
79     makeLinter("pylint", "python", "pylint", {"--output-format=parseable", "--reports=no", "%f"}, "%f:%l: %m")
80     makeLinter("shfmt", "shell", "shfmt", {"%f"}, "%f:%l:%c: %m")
81     makeLinter("swiftc", "swift", "xcrun", {"swiftc", "%f"}, "%f:%l:%c:.+: %m", {"darwin"}, true)
82     makeLinter("swiftc", "swiftc", {"%f"}, "%f:%l:%c:.+: %m", {"linux"}, true)
83     makeLinter("yaml", "yaml", "yamllint", {"--format", "parsable", "%f"}, "%f:%l:%c:.+ %m")
84
85     config.MakeCommand("lint", "linter.lintCmd", config.NoComplete)
86     config.AddRuntimeFile("linter", config.RTHelp, "help/linter.md")
87 end
88
89 function lintCmd(bp, args)
90     bp:Save()
91     runLinter(bp.Buf)
92 end
93
94 function contains(list, element)
95     for k, v in pairs(list) do
96         if v == element then
97             return true
98         end
99     end
100     return false
101 end
102
103 function runLinter(buf)
104     local ft = buf:FileType()
105     local file = buf.Path
106     local dir = filepath.Dir(file)
107
108     for k, v in pairs(linters) do
109         local ftmatch = ft == v.filetype
110         if v.domatch then
111             ftmatch = string.match(ft, v.filetype)
112         end
113
114         local hasOS = contains(v.os, runtime.GOOS)
115         if not hasOS and v.whitelist then
116             ftmatch = false
117         end
118         if hasOS and not v.whitelist then
119             ftmatch = false
120         end
121
122         local args = {}
123         for k, arg in pairs(v.args) do
124             args[k] = arg:gsub("%%f", file):gsub("%%d", dir)
125         end
126
127         if ftmatch then
128             lint(buf, k, v.cmd, args, v.errorformat, v.loffset, v.coffset, v.callback)
129         end
130     end
131 end
132
133 function onSave(bp)
134     runLinter(bp.Buf)
135     return true
136 end
137
138 function lint(buf, linter, cmd, args, errorformat, loff, coff, callback)
139     buf:ClearMessages(linter)
140
141     if callback ~= nil then
142         if not callback(buf) then
143             return
144         end
145     end
146
147     shell.JobSpawn(cmd, args, "", "", "linter.onExit", buf, linter, errorformat, loff, coff)
148 end
149
150 function onExit(output, args)
151     local buf, linter, errorformat, loff, coff = args[1], args[2], args[3], args[4], args[5]
152     local lines = split(output, "\n")
153
154     local regex = errorformat:gsub("%%f", "(..-)"):gsub("%%l", "(%d+)"):gsub("%%c", "(%d+)"):gsub("%%m", "(.+)")
155     for _,line in ipairs(lines) do
156         -- Trim whitespace
157         line = line:match("^%s*(.+)%s*$")
158         if string.find(line, regex) then
159             local file, line, col, msg = string.match(line, regex)
160             local hascol = true
161             if not string.find(errorformat, "%%c") then
162                 hascol = false
163                 msg = col
164             elseif col == nil then
165                 hascol = false
166             end
167             micro.Log(basename(buf.Path), basename(file))
168             if basename(buf.Path) == basename(file) then
169                 local bmsg = nil
170                 if hascol then
171                     local mstart = buffer.Loc(tonumber(col-1+coff), tonumber(line-1+loff))
172                     local mend = buffer.Loc(tonumber(col+coff), tonumber(line-1+loff))
173                     bmsg = buffer.NewMessage(linter, msg, mstart, mend, buffer.MTError)
174                 else
175                     bmsg = buffer.NewMessageAtLine(linter, msg, tonumber(line+loff), buffer.MTError)
176                 end
177                 buf:AddMessage(bmsg)
178             end
179         end
180     end
181 end
182
183 function split(str, sep)
184     local result = {}
185     local regex = ("([^%s]+)"):format(sep)
186     for each in str:gmatch(regex) do
187         table.insert(result, each)
188     end
189     return result
190 end
191
192 function basename(file)
193     local sep = "/"
194     if runtime.GOOS == "windows" then
195         sep = "\\"
196     end
197     local name = string.gsub(file, "(.*" .. sep .. ")(.*)", "%2")
198     return name
199 end