BookmarkResolver.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System.IO; 6 using System.Threading; 7 using System.Threading.Tasks; 8 using ManagedCommon; 9 using Microsoft.CmdPal.Ext.Bookmarks.Helpers; 10 11 namespace Microsoft.CmdPal.Ext.Bookmarks.Services; 12 13 internal sealed partial class BookmarkResolver : IBookmarkResolver 14 { 15 private readonly IPlaceholderParser _placeholderParser; 16 17 private const string UriSchemeShell = "shell"; 18 19 public BookmarkResolver(IPlaceholderParser placeholderParser) 20 { 21 ArgumentNullException.ThrowIfNull(placeholderParser); 22 _placeholderParser = placeholderParser; 23 } 24 25 public async Task<(bool Success, Classification Result)> TryClassifyAsync( 26 string? input, 27 CancellationToken cancellationToken = default) 28 { 29 try 30 { 31 var result = await Task.Run( 32 () => TryClassify(input, out var classification) 33 ? classification 34 : Classification.Unknown(input ?? string.Empty), 35 cancellationToken); 36 return (true, result); 37 } 38 catch (Exception ex) 39 { 40 Logger.LogError("Failed to classify", ex); 41 var result = Classification.Unknown(input ?? string.Empty); 42 return (false, result); 43 } 44 } 45 46 public Classification ClassifyOrUnknown(string input) 47 { 48 return TryClassify(input, out var c) ? c : Classification.Unknown(input); 49 } 50 51 private bool TryClassify(string? input, out Classification result) 52 { 53 try 54 { 55 bool success; 56 57 if (string.IsNullOrWhiteSpace(input)) 58 { 59 result = Classification.Unknown(input ?? string.Empty); 60 success = false; 61 } 62 else 63 { 64 input = input.Trim(); 65 66 // is placeholder? 67 var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _); 68 success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser); 69 } 70 71 return success; 72 } 73 catch (Exception ex) 74 { 75 Logger.LogError($"Failed to classify bookmark \"{input}\"", ex); 76 result = Classification.Unknown(input ?? string.Empty); 77 return false; 78 } 79 } 80 81 private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser) 82 { 83 // 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:) 84 // File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways - 85 // as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want. 86 if (Uri.TryCreate(input, UriKind.Absolute, out var uri) 87 && !string.IsNullOrWhiteSpace(uri.Scheme) 88 && (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) 89 && uri.Scheme != UriSchemeShell) 90 { 91 // http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.) 92 var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; 93 94 result = new Classification( 95 isWeb ? CommandKind.WebUrl : CommandKind.Protocol, 96 input, 97 input, 98 string.Empty, 99 LaunchMethod.ShellExecute, // Shell picks the right handler 100 null, 101 isPlaceholder); 102 103 return true; 104 } 105 106 // 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}") 107 if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _)) 108 { 109 // single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere 110 if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell) 111 { 112 var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); 113 114 result = new Classification( 115 isWeb ? CommandKind.WebUrl : CommandKind.Protocol, 116 input, 117 input, 118 string.Empty, 119 LaunchMethod.ShellExecute, // Shell picks the right handler 120 null, 121 isPlaceholder); 122 return true; 123 } 124 } 125 126 // 2) Existing file/dir or "longest plausible prefix" 127 // Try to grow head (only for unquoted original) to include spaces until a path exists. 128 129 // Find longest unquoted argument string 130 var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); 131 if (longestUnquotedHead == string.Empty) 132 { 133 (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input); 134 } 135 136 var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser); 137 if (headPath is not null) 138 { 139 var args = tailArgs ?? string.Empty; 140 141 if (Directory.Exists(headPath)) 142 { 143 result = new Classification( 144 CommandKind.Directory, 145 input, 146 headPath, 147 string.Empty, 148 LaunchMethod.ExplorerOpen, 149 headPath, 150 isPlaceholder); 151 152 return true; 153 } 154 155 var ext = Path.GetExtension(headPath); 156 if (ShellHelpers.IsExecutableExtension(ext)) 157 { 158 result = new Classification( 159 CommandKind.FileExecutable, 160 input, 161 headPath, 162 args, 163 LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support 164 Path.GetDirectoryName(headPath), 165 isPlaceholder); 166 167 return true; 168 } 169 170 var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase); 171 var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase); 172 if (isShellLink || isUrlLink) 173 { 174 // In the future we can fetch data out of the link 175 result = new Classification( 176 isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut, 177 input, 178 headPath, 179 string.Empty, 180 LaunchMethod.ShellExecute, 181 Path.GetDirectoryName(headPath), 182 isPlaceholder); 183 184 return true; 185 } 186 187 result = new Classification( 188 CommandKind.FileDocument, 189 input, 190 headPath, 191 args, 192 LaunchMethod.ShellExecute, 193 Path.GetDirectoryName(headPath), 194 isPlaceholder); 195 196 return true; 197 } 198 199 if (TryGetAumid(longestUnquotedHead, out var aumid)) 200 { 201 result = new Classification( 202 CommandKind.Aumid, 203 longestUnquotedHead, 204 aumid, 205 tailAfterLongestUnquotedHead, 206 LaunchMethod.ActivateAppId, 207 null, 208 isPlaceholder); 209 210 return true; 211 } 212 213 // 3) Bare command resolution via PATH + executable ext 214 // At this point 'head' is our best intended command token. 215 var (firstHead, tail) = SplitHeadAndArgs(input); 216 CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head); 217 218 // 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app 219 // Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID 220 // as entered and we try to detect packaged app ids (pkgfamily!app). 221 if (TryGetAumid(head, out var aumid2)) 222 { 223 result = new Classification( 224 CommandKind.Aumid, 225 head, 226 aumid2, 227 tail, 228 LaunchMethod.ActivateAppId, 229 null, 230 isPlaceholder); 231 232 return true; 233 } 234 235 // 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC) 236 // Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above. 237 if (CommandLineHelper.HasShellPrefix(head)) 238 { 239 ShellNames.TryGetFriendlyName(input, out var displayName); 240 ShellNames.TryGetFileSystemPath(input, out var fsPath); 241 result = new Classification( 242 CommandKind.VirtualShellItem, 243 input, 244 input, 245 string.Empty, 246 LaunchMethod.ShellExecute, 247 fsPath is not null && Directory.Exists(fsPath) ? fsPath : null, 248 isPlaceholder, 249 fsPath, 250 displayName); 251 return true; 252 } 253 254 // 3.3) Search paths for the file name (with or without ext) 255 // If head is a file name with extension, we look only for that. If there's no extension 256 // we go and follow Windows Shell resolution rules. 257 if (TryResolveViaPath(head, out var resolvedFilePath)) 258 { 259 result = new Classification( 260 CommandKind.PathCommand, 261 input, 262 resolvedFilePath, 263 tail, 264 LaunchMethod.ShellExecute, 265 null, 266 isPlaceholder); 267 268 return true; 269 } 270 271 // 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error) 272 if (LooksPathy(head) && Path.HasExtension(head)) 273 { 274 var extension = Path.GetExtension(head); 275 276 // if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown 277 var hasSpecificExtension = !isPlaceholder || !extension.Contains('{'); 278 if (hasSpecificExtension) 279 { 280 result = new Classification( 281 ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument, 282 input, 283 head, 284 tail, 285 LaunchMethod.ShellExecute, 286 HasDir(head) ? Path.GetDirectoryName(head) : null, 287 isPlaceholder); 288 289 return true; 290 } 291 } 292 293 // 4) looks like a web URL without scheme, but not like a file with extension 294 if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase)) 295 { 296 // treat as URL, add https:// 297 var url = "https://" + input; 298 result = new Classification( 299 CommandKind.WebUrl, 300 input, 301 url, 302 string.Empty, 303 LaunchMethod.ShellExecute, 304 null, 305 isPlaceholder); 306 return true; 307 } 308 309 // 5) Fallback: let ShellExecute try the whole input 310 result = new Classification( 311 CommandKind.Unknown, 312 input, 313 head, 314 tail, 315 LaunchMethod.ShellExecute, 316 null, 317 isPlaceholder); 318 319 return true; 320 } 321 322 private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input); 323 324 // Finds the best existing path prefix in an *unquoted* input by scanning 325 // whitespace boundaries. Prefers files to directories; for same kind, 326 // prefers the longer path. 327 // Returns (head, tail) or (null, null) if nothing found. 328 private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser) 329 { 330 try 331 { 332 // This goes greedy from the longest head down to shortest; exactly opposite of what 333 // CreateProcess rules are for the first token. But here we operate with a slightly different goal. 334 var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser); 335 336 // put tails back together: 337 return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim()); 338 } 339 catch (Exception ex) 340 { 341 Logger.LogError("Failed to find best path", ex); 342 throw; 343 } 344 } 345 346 private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser) 347 { 348 // Be greedy: try to find the longest existing path prefix 349 for (var i = input.Length; i >= 0; i--) 350 { 351 if (i < input.Length && !char.IsWhiteSpace(input[i])) 352 { 353 continue; 354 } 355 356 var candidate = input.AsSpan(0, i).TrimEnd().ToString(); 357 if (candidate.Length == 0) 358 { 359 continue; 360 } 361 362 // If we have placeholders, check if this candidate would contain a non-path placeholder 363 if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser)) 364 { 365 continue; // Skip this candidate, try a shorter one 366 } 367 368 try 369 { 370 if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full)) 371 { 372 var tail = i < input.Length ? input[i..].TrimStart() : string.Empty; 373 return (full, tail); 374 } 375 } 376 catch 377 { 378 // Ignore malformed paths; keep scanning 379 } 380 } 381 382 return (null, null); 383 } 384 385 // Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path. 386 private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser) 387 { 388 placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders); 389 foreach (var match in placeholders) 390 { 391 var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index); 392 393 // If placeholder appears after what looks like a command-line flag/option 394 if (placeholderContext.IsAfterFlag) 395 { 396 return true; 397 } 398 399 // If placeholder doesn't look like a typical path component 400 if (!placeholderContext.LooksLikePathComponent) 401 { 402 return true; 403 } 404 } 405 406 return false; 407 } 408 409 // Heuristically determines the context of a placeholder inside a filesystem-like input string. 410 // Sets: 411 // - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --"). 412 // - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators. 413 private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex) 414 { 415 var beforePlaceholder = input[..placeholderIndex].TrimEnd(); 416 417 var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) || 418 beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) || 419 beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase); 420 421 var looksLikePathComponent = !isAfterFlag; 422 423 var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20))); 424 var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/'); 425 426 if (!hasPathSeparators && isAfterFlag) 427 { 428 looksLikePathComponent = false; 429 } 430 431 return new PlaceholderContext(isAfterFlag, looksLikePathComponent); 432 } 433 434 private static bool TryGetAumid(string input, out string aumid) 435 { 436 // App ids are a lot of fun, since they can look like anything. 437 // And yes, they can contain spaces too, like Zoom: 438 // shell:AppsFolder\zoom.us.Zoom Video Meetings 439 // so unless that thing is quoted, we can't just assume the first token is the AUMID. 440 const string appsFolder = "shell:AppsFolder\\"; 441 442 // Guard against null or empty input 443 if (string.IsNullOrEmpty(input)) 444 { 445 aumid = string.Empty; 446 return false; 447 } 448 449 // Already a fully qualified AUMID path 450 if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase)) 451 { 452 aumid = input; 453 return true; 454 } 455 456 aumid = string.Empty; 457 return false; 458 } 459 460 private static bool LooksPathy(string input) 461 { 462 // Basic: drive:\, UNC, relative with . or .., or has dir separator 463 if (input.Contains('\\') || input.Contains('/')) 464 { 465 return true; 466 } 467 468 if (input is [_, ':', ..]) 469 { 470 return true; 471 } 472 473 if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture)) 474 { 475 return true; 476 } 477 478 return false; 479 } 480 481 private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path)); 482 483 private static bool TryResolveViaPath(string head, out string resolvedFile) 484 { 485 resolvedFile = string.Empty; 486 487 if (string.IsNullOrWhiteSpace(head)) 488 { 489 return false; 490 } 491 492 if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile)) 493 { 494 return true; 495 } 496 497 // If head has dir, treat as path probe 498 if (HasDir(head)) 499 { 500 if (Path.HasExtension(head)) 501 { 502 var p = TryProbe(Environment.CurrentDirectory, head); 503 if (p is not null) 504 { 505 resolvedFile = p; 506 return true; 507 } 508 509 return false; 510 } 511 512 foreach (var ext in ShellHelpers.ExecutableExtensions) 513 { 514 var p = TryProbe(null, head + ext); 515 if (p is not null) 516 { 517 resolvedFile = p; 518 return true; 519 } 520 } 521 522 return false; 523 } 524 525 return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile); 526 } 527 528 private static string? TryProbe(string? dir, string name) 529 { 530 try 531 { 532 var path = dir is null ? name : Path.Combine(dir, name); 533 if (File.Exists(path)) 534 { 535 return Path.GetFullPath(path); 536 } 537 } 538 catch 539 { 540 /* ignore */ 541 } 542 543 return null; 544 } 545 546 private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent); 547 }