/ src / modules / cmdpal / ext / Microsoft.CmdPal.Ext.Bookmark / Services / BookmarkResolver.cs
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  }