nushell on your web browser
nushell wasm terminal

handle subcommands properly and allow combining short flags

ptr.pet 5ea7fa86 360edbf6

verified
Changed files
+300 -40
src
+114 -18
src/completion/context.rs
··· 183 183 ); 184 184 185 185 if is_empty { 186 - // Empty block/closure or just whitespace - command context 187 - console_log!("[completion] {shape_name} is empty, setting Command context"); 188 - Some(CompletionContext::Command { 189 - prefix: String::new(), 190 - span: adjusted_span, 191 - }) 186 + // Empty block/closure or just whitespace 187 + // Check if there's a command shape before this closure/block shape 188 + // If so, we might be completing after that command 189 + let mut found_command: Option<String> = None; 190 + for i in (0..current_idx).rev() { 191 + if let Some((prev_span, prev_shape)) = shapes.get(i) { 192 + let prev_local_span = to_local_span(*prev_span, global_offset); 193 + // Check if this shape is before the current closure and is a command 194 + if prev_local_span.end <= local_span.start { 195 + if is_command_shape(input, prev_shape, prev_local_span) { 196 + let cmd_text = safe_slice(input, prev_local_span); 197 + let cmd_full = cmd_text.trim().to_string(); 198 + 199 + // Extract the full command text - if it contains spaces, it might be a subcommand 200 + // We'll use the first word for parent_command to show subcommands 201 + // The suggestion generator will filter appropriately 202 + let cmd_first_word = extract_command_name(cmd_text).to_string(); 203 + 204 + // If the command contains spaces, it's likely a full command (subcommand) 205 + // In that case, we shouldn't show subcommands 206 + if cmd_full.contains(' ') && cmd_full != cmd_first_word { 207 + // It's a full command (subcommand), don't show subcommands 208 + console_log!( 209 + "[completion] {shape_name} is empty but found full command {cmd_full:?} before it, not showing completions" 210 + ); 211 + return None; 212 + } 213 + 214 + // Use the first word to show subcommands 215 + found_command = Some(cmd_first_word); 216 + console_log!( 217 + "[completion] {shape_name} is empty but found command {found_command:?} before it" 218 + ); 219 + break; 220 + } 221 + } 222 + } 223 + } 224 + 225 + if let Some(cmd_name) = found_command { 226 + // We found a command before the closure, show subcommands of that command 227 + console_log!( 228 + "[completion] {shape_name} is empty, showing subcommands of {cmd_name:?}" 229 + ); 230 + Some(CompletionContext::Command { 231 + prefix: String::new(), 232 + span: adjusted_span, 233 + parent_command: Some(cmd_name), 234 + }) 235 + } else { 236 + // Truly empty - show all commands 237 + console_log!("[completion] {shape_name} is empty, setting Command context"); 238 + Some(CompletionContext::Command { 239 + prefix: String::new(), 240 + span: adjusted_span, 241 + parent_command: None, 242 + }) 243 + } 192 244 } else if let Some(last_sep_pos) = last_sep_pos_in_prefix { 193 245 // After a separator - command context 194 246 let after_sep = prefix[last_sep_pos..].trim_start(); ··· 198 250 Some(CompletionContext::Command { 199 251 prefix: after_sep.to_string(), 200 252 span: Span::new(span.start + last_sep_pos, span.end), 253 + parent_command: None, 201 254 }) 202 255 } else { 203 256 console_log!( ··· 468 521 return Some(CompletionContext::Command { 469 522 prefix: String::new(), 470 523 span: adjusted_span, 524 + parent_command: None, 471 525 }); 472 526 } 473 527 } else { ··· 559 613 return Some(CompletionContext::Command { 560 614 prefix: full_prefix, 561 615 span: full_span, 616 + parent_command: None, 562 617 }); 563 618 } 564 619 FlatShape::Block | FlatShape::Closure => { ··· 696 751 "[completion] Found command shape {shape:?} at {local_span:?}, has_separator_after_command={has_separator_after_command}" 697 752 ); 698 753 if !has_separator_after_command { 699 - // Extract the command text 754 + // Extract the command text (full command including subcommands) 700 755 let cmd = safe_slice(input, local_span); 701 - let cmd_name = extract_command_name(cmd).to_string(); 756 + let cmd_full = cmd.trim().to_string(); 757 + let cmd_first_word = extract_command_name(cmd).to_string(); 702 758 703 759 // Check if we're right after the command (only whitespace between command and cursor) 704 760 let text_after_command = if local_span.end < input.len() { ··· 710 766 711 767 // If we're right after a command, check if it has positional arguments 712 768 if is_right_after_command { 769 + // Check if the command text contains spaces (indicating it's a subcommand like "attr category") 770 + let is_subcommand = cmd_full.contains(' ') && cmd_full != cmd_first_word; 771 + 772 + // First, try the full command name (e.g., "attr category") 773 + // If that doesn't exist, fall back to the first word (e.g., "attr") 774 + let full_cmd_exists = 775 + get_command_signature(engine_guard, &cmd_full).is_some(); 776 + let cmd_name = if full_cmd_exists { 777 + cmd_full.clone() 778 + } else { 779 + cmd_first_word.clone() 780 + }; 781 + 713 782 if let Some(signature) = get_command_signature(engine_guard, &cmd_name) { 714 783 // Check if command has any positional arguments 715 784 let has_positional_args = !signature.required_positional.is_empty() ··· 747 816 arg_index: arg_count, 748 817 }); 749 818 } else { 750 - // No positional arguments, don't show any completions 819 + // No positional arguments 820 + // If this is a subcommand (contains spaces), don't show subcommands 821 + // Only show subcommands if we're using just the base command (single word) 822 + if is_subcommand && full_cmd_exists { 823 + console_log!( 824 + "[completion] Command {cmd_name:?} is a subcommand with no positional args, not showing completions" 825 + ); 826 + return None; 827 + } else { 828 + // Show subcommands of the base command 829 + console_log!( 830 + "[completion] Command {cmd_name:?} has no positional args, showing subcommands" 831 + ); 832 + return Some(CompletionContext::Command { 833 + prefix: String::new(), 834 + span: Span::new(byte_pos, byte_pos), 835 + parent_command: Some(cmd_first_word), 836 + }); 837 + } 838 + } 839 + } else { 840 + // Couldn't find signature 841 + // If this is a subcommand, don't show completions 842 + // Otherwise, show subcommands of the first word 843 + if is_subcommand && full_cmd_exists { 751 844 console_log!( 752 - "[completion] Command {cmd_name:?} has no positional args, not showing completions" 845 + "[completion] Could not find signature for subcommand {cmd_name:?}, not showing completions" 753 846 ); 754 - // Leave context as None to show no completions 755 847 return None; 848 + } else { 849 + console_log!( 850 + "[completion] Could not find signature for {cmd_name:?}, showing subcommands" 851 + ); 852 + return Some(CompletionContext::Command { 853 + prefix: String::new(), 854 + span: Span::new(byte_pos, byte_pos), 855 + parent_command: Some(cmd_first_word), 856 + }); 756 857 } 757 - } else { 758 - // Couldn't find signature, don't show completions 759 - console_log!( 760 - "[completion] Could not find signature for {cmd_name:?}, not showing completions" 761 - ); 762 - // Leave context as None to show no completions 763 - return None; 764 858 } 765 859 } else { 766 860 // Not right after command, complete the command itself ··· 768 862 return Some(CompletionContext::Command { 769 863 prefix: cmd.to_string(), 770 864 span: local_span, 865 + parent_command: None, 771 866 }); 772 867 } 773 868 } ··· 827 922 Some(CompletionContext::Command { 828 923 prefix: last_word.to_string(), 829 924 span: Span::new(last_word_start, byte_pos), 925 + parent_command: None, 830 926 }) 831 927 } else { 832 928 // Check if this is a variable or cell path (starts with $)
+185 -22
src/completion/suggestions.rs
··· 12 12 working_set: &StateWorkingSet, 13 13 prefix: String, 14 14 span: Span, 15 + parent_command: Option<String>, 15 16 ) -> Vec<Suggestion> { 16 - console_log!("[completion] Generating Command suggestions with prefix: {prefix:?}"); 17 - // Command completion 18 - let cmds = 19 - working_set.find_commands_by_predicate(|value| value.starts_with(prefix.as_bytes()), true); 17 + console_log!( 18 + "[completion] Generating Command suggestions with prefix: {prefix:?}, parent_command: {parent_command:?}" 19 + ); 20 20 21 21 let span = to_char_span(input, span); 22 22 let mut suggestions = Vec::new(); 23 23 let mut cmd_count = 0; 24 24 25 + // Determine search prefix and name extraction logic 26 + let (search_prefix, parent_prefix_opt) = if let Some(parent) = &parent_command { 27 + // Show only subcommands of the parent command 28 + // Subcommands are commands that start with "parent_command " (with space) 29 + let parent_prefix = format!("{} ", parent); 30 + let search_prefix = if prefix.is_empty() { 31 + parent_prefix.clone() 32 + } else { 33 + format!("{}{}", parent_prefix, prefix) 34 + }; 35 + (search_prefix, Some(parent_prefix)) 36 + } else { 37 + // Regular command completion - show all commands 38 + (prefix.clone(), None) 39 + }; 40 + 41 + let cmds = working_set 42 + .find_commands_by_predicate(|value| value.starts_with(search_prefix.as_bytes()), true); 43 + 25 44 for (_, name, desc, _) in cmds { 26 45 let name_str = String::from_utf8_lossy(&name).to_string(); 46 + 47 + // Extract the command name to display 48 + // For subcommands, extract just the subcommand name (part after "parent_command ") 49 + // For regular commands, use the full command name 50 + let display_name = if let Some(parent_prefix) = &parent_prefix_opt { 51 + if let Some(subcommand_name) = name_str.strip_prefix(parent_prefix) { 52 + subcommand_name.to_string() 53 + } else { 54 + continue; // Skip if it doesn't match the parent prefix 55 + } 56 + } else { 57 + name_str 58 + }; 59 + 27 60 suggestions.push(Suggestion { 28 61 rendered: { 29 - let name_colored = ansi_term::Color::Green.bold().paint(&name_str); 62 + let name_colored = ansi_term::Color::Green.bold().paint(&display_name); 30 63 let desc_str = desc.as_deref().unwrap_or("<no description>"); 31 64 format!("{name_colored} {desc_str}") 32 65 }, 33 - name: name_str, 34 - description: desc, 66 + name: display_name, 67 + description: desc.map(|d| d.to_string()), 35 68 is_command: true, 36 69 span_start: span.start, 37 70 span_end: span.end, ··· 120 153 121 154 // Add short flag if it matches 122 155 if let Some(short) = &short_name { 156 + let flag_char = flag.short.unwrap_or(' '); 123 157 let should_show_short = if show_all { 124 158 true // Show all flags when prefix is "-" or empty 125 159 } else if prefix.starts_with("-") && !prefix.starts_with("--") { 126 - short.starts_with(&prefix) // Only show short flags matching prefix 160 + // For combined short flags like "-a" or "-af", suggest flags that can be appended 161 + // Extract already used flags from prefix (e.g., "-a" -> ['a'], "-af" -> ['a', 'f']) 162 + let used_flags: Vec<char> = prefix[1..].chars().collect(); 163 + 164 + // Show if this flag isn't already in the prefix 165 + !used_flags.contains(&flag_char) 127 166 } else { 128 167 false // Don't show short flags if prefix is long flag format 129 168 }; 130 169 131 170 if should_show_short { 132 - suggestions.push(create_flag_suggestion(short.clone())); 171 + // If prefix already contains flags (like "-a"), create combined suggestion (like "-af") 172 + let suggestion_name = if prefix.len() > 1 && prefix.starts_with("-") { 173 + format!("{}{}", prefix, flag_char) 174 + } else { 175 + short.clone() 176 + }; 177 + suggestions.push(create_flag_suggestion(suggestion_name)); 133 178 flag_count += 1; 134 179 } 135 180 } ··· 146 191 pub fn generate_command_argument_suggestions( 147 192 input: &str, 148 193 engine_guard: &EngineState, 194 + working_set: &StateWorkingSet, 149 195 prefix: String, 150 196 span: Span, 151 197 command_name: String, ··· 158 204 159 205 let mut suggestions = Vec::new(); 160 206 if let Some(signature) = get_command_signature(engine_guard, &command_name) { 207 + // First, check if we're completing an argument for a flag 208 + // Look backwards from the current position to find the previous flag 209 + let text_before = if span.start < input.len() { 210 + &input[..span.start] 211 + } else { 212 + "" 213 + }; 214 + let text_before_trimmed = text_before.trim_end(); 215 + 216 + // Check if the last word before cursor is a flag 217 + let last_word_start = text_before_trimmed 218 + .rfind(|c: char| c.is_whitespace()) 219 + .map(|i| i + 1) 220 + .unwrap_or(0); 221 + let last_word = &text_before_trimmed[last_word_start..]; 222 + 223 + if last_word.starts_with('-') { 224 + // We're after a flag - check if this flag accepts an argument 225 + let flag_name = last_word.trim(); 226 + let is_long_flag = flag_name.starts_with("--"); 227 + let flag_to_match: Option<(bool, String)> = if is_long_flag { 228 + // Long flag: --flag-name 229 + flag_name.strip_prefix("--").map(|s| (true, s.to_string())) 230 + } else { 231 + // Short flag: -f (single character) 232 + flag_name 233 + .strip_prefix("-") 234 + .and_then(|s| s.chars().next().map(|c| (false, c.to_string()))) 235 + }; 236 + 237 + if let Some((is_long, flag_name_to_match)) = flag_to_match { 238 + // Find the flag in the signature 239 + for flag in &signature.named { 240 + let matches_flag = if is_long { 241 + // Long flag 242 + flag.long == flag_name_to_match 243 + } else { 244 + // Short flag - compare character 245 + flag.short 246 + .map(|c| c.to_string() == flag_name_to_match) 247 + .unwrap_or(false) 248 + }; 249 + 250 + if matches_flag { 251 + // Found the flag - check if it accepts an argument 252 + if let Some(flag_arg_shape) = &flag.arg { 253 + // Flag accepts an argument - use its type 254 + console_log!( 255 + "[completion] Flag {flag_name:?} accepts argument of type {:?}", 256 + flag_arg_shape 257 + ); 258 + match flag_arg_shape { 259 + nu_protocol::SyntaxShape::Filepath 260 + | nu_protocol::SyntaxShape::Any => { 261 + // File/directory completion for flag argument 262 + let file_suggestions = generate_file_suggestions( 263 + &prefix, 264 + span, 265 + root, 266 + Some(flag.desc.clone()), 267 + input, 268 + ); 269 + let file_count = file_suggestions.len(); 270 + suggestions.extend(file_suggestions); 271 + console_log!( 272 + "[completion] Found {file_count} file suggestions for flag argument" 273 + ); 274 + } 275 + _ => { 276 + // Flag argument is not a filepath type 277 + console_log!( 278 + "[completion] Flag {flag_name:?} argument is type {:?}, not suggesting files", 279 + flag_arg_shape 280 + ); 281 + } 282 + } 283 + return suggestions; 284 + } else { 285 + // Flag doesn't accept an argument - fall through to positional argument check 286 + console_log!( 287 + "[completion] Flag {flag_name:?} doesn't accept an argument, checking positional arguments" 288 + ); 289 + break; 290 + } 291 + } 292 + } 293 + } 294 + } 295 + 296 + // Not after a flag, or flag doesn't accept an argument - check positional arguments 161 297 // Get positional arguments from signature 162 - // Combine required and optional positional arguments 163 - let mut all_positional = Vec::new(); 164 - all_positional.extend_from_slice(&signature.required_positional); 165 - all_positional.extend_from_slice(&signature.optional_positional); 298 + // Check if argument is in required or optional positional 299 + let required_count = signature.required_positional.len(); 300 + let is_optional = arg_index >= required_count; 166 301 167 302 // Find the argument at the given index 168 - if let Some(arg) = all_positional.get(arg_index) { 303 + let arg = if arg_index < signature.required_positional.len() { 304 + signature.required_positional.get(arg_index) 305 + } else { 306 + let optional_index = arg_index - required_count; 307 + signature.optional_positional.get(optional_index) 308 + }; 309 + 310 + if let Some(arg) = arg { 169 311 // Check the SyntaxShape to determine completion type 170 312 // Only suggest files/dirs for Filepath type (or "any" when type is unknown) 171 313 match &arg.shape { ··· 183 325 console_log!( 184 326 "[completion] Found {file_count} file suggestions for argument {arg_index}" 185 327 ); 328 + 329 + // If the argument is optional and of type Any or Filepath, also show subcommands 330 + if is_optional { 331 + console_log!( 332 + "[completion] Argument {arg_index} is optional and of type {:?}, also showing subcommands", 333 + arg.shape 334 + ); 335 + let subcommand_suggestions = generate_command_suggestions( 336 + input, 337 + working_set, 338 + prefix.clone(), 339 + span, 340 + Some(command_name.clone()), 341 + ); 342 + let subcommand_count = subcommand_suggestions.len(); 343 + suggestions.extend(subcommand_suggestions); 344 + console_log!( 345 + "[completion] Found {subcommand_count} subcommand suggestions" 346 + ); 347 + } 186 348 } 187 349 _ => { 188 350 // For other types, don't suggest files ··· 193 355 } 194 356 } 195 357 } else { 196 - // Argument index out of range, fall back to file completion 358 + // Argument index out of range - command doesn't accept that many positional arguments 359 + // Don't suggest files since we know the type (it's not a valid argument) 197 360 console_log!( 198 - "[completion] Argument index {arg_index} out of range, using file completion" 361 + "[completion] Argument index {arg_index} out of range, not suggesting files" 199 362 ); 200 - // Use the same file completion logic as Argument context 201 - let file_suggestions = generate_file_suggestions(&prefix, span, root, None, input); 202 - suggestions.extend(file_suggestions); 203 363 } 204 364 } else { 205 365 // No signature found, fall back to file completion ··· 333 493 console_log!("context: {context:?}"); 334 494 335 495 match context { 336 - Some(CompletionContext::Command { prefix, span }) => { 337 - generate_command_suggestions(input, working_set, prefix, span) 338 - } 496 + Some(CompletionContext::Command { 497 + prefix, 498 + span, 499 + parent_command, 500 + }) => generate_command_suggestions(input, working_set, prefix, span, parent_command), 339 501 Some(CompletionContext::Argument { prefix, span }) => { 340 502 generate_argument_suggestions(input, prefix, span, root) 341 503 } ··· 352 514 }) => generate_command_argument_suggestions( 353 515 input, 354 516 engine_guard, 517 + working_set, 355 518 prefix, 356 519 span, 357 520 command_name,
+1
src/completion/types.rs
··· 33 33 Command { 34 34 prefix: String, 35 35 span: Span, 36 + parent_command: Option<String>, // If Some, only show subcommands of this command 36 37 }, 37 38 Argument { 38 39 prefix: String,