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 my.path : AbsolutePath, Path;
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     Path 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;
66     import std.path : dirName, stripExtension, baseName;
67     import std.range : tee;
68     import my.file : which, whichFromEnv;
69 
70     auto pluginPattern = thisExePath.baseName.stripExtension ~ "*";
71 
72     auto primaryPlugins() {
73         return which([AbsolutePath(thisExePath.dirName)], pluginPattern).map!(
74                 a => Validated(a, Kind.primary));
75     }
76 
77     auto secondaryPlugins() {
78         return whichFromEnv("DEXTOOL_PLUGINS", pluginPattern).map!(a => Validated(a,
79                 Kind.secondary));
80     }
81 
82     static auto merge(T0, T1)(T0 primary, T1 secondary) {
83         import std.range : chain;
84         import my.set;
85 
86         // remove secondary that clash with primary.
87         // secondaries may never override a primary.
88         auto prim = toSet(primary.save.map!(a => cast(string) a.path));
89 
90         return chain(primary, secondary.filter!(a => a.path.baseName !in prim)).array;
91     }
92 
93     return merge(primaryPlugins, secondaryPlugins).tee!(
94             a => nothrowTrace("Found executable: ", a)).array;
95 }
96 
97 /** Filter the filenames for those that fulfill the requirement for a plugin.
98  *
99  * Binaries that begin with <this binary>-* are plugins.
100  */
101 auto filterValidPluginsThisExecutable(Validated[] fnames) @safe {
102     import std.file : thisExePath;
103     import std.path : baseName;
104 
105     immutable base_name = thisExePath.baseName ~ "-";
106     return filterValidPlugins(fnames, base_name);
107 }
108 
109 /** Filter the filenames for those that fulfill the requirement for a plugin.
110  *
111  * Binaries that begin with basename are plugins.
112  */
113 auto filterValidPlugins(Validated[] fnames, string base_name) @safe {
114     import std.algorithm : filter, startsWith, map;
115     import std.range : tee;
116     import std.path : baseName, absolutePath;
117 
118     // dfmt off
119     return fnames
120         .filter!(a => a.path.baseName.startsWith(base_name))
121         .tee!(a => nothrowTrace("Valid plugin prefix: ", a));
122     // dfmt on
123 }
124 
125 /// Holds information for a discovered plugin.
126 struct Plugin {
127     string name;
128     string help;
129     Path path;
130     Kind kind;
131 }
132 
133 private struct ExecuteResult {
134     string output;
135     bool isValid;
136     Validated data;
137 }
138 
139 ExecuteResult executePluginForShortHelp(Validated plugin) @safe nothrow {
140     import std.process : execute;
141 
142     auto res = ExecuteResult("", false, plugin);
143 
144     try {
145         res = ExecuteResult(execute([plugin.path, "--short-plugin-help"]).output, true, plugin);
146     } catch (Exception ex) {
147         nothrowTrace("No --short-plugin-help for: ", plugin);
148     }
149 
150     nothrowTrace("Plugin --short-plugin-help: ", res);
151 
152     return res;
153 }
154 
155 Plugin[] toPlugins(alias execFunc, T)(T plugins) @safe {
156     import std.algorithm : filter, map, splitter, each, cache;
157     import std.array : array;
158     import std.ascii : newline;
159     import std.range : tee;
160 
161     static struct Temp {
162         string[] output;
163         Validated data;
164     }
165 
166     // dfmt off
167     auto res = plugins
168         .map!(a => execFunc(a))
169         .cache
170         // plugins that do not implement the required parameter are ignored
171         .filter!(a => a.isValid)
172         // the shorthelp must be two lines, the plugins name and a help text
173         .map!(a => Temp(a.output.splitter(newline).array(), a.data))
174         .filter!(a => a.output.length >= 2)
175         // convert
176         .map!(a => Plugin(a.output[0], a.output[1], a.data.path, a.data.kind))
177         .array();
178     // dfmt on
179 
180     try {
181         res.each!(a => nothrowTracef("Found plugin '%s' (%s) (%s): %s", a.name,
182                 a.path, a.kind, a.help));
183     } catch (Exception ex) {
184     }
185 
186     return res;
187 }
188 
189 string toShortHelp(Plugin[] plugins) @safe {
190     import std.algorithm : map, joiner, reduce, max, copy, filter;
191     import std.array : appender;
192     import std.ascii : newline;
193     import std.conv : text;
194     import std.range : chain, only;
195     import std.string : leftJustifier;
196 
197     // dfmt off
198     // +1 so there is a space left between category and info
199     auto max_length = 1 + reduce!((a,b) => max(a,b))(0UL, plugins.map!(a => a.name.length));
200 
201     auto app = appender!string();
202 
203     // dfmt off
204     plugins
205         .filter!(a => a.kind == Kind.primary)
206         .map!(a =>
207               chain(only("  "),
208                     only(leftJustifier(a.name, max_length).text),
209                     only(a.help))
210               .joiner()
211              )
212         .joiner(newline)
213         .text()
214         .copy(app);
215 
216     app.put(newline);
217 
218     plugins
219         .filter!(a => a.kind == Kind.secondary)
220         .map!(a =>
221               chain(only("  "),
222                     only(leftJustifier(a.name, max_length).text),
223                     only(a.help))
224               .joiner()
225              )
226         .joiner(newline)
227         .text()
228         .copy(app);
229     // dfmt on
230 
231     return app.data;
232 }
233 
234 @("Shall only keep those files prefixed with basename")
235 unittest {
236     import std.algorithm;
237     import std.array;
238 
239     auto fnames = ["/ignore", "/usr/bin/dextool", "/usr/bin/dextool-ctest"].map!(
240             a => Validated(Path(a), Kind.primary)).array();
241 
242     filterValidPlugins(fnames, "dextool-").shouldEqual(
243             [Validated(Path("/usr/bin/dextool-ctest"), Kind.primary)]);
244 }
245 
246 @("Shall get the short text for the plugins")
247 @safe unittest {
248     import std.algorithm;
249     import std.array;
250 
251     auto fakeExec(Path plugin) {
252         if (plugin == "dextool-ctest") {
253             return ExecuteResult("ctest\nc test text", true,
254                     Validated(Path("/a/dextool-ctest"), Kind.primary));
255         } else if (plugin == "dextool-cpp") {
256             return ExecuteResult("cpp\ncpp test text", true,
257                     Validated(Path("/b/dextool-cpp"), Kind.primary));
258         } else if (plugin == "dextool-too_many_lines") {
259             return ExecuteResult("too_many_lines\n\nfoo", true);
260         } else if (plugin == "dextool-fail_run") {
261             return ExecuteResult("fail\nfoo", false);
262         }
263 
264         assert(false); // should not happen
265     }
266 
267     auto fake_plugins = [
268         "dextool-ctest", "dextool-cpp", "dextool-too_many_lines",
269         "dextool-fail_run"
270     ].map!(a => Path(a)).array();
271 
272     toPlugins!fakeExec(fake_plugins).shouldEqual([
273         Plugin("ctest", "c test text", Path("/a/dextool-ctest")),
274         Plugin("cpp", "cpp test text", Path("/b/dextool-cpp")),
275         Plugin("too_many_lines", "", Path(""))
276     ]);
277 }
278 
279 @("A short help text with two plugins")
280 @safe unittest {
281     auto plugins = [
282         Plugin("ctest", "c help text", Path("dummy"), Kind.primary),
283         Plugin("cpp", "c++ help text", Path("dummy"), Kind.secondary)
284     ];
285     plugins.toShortHelp.shouldEqual("  ctest c help text\n  cpp   c++ help text");
286 }