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 }