1 module dsemver.compare;
2 
3 import std.array : array, empty, front;
4 import std.algorithm.searching;
5 import std.algorithm.comparison : equal;
6 import std.typecons : nullable, Nullable;
7 import std.exception : enforce;
8 import std.format;
9 import std.stdio;
10 
11 import dsemver.ast;
12 
13 enum ResultValue {
14 	equal,
15 	minor,
16 	major
17 }
18 
19 ResultValue combine(const ResultValue on, const ResultValue no)
20 		pure @safe
21 {
22 	final switch(on) {
23 		case ResultValue.equal:
24 			final switch(no) {
25 				case ResultValue.equal: return ResultValue.equal;
26 				case ResultValue.minor:
27 						throw new Exception(format("%s %s", on, no));
28 				case ResultValue.major: return ResultValue.minor;
29 			}
30 		case ResultValue.minor: assert(false);
31 		case ResultValue.major:
32 			final switch(no) {
33 				case ResultValue.equal: return on;
34 				case ResultValue.minor:
35 						throw new Exception(format("%s %s", on, no));
36 				case ResultValue.major: return on;
37 			}
38 	}
39 }
40 
41 struct Result {
42 	ResultValue value;
43 	string reason;
44 }
45 
46 ResultValue summarize(const(Result)[] rslts) {
47 	ResultValue rv;
48 	foreach(ref r; rslts) {
49 		if(r.value > rv) {
50 			rv = r.value;
51 		}
52 	}
53 	return rv;
54 }
55 
56 Nullable!(const(Module)) findModule(ref const(Ast) toFindIn
57 		, ref const(Module) mod)
58 {
59 	auto f = mod.name.isNull()
60 		? toFindIn.modules.find!(m => m.file == mod.file)
61 		: toFindIn.modules.find!(m => !m.name.isNull()
62 				&& m.name.get() == mod.name.get());
63 
64 	return f.empty
65 		? Nullable!(const(Module)).init
66 		: nullable(f.front);
67 }
68 
69 Result[] compareOldNew(ref const(Ast) old, ref const(Ast) neu) {
70 	Result[] ret;
71 	foreach(ref mod; old.modules) {
72 		Nullable!(const(Module)) fMod = findModule(neu, mod);
73 		if(fMod.isNull()) { // Not found
74 			ret ~= Result(ResultValue.major, format(
75 				"module '%s' could no longer be found", mod.moduleToName()));
76 			continue;
77 		} else { // module name added
78 			auto fModNN = fMod.get();
79 			if(!fModNN.name.isNull() && mod.name.isNull()) {
80 				ret ~= Result(ResultValue.major, format(
81 					"module '%s' added module name '%s'", mod.moduleToName()
82 						, fModNN.name.get()));
83 				continue;
84 			} else { // recurse into module
85 				foreach(ref mem; mod.members) {
86 					// we can ignore private members
87 					if(!mem.protection.isNull()
88 							&& mem.protection.get() == "private")
89 					{
90 						continue;
91 					}
92 					Nullable!(const(Member)) fm = findMember(fModNN, mem);
93 					if(fm.isNull()) {
94 						ret ~= Result(ResultValue.major, format(
95 							"Ast Member '%s' of module '%s' couldn't be found"
96 								, mem.name
97 								, mod.moduleToName()));
98 						continue;
99 					}
100 					const memsRslt = compareOldNew(mem, fm.get()
101 							, [mod.moduleToName()]);
102 					ret ~= memsRslt;
103 				}
104 			}
105 		}
106 	}
107 
108 	return ret;
109 }
110 
111 Result[] compareOldNew(ref const(Member) old, ref const(Member) neu
112 		, string[] path)
113 {
114 	Result[] ret;
115 
116 	if(old.members.isNull()) {
117 		return ret;
118 	}
119 
120 	Member[] emptyArray;
121 	foreach(ref mem; old.members.get(emptyArray)) {
122 		Nullable!(const(Member)) f = neu.findMember(mem);
123 		if(f.isNull()) {
124 			ret ~= Result(ResultValue.major, format(
125 				"%s of '%--(%s.%)' couldn't be found"
126 					, mem.toString(), path));
127 			continue;
128 		}
129 
130 		string[] np = path ~ mem.name;
131 		if(mem.members.isNull()) {
132 			continue;
133 		}
134 		foreach(ref const(Member) sub; mem.members.get(emptyArray)) {
135 			// we can ignore private members
136 			if(!sub.protection.isNull() && sub.protection.get() == "private") {
137 				continue;
138 			}
139 			Nullable!(const(Member)) fm = findMember(f.get(), sub);
140 			if(fm.isNull()) {
141 				ret ~= Result(ResultValue.major, format(
142 					"%s of '%--(%s.%)' couldn't be found"
143 						, sub.toString(), np));
144 				continue;
145 			}
146 			const memsRslt = compareOldNew(sub, fm.get(), np);
147 			ret ~= memsRslt;
148 		}
149 	}
150 
151 	return ret;
152 }
153 
154 private bool areEqualImpl(T)(ref const(T) a, ref const(T) b) {
155 	import std.traits : isSomeString, isArray, FieldNameTuple, Unqual;
156 	import std.range : ElementEncodingType;
157 
158 	static if(isSomeString!T) {
159 		return a == b;
160 	} else static if(isArray!T) {
161 		if(a.length != b.length) {
162 			return false;
163 		} else {
164 			alias ET = Unqual!(ElementEncodingType!T);
165 			static if(is(ET == string)) {
166 				return a.all!(i => canFind(b, i));
167 			} else {
168 				return equal!((g,h) => areEqualImpl(g, h))(a, b);
169 			}
170 		}
171 	} else static if(is(T == long)) {
172 		return a == b;
173 	} else static if(is(T == Nullable!F, F)) {
174 		if(a.isNull() != b.isNull()) {
175 			return false;
176 		} else if(!a.isNull()) {
177 			return areEqualImpl(a.get(), b.get());
178 		} else {
179 			return true;
180 		}
181 	} else static if(is(T == struct)) {
182 		static foreach(mem; FieldNameTuple!T) {
183 			if(mem != "members"
184 					&& !areEqualImpl(__traits(getMember, a, mem)
185 							, __traits(getMember, b, mem))
186 						)
187 			{
188 				return false;
189 			}
190 		}
191 		return true;
192 	} else {
193 		static assert(false, "Unhandled type " ~ T.stringof);
194 	}
195 }
196 
197 Nullable!(const(Member)) findMember(ref const(Member) toFindIn
198 	, ref const(Member) mem)
199 {
200 	import std.range : isForwardRange;
201 
202 	if(toFindIn.members.isNull()) {
203 		return Nullable!(const(Member)).init;
204 	}
205 
206 	auto n = toFindIn.members.get().find!(a => a.name == mem.name)().array;
207 	auto f = n.find!(areEqualImpl)(mem);
208 	return f.empty
209 		? Nullable!(const(Member)).init
210 		: nullable(f.front);
211 }
212 
213 Nullable!(const(Member)) findMember(ref const(Module) toFindIn
214 	, ref const(Member) mem)
215 {
216 	import std.range : isForwardRange;
217 
218 	static assert(isForwardRange!(typeof(cast()toFindIn.members)));
219 	auto n = toFindIn.members.find!(a => a.name == mem.name)().array;
220 	auto f = n.find!(areEqualImpl)(mem);
221 	return f.empty
222 		? Nullable!(const(Member)).init
223 		: nullable(f.front);
224 }
225 
226 unittest {
227 	import std.file : dirEntries, SpanMode, readText;
228 	import std.algorithm.searching : canFind, startsWith, find;
229 	import std.algorithm.iteration : splitter, map;
230 	import std.string : strip;
231 
232 	struct ExpectedResult {
233 		ResultValue old;
234 		ResultValue neu;
235 		ResultValue oldC;
236 		ResultValue neuC;
237 	}
238 
239 	ExpectedResult getExpected(string fn) {
240 		import std.conv : to;
241 		enum sstr = "// Result";
242 		string t = readText(fn);
243 		auto s = t.splitter("\n").find!(l => l.startsWith(sstr));
244 		assert(!s.empty, fn);
245 
246 		string[] ws = s.front[sstr.length .. $].strip("()")
247 			.splitter(",")
248 			.map!(r => r.strip())
249 			.array;
250 
251 		enforce(ws.length == 4, fn);
252 		return ExpectedResult
253 			( ws[0].to!ResultValue()
254 			, ws[1].to!ResultValue()
255 			, ws[2].to!ResultValue()
256 			, ws[3].to!ResultValue()
257 			);
258 	}
259 
260 	enum o = "old.d.json";
261 	foreach(fn; dirEntries("testdirgen/", "*old.d.json", SpanMode.depth)) {
262 		auto a = parse(fn.name);
263 		auto b = parse(fn.name[0 .. $ - o.length] ~ "new.d.json");
264 
265 		string expected = fn.name[0 .. $ - 5];
266 		const ExpectedResult er = getExpected(expected);
267 
268 		auto cmpsON = compareOldNew(a, b);
269 		auto sON = summarize(cmpsON);
270 		assert(sON == er.old, format("\nwhich: old new\nfile: %s\ngot: %s\nexp: %s", fn.name
271 				, sON, er.old));
272 
273 		auto cmpsNO = compareOldNew(b, a);
274 		auto sNO = summarize(cmpsNO);
275 		assert(sNO == er.neu, format("\nwhich: neu old\nfile: %s\ngot: %s\nexp: %s", fn.name
276 				, sNO, er.neu));
277 
278 		auto compAB = combine(sON, sNO);
279 		assert(compAB == er.oldC, format(
280 				"\nwhich: old new comb\nfile: %s\ngot: %s\nexp: %s", fn.name
281 				, compAB, er.oldC));
282 
283 		auto compBA = combine(sNO, sON);
284 		assert(compBA == er.neuC, format(
285 				"\nwhich: new old comb\nfile: %s\ngot: %s\nexp: %s", fn.name
286 				, compBA, er.neuC));
287 	}
288 }