1 module served.lsp.uri;
2 
3 import served.lsp.protocol;
4 import std.algorithm;
5 import std.conv;
6 import std.path;
7 import std.string;
8 
9 version (unittest)
10 private void assertEquals(T)(T a, T b)
11 {
12 	assert(a == b, "'" ~ a.to!string ~ "' != '" ~ b.to!string ~ "'");
13 }
14 
15 DocumentUri uriFromFile(scope const(char)[] file)
16 {
17 	import std.uri : encodeComponent;
18 
19 	if ((!isAbsolute(file) && !file.startsWith("/"))
20 		|| !file.length)
21 		throw new Exception(text("Tried to pass relative path '", file, "' to uriFromFile"));
22 	file = file.buildNormalizedPath.replace("\\", "/");
23 	assert(file.length);
24 	if (file.ptr[0] != '/')
25 		file = '/' ~ file; // always triple slash at start but never quad slash
26 	if (file.length >= 2 && file[0 .. 2] == "//") // Shares (\\share\bob) are different somehow
27 		file = file[2 .. $];
28 	return text("file://", file.encodeComponent.replace("%2F", "/"));
29 }
30 
31 unittest
32 {
33 	import std.exception;
34 
35 	version (Windows)
36 	{
37 
38 	}
39 	else
40 	{
41 		assertEquals(uriFromFile(`/home/foo/bar.d`), `file:///home/foo/bar.d`);
42 		assertThrown(uriFromFile(`../bar.d`));
43 		assertThrown(uriFromFile(``));
44 		assertEquals(uriFromFile(`/../../bar.d`), `file:///bar.d`);
45 
46 	}
47 }
48 
49 string uriToFile(DocumentUri uri)
50 {
51 	import std.uri : decodeComponent;
52 	import std.string : startsWith;
53 
54 	if (uri.startsWith("file://"))
55 	{
56 		string ret = uri["file://".length .. $].decodeComponent;
57 		if (ret.length >= 3 && ret[0] == '/' && ret[2] == ':') // file:///x: windows path
58 			return ret[1 .. $].replace("/", "\\");
59 		else if (ret.length >= 1 && ret[0] != '/') // file://share windows path
60 			return "\\\\" ~ ret.replace("/", "\\");
61 		return ret;
62 	}
63 	else
64 		return null;
65 }
66 
67 @system unittest
68 {
69 	void testUri(string a, string b)
70 	{
71 		assertEquals(a.uriFromFile, b);
72 		assertEquals(a, b.uriToFile);
73 		assertEquals(a.uriFromFile.uriToFile, a);
74 		assertEquals(uriBuildNormalized("file:///unrelated/path.txt", a), b);
75 	}
76 
77 	version (Windows)
78 	{
79 		// taken from vscode-uri
80 		testUri(`c:\test with %\path`, `file:///c%3A/test%20with%20%25/path`);
81 		testUri(`c:\test with %25\path`, `file:///c%3A/test%20with%20%2525/path`);
82 		testUri(`c:\test with %25\c#code`, `file:///c%3A/test%20with%20%2525/c%23code`);
83 		testUri(`\\shäres\path\c#\plugin.json`, `file://sh%C3%A4res/path/c%23/plugin.json`);
84 		testUri(`\\localhost\c$\GitDevelopment\express`, `file://localhost/c%24/GitDevelopment/express`);
85 	}
86 	else version (Posix)
87 	{
88 		testUri(`/home/pi/.bashrc`, `file:///home/pi/.bashrc`);
89 		testUri(`/home/pi/Development Projects/D-code`, `file:///home/pi/Development%20Projects/D-code`);
90 	}
91 
92 	assertEquals("file:///c:/foo/bar.d".uriToFile, `c:\foo\bar.d`);
93 	assertEquals("file://share/foo/bar.d".uriToFile, `\\share\foo\bar.d`);
94 
95 	assert(!uriToFile("/foo").length);
96 	assert(!uriToFile("http://foo.de/bar.d").length);
97 }
98 
99 ///
100 DocumentUri uri(string scheme, string authority, string path, string query, string fragment)
101 {
102 	import std.array;
103 	import std.uri : encodeComponent;
104 
105 	// from https://github.com/microsoft/vscode-uri/blob/96acdc0be5f9d5f2640e1c1f6733bbf51ec95177/src/uri.ts#L589
106 	auto res = appender!string;
107 	if (scheme.length) {
108 		res ~= scheme;
109 		res ~= ':';
110 	}
111 	if (authority.length || scheme == "file") {
112 		res ~= "//";
113 	}
114 	if (authority.length) {
115 		auto idx = authority.indexOf('@');
116 		if (idx != -1) {
117 			// <user>@<auth>
118 			const userinfo = authority[0 .. idx];
119 			authority = authority[idx + 1 .. $];
120 			idx = userinfo.indexOf(':');
121 			if (idx == -1) {
122 				res ~= userinfo.encodeComponent;
123 			} else {
124 				// <user>:<pass>@<auth>
125 				res ~= userinfo[0 .. idx].encodeComponent;
126 				res ~= ':';
127 				res ~= userinfo[idx + 1 .. $].encodeComponent;
128 			}
129 			res ~= '@';
130 		}
131 		authority = authority.toLower();
132 		idx = authority.indexOf(':');
133 		if (idx == -1) {
134 			res ~= authority.encodeComponent;
135 		} else {
136 			// <auth>:<port>
137 			res ~= authority[0 .. idx].encodeComponent;
138 			res ~= authority[idx .. $];
139 		}
140 	}
141 	if (path.length) {
142 		// lower-case windows drive letters in /C:/fff or C:/fff
143 		if (path.length >= 3 && path[0] == '/' && path[2] == ':') {
144 			const code = path[1];
145 			if (code >= 'A' && code <= 'Z') {
146 				path = ['/', cast(char)(code + 32), ':'].idup ~ path[3 .. $];
147 			}
148 		} else if (path.length >= 2 && path[1] == ':') {
149 			const code = path[0];
150 			if (code >= 'A' && code <= 'Z') {
151 				path = [cast(char)(code + 32), ':'].idup ~ path[2 .. $];
152 			}
153 		}
154 		// encode the rest of the path
155 		res ~= path.encodeComponent.replace("%2F", "/");
156 	}
157 	if (query.length) {
158 		res ~= '?';
159 		res ~= query.encodeComponent;
160 	}
161 	if (fragment.length) {
162 		res ~= '#';
163 		res ~= fragment.encodeComponent;
164 	}
165 	return res.data;
166 }
167 
168 ///
169 unittest
170 {
171 	assert(uri("file", null, "/home/foo/bar.d", null, null) == "file:///home/foo/bar.d");
172 	assert(uri("file", null, "/home/foo bar.d", null, null) == "file:///home/foo%20bar.d");
173 }
174 
175 inout(char)[] uriDirName(scope return inout(char)[] uri)
176 {
177 	auto slash = uri.lastIndexOf('/');
178 	if (slash == 0)
179 		return uri[0 .. 1];
180 	else if (slash == -1)
181 		return null;
182 
183 	if (uri[slash - 1] == '/')
184 	{
185 		if (slash == uri.length - 1)
186 			return uri;
187 		else
188 			return uri[0 .. slash + 1];
189 	}
190 	return uri[0 .. slash];
191 }
192 
193 ///
194 unittest
195 {
196 	assert("/".uriDirName == "/");
197 	assert("a/".uriDirName == "a");
198 	assert("a".uriDirName == "");
199 	assert("/a".uriDirName == "/");
200 	assert("file:///".uriDirName == "file:///");
201 	assert("file:///a".uriDirName == "file:///");
202 	assert("file:///a/".uriDirName == "file:///a");
203 	assert("file:///foo/bar/".uriDirName == "file:///foo/bar");
204 	assert("file:///foo/bar".uriDirName == "file:///foo");
205 	assert("file:///foo/bar".uriDirName.uriDirName == "file:///");
206 	assert("file:///foo/bar".uriDirName.uriDirName.uriDirName == "file:///");
207 }
208 
209 /// Appends the path to the uri, potentially replacing the whole thing if the
210 /// path is absolute. Cleans `./` and `../` sequences using `uriNormalize`.
211 DocumentUri uriBuildNormalized(DocumentUri uri, scope const(char)[] path)
212 {
213 	if (isAbsolute(path))
214 		return uriFromFile(path).uriNormalize;
215 
216 	path = path.replace("\\", "/");
217 
218 	if (path.startsWith("/"))
219 	{
220 		auto scheme = uri.indexOf("://");
221 		if (scheme == -1)
222 			return uriFromFile(path).uriNormalize;
223 		else
224 			return text(uri[0 .. scheme + 3], path).uriNormalize;
225 	}
226 	else
227 	{
228 		return text(uri, "/", path).uriNormalize;
229 	}
230 }
231 
232 ///
233 unittest
234 {
235 	assertEquals(uriBuildNormalized("file:///foo/bar", "baz"), "file:///foo/bar/baz");
236 	assertEquals(uriBuildNormalized("file:///foo/bar", "../baz"), "file:///foo/baz");
237 	version (Windows)
238 		assertEquals(uriBuildNormalized("file:///foo/bar", `c:\home\baz`), "file:///c%3A/home/baz");
239 	else
240 		assertEquals(uriBuildNormalized("file:///foo/bar", "/homr/../home/baz"), "file:///home/baz");
241 	assertEquals(uriBuildNormalized("file:///foo/bar", "../../../../baz"), "file:///../../baz");
242 }
243 
244 /// Cleans `./` and `../` from the URI.
245 inout(char)[] uriNormalize(scope return inout(char)[] uri)
246 {
247 	ptrdiff_t index;
248 	while (true)
249 	{
250 		auto sameDir = uri.indexOf("./", index);
251 		if (sameDir == -1)
252 			break;
253 		if (sameDir == 0)
254 			uri = uri.ptr[2 .. uri.length];
255 		else if (sameDir >= 2 && uri.ptr[sameDir - 1] == '.' && uri.ptr[sameDir - 2] == '/') // /../
256 		{
257 			auto pre = uri.ptr[0 .. sameDir - 1];
258 			if (pre.endsWith("//", "../") || pre == "/")
259 			{
260 				index = sameDir + 2;
261 			}
262 			else
263 			{
264 				auto dirName = pre.ptr[0 .. pre.length - 1].uriDirName;
265 				if (dirName.endsWith("/") || !dirName.length)
266 					uri = dirName ~ uri.ptr[sameDir + 2 .. uri.length];
267 				else
268 					uri = dirName ~ uri.ptr[sameDir + 1 .. uri.length];
269 			}
270 		}
271 		else if (sameDir == 1 && uri.ptr[0] == '.') // ^../
272 			index = sameDir + 2;
273 		else if (uri.ptr[sameDir - 1] == '/') // /./
274 			uri = uri.ptr[0 .. sameDir] ~ uri.ptr[sameDir + 2 .. uri.length];
275 		else // a./b
276 			index = sameDir + 2;
277 	}
278 
279 	return uri;
280 }
281 
282 unittest
283 {
284 	assertEquals(uriNormalize(`b/../a.d`), `a.d`);
285 	assertEquals(uriNormalize(`b/../../a.d`), `../a.d`);
286 	
287 	foreach (prefix; ["file:///", "file://", "", "/", "//"])
288 	{
289 		assertEquals(uriNormalize(prefix ~ `foo/bar/./a.d`), prefix ~ `foo/bar/a.d`);
290 		assertEquals(uriNormalize(prefix ~ `foo/bar/../a.d`), prefix ~ `foo/a.d`);
291 		assertEquals(uriNormalize(prefix ~ `foo/bar/./.././a.d`), prefix ~ `foo/a.d`);
292 		assertEquals(uriNormalize(prefix ~ `../a.d`), prefix ~ `../a.d`);
293 		assertEquals(uriNormalize(prefix ~ `b/../../../../d/../../../a.d`), prefix ~ `../../../../../a.d`);
294 		assertEquals(uriNormalize(prefix ~ `./a.d`), prefix ~ `a.d`);
295 		assertEquals(uriNormalize(prefix ~ `a./a.d`), prefix ~ `a./a.d`);
296 		assertEquals(uriNormalize(prefix ~ `.a/a.d`), prefix ~ `.a/a.d`);
297 		assertEquals(uriNormalize(prefix ~ `foo/a./../a.d`), prefix ~ `foo/a.d`);
298 		assertEquals(uriNormalize(prefix ~ `a./../a.d`), prefix ~ `a.d`);
299 	}
300 }