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 }