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