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 }