/ src / modules / cmdpal / ext / Microsoft.CmdPal.Ext.Bookmark / Helpers / CommandLineHelper.cs
CommandLineHelper.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.ComponentModel;
  6  using System.IO;
  7  using System.Runtime.InteropServices;
  8  
  9  namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
 10  
 11  /// <summary>
 12  /// Provides helper methods for parsing command lines and expanding paths.
 13  /// </summary>
 14  /// <remarks>
 15  /// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser.
 16  /// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also
 17  /// bend the rules to be more forgiving.
 18  /// </remarks>
 19  internal static partial class CommandLineHelper
 20  {
 21      private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
 22  
 23      public static string[] SplitCommandLine(string commandLine)
 24      {
 25          ArgumentNullException.ThrowIfNull(commandLine);
 26  
 27          var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc);
 28          if (argv == IntPtr.Zero)
 29          {
 30              throw new Win32Exception(Marshal.GetLastWin32Error());
 31          }
 32  
 33          try
 34          {
 35              var result = new string[argc];
 36              for (var i = 0; i < argc; i++)
 37              {
 38                  var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
 39                  result[i] = Marshal.PtrToStringUni(p)!;
 40              }
 41  
 42              return result;
 43          }
 44          finally
 45          {
 46              NativeMethods.LocalFree(argv);
 47          }
 48      }
 49  
 50      /// <summary>
 51      /// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules
 52      /// of CommandLineToArgvW.
 53      /// </summary>
 54      /// <remarks>
 55      /// This is a mental support for SplitLongestHeadBeforeQuotedArg.
 56      ///
 57      /// Rules:
 58      /// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules).
 59      /// - Otherwise, Head uses the CreateProcess "program name" rule:
 60      ///     - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it).
 61      ///     - Else, Head is the run up to the first whitespace.
 62      /// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains).
 63      /// No normalization is performed; returned slices preserve the original text (no un/escaping).
 64      /// </remarks>
 65      public static (string Head, string Tail) SplitHeadAndArgs(string input)
 66      {
 67          ArgumentNullException.ThrowIfNull(input);
 68  
 69          if (input.Length == 0)
 70          {
 71              return (string.Empty, string.Empty);
 72          }
 73  
 74          var s = input.AsSpan();
 75          var n = s.Length;
 76          var i = 0;
 77  
 78          // Leading whitespace -> empty argv[0]
 79          if (char.IsWhiteSpace(s[0]))
 80          {
 81              while (i < n && char.IsWhiteSpace(s[i]))
 82              {
 83                  i++;
 84              }
 85  
 86              var tailAfterWs = i < n ? input[i..] : string.Empty;
 87              return (string.Empty, tailAfterWs);
 88          }
 89  
 90          string head;
 91          if (s[i] == '"')
 92          {
 93              // Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here)
 94              i++;
 95              var start = i;
 96              while (i < n && s[i] != '"')
 97              {
 98                  i++;
 99              }
100  
101              head = input.Substring(start, i - start);
102              if (i < n && s[i] == '"')
103              {
104                  i++; // consume closing quote
105              }
106          }
107          else
108          {
109              // Unquoted program name: read to next whitespace
110              var start = i;
111              while (i < n && !char.IsWhiteSpace(s[i]))
112              {
113                  i++;
114              }
115  
116              head = input.Substring(start, i - start);
117          }
118  
119          // Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty)
120          while (i < n && char.IsWhiteSpace(s[i]))
121          {
122              i++;
123          }
124  
125          var tail = i < n ? input[i..] : string.Empty;
126  
127          return (head, tail);
128      }
129  
130      /// <summary>
131      /// Returns the longest possible head (may include spaces) and the tail that starts at the
132      /// first *quoted argument*.
133      ///
134      /// Definition of "quoted argument start":
135      /// - A token boundary (start-of-line or preceded by whitespace),
136      /// - followed by zero or more backslashes,
137      /// - followed by a double-quote ("),
138      /// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting).
139      ///
140      /// Notes:
141      /// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head.
142      /// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote.
143      /// - Leading whitespace before the first token is ignored (Head starts from first non-ws).
144      /// Examples:
145      ///   C:\app exe -p "1" -q        -> Head: "C:\app exe -p", Tail: "\"1\" -q"
146      ///   "\\server\share\" with args  -> Head: "", Tail: "\"\\\\server\\share\\\" with args"
147      ///   C:\Some\"Path\file.txt       -> Head: "C:\\Some\\\"Path\\file.txt", Tail: ""
148      /// </summary>
149      public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input)
150      {
151          ArgumentNullException.ThrowIfNull(input);
152  
153          if (input.Length == 0)
154          {
155              return (string.Empty, string.Empty);
156          }
157  
158          var s = input.AsSpan();
159          var n = s.Length;
160  
161          // Start at first non-whitespace (we don't treat leading ws as part of Head here)
162          var start = 0;
163          while (start < n && char.IsWhiteSpace(s[start]))
164          {
165              start++;
166          }
167  
168          if (start >= n)
169          {
170              return (string.Empty, string.Empty);
171          }
172  
173          // Scan for a quote that OPENS a quoted argument at a token boundary.
174          for (var i = start; i < n; i++)
175          {
176              if (s[i] != '"')
177              {
178                  continue;
179              }
180  
181              // Count immediate backslashes before this quote
182              int j = i - 1, backslashes = 0;
183              while (j >= start && s[j] == '\\')
184              {
185                  backslashes++;
186                  j--;
187              }
188  
189              // The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace.
190              var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]);
191  
192              // Even number of backslashes -> this quote toggles quoting (opens if at boundary).
193              if (atTokenBoundary && (backslashes % 2 == 0))
194              {
195                  // Trim trailing spaces off Head so Tail starts exactly at the opening quote
196                  var headEnd = i;
197                  while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1]))
198                  {
199                      headEnd--;
200                  }
201  
202                  var head = input[start..headEnd];
203                  var tail = input[headEnd..]; // starts at the opening quote
204                  return (head, tail.Trim());
205              }
206          }
207  
208          // No quoted-arg start found: entire remainder (trimmed right) is the Head
209          var wholeHead = input[start..].TrimEnd();
210          return (wholeHead, string.Empty);
211      }
212  
213      /// <summary>
214      /// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers.
215      /// </summary>
216      internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full)
217      {
218          if (string.IsNullOrEmpty(input))
219          {
220              full = string.Empty;
221              return false;
222          }
223  
224          var expanded = Environment.ExpandEnvironmentVariables(input);
225  
226          var firstSegment = GetFirstPathSegment(expanded);
227          if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded))
228          {
229              expanded = shellExpanded;
230          }
231          else if (firstSegment is "~" or "." or "..")
232          {
233              expanded = ExpandUserRelative(firstSegment, expanded);
234          }
235  
236          if (Path.Exists(expanded))
237          {
238              full = Path.GetFullPath(expanded);
239              return true;
240          }
241  
242          full = expanded; // return the attempted expansion even if it doesn't exist
243          return false;
244      }
245  
246      private static bool TryExpandShellMoniker(string input, out string expanded)
247      {
248          var separatorIndex = input.IndexOfAny(PathSeparators);
249          var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input;
250          var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty;
251  
252          if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath))
253          {
254              expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath));
255              return true;
256          }
257  
258          expanded = input;
259          return false;
260      }
261  
262      private static string ExpandUserRelative(string firstSegment, string input)
263      {
264          // Treat relative paths as relative to the user home directory.
265          var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
266  
267          if (firstSegment == "~")
268          {
269              // Remove "~" (+ optional following separator) before combining.
270              var skip = 1;
271              if (input.Length > 1 && IsSeparator(input[1]))
272              {
273                  skip++;
274              }
275  
276              input = input[skip..];
277          }
278  
279          return Path.GetFullPath(Path.Combine(homeDirectory, input));
280      }
281  
282      private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
283  
284      private static string GetFirstPathSegment(string input)
285      {
286          var separatorIndex = input.IndexOfAny(PathSeparators);
287          return separatorIndex > 0 ? input[..separatorIndex] : input;
288      }
289  
290      internal static bool HasShellPrefix(string input)
291      {
292          return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal);
293      }
294  }