1 module workspaced.com.moduleman;
2 
3 import dparse.ast;
4 import dparse.lexer;
5 import dparse.parser;
6 import dparse.rollback_allocator;
7 
8 import std.algorithm;
9 import std.array;
10 import std.conv;
11 import std.file;
12 import std.format;
13 import std.functional;
14 import std.path;
15 import std..string;
16 
17 import workspaced.api;
18 
19 @component("moduleman")
20 class ModulemanComponent : ComponentWrapper
21 {
22 	mixin DefaultComponentWrapper;
23 
24 	protected void load()
25 	{
26 		config.stringBehavior = StringBehavior.source;
27 	}
28 
29 	/// Renames a module to something else (only in the project root).
30 	/// Params:
31 	/// 	renameSubmodules: when `true`, this will rename submodules of the module too. For example when renaming `lib.com` to `lib.coms` this will also rename `lib.com.*` to `lib.coms.*`
32 	/// Returns: all changes that need to happen to rename the module. If no module statement could be found this will return an empty array.
33 	FileChanges[] rename(string mod, string rename, bool renameSubmodules = true)
34 	{
35 		if (!refInstance)
36 			throw new Exception("moduleman.rename requires to be instanced");
37 
38 		RollbackAllocator rba;
39 		FileChanges[] changes;
40 		bool foundModule = false;
41 		auto from = mod.split('.');
42 		auto to = rename.split('.');
43 		foreach (file; dirEntries(instance.cwd, SpanMode.depth))
44 		{
45 			if (file.extension != ".d")
46 				continue;
47 			string code = readText(file);
48 			auto tokens = getTokensForParser(cast(ubyte[]) code, config, &workspaced.stringCache);
49 			auto parsed = parseModule(tokens, file, &rba);
50 			auto reader = new ModuleChangerVisitor(file, from, to, renameSubmodules);
51 			reader.visit(parsed);
52 			if (reader.changes.replacements.length)
53 				changes ~= reader.changes;
54 			if (reader.foundModule)
55 				foundModule = true;
56 		}
57 		if (!foundModule)
58 			return [];
59 		return changes;
60 	}
61 
62 	/// Renames/adds/removes a module from a file to match the majority of files in the folder.
63 	/// Params:
64 	/// 	file: File path to the file to normalize
65 	/// 	code: Current code inside the text buffer
66 	CodeReplacement[] normalizeModules(scope const(char)[] file, scope const(char)[] code)
67 	{
68 		if (!refInstance)
69 			throw new Exception("moduleman.normalizeModules requires to be instanced");
70 
71 		int[string] modulePrefixes;
72 		modulePrefixes[""] = 0;
73 		auto modName = file.replace("\\", "/").stripExtension;
74 		if (modName.baseName == "package")
75 			modName = modName.dirName;
76 		if (modName.startsWith(instance.cwd.replace("\\", "/")))
77 			modName = modName[instance.cwd.length .. $];
78 		modName = modName.stripLeft('/');
79 		auto longest = modName;
80 		foreach (imp; importPaths)
81 		{
82 			imp = imp.replace("\\", "/");
83 			if (imp.startsWith(instance.cwd.replace("\\", "/")))
84 				imp = imp[instance.cwd.length .. $];
85 			imp = imp.stripLeft('/');
86 			if (longest.startsWith(imp))
87 			{
88 				auto shortened = longest[imp.length .. $];
89 				if (shortened.length < modName.length)
90 					modName = shortened;
91 			}
92 		}
93 		auto sourcePos = (modName ~ '/').indexOf("/source/");
94 		if (sourcePos != -1)
95 			modName = modName[sourcePos + "/source".length .. $];
96 		modName = modName.stripLeft('/').replace("/", ".");
97 		if (!modName.length)
98 			return [];
99 		auto existing = describeModule(code);
100 		if (modName == existing.moduleName)
101 		{
102 			return [];
103 		}
104 		else
105 		{
106 			if (modName == "")
107 			{
108 				return [CodeReplacement([existing.outerFrom, existing.outerTo], "")];
109 			}
110 			else
111 			{
112 				const trailing = code[min(existing.outerTo, $) .. $];
113 				// determine number of new lines to insert after full module name + semicolon
114 				string semicolonNewlines = trailing.startsWith("\n\n", "\r\r", "\r\n\r\n") ? ";"
115 					: trailing.startsWith("\n", "\r") ? ";\n"
116 					: ";\n\n";
117 				return [
118 					CodeReplacement([existing.outerFrom, existing.outerTo], text("module ",
119 							modName, (existing.outerTo == existing.outerFrom ? semicolonNewlines : ";")))
120 				];
121 			}
122 		}
123 	}
124 
125 	/// Returns the module name parts of a D code
126 	const(string)[] getModule(scope const(char)[] code)
127 	{
128 		return describeModule(code).raw;
129 	}
130 
131 	/// Returns the normalized module name as string of a D code
132 	string moduleName(scope const(char)[] code)
133 	{
134 		return describeModule(code).moduleName;
135 	}
136 
137 	///
138 	FileModuleInfo describeModule(scope const(char)[] code)
139 	{
140 		auto tokens = getTokensForParser(cast(ubyte[]) code, config, &workspaced.stringCache);
141 		ptrdiff_t start = -1;
142 		size_t from, to;
143 		size_t outerFrom, outerTo;
144 
145 		foreach (i, Token t; tokens)
146 		{
147 			if (t.type == tok!"module")
148 			{
149 				start = i;
150 				outerFrom = t.index;
151 				break;
152 			}
153 		}
154 
155 		if (start == -1)
156 		{
157 			FileModuleInfo ret;
158 			start = 0;
159 			if (tokens.length && tokens[0].type == tok!"scriptLine")
160 			{
161 				start = code.indexOfAny("\r\n");
162 				if (start == -1)
163 				{
164 					start = 0;
165 				}
166 				else
167 				{
168 					if (start + 1 < code.length && code[start] == '\r' && code[start + 1] == '\n')
169 						start += 2;
170 					else
171 						start++;
172 					// support /+ dub.sdl: lines or similar starts directly after a script line
173 					auto leading = code[start .. $].stripLeft;
174 					if (leading.startsWith("/+", "/*"))
175 					{
176 						size_t end = code.indexOf(leading[1] == '+' ? "+/" : "*/", start);
177 						if (end != -1)
178 						{
179 							start = end + 2;
180 							if (code[start .. $].startsWith("\r\n"))
181 								start += 2;
182 							else if (code[start .. $].startsWith("\r", "\n"))
183 								start += 1;
184 						}
185 					}
186 				}
187 			}
188 			ret.outerFrom = ret.outerTo = ret.from = ret.to = start;
189 			return ret;
190 		}
191 
192 		const(string)[] raw;
193 		string moduleName;
194 		foreach (t; tokens[start + 1 .. $])
195 		{
196 			if (t.type == tok!";")
197 			{
198 				outerTo = t.index + 1;
199 				break;
200 			}
201 			if (t.type == tok!"identifier")
202 			{
203 				if (from == 0)
204 					from = t.index;
205 				moduleName ~= t.text;
206 				to = t.index + t.text.length;
207 				raw ~= t.text;
208 			}
209 			if (t.type == tok!".")
210 			{
211 				moduleName ~= ".";
212 			}
213 		}
214 		return FileModuleInfo(raw, moduleName, from, to, outerFrom, outerTo);
215 	}
216 
217 private:
218 	LexerConfig config;
219 }
220 
221 /// Represents a module statement in a file.
222 struct FileModuleInfo
223 {
224 	/// Parts of the module name as array.
225 	const(string)[] raw;
226 	/// Whole modulename as normalized string in form a.b.c etc.
227 	string moduleName = "";
228 	/// Code index of the moduleName
229 	size_t from, to;
230 	/// Code index of the whole module statement starting right at module and ending right after the semicolon.
231 	size_t outerFrom, outerTo;
232 
233 	string toString() const
234 	{
235 		return format!"FileModuleInfo[%s..%s](name[%s..%s]=%s)"(outerFrom, outerTo, from, to, moduleName);
236 	}
237 }
238 
239 private:
240 
241 class ModuleChangerVisitor : ASTVisitor
242 {
243 	this(string file, string[] from, string[] to, bool renameSubmodules)
244 	{
245 		changes.file = file;
246 		this.from = from;
247 		this.to = to;
248 		this.renameSubmodules = renameSubmodules;
249 	}
250 
251 	alias visit = ASTVisitor.visit;
252 
253 	override void visit(const ModuleDeclaration decl)
254 	{
255 		auto mod = decl.moduleName.identifiers.map!(a => a.text).array;
256 		auto orig = mod;
257 		if (mod.startsWith(from) && renameSubmodules)
258 			mod = to ~ mod[from.length .. $];
259 		else if (mod == from)
260 			mod = to;
261 		if (mod != orig)
262 		{
263 			foundModule = true;
264 			changes.replacements ~= CodeReplacement([
265 					decl.moduleName.identifiers[0].index,
266 					decl.moduleName.identifiers[$ - 1].index + decl.moduleName.identifiers[$ - 1].text.length
267 					], mod.join('.'));
268 		}
269 	}
270 
271 	override void visit(const SingleImport imp)
272 	{
273 		auto mod = imp.identifierChain.identifiers.map!(a => a.text).array;
274 		auto orig = mod;
275 		if (mod.startsWith(from) && renameSubmodules)
276 			mod = to ~ mod[from.length .. $];
277 		else if (mod == from)
278 			mod = to;
279 		if (mod != orig)
280 		{
281 			changes.replacements ~= CodeReplacement([
282 					imp.identifierChain.identifiers[0].index,
283 					imp.identifierChain.identifiers[$ - 1].index
284 					+ imp.identifierChain.identifiers[$ - 1].text.length
285 					], mod.join('.'));
286 		}
287 	}
288 
289 	override void visit(const ImportDeclaration decl)
290 	{
291 		if (decl)
292 		{
293 			return decl.accept(this);
294 		}
295 	}
296 
297 	override void visit(const BlockStatement content)
298 	{
299 		if (content)
300 		{
301 			return content.accept(this);
302 		}
303 	}
304 
305 	string[] from, to;
306 	FileChanges changes;
307 	bool renameSubmodules, foundModule;
308 }
309 
310 unittest
311 {
312 	scope backend = new WorkspaceD();
313 	auto workspace = makeTemporaryTestingWorkspace;
314 	workspace.createDir("source/newmod");
315 	workspace.createDir("unregistered/source");
316 	workspace.writeFile("source/newmod/color.d", "module oldmod.color;void foo(){}");
317 	workspace.writeFile("source/newmod/render.d", "module oldmod.render;import std.color,oldmod.color;import oldmod.color.oldmod:a=b, c;import a=oldmod.a;void bar(){}");
318 	workspace.writeFile("source/newmod/display.d", "module newmod.displaf;");
319 	workspace.writeFile("source/newmod/input.d", "");
320 	workspace.writeFile("source/newmod/package.d", "");
321 	workspace.writeFile("unregistered/source/package.d", "");
322 	workspace.writeFile("unregistered/source/app.d", "");
323 	auto instance = backend.addInstance(workspace.directory);
324 	backend.register!ModulemanComponent;
325 	auto mod = backend.get!ModulemanComponent(workspace.directory);
326 
327 	instance.importPathProvider = () => ["source", "source/deeply/nested/source"];
328 
329 	FileChanges[] changes = mod.rename("oldmod", "newmod").sort!"a.file < b.file".array;
330 
331 	assert(changes.length == 2);
332 	assert(changes[0].file.endsWith("color.d"));
333 	assert(changes[1].file.endsWith("render.d"));
334 
335 	assert(changes[0].replacements == [CodeReplacement([7, 19], "newmod.color")]);
336 	assert(changes[1].replacements == [
337 			CodeReplacement([7, 20], "newmod.render"),
338 			CodeReplacement([38, 50], "newmod.color"),
339 			CodeReplacement([58, 77], "newmod.color.oldmod"),
340 			CodeReplacement([94, 102], "newmod.a")
341 			]);
342 
343 	foreach (change; changes)
344 	{
345 		string code = readText(change.file);
346 		foreach_reverse (op; change.replacements)
347 			code = op.apply(code);
348 		std.file.write(change.file, code);
349 	}
350 
351 	auto nrm = mod.normalizeModules(workspace.getPath("source/newmod/input.d"), "");
352 	assert(nrm == [CodeReplacement([0, 0], "module newmod.input;\n\n")]);
353 
354 	nrm = mod.normalizeModules(workspace.getPath("source/newmod/package.d"), "");
355 	assert(nrm == [CodeReplacement([0, 0], "module newmod;\n\n")]);
356 
357 	nrm = mod.normalizeModules(workspace.getPath("source/newmod/display.d"),
358 			"module oldmod.displaf;");
359 	assert(nrm == [CodeReplacement([0, 22], "module newmod.display;")]);
360 
361 	nrm = mod.normalizeModules(workspace.getPath("unregistered/source/app.d"), "");
362 	assert(nrm == [CodeReplacement([0, 0], "module app;\n\n")]);
363 
364 	nrm = mod.normalizeModules(workspace.getPath("unregistered/source/package.d"), "");
365 	assert(nrm == []);
366 
367 	nrm = mod.normalizeModules(workspace.getPath("source/deeply/nested/source/pkg/test.d"), "");
368 	assert(nrm == [CodeReplacement([0, 0], "module pkg.test;\n\n")]);
369 
370 	auto fetched = mod.describeModule("/* hello world */ module\nfoo . \nbar  ;\n\nvoid foo() {");
371 	assert(fetched == FileModuleInfo(["foo", "bar"], "foo.bar", 25, 35, 18, 38));
372 
373 	fetched = mod.describeModule(`#!/usr/bin/env dub
374 /+ dub.sdl:
375 	name "hello"
376 +/
377 void main() {}`);
378 	assert(fetched == FileModuleInfo([], "", 48, 48, 48, 48));
379 
380 	fetched = mod.describeModule("#!/usr/bin/rdmd\r\n");
381 	assert(fetched == FileModuleInfo([], "", 17, 17, 17, 17));
382 
383 	fetched = mod.describeModule("#!/usr/bin/rdmd\n");
384 	assert(fetched == FileModuleInfo([], "", 16, 16, 16, 16));
385 }