1 /*
2  * Copyright (c) 2017-2018 sel-project
3  *
4  * Permission is hereby granted, free of charge, to any person obtaining a copy
5  * of this software and associated documentation files (the "Software"), to deal
6  * in the Software without restriction, including without limitation the rights
7  * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8  * copies of the Software, and to permit persons to whom the Software is
9  * furnished to do so, subject to the following conditions:
10  *
11  * The above copyright notice and this permission notice shall be included in all
12  * copies or substantial portions of the Software.
13  *
14  * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15  * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16  * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17  * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18  * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19  * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20  * SOFTWARE.
21  *
22  */
23 module init;
24 
25 import std.algorithm : sort, canFind, clamp;
26 import std.array : join, split;
27 import std.ascii : newline;
28 import std.conv : ConvException, to;
29 import std.file;
30 import std.json;
31 import std.path : dirSeparator, buildNormalizedPath, absolutePath, relativePath;
32 import std.process : environment, executeShell;
33 import std.regex : matchFirst, ctRegex;
34 import std.stdio : writeln;
35 import std.string;
36 import std.zip;
37 
38 import selery.about;
39 
40 import toml;
41 import toml.json;
42 
43 enum size_t __GENERATOR__ = 49;
44 
45 void main(string[] args) {
46 
47 	string libraries;
48 	if(exists(".selery/libraries")) {
49 		// should be an absolute normalised path
50 		libraries = cast(string)read(".selery/libraries");
51 	} else {
52 		// assuming this file is executed in ../
53 		libraries = buildNormalizedPath(absolutePath(".."));
54 	}
55 	if(!libraries.endsWith(dirSeparator)) libraries ~= dirSeparator;
56 	
57 	bool portable = false;
58 	string type = "default";
59 	
60 	bool plugins = true;
61 	
62 	foreach(arg ; args) {
63 		switch(arg.toLower()) {
64 			case "--generate-files":
65 				mkdirRecurse("views");
66 				write("views/version.txt", Software.displayVersion);
67 				string[] notes;
68 				string history = cast(string)read("../docs/history.md");
69 				immutable v = "### " ~ Software.displayVersion;
70 				auto start = history.indexOf(v);
71 				if(start != -1) {
72 					start += v.length;
73 					history = history[start..$];
74 					immutable end = history.indexOf("##");
75 					write("views/notes.txt", history[0..(end==-1?$:end)].strip.replace("\n", "\\n"));
76 				} else {
77 					write("views/notes.txt", "");
78 				}
79 				JSONValue[string] release;
80 				if(environment.get("TRAVIS", "") == "true") {
81 					release["ci"] = "travis-ci";
82 					release["repo"] = environment["TRAVIS_REPO_SLUG"];
83 					release["job"] = environment["TRAVIS_JOB_NUMBER"];
84 				} else if(environment.get("APPVEYOR", "").toLower == "true") {
85 					release["ci"] = "appveyor";
86 					release["repo"] = environment["APPVEYOR_REPO_NAME"];
87 					release["job"] = environment["APPVEYOR_JOB_NUMBER"];
88 				}
89 				write("views/release.json", JSONValue(release).toString());
90 				write("views/is_release.txt", to!string(environment.get("APPVEYOR_REPO_COMMIT_MESSAGE", "").indexOf("[release]") != -1));
91 				return;
92 			case "--no-plugins":
93 				plugins = false;
94 				break;
95 			case "--portable":
96 				portable = true;
97 				break;
98 			case "default":
99 			case "classic":
100 			case "allinone":
101 			case "all-in-one":
102 				type = "default";
103 				break;
104 			case "hub":
105 				type = "hub";
106 				break;
107 			case "node":
108 				type = "node";
109 				break;
110 			default:
111 				break;
112 		}		
113 	}
114 
115 	if(portable) {
116 
117 		auto zip = new ZipArchive();
118 
119 		// get all files in assets
120 		foreach(string file ; dirEntries("../assets/", SpanMode.breadth)) {
121 			immutable name = file[10..$].replace("\\", "/");
122 			if(file.isFile && !name.startsWith(".") && !name.endsWith(".ico") && (!name.startsWith("web/") || name.endsWith("/main.css") || name.indexOf("/res/") != -1)) {
123 				//TODO optimise .lang files by removing empty lines, windows endings and comments
124 				auto data = read(file);
125 				auto member = new ArchiveMember();
126 				member.name = name;
127 				member.expandedData(cast(ubyte[])(file.endsWith(".json") ? parseJSON(cast(string)data).toString() : data));
128 				member.compressionMethod = CompressionMethod.deflate;
129 				zip.addMember(member);
130 			}
131 		}
132 		mkdirRecurse("views");
133 		write("views/portable.zip", zip.build());
134 
135 	} else if(exists("views/portable.zip")) {
136 
137 		remove("views/portable.zip");
138 
139 	}
140 
141 	TOMLDocument[string] plugs; // plugs[location] = settingsfile
142 
143 	if(plugins) {
144 
145 		bool loadPlugin(string path) {
146 			if(!path.endsWith(dirSeparator)) path ~= dirSeparator;
147 			foreach(pack ; ["plugin.toml", "plugin.json"]) {
148 				if(exists(path ~ pack)) {
149 					if(pack.endsWith(".toml")) {
150 						auto toml = parseTOML(cast(string)read(path ~ pack));
151 						toml["single"] = false;
152 						plugs[path] = toml;
153 						return true;
154 					} else {
155 						auto json = parseJSON(cast(string)read(path ~ pack));
156 						if(json.type == JSON_TYPE.OBJECT) {
157 							json["single"] = false;
158 							plugs[path] = TOMLDocument(toTOML(json).table);
159 							return true;
160 						}
161 					}
162 				}
163 			}
164 			return false;
165 		}
166 		
167 		void loadZippedPlugin(string path) {
168 			// unzip and load as normal plugin
169 			auto data = read(path);
170 			auto zip = new ZipArchive(data);
171 			immutable name = path[path.lastIndexOf("/")+1..$-4];
172 			immutable dest = ".selery/plugins/" ~ name ~ "/";
173 			bool update = true;
174 			if(exists(dest)) {
175 				if(exists(dest ~ "crc32.json")) {
176 					update = false;
177 					auto json = parseJSON(cast(string)read(dest ~ "crc32.json")).object;
178 					// compare file names
179 					if(sort(json.keys).release() != sort(zip.directory.keys).release()) update = true;
180 					else {
181 						// compare file's crc32
182 						foreach(name, member; zip.directory) {
183 							if(member.crc32 != json[name].integer) {
184 								update = true;
185 								break;
186 							}
187 						}
188 					}
189 				}
190 				if(update) {
191 					foreach(string file ; dirEntries(dest, SpanMode.breadth)) {
192 						if(file.isFile) remove(file);
193 					}
194 				}
195 			} else {
196 				mkdirRecurse(dest);
197 			}
198 			if(update) {
199 				JSONValue[string] files;
200 				foreach(name, member; zip.directory) {
201 					files[name] = member.crc32;
202 					if(!name.endsWith("/")) {
203 						zip.expand(member);
204 						if(name.indexOf("/") != -1) mkdirRecurse(dest ~ name[0..name.lastIndexOf("/")]);
205 						write(dest ~ name, member.expandedData);
206 					}
207 				}
208 				write(dest ~ "crc32.json", JSONValue(files).toString());
209 			}
210 			if(!loadPlugin(dest)) loadPlugin(dest ~ name);
211 		}
212 
213 		void loadSinglePlugin(string location) {
214 			immutable name = location[location.lastIndexOf("/")+1..$-2].replace("-", "_");
215 			foreach(line ; split(cast(string)read(location), "\n")) {
216 				if(line.strip.startsWith("module") && line[6..$].strip.startsWith(name ~ ";")) {
217 					string main = name ~ ".";
218 					bool uppercase = true;
219 					foreach(c ; name) {
220 						if(c == '_') {
221 							uppercase = true;
222 						} else {
223 							if(uppercase) main ~= toUpper("" ~ c);
224 							else main ~= c;
225 							uppercase = false;
226 						}
227 					}
228 					plugs[location] = TOMLDocument(["name": TOMLValue(name.replace("_", "-")), "main": TOMLValue(main)]);
229 					break;
230 				}
231 			}
232 		}
233 
234 		writeln("Loading plugins for ", Software.name, " ", Software.fullVersion, " configuration \"", type, "\"");
235 
236 		// load plugins in plugins folder
237 		if(exists("../plugins")) {
238 			foreach(string ppath ; dirEntries("../plugins/", SpanMode.shallow)) {
239 				if(ppath.isDir) {
240 					loadPlugin(ppath);
241 				} else if(ppath.isFile && ppath.endsWith(".zip")) {
242 					loadZippedPlugin(ppath);
243 				} else if(ppath.isFile && ppath.endsWith(".d")) {
244 					loadSinglePlugin(ppath);
245 				}
246 			}
247 		}
248 
249 	}
250 
251 	Info[string] info;
252 	
253 	foreach(path, value; plugs) {
254 		Info plugin;
255 		plugin.name = value["name"].str;
256 		checkName(plugin.name);
257 		if(path.isFile) {
258 			plugin.single = buildNormalizedPath(absolutePath(path));
259 		} else {
260 			if(!path.endsWith(dirSeparator)) path ~= dirSeparator;
261 		}
262 		if(plugin.name !in info) {
263 			plugin.toml = value;
264 			plugin.path = buildNormalizedPath(absolutePath(path));
265 			if(!plugin.path.endsWith(dirSeparator)) plugin.path ~= dirSeparator;
266 			auto priority = "priority" in value;
267 			if(priority) {
268 				if(priority.type == TOML_TYPE.STRING) {
269 					immutable p = priority.str.toLower;
270 					plugin.priority = (p == "high" || p == "🔥") ? 10 : (p == "medium" || p == "normal" ? 5 : 1);
271 				} else if(priority.type == TOML_TYPE.INTEGER) {
272 					plugin.priority = clamp(priority.integer.to!size_t, 1, 10);
273 				}
274 			}
275 			auto authors = "authors" in value;
276 			auto author = "author" in value;
277 			if(authors && authors.type == TOML_TYPE.ARRAY) {
278 				foreach(a ; authors.array) {
279 					if(a.type == TOML_TYPE.STRING) {
280 						plugin.authors ~= a.str;
281 					}
282 				}
283 			} else if(author && author.type == TOML_TYPE.STRING) {
284 				plugin.authors = [author.str];
285 			}
286 			auto main = "main" in value;
287 			if(main && main.type == TOML_TYPE.STRING) {
288 				string[] spl = main.str.split(".");
289 				if(plugin.single.length) {
290 					plugin.mod = spl[0];
291 					plugin.main = main.str;
292 				} else {
293 					immutable m = main.str.lastIndexOf(".");
294 					if(m != -1) {
295 						plugin.mod = main.str[0..m];
296 						plugin.main = main.str;
297 					}
298 				}
299 			}
300 			if(plugin.single.length) {
301 				plugin.version_ = "~single";
302 			} else {
303 				foreach(string file ; dirEntries(plugin.path ~ "src", SpanMode.breadth)) {
304 					if(file.isFile && file.endsWith(dirSeparator ~ "api.d")) {
305 						plugin.api = true;
306 						break;
307 					}
308 				}
309 			}
310 			info[plugin.name] = plugin;
311 		} else {
312 			throw new Exception("Plugin '" ~ plugin.name ~ " at " ~ plugin.path ~ " conflicts with a plugin with the same name at " ~ info[plugin.name].path);
313 		}
314 	}
315 
316 	auto ordered = info.values;
317 
318 	// sort by priority (or alphabetically)
319 	sort!"a.priority == b.priority ? a.name < b.name : a.priority > b.priority"(ordered);
320 
321 	// control api version
322 	foreach(ref inf ; ordered) {
323 		if(inf.active) {
324 			long[] api;
325 			auto ptr = "api" in inf.toml;
326 			if(ptr) {
327 				if((*ptr).type == TOML_TYPE.INTEGER) {
328 					api ~= (*ptr).integer;
329 				} else if((*ptr).type == TOML_TYPE.ARRAY) {
330 					foreach(v ; (*ptr).array) {
331 						if(v.type == TOML_TYPE.INTEGER) api ~= v.integer;
332 					}
333 				} else if((*ptr).type == TOML_TYPE.TABLE) {
334 					auto from = "from" in *ptr;
335 					auto to = "to" in *ptr;
336 					if(from && (*from).type == TOML_TYPE.INTEGER && to && (*to).type == TOML_TYPE.INTEGER) {
337 						foreach(a ; (*from).integer..(*to).integer+1) {
338 							api ~= a;
339 						}
340 					}
341 				}
342 			}
343 			if(api.length == 0 || api.canFind(Software.api)) {
344 				writeln(inf.name, " ", inf.version_, ": loaded");
345 			} else {
346 				writeln(inf.name, " ", inf.version_, ": cannot load due to wrong api ", api);
347 				inf.active = false;
348 			}
349 		}
350 	}
351 	
352 	JSONValue[string] builder;
353 	builder["name"] = "selery-builder";
354 	builder["targetType"] = "executable";
355 	builder["targetName"] = (type == "default" ? "selery" : ("selery-" ~ type)) ~ (portable ? "-" ~ Software.displayVersion : "");
356 	builder["targetPath"] = "..";
357 	builder["workingDirectory"] = "..";
358 	builder["sourceFiles"] = ["main/" ~ type ~ ".d", ".selery/builder.d"];
359 	builder["configurations"] = [["name": type]];
360 	builder["dependencies"] = [
361 		"selery": ["path": ".."],
362 		"arsd-official:terminal": ["version": "~>1.2.2"], // bug in dub
363 		"toml": ["version": "~>0.4.0-rc.4"],
364 		"toml:json": ["version": "~>0.4.0-rc.4"],
365 	];
366 	builder["subPackages"] = new JSONValue[0];
367 	
368 	size_t count = 0;
369 		
370 	string imports = "";
371 	string loads = "";
372 
373 	string[] fimports;
374 	
375 	if(!exists(".selery")) mkdir(".selery");
376 
377 	foreach(ref value ; ordered) {
378 		if(value.active) {
379 			count++;
380 			if(value.single.length) {
381 				builder["sourceFiles"].array ~= JSONValue(relativePath(value.single));
382 			} else {
383 				JSONValue[string] sub;
384 				sub["name"] = value.name;
385 				sub["targetType"] = "library";
386 				sub["targetPath"] = ".." ~ dirSeparator ~ "libs";
387 				sub["configurations"] = [["name": "plugin"]];
388 				sub["dependencies"] = ["selery": ["path": ".."], "arsd-official:terminal": ["version": "~>1.2.2"]], // bug in dub;
389 				sub["sourcePaths"] = [relativePath(value.path ~ "src")];
390 				sub["importPaths"] = [relativePath(value.path ~ "src")];
391 				auto dptr = "dependencies" in value.toml;
392 				if(dptr && dptr.type == TOML_TYPE.TABLE) {
393 					foreach(name, d; dptr.table) {
394 						if(name.startsWith("dub:")) {
395 							sub["dependencies"][name[4..$]] = toJSON(d);
396 						} else {
397 							//TODO depends on another plugin
398 						}
399 					}
400 				}
401 				builder["subPackages"].array ~= JSONValue(sub);
402 				builder["dependencies"][":" ~ value.name] = "*";
403 			}
404 			string extra(string path) {
405 				auto ret = value.path ~ path;
406 				if((value.main.length || value.api) && exists(ret) && ret.isDir) {
407 					foreach(f ; dirEntries(ret, SpanMode.breadth)) {
408 						// at least one element inside
409 						if(f.isFile) return "`" ~ buildNormalizedPath(absolutePath(ret)) ~ dirSeparator ~ "`";
410 					}
411 				}
412 				return "null";
413 			}
414 			if(value.main.length) {
415 				imports ~= "static import " ~ value.mod ~ ";\n";
416 			}
417 			string load = "ret ~= new PluginOf!(" ~ (value.main.length ? value.main : "Object") ~ ")(`" ~ value.name ~ "`, " ~ value.authors.to!string ~ ", `" ~ value.version_ ~ "`, " ~ to!string(value.api) ~ ", " ~ extra("lang") ~ ", " ~ extra("textures") ~ ");";
418 			auto conditions = "conditions" in value.toml;
419 			if(conditions && conditions.type == TOML_TYPE.TABLE) {
420 				string[] conds;
421 				foreach(key, value; conditions.table) {
422 					if(value.type == TOML_TYPE.BOOL) conds ~= "cond!(`" ~ key ~ "`, is_node)(config, " ~ to!string(value.boolean) ~ ")";
423 				}
424 				load = "if(" ~ conds.join("&&") ~ "){ " ~ load ~ " }";
425 			}
426 			if(value.main.length) load = "static if(is(" ~ value.main ~ " : T)){ " ~ load ~ " }";
427 			if(value.single.length) load = "static if(is(" ~ value.main ~ " == class)){ " ~ load ~ " }";
428 			loads ~= "\t" ~ load ~ "\n";
429 		}
430 		
431 	}
432 
433 	writeDiff(".selery/builder.d", "module pluginloader;\n\nimport selery.config : Config;\nimport selery.plugin : Plugin;\n\nimport condition;\n\n" ~ imports ~ "\nPlugin[] loadPlugins(alias PluginOf, T, bool is_node)(inout Config config){\n\tPlugin[] ret;\n" ~ loads ~ "\treturn ret;\n}");
434 	
435 	writeDiff("dub.json", JSONValue(builder).toString());
436 
437 }
438 
439 enum invalid = ["selery", "sel", "toml", "default", "hub", "node", "builder", "condition", "config", "starter", "pluginloader"];
440 
441 void checkName(string name) {
442 	void error(string message) {
443 		throw new Exception("Cannot load plugin '" ~ name ~ "': " ~ message);
444 	}
445 	if(name.matchFirst(ctRegex!`[^a-z0-9\-]`)) error("Name contains characters outside the range a-z0-9-");
446 	if(name.length == 0 || name.length > 64) error("Invalid name length: " ~ name.length.to!string ~ " is not between 1 and 64");
447 	if(invalid.canFind(name)) error("Name is reserved");
448 }
449 
450 void writeDiff(string location, const void[] data) {
451 	if(!exists(location) || read(location) != data) write(location, data);
452 }
453 
454 struct Info {
455 
456 	public TOMLDocument toml;
457 
458 	public string single;
459 
460 	public bool active = true;
461 	public size_t priority = 1;
462 
463 	public bool api;
464 
465 	public string name = "";
466 	public string[] authors = [];
467 	public string version_ = "~local";
468 	
469 	public string path;
470 	public string mod;
471 	public string main;
472 
473 }