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 }