1 /++
2 	A simplified version of `std.conv` with better error messages and faster compiles for supported types.
3 
4 	History:
5 		Added May 22, 2025
6 +/
7 module arsd.conv;
8 
9 static import arsd.core;
10 
11 // FIXME: thousands separator for int to string (and float to string)
12 // FIXME: intToStringArgs
13 // FIXME: floatToStringArgs
14 
15 /++
16 	Converts a string into the other given type. Throws on failure.
17 +/
18 T to(T)(scope const(char)[] str) {
19 	static if(is(T == enum)) {
20 		switch(str) {
21 			default:
22 				throw new EnumConvException(T.stringof, str.idup);
23 			foreach(memberName; __traits(allMembers, T))
24 			case memberName:
25 				return __traits(getMember, T, memberName);
26 		}
27 
28 	}
29 	else
30 	static if(is(T : long)) {
31 		// FIXME: unsigned? overflowing? radix? keep reading or stop on invalid char?
32 		StringToIntArgs args;
33 		args.unsigned = __traits(isUnsigned, T);
34 		long v = stringToInt(str, args);
35 		T ret = cast(T) v;
36 		if(ret != v)
37 			throw new StringToIntConvException("overflow", 0, str.idup, 0);
38 		return ret;
39 	}
40 	else
41 	static if(is(T : double)) {
42 		import core.stdc.stdlib;
43 		import core.stdc.errno;
44 		arsd.core.CharzBuffer z = str;
45 		char* end;
46 		errno = 0;
47 		double res = strtod(z.ptr, &end);
48 		if(end !is (z.ptr + z.length) || errno) {
49 			string msg = errno == ERANGE ? "Over/underflow" : "Invalid input";
50 			throw new StringToIntConvException(msg, 10, str.idup, end - z.ptr);
51 		}
52 
53 		return res;
54 	}
55 	else
56 	{
57 		static assert(0, "Unsupported type: " ~ T.stringof);
58 	}
59 }
60 
61 /++
62 	Converts any given value to a string. The format of the string is unspecified; it is meant for a human reader and might be overridden by types.
63 +/
64 string to(T:string, From)(From value) {
65 	static if(is(From == enum))
66 		return arsd.core.enumNameForValue(value);
67 	else
68 		return arsd.core.toStringInternal(value);
69 }
70 
71 /++
72 	Converts ints to other types of ints or enums
73 +/
74 T to(T)(long value) {
75 	static if(is(T == enum))
76 		return cast(T) value; // FIXME check if the value is actually in range
77 	else
78 		return checkedConversion!T(value);
79 }
80 
81 /+
82 T to(T, F)(F value) if(!is(F : const(char)[])) {
83 	// if the language allows implicit conversion, let it do its thing
84 	static if(is(T : F)) {
85 		return value;
86 	}
87 	else
88 	// integral type conversions do checked things
89 	static if(is(T : long) && is(F : long)) {
90 		return checkedConversion!T(value);
91 	}
92 	else
93 	// array to array conversion: try to convert the individual elements, allocating a new return value.
94 	static if(is(T : TE[], TE) && is(F : FE[], FE)) {
95 		F ret = new F(value.length);
96 		foreach(i, e; value)
97 			ret[i] = to!TE(e);
98 		return ret;
99 	}
100 	else
101 		static assert(0, "Unsupported conversion types");
102 }
103 +/
104 
105 unittest {
106 	assert(to!int("5") == 5);
107 	assert(to!int("35") == 35);
108 	assert(to!string(35) == "35");
109 	assert(to!int("0xA35d") == 0xA35d);
110 	assert(to!int("0b11001001") == 0b11001001);
111 	assert(to!int("0o777") == 511 /*0o777*/);
112 
113 	assert(to!ubyte("255") == 255);
114 	assert(to!ulong("18446744073709551615") == ulong.max);
115 
116 	void expectedToThrow(T...)(lazy T items) {
117 		int count;
118 		string messages;
119 		static foreach(idx, item; items) {
120 			try {
121 				auto result = item;
122 				if(messages.length)
123 					messages ~= ",";
124 				messages ~= idx.stringof[0..$-2];
125 			} catch(StringToIntConvException e) {
126 				// passed the test; it was supposed to throw.
127 				 // arsd.core.writeln(e);
128 				count++;
129 			}
130 		}
131 
132 		assert(count == T.length, "Arg(s) " ~ messages ~ " did not throw");
133 	}
134 
135 	expectedToThrow(
136 		to!uint("-44"), // negative number to unsigned reuslt
137 		to!int("add"), // invalid base 10 chars
138 		to!byte("129"), // wrapped to negative
139 		to!int("0p4a0"), // invalid radix prefix
140 		to!int("5000000000"), // doesn't fit in int
141 		to!ulong("6000000000000000000900"), // overflow when reading into the ulong buffer
142 	);
143 }
144 
145 /++
146 
147 +/
148 class ConvException : arsd.core.ArsdExceptionBase {
149 	this(string msg, string file, size_t line) {
150 		super(msg, file, line);
151 	}
152 }
153 
154 /++
155 
156 +/
157 class ValueOutOfRangeException : ConvException {
158 	this(string type, long userSuppliedValue, long minimumAcceptableValue, long maximumAcceptableValue, string file = __FILE__, size_t line = __LINE__) {
159 		this.type = type;
160 		this.userSuppliedValue = userSuppliedValue;
161 		this.minimumAcceptableValue = minimumAcceptableValue;
162 		this.maximumAcceptableValue = maximumAcceptableValue;
163 		super("Value was out of range", file, line);
164 	}
165 
166 	string type;
167 	long userSuppliedValue;
168 	long minimumAcceptableValue;
169 	long maximumAcceptableValue;
170 
171 	override void getAdditionalPrintableInformation(scope void delegate(string name, in char[] value) sink) const {
172 		sink("type", type);
173 		sink("userSuppliedValue", arsd.core.toStringInternal(userSuppliedValue));
174 		sink("minimumAcceptableValue", arsd.core.toStringInternal(minimumAcceptableValue));
175 		sink("maximumAcceptableValue", arsd.core.toStringInternal(maximumAcceptableValue));
176 	}
177 }
178 
179 /++
180 
181 +/
182 class EnumConvException : ConvException {
183 	this(string type, string userSuppliedValue, string file = __FILE__, size_t line = __LINE__) {
184 		this.type = type;
185 		this.userSuppliedValue = userSuppliedValue;
186 
187 		super("No such enum value", file, line);
188 
189 	}
190 	string type;
191 	string userSuppliedValue;
192 
193 	override void getAdditionalPrintableInformation(scope void delegate(string name, in char[] value) sink) const {
194 		sink("type", type);
195 		sink("userSuppliedValue", userSuppliedValue);
196 	}
197 }
198 
199 unittest {
200 	enum A { a, b, c }
201 	// to!A("d");
202 }
203 
204 
205 /++
206 
207 +/
208 class StringToIntConvException : arsd.core.ArsdExceptionBase /*InvalidDataException*/ {
209 	this(string msg, int radix, string userInput, size_t offset, string file = __FILE__, size_t line = __LINE__) {
210 		this.radix = radix;
211 		this.userInput = userInput;
212 		this.offset = offset;
213 
214 		super(msg, file, line);
215 	}
216 
217 	override void getAdditionalPrintableInformation(scope void delegate(string name, in char[] value) sink) const {
218 		sink("radix", arsd.core.toStringInternal(radix));
219 		sink("userInput", arsd.core.toStringInternal(userInput));
220 		if(offset < userInput.length)
221 		sink("offset", arsd.core.toStringInternal(offset) ~ " ('" ~ userInput[offset] ~ "')");
222 
223 	}
224 
225 	///
226 	int radix;
227 	///
228 	string userInput;
229 	///
230 	size_t offset;
231 }
232 
233 /++
234 	if radix is 0, guess from 0o, 0x, 0b prefixes.
235 +/
236 long stringToInt(scope const(char)[] str, StringToIntArgs args = StringToIntArgs.init) {
237 	long accumulator;
238 
239 	auto original = str;
240 
241 	Exception exception(string msg, size_t loopOffset = 0, string file = __FILE__, size_t line = __LINE__) {
242 		return new StringToIntConvException(msg, args.radix, original.dup, loopOffset + str.ptr - original.ptr, file, line);
243 	}
244 
245 	if(str.length == 0)
246 		throw exception("empty string");
247 
248 	bool isNegative;
249 	if(str[0] == '-') {
250 		if(args.unsigned)
251 			throw exception("negative number given, but unsigned result desired");
252 
253 		isNegative = true;
254 		str = str[1 .. $];
255 	}
256 
257 	if(str.length == 0)
258 		throw exception("just a dash");
259 
260 	if(str[0] == '0') {
261 		if(str.length > 1 && (str[1] == 'b' || str[1] == 'x' || str[1] == 'o')) {
262 			if(args.radix != 0) {
263 				throw exception("string had specified base, but the radix arg was already supplied");
264 			}
265 
266 			switch(str[1]) {
267 				case 'b':
268 					args.radix = 2;
269 				break;
270 				case 'o':
271 					args.radix = 8;
272 				break;
273 				case 'x':
274 					args.radix = 16;
275 				break;
276 				default:
277 					assert(0);
278 			}
279 
280 			str = str[2 .. $];
281 
282 			if(str.length == 0)
283 				throw exception("just a prefix");
284 		}
285 	}
286 
287 	if(args.radix == 0)
288 		args.radix = 10;
289 
290 	foreach(idx, char ch; str) {
291 
292 		if(ch && ch == args.ignoredSeparator)
293 			continue;
294 
295 		auto before = accumulator;
296 
297 		accumulator *= args.radix;
298 
299 		int value = -1;
300 		if(ch >= '0' && ch <= '9') {
301 			value = ch - '0';
302 		} else {
303 			ch |= 32;
304 			if(ch >= 'a' && ch <= 'z')
305 				value = ch - 'a' + 10;
306 		}
307 
308 		if(value < 0)
309 			throw exception("invalid char", idx);
310 		if(value >= args.radix)
311 			throw exception("invalid char for given radix", idx);
312 
313 		accumulator += value;
314 		if(args.unsigned) {
315 			auto b = cast(ulong) before;
316 			auto a = cast(ulong) accumulator;
317 			if(a < b)
318 				throw exception("value too big to fit in unsigned buffer", idx);
319 		} else {
320 			if(accumulator < before && !args.unsigned)
321 				throw exception("value too big to fit in signed buffer", idx);
322 		}
323 	}
324 
325 	if(isNegative)
326 		accumulator = -accumulator;
327 
328 	return accumulator;
329 }
330 
331 /// ditto
332 struct StringToIntArgs {
333 	int radix;
334 	bool unsigned;
335 	char ignoredSeparator = 0;
336 }
337 
338 /++
339 	Converts two integer types, returning the min/max of the desired type if the given value is out of range for it.
340 +/
341 T saturatingConversion(T)(long value) {
342 	static assert(is(T : long), "Only works on integer types");
343 
344 	static if(is(T == ulong)) // the special case to try to handle the full range there
345 		ulong mv = cast(ulong) value;
346 	else
347 		long mv = value;
348 
349 	if(mv > T.max)
350 		return T.max;
351 	else if(value < T.min)
352 		return T.min;
353 	else
354 		return cast(T) value;
355 }
356 
357 unittest {
358 	assert(saturatingConversion!ubyte(256) == 255);
359 	assert(saturatingConversion!byte(256) == 127);
360 	assert(saturatingConversion!byte(-256) == -128);
361 
362 	assert(saturatingConversion!ulong(0) == 0);
363 	assert(saturatingConversion!long(-5) == -5);
364 
365 	assert(saturatingConversion!uint(-5) == 0);
366 
367 	// assert(saturatingConversion!ulong(-5) == 0); // it can't catch this since the -5 is indistinguishable from the large ulong value here
368 }
369 
370 /++
371 	Truncates off bits that won't fit; equivalent to a built-in cast operation (you can just use a cast instead if you want).
372 +/
373 T truncatingConversion(T)(long value) {
374 	static assert(is(T : long), "Only works on integer types");
375 
376 	return cast(T) value;
377 
378 }
379 
380 /++
381 	Converts two integer types, throwing an exception if the given value is out of range for it.
382 +/
383 T checkedConversion(T)(long value, long minimumAcceptableValue = T.min, long maximumAcceptableValue = T.max) {
384 	static assert(is(T : long), "Only works on integer types");
385 
386 	if(value > maximumAcceptableValue)
387 		throw new ValueOutOfRangeException(T.stringof, value, minimumAcceptableValue, maximumAcceptableValue);
388 	else if(value < minimumAcceptableValue)
389 		throw new ValueOutOfRangeException(T.stringof, value, minimumAcceptableValue, maximumAcceptableValue);
390 	else
391 		return cast(T) value;
392 }
393 /// ditto
394 T checkedConversion(T:ulong)(ulong value, ulong minimumAcceptableValue = T.min, ulong maximumAcceptableValue = T.max) {
395 	if(value > maximumAcceptableValue)
396 		throw new ValueOutOfRangeException(T.stringof, value, minimumAcceptableValue, maximumAcceptableValue);
397 	else if(value < minimumAcceptableValue)
398 		throw new ValueOutOfRangeException(T.stringof, value, minimumAcceptableValue, maximumAcceptableValue);
399 	else
400 		return cast(T) value;
401 }
402 
403 unittest {
404 	try {
405 		assert(checkedConversion!byte(155));
406 		assert(0);
407 	} catch(ValueOutOfRangeException e) {
408 
409 	}
410 }