1 /**
2 Copyright: Copyright (c) 2017, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 This module contains the functions that realize the plugin architecture.
11 
12 All logger.XXX calls shall be dependent on the DebugLogging enum.
13 This is because this module otherwise produces a lot of junk logging that is
14 almost never relevant besides when making changes to this module.
15 */
16 module application.plugin;
17 
18 import dextool.type : FileName;
19 
20 import logger = std.experimental.logger;
21 
22 /// Kind of plugin, part of the primary installation or found in DEXTOOL_PLUGINS.
23 enum Kind {
24     /// A plugin that is found in the same directory as _this_ executable
25     primary,
26     /// A plugin found in the PATH
27     secondary
28 }
29 
30 /// Validated plugin with kind separating primary and secondar plugins.
31 struct Validated {
32     FileName path;
33     Kind kind;
34 }
35 
36 version (unittest) {
37     import unit_threaded : shouldEqual;
38 }
39 
40 // change this to true to activate debug logging for this module.
41 private enum DebugLogging = false;
42 
43 private void nothrowTrace(T...)(auto ref T args) @safe nothrow {
44     if (DebugLogging) {
45         try {
46             logger.trace(args);
47         } catch (Exception ex) {
48         }
49     }
50 }
51 
52 private void nothrowTracef(T...)(auto ref T args) @safe nothrow {
53     if (DebugLogging) {
54         try {
55             logger.tracef(args);
56         } catch (Exception ex) {
57         }
58     }
59 }
60 
61 /// Scan for files in the same directory as the executable.
62 Validated[] scanForExecutables() {
63     import std.algorithm : filter, map;
64     import std.array : array;
65     import std.file : thisExePath, dirEntries, SpanMode;
66     import std.path : absolutePath, dirName;
67     import std.range : tee;
68 
69     static bool isExecutable(uint attrs) {
70         import core.sys.posix.sys.stat;
71         import std.file : attrIsSymlink;
72 
73         // is a regular file and any of owner/group/other have execute
74         // permission.
75         // symlinks are NOT checked but accepted as they are.
76         //  - simplifies the logic
77         //  - makes it possible for the user to use symlinks.
78         //      it is the users responsibility that the symlink is correct.
79         return attrIsSymlink(attrs) || (attrs & S_IFMT) == S_IFREG
80             && ((attrs & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0);
81     }
82 
83     static FileName[] safeDirEntries(string path) nothrow {
84         import std.array : appender;
85 
86         auto res = appender!(FileName[])();
87         string err_msg;
88         try {
89             // dfmt off
90             foreach (e; dirEntries(path, SpanMode.shallow)
91                      .filter!(a => isExecutable(a.attributes))
92                      .map!(a => FileName(a.name.absolutePath))) {
93                 res.put(e);
94             }
95             // dfmt on
96         } catch (Exception ex) {
97             err_msg = ex.msg;
98         }
99 
100         nothrowTrace(err_msg.length != 0, "Unable to access ", err_msg);
101 
102         return res.data;
103     }
104 
105     static auto primaryPlugins() {
106         return safeDirEntries(thisExePath.dirName).map!(a => Validated(a, Kind.primary));
107     }
108 
109     static auto secondaryPlugins() {
110         import std.algorithm : splitter, joiner, map;
111         import std.process : environment;
112 
113         auto env_plugin = environment.get("DEXTOOL_PLUGINS", null);
114 
115         // dfmt off
116         return env_plugin.splitter(":")
117             .map!(a => safeDirEntries(a))
118             .joiner
119             .map!(a => Validated(a, Kind.secondary));
120         // dfmt on
121     }
122 
123     static auto merge(T0, T1)(T0 primary, T1 secondary) {
124         import std.array : array;
125         import std.path : baseName;
126         import std.range : chain;
127 
128         // remove secondary that clash with primary.
129         // secondaries may never override a primary.
130         bool[string] prim;
131         foreach (p; primary.save) {
132             prim[p.path.baseName] = true;
133         }
134 
135         // dfmt off
136         return chain(primary,
137                      secondary.filter!(a => a.path.baseName !in prim))
138             .array();
139         // dfmt on
140     }
141 
142     // dfmt off
143     return merge(primaryPlugins, secondaryPlugins)
144         .tee!(a => nothrowTrace("Found executable: ", a))
145         .array();
146     // dfmt on
147 }
148 
149 /** Filter the filenames for those that fulfill the requirement for a plugin.
150  *
151  * Binaries that begin with <this binary>-* are plugins.
152  */
153 auto filterValidPluginsThisExecutable(Validated[] fnames) @safe {
154     import std.file : thisExePath;
155     import std.path : baseName;
156 
157     immutable base_name = thisExePath.baseName ~ "-";
158     return filterValidPlugins(fnames, base_name);
159 }
160 
161 /** Filter the filenames for those that fulfill the requirement for a plugin.
162  *
163  * Binaries that begin with basename are plugins.
164  */
165 auto filterValidPlugins(Validated[] fnames, string base_name) @safe {
166     import std.algorithm : filter, startsWith, map;
167     import std.range : tee;
168     import std.path : baseName, absolutePath;
169 
170     // dfmt off
171     return fnames
172         .filter!(a => a.path.baseName.startsWith(base_name))
173         .tee!(a => nothrowTrace("Valid plugin prefix: ", a));
174     // dfmt on
175 }
176 
177 /// Holds information for a discovered plugin.
178 struct Plugin {
179     string name;
180     string help;
181     FileName path;
182     Kind kind;
183 }
184 
185 private struct ExecuteResult {
186     string output;
187     bool isValid;
188     Validated data;
189 }
190 
191 ExecuteResult executePluginForShortHelp(Validated plugin) @safe nothrow {
192     import std.process : execute;
193 
194     auto res = ExecuteResult("", false, plugin);
195 
196     try {
197         res = ExecuteResult(execute([plugin.path, "--short-plugin-help"]).output, true, plugin);
198     } catch (Exception ex) {
199         nothrowTrace("No --short-plugin-help for: ", plugin);
200     }
201 
202     nothrowTrace("Plugin --short-plugin-help: ", res);
203 
204     return res;
205 }
206 
207 Plugin[] toPlugins(alias execFunc, T)(T plugins) @safe nothrow {
208     import std.algorithm : filter, map, splitter, each, cache;
209     import std.array : array;
210     import std.ascii : newline;
211     import std.range : tee;
212 
213     static struct Temp {
214         string[] output;
215         Validated data;
216     }
217 
218     // dfmt off
219     auto res = plugins
220         .map!(a => execFunc(a))
221         .cache
222         // plugins that do not implement the required parameter are ignored
223         .filter!(a => a.isValid)
224         // the shorthelp must be two lines, the plugins name and a help text
225         .map!(a => Temp(a.output.splitter(newline).array(), a.data))
226         .filter!(a => a.output.length >= 2)
227         // convert
228         .map!(a => Plugin(a.output[0], a.output[1], a.data.path, a.data.kind))
229         .array();
230     // dfmt on
231 
232     try {
233         res.each!(a => nothrowTracef("Found plugin '%s' (%s) (%s): %s", a.name,
234                 a.path, a.kind, a.help));
235     } catch (Exception ex) {
236     }
237 
238     return res;
239 }
240 
241 string toShortHelp(Plugin[] plugins) @safe {
242     import std.algorithm : map, joiner, reduce, max, copy, filter;
243     import std.array : appender;
244     import std.ascii : newline;
245     import std.conv : text;
246     import std.range : chain, only;
247     import std.string : leftJustifier;
248 
249     // dfmt off
250     // +1 so there is a space left between category and info
251     auto max_length = 1 + reduce!((a,b) => max(a,b))(0UL, plugins.map!(a => a.name.length));
252 
253     auto app = appender!string();
254 
255     // dfmt off
256     plugins
257         .filter!(a => a.kind == Kind.primary)
258         .map!(a =>
259               chain(only("  "),
260                     only(leftJustifier(a.name, max_length).text),
261                     only(a.help))
262               .joiner()
263              )
264         .joiner(newline)
265         .text()
266         .copy(app);
267 
268     app.put(newline);
269 
270     plugins
271         .filter!(a => a.kind == Kind.secondary)
272         .map!(a =>
273               chain(only("  "),
274                     only(leftJustifier(a.name, max_length).text),
275                     only(a.help))
276               .joiner()
277              )
278         .joiner(newline)
279         .text()
280         .copy(app);
281     // dfmt on
282 
283     return app.data;
284 }
285 
286 @("Shall only keep those files prefixed with basename")
287 @safe unittest {
288     import std.algorithm;
289     import std.array;
290 
291     auto fnames = ["/ignore", "/usr/bin/dextool", "/usr/bin/dextool-ctest"].map!(
292             a => Validated(FileName(a), Kind.primary)).array();
293 
294     filterValidPlugins(fnames, "dextool-").shouldEqual(
295             [Validated(FileName("/usr/bin/dextool-ctest"), Kind.primary)]);
296 }
297 
298 @("Shall get the short text for the plugins")
299 @safe unittest {
300     import std.algorithm;
301     import std.array;
302 
303     auto fakeExec(FileName plugin) {
304         if (plugin == "dextool-ctest") {
305             return ExecuteResult("ctest\nc test text", true,
306                     Validated(FileName("/a/dextool-ctest"), Kind.primary));
307         } else if (plugin == "dextool-cpp") {
308             return ExecuteResult("cpp\ncpp test text", true,
309                     Validated(FileName("/b/dextool-cpp"), Kind.primary));
310         } else if (plugin == "dextool-too_many_lines") {
311             return ExecuteResult("too_many_lines\n\nfoo", true);
312         } else if (plugin == "dextool-fail_run") {
313             return ExecuteResult("fail\nfoo", false);
314         }
315 
316         assert(false); // should not happen
317     }
318 
319     auto fake_plugins = [
320         "dextool-ctest", "dextool-cpp", "dextool-too_many_lines",
321         "dextool-fail_run"
322     ].map!(a => FileName(a)).array();
323 
324     toPlugins!fakeExec(fake_plugins).shouldEqual([
325             Plugin("ctest", "c test text", FileName("/a/dextool-ctest")),
326             Plugin("cpp", "cpp test text", FileName("/b/dextool-cpp")),
327             Plugin("too_many_lines", "", FileName(""))
328             ]);
329 }
330 
331 @("A short help text with two plugins")
332 @safe unittest {
333     auto plugins = [
334         Plugin("ctest", "c help text", FileName("dummy"), Kind.primary),
335         Plugin("cpp", "c++ help text", FileName("dummy"), Kind.secondary)
336     ];
337     plugins.toShortHelp.shouldEqual("  ctest c help text\n  cpp   c++ help text");
338 }