]> git.lizzy.rs Git - micro.git/blob - runtime/plugins/linter/linter.lua
546f5779b68d4a249bd612e84ac78ada3079af19
[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", function(bp, args)
86         bp:Save()
87         runLinter(bp.Buf)
88     end, config.NoComplete)
89
90     config.AddRuntimeFile("linter", config.RTHelp, "help/linter.md")
91 end
92
93 function contains(list, element)
94     for k, v in pairs(list) do
95         if v == element then
96             return true
97         end
98     end
99     return false
100 end
101
102 function runLinter(buf)
103     local ft = buf:FileType()
104     local file = buf.Path
105     local dir = filepath.Dir(file)
106
107     for k, v in pairs(linters) do
108         local ftmatch = ft == v.filetype
109         if v.domatch then
110             ftmatch = string.match(ft, v.filetype)
111         end
112
113         local hasOS = contains(v.os, runtime.GOOS)
114         if not hasOS and v.whitelist then
115             ftmatch = false
116         end
117         if hasOS and not v.whitelist then
118             ftmatch = false
119         end
120
121         local args = {}
122         for k, arg in pairs(v.args) do
123             args[k] = arg:gsub("%%f", file):gsub("%%d", dir)
124         end
125
126         if ftmatch then
127             lint(buf, k, v.cmd, args, v.errorformat, v.loffset, v.coffset, v.callback)
128         end
129     end
130 end
131
132 function onSave(bp)
133     runLinter(bp.Buf)
134     return true
135 end
136
137 function lint(buf, linter, cmd, args, errorformat, loff, coff, callback)
138     buf:ClearMessages(linter)
139
140     if callback ~= nil then
141         if not callback(buf) then
142             return
143         end
144     end
145
146     shell.JobSpawn(cmd, args, nil, nil, onExit, buf, linter, errorformat, loff, coff)
147 end
148
149 function onExit(output, args)
150     local buf, linter, errorformat, loff, coff = args[1], args[2], args[3], args[4], args[5]
151     local lines = split(output, "\n")
152
153     local regex = errorformat:gsub("%%f", "(..-)"):gsub("%%l", "(%d+)"):gsub("%%c", "(%d+)"):gsub("%%m", "(.+)")
154     for _,line in ipairs(lines) do
155         -- Trim whitespace
156         line = line:match("^%s*(.+)%s*$")
157         if string.find(line, regex) then
158             local file, line, col, msg = string.match(line, regex)
159             local hascol = true
160             if not string.find(errorformat, "%%c") then
161                 hascol = false
162                 msg = col
163             elseif col == nil then
164                 hascol = false
165             end
166             micro.Log(basename(buf.Path), basename(file))
167             if basename(buf.Path) == basename(file) then
168                 local bmsg = nil
169                 if hascol then
170                     local mstart = buffer.Loc(tonumber(col-1+coff), tonumber(line-1+loff))
171                     local mend = buffer.Loc(tonumber(col+coff), tonumber(line-1+loff))
172                     bmsg = buffer.NewMessage(linter, msg, mstart, mend, buffer.MTError)
173                 else
174                     bmsg = buffer.NewMessageAtLine(linter, msg, tonumber(line+loff), buffer.MTError)
175                 end
176                 buf:AddMessage(bmsg)
177             end
178         end
179     end
180 end
181
182 function split(str, sep)
183     local result = {}
184     local regex = ("([^%s]+)"):format(sep)
185     for each in str:gmatch(regex) do
186         table.insert(result, each)
187     end
188     return result
189 end
190
191 function basename(file)
192     local sep = "/"
193     if runtime.GOOS == "windows" then
194         sep = "\\"
195     end
196     local name = string.gsub(file, "(.*" .. sep .. ")(.*)", "%2")
197     return name
198 end