diff --git a/src/cmd/ls.rs b/src/cmd/ls.rs index 6ae74b0..96ddc08 100644 --- a/src/cmd/ls.rs +++ b/src/cmd/ls.rs @@ -22,7 +22,7 @@ impl Command for Ls { fn signature(&self) -> Signature { Signature::build("ls") - .optional("path", SyntaxShape::String, "the path to list") + .optional("path", SyntaxShape::Filepath, "the path to list") .switch( "all", "include hidden paths (that start with a dot)", diff --git a/src/cmd/open.rs b/src/cmd/open.rs index 1ad008b..6a9cf1e 100644 --- a/src/cmd/open.rs +++ b/src/cmd/open.rs @@ -18,7 +18,7 @@ impl Command for Open { fn signature(&self) -> Signature { Signature::build("open") - .required("path", SyntaxShape::String, "path to the file") + .required("path", SyntaxShape::Filepath, "path to the file") .switch( "raw", "output content as raw string/binary without parsing", diff --git a/src/cmd/save.rs b/src/cmd/save.rs index e3f5c63..b835f68 100644 --- a/src/cmd/save.rs +++ b/src/cmd/save.rs @@ -16,7 +16,7 @@ impl Command for Save { fn signature(&self) -> Signature { Signature::build("save") - .required("path", SyntaxShape::String, "path to write the data to") + .required("path", SyntaxShape::Filepath, "path to write the data to") .input_output_types(vec![(Type::Any, Type::Nothing)]) .category(Category::FileSystem) } diff --git a/src/completion.rs b/src/completion.rs deleted file mode 100644 index 18bc49e..0000000 --- a/src/completion.rs +++ /dev/null @@ -1,1984 +0,0 @@ -use futures::FutureExt; -use js_sys::Promise; -use nu_protocol::{ENV_VARIABLE_ID, IN_VARIABLE_ID, NU_VARIABLE_ID, Value}; -use std::collections::HashMap; -use wasm_bindgen_futures::future_to_promise; - -use super::*; - -#[derive(Debug, Serialize)] -struct Suggestion { - name: String, - description: Option, - is_command: bool, - rendered: String, - span_start: usize, // char index (not byte) - span_end: usize, // char index (not byte) -} - -impl PartialEq for Suggestion { - fn eq(&self, other: &Self) -> bool { - self.name == other.name - } -} -impl Eq for Suggestion {} -impl PartialOrd for Suggestion { - fn partial_cmp(&self, other: &Self) -> Option { - self.name.partial_cmp(&other.name) - } -} -impl Ord for Suggestion { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.name.cmp(&other.name) - } -} - -#[wasm_bindgen] -pub fn completion(input: String, js_cursor_pos: usize) -> Promise { - future_to_promise(completion_impl(input, js_cursor_pos).map(|s| Ok(JsValue::from_str(&s)))) -} - -pub async fn completion_impl(input: String, js_cursor_pos: usize) -> String { - let engine_guard = read_engine_state().await; - let stack_guard = crate::read_stack().await; - let root = get_pwd(); - - // Map UTF-16 cursor position (from JS) to Byte index (for Rust) - let byte_pos = input - .char_indices() - .map(|(i, _)| i) - .nth(js_cursor_pos) - .unwrap_or(input.len()); - - let (working_set, shapes, global_offset) = { - let mut working_set = StateWorkingSet::new(&engine_guard); - let global_offset = working_set.next_span_start(); - let block = parse(&mut working_set, None, input.as_bytes(), false); - let shapes = flatten_block(&working_set, &block); - (working_set, shapes, global_offset) - }; - - // Initial state logging - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Input: {:?}, JS cursor: {}, byte cursor: {}", - input, js_cursor_pos, byte_pos - ))); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} shapes, global_offset: {}", - shapes.len(), - global_offset - ))); - for (idx, (span, shape)) in shapes.iter().enumerate() { - let (local_start, local_end) = ( - span.start.saturating_sub(global_offset), - span.end.saturating_sub(global_offset), - ); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Shape {}: {:?} at [{}, {}] (local: [{}, {}])", - idx, shape, span.start, span.end, local_start, local_end - ))); - } - - // Helper functions - let is_separator_char = |c: char| -> bool { ['|', ';', '(', '{'].contains(&c) }; - - let is_command_separator_char = |c: char| -> bool { ['|', ';'].contains(&c) }; - - let has_separator_between = |start: usize, end: usize| -> bool { - if start < end && start < input.len() { - let text_between = &input[start..std::cmp::min(end, input.len())]; - text_between.chars().any(|c| is_separator_char(c)) - } else { - false - } - }; - - let find_last_separator_pos = |text: &str| -> Option { - text.rfind(|c| is_command_separator_char(c)).map(|i| i + 1) - }; - - let ends_with_separator = |text: &str| -> bool { - let text = text.trim_end(); - text.ends_with('|') || text.ends_with(';') - }; - - let to_local_span = |span: Span| -> Span { - Span::new( - span.start.saturating_sub(global_offset), - span.end.saturating_sub(global_offset), - ) - }; - - let safe_slice = |span: Span| -> String { - (span.start < input.len()) - .then(|| { - let safe_end = std::cmp::min(span.end, input.len()); - input[span.start..safe_end].to_string() - }) - .unwrap_or_default() - }; - - let is_command_shape = |shape: &FlatShape, local_span: Span| -> bool { - matches!( - shape, - FlatShape::External(_) | FlatShape::InternalCall(_) | FlatShape::Keyword - ) || matches!(shape, FlatShape::Garbage) && { - if local_span.start < input.len() { - let prev_text = &safe_slice(local_span); - !prev_text.trim().starts_with('-') - } else { - false - } - } - }; - - let handle_block_prefix = |prefix: &str, span: Span| -> Option<(String, Span, bool)> { - let mut block_prefix = prefix; - let mut block_span_start = span.start; - - // Remove leading '{' and whitespace - if block_prefix.starts_with('{') { - block_prefix = &block_prefix[1..]; - block_span_start += 1; - } - let trimmed_block_prefix = block_prefix.trim_start(); - if trimmed_block_prefix != block_prefix { - // Adjust span start to skip whitespace - block_span_start += block_prefix.len() - trimmed_block_prefix.len(); - } - - let is_empty = trimmed_block_prefix.is_empty(); - Some(( - trimmed_block_prefix.to_string(), - Span::new(block_span_start, span.end), - is_empty, - )) - }; - - // Helper function to find command name and count arguments before cursor - let find_command_and_arg_index = - |current_idx: usize, current_local_span: Span| -> Option<(String, usize)> { - let mut command_name: Option = None; - let mut arg_count = 0; - - // Look backwards through shapes to find the command - for i in (0..current_idx).rev() { - if let Some((prev_span, prev_shape)) = shapes.get(i) { - let prev_local_span = to_local_span(*prev_span); - - // Check if there's a separator between this shape and the next one - let next_shape_start = if i + 1 < shapes.len() { - to_local_span(shapes[i + 1].0).start - } else { - current_local_span.start - }; - - if has_separator_between(prev_local_span.end, next_shape_start) { - break; // Stop at separator - } - - if is_command_shape(prev_shape, prev_local_span) { - // Found the command - let cmd_text = safe_slice(prev_local_span); - // Extract just the command name (first word, no flags) - let cmd_name = cmd_text - .split_whitespace() - .next() - .unwrap_or(&cmd_text) - .trim(); - command_name = Some(cmd_name.to_string()); - break; - } else { - // This is an argument - count it if it's not a flag - let arg_text = safe_slice(prev_local_span); - let trimmed_arg = arg_text.trim(); - // Don't count flags (starting with -) or empty arguments - if !trimmed_arg.is_empty() && !trimmed_arg.starts_with('-') { - arg_count += 1; - } - } - } - } - - command_name.map(|name| (name, arg_count)) - }; - - // Helper function to handle both Block and Closure shapes - let handle_block_or_closure = |prefix: &str, - span: Span, - shape_name: &str, - current_idx: usize, - local_span: Span| - -> Option { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Processing {} shape with prefix: {:?}", - shape_name, prefix - ))); - - // Check if the content ends with a pipe or semicolon - let prefix_ends_with_separator = ends_with_separator(prefix); - let last_sep_pos_in_prefix = if prefix_ends_with_separator { - find_last_separator_pos(prefix) - } else { - None - }; - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: prefix_ends_with_separator={}, last_sep_pos_in_prefix={:?}", - shape_name, prefix_ends_with_separator, last_sep_pos_in_prefix - ))); - - if let Some((trimmed_prefix, adjusted_span, is_empty)) = handle_block_prefix(prefix, span) { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: trimmed_prefix={:?}, is_empty={}", - shape_name, trimmed_prefix, is_empty - ))); - - if is_empty { - // Empty block/closure or just whitespace - command context - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {} is empty, setting Command context", - shape_name - ))); - Some(CompletionContext::Command { - prefix: String::new(), - span: adjusted_span, - }) - } else if let Some(last_sep_pos) = last_sep_pos_in_prefix { - // After a separator - command context - let after_sep = prefix[last_sep_pos..].trim_start(); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {} has separator at {}, after_sep={:?}, setting Command context", - shape_name, last_sep_pos, after_sep - ))); - Some(CompletionContext::Command { - prefix: after_sep.to_string(), - span: Span::new(span.start + last_sep_pos, span.end), - }) - } else { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {} has no separator, checking for variable/flag/argument context", - shape_name - ))); - // Check if this is a variable or cell path first - let trimmed = trimmed_prefix.trim(); - - if trimmed.starts_with('$') { - // Variable or cell path completion - if let Some(dot_pos) = trimmed[1..].find('.') { - // Cell path completion: $in.name, $env.PWD, etc. - let var_name = &trimmed[1..dot_pos + 1]; - let after_var = &trimmed[dot_pos + 2..]; - let parts: Vec<&str> = after_var.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if after_var.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let var_id = match var_name { - "env" => Some(ENV_VARIABLE_ID), - "nu" => Some(NU_VARIABLE_ID), - "in" => Some(IN_VARIABLE_ID), - _ => working_set.find_variable(var_name.as_bytes()), - }; - - if let Some(var_id) = var_id { - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = adjusted_span.end.saturating_sub(prefix_byte_len); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: Setting CellPath context with var {:?}, prefix {:?}", - shape_name, var_name, cell_prefix - ))); - Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, adjusted_span.end), - var_id, - path_so_far, - }) - } else { - // Unknown variable, fall back to variable completion - let var_prefix = trimmed[1..].to_string(); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: Unknown var, setting Variable context with prefix {:?}", - shape_name, var_prefix - ))); - Some(CompletionContext::Variable { - prefix: var_prefix, - span: adjusted_span, - }) - } - } else { - // Simple variable completion (no dot) - let var_prefix = if trimmed.len() > 1 { - trimmed[1..].to_string() - } else { - String::new() - }; - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: Setting Variable context with prefix {:?}", - shape_name, var_prefix - ))); - Some(CompletionContext::Variable { - prefix: var_prefix, - span: adjusted_span, - }) - } - } else if trimmed.starts_with('-') { - // Flag completion - if let Some((cmd_name, _)) = find_command_and_arg_index(current_idx, local_span) - { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: Found command {:?} for flag completion", - shape_name, cmd_name - ))); - Some(CompletionContext::Flag { - prefix: trimmed.to_string(), - span: adjusted_span, - command_name: cmd_name, - }) - } else { - Some(CompletionContext::Argument { - prefix: trimmed_prefix, - span: adjusted_span, - }) - } - } else { - // Try to find the command and argument index - if let Some((cmd_name, arg_index)) = - find_command_and_arg_index(current_idx, local_span) - { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: Found command {:?} with arg_index {} for argument completion", - shape_name, cmd_name, arg_index - ))); - Some(CompletionContext::CommandArgument { - prefix: trimmed.to_string(), - span: adjusted_span, - command_name: cmd_name, - arg_index, - }) - } else { - // No command found, treat as regular argument - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] {}: No command found, using Argument context", - shape_name - ))); - Some(CompletionContext::Argument { - prefix: trimmed_prefix, - span: adjusted_span, - }) - } - } - } - } else { - None - } - }; - - // Helper function to evaluate a variable for completion - // Returns the Value of a variable if it can be evaluated - let eval_variable_for_completion = |var_id: nu_protocol::VarId, - working_set: &StateWorkingSet| - -> Option { - match var_id { - id if id == NU_VARIABLE_ID => { - // $nu - get from engine state constant - engine_guard.get_constant(id).cloned() - } - id if id == ENV_VARIABLE_ID => { - // $env - build from environment variables in engine state - // EnvVars is HashMap> (overlay -> vars) - let mut pairs: Vec<(String, Value)> = Vec::new(); - for overlay_env in engine_guard.env_vars.values() { - for (name, value) in overlay_env.iter() { - pairs.push((name.clone(), value.clone())); - } - } - pairs.sort_by(|a, b| a.0.cmp(&b.0)); - // Deduplicate by name (later overlays override earlier ones) - pairs.dedup_by(|a, b| a.0 == b.0); - Some(Value::record(pairs.into_iter().collect(), Span::unknown())) - } - id if id == IN_VARIABLE_ID => { - // $in - typically not available at completion time - None - } - _ => { - // User-defined variable - try to get const value first - let var_info = working_set.get_variable(var_id); - if let Some(const_val) = &var_info.const_val { - Some(const_val.clone()) - } else { - // Variable doesn't have a const value (runtime value) - // Try to get the value from the stack (runtime storage) - match stack_guard.get_var(var_id, Span::unknown()) { - Ok(value) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found variable {:?} value in stack", - var_id - ))); - Some(value) - } - Err(_) => { - // Variable not in stack either - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Variable {:?} has no const value and not in stack, type: {:?}", - var_id, var_info.ty - ))); - None - } - } - } - } - } - }; - - // Helper function to extract column/field names from a Value - let get_columns_from_value = |value: &Value| -> Vec<(String, Option)> { - match value { - Value::Record { val, .. } => val - .iter() - .map(|(name, v)| (name.to_string(), Some(v.get_type().to_string()))) - .collect(), - Value::List { vals, .. } => { - // Get common columns from list of records - if let Some(first) = vals.first() { - if let Value::Record { val, .. } = first { - return val - .iter() - .map(|(name, v)| (name.to_string(), Some(v.get_type().to_string()))) - .collect(); - } - } - vec![] - } - _ => vec![], - } - }; - - // Helper function to follow a cell path and get the value at that path - let follow_cell_path = |value: &Value, path: &[String]| -> Option { - let mut current = value.clone(); - for member in path { - match ¤t { - Value::Record { val, .. } => { - current = val.get(member)?.clone(); - } - Value::List { vals, .. } => { - // Try to parse as index or get from first record - if let Ok(idx) = member.parse::() { - current = vals.get(idx)?.clone(); - } else if let Some(first) = vals.first() { - if let Value::Record { val, .. } = first { - current = val.get(member)?.clone(); - } else { - return None; - } - } else { - return None; - } - } - _ => return None, - } - } - Some(current) - }; - - // Helper function to extract closure parameters from input string at cursor position - // We parse the input directly to find closures containing the cursor and extract their parameters - let extract_closure_params = |input: &str, cursor_pos: usize| -> Vec { - let mut params = Vec::new(); - - // Find all closures in the input by looking for {|...| patterns - // We need to find closures that contain the cursor position - let mut brace_stack: Vec = Vec::new(); // Stack of opening brace positions - let mut closures: Vec<(usize, usize, Vec)> = Vec::new(); // (start, end, params) - - let mut i = 0; - let chars: Vec = input.chars().collect(); - - while i < chars.len() { - if chars[i] == '{' { - brace_stack.push(i); - } else if chars[i] == '}' { - if let Some(start) = brace_stack.pop() { - // Check if this is a closure with parameters: {|param| ...} - if start + 1 < chars.len() && chars[start + 1] == '|' { - // Find the parameter list - let param_start = start + 2; - let mut param_end = param_start; - - // Find the closing | of the parameter list - while param_end < chars.len() && chars[param_end] != '|' { - param_end += 1; - } - - if param_end < chars.len() { - // Extract parameter names - let params_text: String = - chars[param_start..param_end].iter().collect(); - let param_names: Vec = params_text - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - closures.push((start, i + 1, param_names)); - } - } - } - } - i += 1; - } - - // Find closures that contain the cursor position - // A closure contains the cursor if: start <= cursor_pos < end - for (start, end, param_names) in closures { - if start <= cursor_pos && cursor_pos < end { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found closure at [{}, {}) containing cursor {}, params: {:?}", - start, end, cursor_pos, param_names - ))); - params.extend(param_names); - } - } - - params - }; - - // Helper function to collect variables from working set - let collect_variables = |working_set: &StateWorkingSet, - input: &str, - cursor_pos: usize| - -> HashMap { - let mut variables = HashMap::new(); - - // Add built-in variables - variables.insert("$nu".to_string(), NU_VARIABLE_ID); - variables.insert("$in".to_string(), IN_VARIABLE_ID); - variables.insert("$env".to_string(), ENV_VARIABLE_ID); - - // Collect closure parameters at cursor position - // We don't need real var_ids for closure parameters since they're not evaluated yet - // We'll use a placeholder var_id (using IN_VARIABLE_ID as a safe placeholder) - // The actual var_id lookup will happen when the variable is used - let closure_params = extract_closure_params(input, cursor_pos); - for param_name in closure_params { - let var_name = format!("${}", param_name); - // Use IN_VARIABLE_ID as placeholder - it's safe since we're just using it for the name - // The completion logic only needs the name, not the actual var_id - variables.insert(var_name.clone(), IN_VARIABLE_ID); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Added closure parameter: {:?}", - var_name - ))); - } - - // Collect from working set delta scope - let mut removed_overlays = vec![]; - for scope_frame in working_set.delta.scope.iter().rev() { - for overlay_frame in scope_frame.active_overlays(&mut removed_overlays).rev() { - for (name, var_id) in &overlay_frame.vars { - let name = String::from_utf8_lossy(name).to_string(); - variables.insert(name, *var_id); - } - } - } - - // Collect from permanent state scope - for overlay_frame in working_set - .permanent_state - .active_overlays(&removed_overlays) - .rev() - { - for (name, var_id) in &overlay_frame.vars { - let name = String::from_utf8_lossy(name).to_string(); - variables.insert(name, *var_id); - } - } - - variables - }; - - // Find what we're completing - #[derive(Debug)] - enum CompletionContext { - Command { - prefix: String, - span: Span, - }, - Argument { - prefix: String, - span: Span, - }, - Flag { - prefix: String, - span: Span, - command_name: String, - }, - CommandArgument { - prefix: String, - span: Span, - command_name: String, - arg_index: usize, - }, - Variable { - prefix: String, // without the $ prefix - span: Span, - }, - CellPath { - prefix: String, // the partial field name being typed (after the last dot) - span: Span, // replacement span - var_id: nu_protocol::VarId, // variable ID for evaluation - path_so_far: Vec, // path members accessed before current one - }, - } - - let mut context: Option = None; - - // Helper function to build full command prefix by looking backwards through shapes - let build_command_prefix = - |current_idx: usize, current_local_span: Span, current_prefix: &str| -> (String, Span) { - let mut span_start = current_local_span.start; - - // Look backwards through shapes to find previous command words - for i in (0..current_idx).rev() { - if let Some((prev_span, prev_shape)) = shapes.get(i) { - let prev_local_span = to_local_span(*prev_span); - - if is_command_shape(prev_shape, prev_local_span) { - // Check if there's a separator between this shape and the next one - let next_shape_start = if i + 1 < shapes.len() { - to_local_span(shapes[i + 1].0).start - } else { - current_local_span.start - }; - - // Check if there's a separator (pipe, semicolon, etc.) between shapes - // Whitespace is fine, but separators indicate a new command - if has_separator_between(prev_local_span.end, next_shape_start) { - break; // Stop at separator - } - - // Update span start to include this command word - span_start = prev_local_span.start; - } else { - // Not a command shape, stop looking backwards - break; - } - } - } - - // Extract the full prefix from the input, preserving exact spacing - let span_end = current_local_span.end; - let full_prefix = if span_start < input.len() { - safe_slice(Span::new(span_start, span_end)) - } else { - current_prefix.to_string() - }; - - (full_prefix, Span::new(span_start, span_end)) - }; - - // Helper function to get command signature (needed for context determination) - let get_command_signature = |cmd_name: &str| -> Option { - engine_guard - .find_decl(cmd_name.as_bytes(), &[]) - .map(|id| engine_guard.get_decl(id).signature()) - }; - - // First, check if cursor is within a shape - for (idx, (span, shape)) in shapes.iter().enumerate() { - let local_span = to_local_span(*span); - - if local_span.start <= byte_pos && byte_pos <= local_span.end { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Cursor in shape {}: {:?} at {:?}", - idx, shape, local_span - ))); - - // Check if there's a pipe or semicolon between this shape's end and the cursor - // If so, we're starting a new command and should ignore this shape - let has_sep = has_separator_between(local_span.end, byte_pos); - if has_sep { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Separator found between shape end ({}) and cursor ({}), skipping shape", - local_span.end, byte_pos - ))); - // There's a separator, so we're starting a new command - skip this shape - continue; - } - - let span = Span::new(local_span.start, std::cmp::min(local_span.end, byte_pos)); - let prefix = safe_slice(span); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Processing shape {} with prefix: {:?}", - idx, prefix - ))); - - // Special case: if prefix is just '{' (possibly with whitespace), - // we're at the start of a block and should complete commands - let trimmed_prefix = prefix.trim(); - if trimmed_prefix == "{" { - // We're right after '{' - command context - if let Some((_, adjusted_span, _)) = handle_block_prefix(&prefix, span) { - context = Some(CompletionContext::Command { - prefix: String::new(), - span: adjusted_span, - }); - } - } else { - match shape { - // Special case: Check if we're completing a cell path where the Variable and field are in separate shapes - // e.g., `$a.na` where $a is a Variable shape and `.na` is a String shape - _ if { idx > 0 && matches!(shape, FlatShape::String) } => { - // Look at the previous shape to see if it's a Variable - let prev_shape = &shapes[idx - 1]; - let prev_local_span = to_local_span(prev_shape.0); - - if let FlatShape::Variable(var_id) = prev_shape.1 { - // Check if the variable shape ends right where this shape starts (or very close) - // Allow for a small gap (like a dot) between shapes - let gap = local_span.start.saturating_sub(prev_local_span.end); - if gap <= 1 { - // This is a cell path - the String shape contains the field name(s) - // The prefix might be like "na" or "field.subfield" - let trimmed_prefix = prefix.trim(); - let parts: Vec<&str> = trimmed_prefix.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if trimmed_prefix.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = - parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = span.end.saturating_sub(prefix_byte_len); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Detected cell path from Variable+String shapes, var_id={:?}, prefix={:?}, path={:?}", - var_id, cell_prefix, path_so_far - ))); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, span.end), - var_id, - path_so_far, - }); - } else { - // Gap between shapes, check if this is a flag - let trimmed_prefix = prefix.trim(); - if trimmed_prefix.starts_with('-') { - // This looks like a flag - find the command - if let Some((cmd_name, _)) = - find_command_and_arg_index(idx, local_span) - { - context = Some(CompletionContext::Flag { - prefix: trimmed_prefix.to_string(), - span, - command_name: cmd_name, - }); - } else { - context = - Some(CompletionContext::Argument { prefix, span }); - } - } else { - context = Some(CompletionContext::Argument { prefix, span }); - } - } - } else { - // Previous shape is not a Variable, check if this is a flag - let trimmed_prefix = prefix.trim(); - if trimmed_prefix.starts_with('-') { - // This looks like a flag - find the command - if let Some((cmd_name, _)) = - find_command_and_arg_index(idx, local_span) - { - context = Some(CompletionContext::Flag { - prefix: trimmed_prefix.to_string(), - span, - command_name: cmd_name, - }); - } else { - context = Some(CompletionContext::Argument { prefix, span }); - } - } else { - // This is likely a regular string argument - context = Some(CompletionContext::Argument { prefix, span }); - } - } - } - // Special case: Check if we're completing a cell path where the Variable and dot are in separate shapes - // e.g., `{ $in. }` where $in is Shape 4 (Variable) and `. }` is Shape 5 (Block) - _ if { - let trimmed_prefix = prefix.trim(); - trimmed_prefix.starts_with('.') && idx > 0 - } => - { - // Look at the previous shape to see if it's a Variable - let prev_shape = &shapes[idx - 1]; - let prev_local_span = to_local_span(prev_shape.0); - - if let FlatShape::Variable(var_id) = prev_shape.1 { - // Check if the variable shape ends right where this shape starts - if prev_local_span.end == local_span.start { - let trimmed_prefix = prefix.trim(); - // Parse path members from the prefix (which is like ".field" or ".field.subfield") - let after_dot = &trimmed_prefix[1..]; // Remove leading dot - let parts: Vec<&str> = after_dot.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() - || (parts.len() == 1 && parts[0].is_empty()) - { - (vec![], String::new()) - } else if after_dot.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = - parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = span.end.saturating_sub(prefix_byte_len); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Detected cell path from adjacent Variable shape, var_id={:?}, prefix={:?}", - var_id, cell_prefix - ))); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, span.end), - var_id, - path_so_far, - }); - } else { - // Gap between shapes, fall through to default handling - context = Some(CompletionContext::Argument { prefix, span }); - } - } else { - // Previous shape is not a Variable, this is likely a file path starting with . - context = Some(CompletionContext::Argument { prefix, span }); - } - } - _ if { - // Check if this is a variable or cell path (starts with $) before treating as command - let trimmed_prefix = prefix.trim(); - trimmed_prefix.starts_with('$') - } => - { - let trimmed_prefix = prefix.trim(); - // Check if this is a cell path (contains a dot after $) - if let Some(dot_pos) = trimmed_prefix[1..].find('.') { - // Cell path completion: $env.PWD, $nu.home-path, etc. - let var_name = &trimmed_prefix[1..dot_pos + 1]; // e.g., "env" - let after_var = &trimmed_prefix[dot_pos + 2..]; // e.g., "PWD" or "config.color" - - // Parse path members and current prefix - let parts: Vec<&str> = after_var.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if after_var.ends_with('.') { - // Cursor is right after a dot, complete all fields - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - // Cursor is in the middle of typing a field name - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = - parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - // Find the variable ID - let var_id = match var_name { - "env" => Some(ENV_VARIABLE_ID), - "nu" => Some(NU_VARIABLE_ID), - "in" => Some(IN_VARIABLE_ID), - _ => { - // Try to find user-defined variable - working_set.find_variable(var_name.as_bytes()) - } - }; - - if let Some(var_id) = var_id { - // Calculate span for the cell path member being completed - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = span.end.saturating_sub(prefix_byte_len); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, span.end), - var_id, - path_so_far, - }); - } else { - // Unknown variable, fall back to variable completion - let var_prefix = trimmed_prefix[1..].to_string(); - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span, - }); - } - } else { - // Variable completion context (no dot) - let var_prefix = if trimmed_prefix.len() > 1 { - trimmed_prefix[1..].to_string() - } else { - String::new() - }; - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span, - }); - } - } - _ if is_command_shape(shape, local_span) => { - let (full_prefix, full_span) = build_command_prefix(idx, span, &prefix); - context = Some(CompletionContext::Command { - prefix: full_prefix, - span: full_span, - }); - } - FlatShape::Block | FlatShape::Closure => { - if let Some(ctx) = handle_block_or_closure( - &prefix, - span, - shape.as_str().trim_start_matches("shape_"), - idx, - local_span, - ) { - context = Some(ctx); - } - } - FlatShape::Variable(var_id) => { - // Variable or cell path completion context - let trimmed_prefix = prefix.trim(); - if trimmed_prefix.starts_with('$') { - // Check if this is a cell path (contains a dot after $) - if let Some(dot_pos) = trimmed_prefix[1..].find('.') { - // Cell path completion - let after_var = &trimmed_prefix[dot_pos + 2..]; - let parts: Vec<&str> = after_var.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if after_var.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = - parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = span.end.saturating_sub(prefix_byte_len); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, span.end), - var_id: *var_id, - path_so_far, - }); - } else { - // Simple variable completion - let var_prefix = trimmed_prefix[1..].to_string(); - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span, - }); - } - } else { - // Fallback to argument context if no $ found - context = Some(CompletionContext::Argument { prefix, span }); - } - } - _ => { - // Check if this is a variable or cell path (starts with $) - let trimmed_prefix = prefix.trim(); - if trimmed_prefix.starts_with('$') { - // Check if this is a cell path (contains a dot after $) - if let Some(dot_pos) = trimmed_prefix[1..].find('.') { - // Cell path completion - let var_name = &trimmed_prefix[1..dot_pos + 1]; - let after_var = &trimmed_prefix[dot_pos + 2..]; - let parts: Vec<&str> = after_var.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if after_var.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = - parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let var_id = match var_name { - "env" => Some(ENV_VARIABLE_ID), - "nu" => Some(NU_VARIABLE_ID), - "in" => Some(IN_VARIABLE_ID), - _ => working_set.find_variable(var_name.as_bytes()), - }; - - if let Some(var_id) = var_id { - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = span.end.saturating_sub(prefix_byte_len); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, span.end), - var_id, - path_so_far, - }); - } else { - let var_prefix = trimmed_prefix[1..].to_string(); - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span, - }); - } - } else { - // Simple variable completion - let var_prefix = if trimmed_prefix.len() > 1 { - trimmed_prefix[1..].to_string() - } else { - String::new() - }; - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span, - }); - } - } else if trimmed_prefix.starts_with('-') { - // This looks like a flag - find the command - if let Some((cmd_name, _)) = find_command_and_arg_index(idx, local_span) - { - context = Some(CompletionContext::Flag { - prefix: trimmed_prefix.to_string(), - span, - command_name: cmd_name, - }); - } else { - context = Some(CompletionContext::Argument { prefix, span }); - } - } else { - // This is a positional argument - find the command and argument index - if let Some((cmd_name, arg_index)) = - find_command_and_arg_index(idx, local_span) - { - context = Some(CompletionContext::CommandArgument { - prefix: trimmed_prefix.to_string(), - span, - command_name: cmd_name, - arg_index, - }); - } else { - context = Some(CompletionContext::Argument { prefix, span }); - } - } - } - } - } - break; - } - } - - // If not in a shape, check what comes before the cursor - if context.is_none() { - web_sys::console::log_1(&JsValue::from_str( - "[completion] Context is None, entering fallback logic", - )); - // Check if there's a command-like shape before us - let mut found_command_before = false; - let mut has_separator_after_command = false; - for (span, shape) in shapes.iter().rev() { - let local_span = to_local_span(*span); - if local_span.end <= byte_pos { - if is_command_shape(shape, local_span) { - // Check if there's a pipe or semicolon between this command and the cursor - has_separator_after_command = has_separator_between(local_span.end, byte_pos); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found command shape {:?} at {:?}, has_separator_after_command={}", - shape, local_span, has_separator_after_command - ))); - if !has_separator_after_command { - found_command_before = true; - - // Extract the command text - let cmd = safe_slice(local_span); - let cmd_name = cmd.split_whitespace().next().unwrap_or(&cmd).trim(); - - // Check if we're right after the command (only whitespace between command and cursor) - let text_after_command = if local_span.end < input.len() { - &input[local_span.end..byte_pos] - } else { - "" - }; - let is_right_after_command = text_after_command.trim().is_empty(); - - // If we're right after a command, check if it has positional arguments - if is_right_after_command { - if let Some(signature) = get_command_signature(cmd_name) { - // Check if command has any positional arguments - let has_positional_args = !signature.required_positional.is_empty() - || !signature.optional_positional.is_empty(); - - if has_positional_args { - // Count existing arguments before cursor - let mut arg_count = 0; - for (prev_span, prev_shape) in shapes.iter().rev() { - let prev_local_span = to_local_span(*prev_span); - if prev_local_span.end <= byte_pos - && prev_local_span.end > local_span.end - { - if !is_command_shape(prev_shape, prev_local_span) { - let arg_text = safe_slice(prev_local_span); - let trimmed_arg = arg_text.trim(); - // Don't count flags (starting with -) or empty arguments - if !trimmed_arg.is_empty() - && !trimmed_arg.starts_with('-') - { - arg_count += 1; - } - } - } - } - - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Right after command {:?}, setting CommandArgument context with arg_index: {}", - cmd_name, arg_count - ))); - - context = Some(CompletionContext::CommandArgument { - prefix: String::new(), - span: Span::new(byte_pos, byte_pos), - command_name: cmd_name.to_string(), - arg_index: arg_count, - }); - } else { - // No positional arguments, don't show any completions - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Command {:?} has no positional args, not showing completions", - cmd_name - ))); - // Leave context as None to show no completions - } - } else { - // Couldn't find signature, don't show completions - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Could not find signature for {:?}, not showing completions", - cmd_name - ))); - // Leave context as None to show no completions - } - } else { - // Not right after command, complete the command itself - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Command context with prefix: {:?}", - cmd - ))); - context = Some(CompletionContext::Command { - prefix: cmd, - span: local_span, - }); - } - } - } - break; - } - } - - if !found_command_before { - web_sys::console::log_1(&JsValue::from_str( - "[completion] No command found before cursor, checking tokens", - )); - // No command before, check context from tokens - let (tokens, _) = lex(input.as_bytes(), 0, &[], &[], true); - let last_token = tokens.iter().filter(|t| t.span.end <= byte_pos).last(); - - let is_cmd_context = if let Some(token) = last_token { - let matches = matches!( - token.contents, - TokenContents::Pipe - | TokenContents::PipePipe - | TokenContents::Semicolon - | TokenContents::Eol - ); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Last token: {:?}, is_cmd_context from token={}", - token.contents, matches - ))); - matches - } else { - web_sys::console::log_1(&JsValue::from_str( - "[completion] No last token found, assuming start of input (is_cmd_context=true)", - )); - true // Start of input - }; - - // Look for the last non-whitespace token before cursor - let text_before = &input[..byte_pos]; - - // Also check if we're inside a block - if the last non-whitespace char before cursor is '{' - let text_before_trimmed = text_before.trim_end(); - let is_inside_block = text_before_trimmed.ends_with('{'); - // If we found a separator after a command, we're starting a new command - let is_cmd_context = is_cmd_context || is_inside_block || has_separator_after_command; - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] is_inside_block={}, has_separator_after_command={}, final is_cmd_context={}", - is_inside_block, has_separator_after_command, is_cmd_context - ))); - - // Find the last word before cursor - let last_word_start = text_before - .rfind(|c: char| c.is_whitespace() || is_separator_char(c)) - .map(|i| i + 1) - .unwrap_or(0); - - let last_word = text_before[last_word_start..].trim_start(); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] last_word_start={}, last_word={:?}", - last_word_start, last_word - ))); - - if is_cmd_context { - context = Some(CompletionContext::Command { - prefix: last_word.to_string(), - span: Span::new(last_word_start, byte_pos), - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Command context with prefix: {:?}", - last_word - ))); - } else { - // Check if this is a variable or cell path (starts with $) - let trimmed_word = last_word.trim(); - if trimmed_word.starts_with('$') { - // Check if this is a cell path (contains a dot after $) - if let Some(dot_pos) = trimmed_word[1..].find('.') { - // Cell path completion - let var_name = &trimmed_word[1..dot_pos + 1]; - let after_var = &trimmed_word[dot_pos + 2..]; - let parts: Vec<&str> = after_var.split('.').collect(); - let (path_so_far, cell_prefix) = if parts.is_empty() { - (vec![], String::new()) - } else if after_var.ends_with('.') { - ( - parts - .iter() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(), - String::new(), - ) - } else { - let path: Vec = parts[..parts.len().saturating_sub(1)] - .iter() - .map(|s| s.to_string()) - .collect(); - let prefix = parts.last().map(|s| s.to_string()).unwrap_or_default(); - (path, prefix) - }; - - let var_id = match var_name { - "env" => Some(ENV_VARIABLE_ID), - "nu" => Some(NU_VARIABLE_ID), - "in" => Some(IN_VARIABLE_ID), - _ => working_set.find_variable(var_name.as_bytes()), - }; - - if let Some(var_id) = var_id { - let prefix_byte_len = cell_prefix.len(); - let cell_span_start = byte_pos.saturating_sub(prefix_byte_len); - let cell_prefix_clone = cell_prefix.clone(); - context = Some(CompletionContext::CellPath { - prefix: cell_prefix, - span: Span::new(cell_span_start, byte_pos), - var_id, - path_so_far, - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set CellPath context with prefix: {:?}", - cell_prefix_clone - ))); - } else { - let var_prefix = trimmed_word[1..].to_string(); - let var_prefix_clone = var_prefix.clone(); - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span: Span::new(last_word_start, byte_pos), - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Variable context with prefix: {:?}", - var_prefix_clone - ))); - } - } else { - // Simple variable completion - let var_prefix = trimmed_word[1..].to_string(); - let var_prefix_clone = var_prefix.clone(); - context = Some(CompletionContext::Variable { - prefix: var_prefix, - span: Span::new(last_word_start, byte_pos), - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Variable context with prefix: {:?}", - var_prefix_clone - ))); - } - } else if trimmed_word.starts_with('-') { - // Try to find command by looking backwards through shapes - let mut found_cmd = None; - for (span, shape) in shapes.iter().rev() { - let local_span = to_local_span(*span); - if local_span.end <= byte_pos && is_command_shape(shape, local_span) { - let cmd_text = safe_slice(local_span); - let cmd_name = cmd_text - .split_whitespace() - .next() - .unwrap_or(&cmd_text) - .trim(); - found_cmd = Some(cmd_name.to_string()); - break; - } - } - if let Some(cmd_name) = found_cmd { - let cmd_name_clone = cmd_name.clone(); - context = Some(CompletionContext::Flag { - prefix: trimmed_word.to_string(), - span: Span::new(last_word_start, byte_pos), - command_name: cmd_name, - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Flag context with prefix: {:?}, command: {:?}", - trimmed_word, cmd_name_clone - ))); - } else { - context = Some(CompletionContext::Argument { - prefix: last_word.to_string(), - span: Span::new(last_word_start, byte_pos), - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Argument context with prefix: {:?}", - last_word - ))); - } - } else { - // Try to find command and argument index - let mut found_cmd = None; - let mut arg_count = 0; - for (span, shape) in shapes.iter().rev() { - let local_span = to_local_span(*span); - if local_span.end <= byte_pos { - if is_command_shape(shape, local_span) { - let cmd_text = safe_slice(local_span); - let cmd_name = cmd_text - .split_whitespace() - .next() - .unwrap_or(&cmd_text) - .trim(); - found_cmd = Some(cmd_name.to_string()); - break; - } else { - let arg_text = safe_slice(local_span); - let trimmed_arg = arg_text.trim(); - if !trimmed_arg.is_empty() && !trimmed_arg.starts_with('-') { - arg_count += 1; - } - } - } - } - if let Some(cmd_name) = found_cmd { - let cmd_name_clone = cmd_name.clone(); - context = Some(CompletionContext::CommandArgument { - prefix: trimmed_word.to_string(), - span: Span::new(last_word_start, byte_pos), - command_name: cmd_name, - arg_index: arg_count, - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set CommandArgument context with prefix: {:?}, command: {:?}, arg_index: {}", - trimmed_word, cmd_name_clone, arg_count - ))); - } else { - context = Some(CompletionContext::Argument { - prefix: last_word.to_string(), - span: Span::new(last_word_start, byte_pos), - }); - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Set Argument context with prefix: {:?}", - last_word - ))); - } - } - } - } - } - - web_sys::console::log_1(&JsValue::from_str(&format!("context: {:?}", context))); - - let mut suggestions: Vec = Vec::new(); - - // Convert byte-spans back to char-spans for JS - let to_char_span = |span: Span| -> Span { - let char_start = input[..span.start].chars().count(); - let char_end = input[..span.end].chars().count(); - Span::new(char_start, char_end) - }; - - match context { - Some(CompletionContext::Command { prefix, span }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating Command suggestions with prefix: {:?}", - prefix - ))); - // Command completion - let cmds = working_set - .find_commands_by_predicate(|value| value.starts_with(prefix.as_bytes()), true); - - let span = to_char_span(span); - let mut cmd_count = 0; - - for (_, name, desc, _) in cmds { - let name_str = String::from_utf8_lossy(&name).to_string(); - suggestions.push(Suggestion { - rendered: { - let name_colored = ansi_term::Color::Green.bold().paint(&name_str); - let desc_str = desc.as_deref().unwrap_or(""); - format!("{name_colored} {desc_str}") - }, - name: name_str, - description: desc, - is_command: true, - span_start: span.start, - span_end: span.end, - }); - cmd_count += 1; - } - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} command suggestions", - cmd_count - ))); - } - Some(CompletionContext::Argument { prefix, span }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating Argument suggestions with prefix: {:?}", - prefix - ))); - // File completion - let (dir, file_prefix) = prefix - .rfind('/') - .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) - .unwrap_or(("", prefix.as_str())); - - let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) - .then(|| &dir[..dir.len() - 1]) - .unwrap_or(dir); - - let target_dir = if !dir.is_empty() { - match root.join(dir_to_join) { - Ok(d) if d.is_dir().unwrap_or(false) => Some(d), - _ => None, - } - } else { - Some(root.join("").unwrap()) - }; - - let mut file_count = 0; - if let Some(d) = target_dir { - if let Ok(iterator) = d.read_dir() { - let span = to_char_span(span); - - for entry in iterator { - let name = entry.filename(); - if name.starts_with(file_prefix) { - let full_completion = format!("{}{}", dir, name); - suggestions.push(Suggestion { - name: full_completion.clone(), - description: None, - is_command: false, - rendered: full_completion, - span_start: span.start, - span_end: span.end, - }); - file_count += 1; - } - } - } - } - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} file suggestions", - file_count - ))); - } - Some(CompletionContext::Flag { - prefix, - span, - command_name, - }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating Flag suggestions for command: {:?}, prefix: {:?}", - command_name, prefix - ))); - - if let Some(signature) = get_command_signature(&command_name) { - let span = to_char_span(span); - let mut flag_count = 0; - - // Get switches from signature - // Signature has a named field that contains named arguments (including switches) - for flag in &signature.named { - // Check if this is a switch (has no argument) - // Switches have arg: None, named arguments have arg: Some(SyntaxShape) - let is_switch = flag.arg.is_none(); - - if is_switch { - let long_name = format!("--{}", flag.long); - let short_name = flag.short.map(|c| format!("-{}", c)); - - // Determine which flags to show based on prefix: - // - If prefix is empty or exactly "-", show all flags (both short and long) - // - If prefix starts with "--", only show long flags that match the prefix - // - If prefix starts with "-" (but not "--"), only show short flags that match the prefix - let show_all = prefix.is_empty() || prefix == "-"; - - // Helper to create a flag suggestion - let create_flag_suggestion = |flag_name: String| -> Suggestion { - Suggestion { - name: flag_name.clone(), - description: Some(flag.desc.clone()), - is_command: false, - rendered: { - let flag_colored = - ansi_term::Color::Cyan.bold().paint(&flag_name); - format!("{flag_colored} {}", flag.desc) - }, - span_start: span.start, - span_end: span.end, - } - }; - - // Add long flag if it matches - let should_show_long = if show_all { - true // Show all flags when prefix is "-" or empty - } else if prefix.starts_with("--") { - long_name.starts_with(&prefix) // Only show long flags matching prefix - } else { - false // Don't show long flags if prefix is short flag format - }; - - if should_show_long { - suggestions.push(create_flag_suggestion(long_name)); - flag_count += 1; - } - - // Add short flag if it matches - if let Some(short) = &short_name { - let should_show_short = if show_all { - true // Show all flags when prefix is "-" or empty - } else if prefix.starts_with("-") && !prefix.starts_with("--") { - short.starts_with(&prefix) // Only show short flags matching prefix - } else { - false // Don't show short flags if prefix is long flag format - }; - - if should_show_short { - suggestions.push(create_flag_suggestion(short.clone())); - flag_count += 1; - } - } - } - } - - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} flag suggestions", - flag_count - ))); - } else { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Could not find signature for command: {:?}", - command_name - ))); - } - } - Some(CompletionContext::CommandArgument { - prefix, - span, - command_name, - arg_index, - }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating CommandArgument suggestions for command: {:?}, arg_index: {}, prefix: {:?}", - command_name, arg_index, prefix - ))); - - if let Some(signature) = get_command_signature(&command_name) { - // Get positional arguments from signature - // Combine required and optional positional arguments - let mut all_positional = Vec::new(); - all_positional.extend_from_slice(&signature.required_positional); - all_positional.extend_from_slice(&signature.optional_positional); - - // Find the argument at the given index - if let Some(arg) = all_positional.get(arg_index) { - // Check the SyntaxShape to determine completion type - match &arg.shape { - nu_protocol::SyntaxShape::String | nu_protocol::SyntaxShape::Filepath => { - // File/directory completion - let (dir, file_prefix) = prefix - .rfind('/') - .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) - .unwrap_or(("", prefix.as_str())); - - let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) - .then(|| &dir[..dir.len() - 1]) - .unwrap_or(dir); - - let target_dir = if !dir.is_empty() { - match root.join(dir_to_join) { - Ok(d) if d.is_dir().unwrap_or(false) => Some(d), - _ => None, - } - } else { - Some(root.join("").unwrap()) - }; - - let span = to_char_span(span); - let mut file_count = 0; - if let Some(d) = target_dir { - if let Ok(iterator) = d.read_dir() { - for entry in iterator { - let name = entry.filename(); - if name.starts_with(file_prefix) { - let full_completion = format!("{}{}", dir, name); - suggestions.push(Suggestion { - name: full_completion.clone(), - description: Some(arg.desc.clone()), - is_command: false, - rendered: full_completion, - span_start: span.start, - span_end: span.end, - }); - file_count += 1; - } - } - } - } - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} file suggestions for argument {}", - file_count, arg_index - ))); - } - _ => { - // For other types, fall back to file completion - let (dir, file_prefix) = prefix - .rfind('/') - .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) - .unwrap_or(("", prefix.as_str())); - - let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) - .then(|| &dir[..dir.len() - 1]) - .unwrap_or(dir); - - let target_dir = if !dir.is_empty() { - match root.join(dir_to_join) { - Ok(d) if d.is_dir().unwrap_or(false) => Some(d), - _ => None, - } - } else { - Some(root.join("").unwrap()) - }; - - let span = to_char_span(span); - if let Some(d) = target_dir { - if let Ok(iterator) = d.read_dir() { - for entry in iterator { - let name = entry.filename(); - if name.starts_with(file_prefix) { - let full_completion = format!("{}{}", dir, name); - suggestions.push(Suggestion { - name: full_completion.clone(), - description: Some(arg.desc.clone()), - is_command: false, - rendered: full_completion, - span_start: span.start, - span_end: span.end, - }); - } - } - } - } - } - } - } else { - // Argument index out of range, fall back to file completion - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Argument index {} out of range, using file completion", - arg_index - ))); - // Use the same file completion logic as Argument context - let (dir, file_prefix) = prefix - .rfind('/') - .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) - .unwrap_or(("", prefix.as_str())); - - let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) - .then(|| &dir[..dir.len() - 1]) - .unwrap_or(dir); - - let target_dir = if !dir.is_empty() { - match root.join(dir_to_join) { - Ok(d) if d.is_dir().unwrap_or(false) => Some(d), - _ => None, - } - } else { - Some(root.join("").unwrap()) - }; - - let span = to_char_span(span); - if let Some(d) = target_dir { - if let Ok(iterator) = d.read_dir() { - for entry in iterator { - let name = entry.filename(); - if name.starts_with(file_prefix) { - let full_completion = format!("{}{}", dir, name); - suggestions.push(Suggestion { - name: full_completion.clone(), - description: None, - is_command: false, - rendered: full_completion, - span_start: span.start, - span_end: span.end, - }); - } - } - } - } - } - } else { - // No signature found, fall back to file completion - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Could not find signature for command: {:?}, using file completion", - command_name - ))); - let (dir, file_prefix) = prefix - .rfind('/') - .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) - .unwrap_or(("", prefix.as_str())); - - let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) - .then(|| &dir[..dir.len() - 1]) - .unwrap_or(dir); - - let target_dir = if !dir.is_empty() { - match root.join(dir_to_join) { - Ok(d) if d.is_dir().unwrap_or(false) => Some(d), - _ => None, - } - } else { - Some(root.join("").unwrap()) - }; - - let span = to_char_span(span); - if let Some(d) = target_dir { - if let Ok(iterator) = d.read_dir() { - for entry in iterator { - let name = entry.filename(); - if name.starts_with(file_prefix) { - let full_completion = format!("{}{}", dir, name); - suggestions.push(Suggestion { - name: full_completion.clone(), - description: None, - is_command: false, - rendered: full_completion, - span_start: span.start, - span_end: span.end, - }); - } - } - } - } - } - } - Some(CompletionContext::Variable { prefix, span }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating Variable suggestions with prefix: {:?}", - prefix - ))); - - // Collect all available variables - let variables = collect_variables(&working_set, &input, byte_pos); - let span = to_char_span(span); - let mut var_count = 0; - - for (var_name, var_id) in variables { - // Filter by prefix (variable name includes $, so we need to check after $) - if var_name.len() > 1 && var_name[1..].starts_with(&prefix) { - // Get variable type - let var_type = working_set.get_variable(var_id).ty.to_string(); - - suggestions.push(Suggestion { - name: var_name.clone(), - description: Some(var_type.clone()), - is_command: false, - rendered: { - let var_colored = ansi_term::Color::Blue.bold().paint(&var_name); - format!("{var_colored} {var_type}") - }, - span_start: span.start, - span_end: span.end, - }); - var_count += 1; - } - } - - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} variable suggestions", - var_count - ))); - } - Some(CompletionContext::CellPath { - prefix, - span, - var_id, - path_so_far, - }) => { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Generating CellPath suggestions with prefix: {:?}, path: {:?}", - prefix, path_so_far - ))); - - // Evaluate the variable to get its value - if let Some(var_value) = eval_variable_for_completion(var_id, &working_set) { - // Follow the path to get the value at the current level - let current_value = if path_so_far.is_empty() { - var_value - } else { - follow_cell_path(&var_value, &path_so_far).unwrap_or(var_value) - }; - - // Get columns/fields from the current value - let columns = get_columns_from_value(¤t_value); - let span = to_char_span(span); - let mut field_count = 0; - - for (col_name, col_type) in columns { - // Filter by prefix - if col_name.starts_with(&prefix) { - let type_str = col_type.as_deref().unwrap_or("any"); - suggestions.push(Suggestion { - name: col_name.clone(), - description: Some(type_str.to_string()), - is_command: false, - rendered: { - let col_colored = ansi_term::Color::Yellow.paint(&col_name); - format!("{col_colored} {type_str}") - }, - span_start: span.start, - span_end: span.end, - }); - field_count += 1; - } - } - - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Found {} cell path suggestions", - field_count - ))); - } else { - // Variable couldn't be evaluated - this is expected for runtime variables - // We can't provide cell path completions without knowing the structure - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Could not evaluate variable {:?} for cell path completion (runtime variable)", - var_id - ))); - - // Try to get type information to provide better feedback - if let Ok(var_info) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - working_set.get_variable(var_id) - })) { - web_sys::console::log_1(&JsValue::from_str(&format!( - "[completion] Variable type: {:?}", - var_info.ty - ))); - } - } - } - _ => { - web_sys::console::log_1(&JsValue::from_str( - "[completion] Context is None, no suggestions generated", - )); - } - } - - drop(working_set); - drop(engine_guard); - - suggestions.sort(); - let suggestions = serde_json::to_string(&suggestions).unwrap_or_else(|_| "[]".to_string()); - web_sys::console::log_1(&JsValue::from_str(&suggestions)); - suggestions -} diff --git a/src/completion/context.rs b/src/completion/context.rs new file mode 100644 index 0000000..02a07e6 --- /dev/null +++ b/src/completion/context.rs @@ -0,0 +1,949 @@ +use crate::completion::helpers::*; +use crate::completion::types::CompletionContext; +use crate::console_log; +use nu_parser::FlatShape; +use nu_protocol::engine::{EngineState, StateWorkingSet}; +use nu_protocol::{Signature, Span}; + +pub fn find_command_and_arg_index( + input: &str, + shapes: &[(Span, FlatShape)], + current_idx: usize, + current_local_span: Span, + global_offset: usize, +) -> Option<(String, usize)> { + let mut command_name: Option = None; + let mut arg_count = 0; + + // Look backwards through shapes to find the command + for i in (0..current_idx).rev() { + if let Some((prev_span, prev_shape)) = shapes.get(i) { + let prev_local_span = to_local_span(*prev_span, global_offset); + + // Check if there's a separator between this shape and the next one + let next_shape_start = if i + 1 < shapes.len() { + to_local_span(shapes[i + 1].0, global_offset).start + } else { + current_local_span.start + }; + + if has_separator_between(input, prev_local_span.end, next_shape_start) { + break; // Stop at separator + } + + if is_command_shape(input, prev_shape, prev_local_span) { + // Found the command + let cmd_text = safe_slice(input, prev_local_span); + let cmd_name = extract_command_name(cmd_text); + command_name = Some(cmd_name.to_string()); + break; + } else { + // This is an argument - count it if it's not a flag + let arg_text = safe_slice(input, prev_local_span); + let trimmed_arg = arg_text.trim(); + // Don't count flags (starting with -) or empty arguments + if !trimmed_arg.is_empty() && !trimmed_arg.starts_with('-') { + arg_count += 1; + } + } + } + } + + command_name.map(|name| (name, arg_count)) +} + +pub fn build_command_prefix( + input: &str, + shapes: &[(Span, FlatShape)], + current_idx: usize, + current_local_span: Span, + current_prefix: &str, + global_offset: usize, +) -> (String, Span) { + let mut span_start = current_local_span.start; + + // Look backwards through shapes to find previous command words + for i in (0..current_idx).rev() { + if let Some((prev_span, prev_shape)) = shapes.get(i) { + let prev_local_span = to_local_span(*prev_span, global_offset); + + if is_command_shape(input, prev_shape, prev_local_span) { + // Check if there's a separator between this shape and the next one + let next_shape_start = if i + 1 < shapes.len() { + to_local_span(shapes[i + 1].0, global_offset).start + } else { + current_local_span.start + }; + + // Check if there's a separator (pipe, semicolon, etc.) between shapes + // Whitespace is fine, but separators indicate a new command + if has_separator_between(input, prev_local_span.end, next_shape_start) { + break; // Stop at separator + } + + // Update span start to include this command word + span_start = prev_local_span.start; + } else { + // Not a command shape, stop looking backwards + break; + } + } + } + + // Extract the full prefix from the input, preserving exact spacing + let span_end = current_local_span.end; + let full_prefix = if span_start < input.len() { + safe_slice(input, Span::new(span_start, span_end)).to_string() + } else { + current_prefix.to_string() + }; + + (full_prefix, Span::new(span_start, span_end)) +} + +pub fn get_command_signature(engine_guard: &EngineState, cmd_name: &str) -> Option { + engine_guard + .find_decl(cmd_name.as_bytes(), &[]) + .map(|id| engine_guard.get_decl(id).signature()) +} + +pub fn determine_flag_or_argument_context( + input: &str, + shapes: &[(Span, FlatShape)], + prefix: &str, + idx: usize, + local_span: Span, + span: Span, + global_offset: usize, +) -> CompletionContext { + let trimmed_prefix = prefix.trim(); + if trimmed_prefix.starts_with('-') { + // This looks like a flag - find the command + if let Some((cmd_name, _)) = + find_command_and_arg_index(input, shapes, idx, local_span, global_offset) + { + CompletionContext::Flag { + prefix: trimmed_prefix.to_string(), + span, + command_name: cmd_name, + } + } else { + CompletionContext::Argument { + prefix: prefix.to_string(), + span, + } + } + } else { + // This is a positional argument - find the command and argument index + if let Some((cmd_name, arg_index)) = + find_command_and_arg_index(input, shapes, idx, local_span, global_offset) + { + CompletionContext::CommandArgument { + prefix: trimmed_prefix.to_string(), + span, + command_name: cmd_name, + arg_index, + } + } else { + CompletionContext::Argument { + prefix: prefix.to_string(), + span, + } + } + } +} + +pub fn handle_block_or_closure( + input: &str, + shapes: &[(Span, FlatShape)], + working_set: &StateWorkingSet, + prefix: &str, + span: Span, + shape_name: &str, + current_idx: usize, + local_span: Span, + global_offset: usize, +) -> Option { + console_log!("[completion] Processing {shape_name} shape with prefix: {prefix:?}"); + + // Check if the content ends with a pipe or semicolon + let prefix_ends_with_separator = ends_with_separator(prefix); + let last_sep_pos_in_prefix = if prefix_ends_with_separator { + find_last_separator_pos(prefix) + } else { + None + }; + console_log!( + "[completion] {shape_name}: prefix_ends_with_separator={prefix_ends_with_separator}, last_sep_pos_in_prefix={last_sep_pos_in_prefix:?}" + ); + + if let Some((trimmed_prefix, adjusted_span, is_empty)) = handle_block_prefix(prefix, span) { + console_log!( + "[completion] {shape_name}: trimmed_prefix={trimmed_prefix:?}, is_empty={is_empty}" + ); + + if is_empty { + // Empty block/closure or just whitespace - command context + console_log!("[completion] {shape_name} is empty, setting Command context"); + Some(CompletionContext::Command { + prefix: String::new(), + span: adjusted_span, + }) + } else if let Some(last_sep_pos) = last_sep_pos_in_prefix { + // After a separator - command context + let after_sep = prefix[last_sep_pos..].trim_start(); + console_log!( + "[completion] {shape_name} has separator at {last_sep_pos}, after_sep={after_sep:?}, setting Command context" + ); + Some(CompletionContext::Command { + prefix: after_sep.to_string(), + span: Span::new(span.start + last_sep_pos, span.end), + }) + } else { + console_log!( + "[completion] {shape_name} has no separator, checking for variable/flag/argument context" + ); + // Check if this is a variable or cell path first + let trimmed = trimmed_prefix.trim(); + + if trimmed.starts_with('$') { + // Variable or cell path completion + if let Some((var_name, path_so_far, cell_prefix)) = parse_cell_path(trimmed) { + let var_id = lookup_variable_id(var_name, working_set); + + if let Some(var_id) = var_id { + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = adjusted_span.end.saturating_sub(prefix_byte_len); + console_log!( + "[completion] {shape_name}: Setting CellPath context with var {var_name:?}, prefix {cell_prefix:?}" + ); + Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, adjusted_span.end), + var_id, + path_so_far: path_so_far.iter().map(|s| s.to_string()).collect(), + }) + } else { + // Unknown variable, fall back to variable completion + let var_prefix = trimmed[1..].to_string(); + console_log!( + "[completion] {shape_name}: Unknown var, setting Variable context with prefix {var_prefix:?}" + ); + Some(CompletionContext::Variable { + prefix: var_prefix, + span: adjusted_span, + }) + } + } else { + // Simple variable completion (no dot) + let var_prefix = if trimmed.len() > 1 { + trimmed[1..].to_string() + } else { + String::new() + }; + console_log!( + "[completion] {shape_name}: Setting Variable context with prefix {var_prefix:?}" + ); + Some(CompletionContext::Variable { + prefix: var_prefix, + span: adjusted_span, + }) + } + } else if trimmed.starts_with('-') { + // Flag completion + if let Some((cmd_name, _)) = find_command_and_arg_index( + input, + shapes, + current_idx, + local_span, + global_offset, + ) { + console_log!( + "[completion] {shape_name}: Found command {cmd_name:?} for flag completion" + ); + Some(CompletionContext::Flag { + prefix: trimmed.to_string(), + span: adjusted_span, + command_name: cmd_name, + }) + } else { + Some(CompletionContext::Argument { + prefix: trimmed_prefix.to_string(), + span: adjusted_span, + }) + } + } else { + // Try to find the command and argument index + if let Some((cmd_name, arg_index)) = find_command_and_arg_index( + input, + shapes, + current_idx, + local_span, + global_offset, + ) { + console_log!( + "[completion] {shape_name}: Found command {cmd_name:?} with arg_index {arg_index} for argument completion" + ); + Some(CompletionContext::CommandArgument { + prefix: trimmed.to_string(), + span: adjusted_span, + command_name: cmd_name, + arg_index, + }) + } else { + // No command found, treat as regular argument + console_log!( + "[completion] {shape_name}: No command found, using Argument context" + ); + Some(CompletionContext::Argument { + prefix: trimmed_prefix.to_string(), + span: adjusted_span, + }) + } + } + } + } else { + None + } +} + +pub fn handle_variable_string_shape( + input: &str, + shapes: &[(Span, FlatShape)], + _working_set: &StateWorkingSet, + idx: usize, + prefix: &str, + span: Span, + local_span: Span, + global_offset: usize, +) -> Option { + if idx == 0 { + return None; + } + + let prev_shape = &shapes[idx - 1]; + let prev_local_span = to_local_span(prev_shape.0, global_offset); + + if let FlatShape::Variable(var_id) = prev_shape.1 { + // Check if the variable shape ends right where this shape starts (or very close) + // Allow for a small gap (like a dot) between shapes + let gap = local_span.start.saturating_sub(prev_local_span.end); + if gap <= 1 { + // This is a cell path - the String shape contains the field name(s) + // The prefix might be like "na" or "field.subfield" + let trimmed_prefix = prefix.trim(); + let (path_so_far, cell_prefix) = parse_cell_path_from_fields(trimmed_prefix); + + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = span.end.saturating_sub(prefix_byte_len); + console_log!( + "[completion] Detected cell path from Variable+String shapes, var_id={var_id:?}, prefix={cell_prefix:?}, path={path_so_far:?}" + ); + Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, span.end), + var_id, + path_so_far: path_so_far.iter().map(|s| s.to_string()).collect(), + }) + } else { + // Gap between shapes, use helper to determine context + Some(determine_flag_or_argument_context( + input, + shapes, + &prefix.trim(), + idx, + local_span, + span, + global_offset, + )) + } + } else { + // Previous shape is not a Variable, use helper to determine context + Some(determine_flag_or_argument_context( + input, + shapes, + &prefix.trim(), + idx, + local_span, + span, + global_offset, + )) + } +} + +pub fn handle_dot_shape( + _input: &str, + shapes: &[(Span, FlatShape)], + idx: usize, + prefix: &str, + span: Span, + local_span: Span, + global_offset: usize, +) -> Option { + if idx == 0 { + return Some(CompletionContext::Argument { + prefix: prefix.to_string(), + span, + }); + } + + let prev_shape = &shapes[idx - 1]; + let prev_local_span = to_local_span(prev_shape.0, global_offset); + + if let FlatShape::Variable(var_id) = prev_shape.1 { + // Check if the variable shape ends right where this shape starts + if prev_local_span.end == local_span.start { + let trimmed_prefix = prefix.trim(); + // Parse path members from the prefix (which is like ".field" or ".field.subfield") + let after_dot = &trimmed_prefix[1..]; // Remove leading dot + let (path_so_far, cell_prefix) = if after_dot.is_empty() { + (vec![], "") + } else { + parse_cell_path_from_fields(after_dot) + }; + + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = span.end.saturating_sub(prefix_byte_len); + console_log!( + "[completion] Detected cell path from adjacent Variable shape, var_id={var_id:?}, prefix={cell_prefix:?}" + ); + Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, span.end), + var_id, + path_so_far: path_so_far.iter().map(|s| s.to_string()).collect(), + }) + } else { + // Gap between shapes, fall through to default handling + Some(CompletionContext::Argument { + prefix: prefix.to_string(), + span, + }) + } + } else { + // Previous shape is not a Variable, this is likely a file path starting with . + Some(CompletionContext::Argument { + prefix: prefix.to_string(), + span, + }) + } +} + +pub fn determine_context_from_shape( + input: &str, + shapes: &[(Span, FlatShape)], + working_set: &StateWorkingSet, + byte_pos: usize, + global_offset: usize, +) -> Option { + // First, check if cursor is within a shape + for (idx, (span, shape)) in shapes.iter().enumerate() { + let local_span = to_local_span(*span, global_offset); + + if local_span.start <= byte_pos && byte_pos <= local_span.end { + console_log!("[completion] Cursor in shape {idx}: {shape:?} at {local_span:?}"); + + // Check if there's a pipe or semicolon between this shape's end and the cursor + // If so, we're starting a new command and should ignore this shape + let has_sep = has_separator_between(input, local_span.end, byte_pos); + if has_sep { + console_log!( + "[completion] Separator found between shape end ({end}) and cursor ({byte_pos}), skipping shape", + end = local_span.end + ); + // There's a separator, so we're starting a new command - skip this shape + continue; + } + + let span = Span::new(local_span.start, std::cmp::min(local_span.end, byte_pos)); + let prefix = safe_slice(input, span); + console_log!("[completion] Processing shape {idx} with prefix: {prefix:?}"); + + // Special case: if prefix is just '{' (possibly with whitespace), + // we're at the start of a block and should complete commands + let trimmed_prefix = prefix.trim(); + if trimmed_prefix == "{" { + // We're right after '{' - command context + if let Some((_, adjusted_span, _)) = handle_block_prefix(&prefix, span) { + return Some(CompletionContext::Command { + prefix: String::new(), + span: adjusted_span, + }); + } + } else { + match shape { + // Special case: Check if we're completing a cell path where the Variable and field are in separate shapes + _ if { idx > 0 && matches!(shape, FlatShape::String) } => { + if let Some(ctx) = handle_variable_string_shape( + input, + shapes, + working_set, + idx, + &prefix, + span, + local_span, + global_offset, + ) { + return Some(ctx); + } + } + // Special case: Check if we're completing a cell path where the Variable and dot are in separate shapes + _ if { + let trimmed_prefix = prefix.trim(); + trimmed_prefix.starts_with('.') && idx > 0 + } => + { + if let Some(ctx) = handle_dot_shape( + input, + shapes, + idx, + &prefix, + span, + local_span, + global_offset, + ) { + return Some(ctx); + } + } + _ if { + // Check if this is a variable or cell path (starts with $) before treating as command + let trimmed_prefix = prefix.trim(); + trimmed_prefix.starts_with('$') + } => + { + let trimmed_prefix = prefix.trim(); + // Check if this is a cell path (contains a dot after $) + if let Some((var_name, path_so_far, cell_prefix)) = + parse_cell_path(trimmed_prefix) + { + // Find the variable ID + let var_id = lookup_variable_id(var_name, working_set); + + if let Some(var_id) = var_id { + // Calculate span for the cell path member being completed + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = span.end.saturating_sub(prefix_byte_len); + return Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, span.end), + var_id, + path_so_far: path_so_far + .iter() + .map(|s| s.to_string()) + .collect(), + }); + } else { + // Unknown variable, fall back to variable completion + let var_prefix = trimmed_prefix[1..].to_string(); + return Some(CompletionContext::Variable { + prefix: var_prefix, + span, + }); + } + } else { + // Variable completion context (no dot) + let var_prefix = if trimmed_prefix.len() > 1 { + trimmed_prefix[1..].to_string() + } else { + String::new() + }; + return Some(CompletionContext::Variable { + prefix: var_prefix, + span, + }); + } + } + _ if is_command_shape(input, shape, local_span) => { + let (full_prefix, full_span) = + build_command_prefix(input, shapes, idx, span, &prefix, global_offset); + return Some(CompletionContext::Command { + prefix: full_prefix, + span: full_span, + }); + } + FlatShape::Block | FlatShape::Closure => { + if let Some(ctx) = handle_block_or_closure( + input, + shapes, + working_set, + &prefix, + span, + shape.as_str().trim_start_matches("shape_"), + idx, + local_span, + global_offset, + ) { + return Some(ctx); + } + } + FlatShape::Variable(var_id) => { + // Variable or cell path completion context + let trimmed_prefix = prefix.trim(); + if trimmed_prefix.starts_with('$') { + // Check if this is a cell path (contains a dot after $) + if let Some((_, path_so_far, cell_prefix)) = + parse_cell_path(trimmed_prefix) + { + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = span.end.saturating_sub(prefix_byte_len); + return Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, span.end), + var_id: *var_id, + path_so_far: path_so_far + .iter() + .map(|s| s.to_string()) + .collect(), + }); + } else { + // Simple variable completion + let var_prefix = trimmed_prefix[1..].to_string(); + return Some(CompletionContext::Variable { + prefix: var_prefix, + span, + }); + } + } else { + // Fallback to argument context if no $ found + return Some(CompletionContext::Argument { + prefix: prefix.to_string(), + span, + }); + } + } + _ => { + // Check if this is a variable or cell path (starts with $) + let trimmed_prefix = prefix.trim(); + if trimmed_prefix.starts_with('$') { + // Check if this is a cell path (contains a dot after $) + if let Some((var_name, path_so_far, cell_prefix)) = + parse_cell_path(trimmed_prefix) + { + let var_id = lookup_variable_id(var_name, working_set); + if let Some(var_id) = var_id { + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = span.end.saturating_sub(prefix_byte_len); + return Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, span.end), + var_id, + path_so_far: path_so_far + .iter() + .map(|s| s.to_string()) + .collect(), + }); + } else { + let var_prefix = trimmed_prefix[1..].to_string(); + return Some(CompletionContext::Variable { + prefix: var_prefix, + span, + }); + } + } else { + // Simple variable completion + let var_prefix = if trimmed_prefix.len() > 1 { + trimmed_prefix[1..].to_string() + } else { + String::new() + }; + return Some(CompletionContext::Variable { + prefix: var_prefix, + span, + }); + } + } else { + // Use helper to determine flag or argument context + return Some(determine_flag_or_argument_context( + input, + shapes, + &trimmed_prefix, + idx, + local_span, + span, + global_offset, + )); + } + } + } + } + break; + } + } + None +} + +pub fn determine_context_fallback( + input: &str, + shapes: &[(Span, FlatShape)], + working_set: &StateWorkingSet, + engine_guard: &EngineState, + byte_pos: usize, + global_offset: usize, +) -> Option { + use nu_parser::{TokenContents, lex}; + + console_log!("[completion] Context is None, entering fallback logic"); + // Check if there's a command-like shape before us + let mut has_separator_after_command = false; + for (span, shape) in shapes.iter().rev() { + let local_span = to_local_span(*span, global_offset); + if local_span.end <= byte_pos { + if is_command_shape(input, shape, local_span) { + // Check if there's a pipe or semicolon between this command and the cursor + has_separator_after_command = + has_separator_between(input, local_span.end, byte_pos); + console_log!( + "[completion] Found command shape {shape:?} at {local_span:?}, has_separator_after_command={has_separator_after_command}" + ); + if !has_separator_after_command { + // Extract the command text + let cmd = safe_slice(input, local_span); + let cmd_name = extract_command_name(cmd).to_string(); + + // Check if we're right after the command (only whitespace between command and cursor) + let text_after_command = if local_span.end < input.len() { + &input[local_span.end..byte_pos] + } else { + "" + }; + let is_right_after_command = text_after_command.trim().is_empty(); + + // If we're right after a command, check if it has positional arguments + if is_right_after_command { + if let Some(signature) = get_command_signature(engine_guard, &cmd_name) { + // Check if command has any positional arguments + let has_positional_args = !signature.required_positional.is_empty() + || !signature.optional_positional.is_empty(); + + if has_positional_args { + // Count existing arguments before cursor + let mut arg_count = 0; + for (prev_span, prev_shape) in shapes.iter().rev() { + let prev_local_span = to_local_span(*prev_span, global_offset); + if prev_local_span.end <= byte_pos + && prev_local_span.end > local_span.end + { + if !is_command_shape(input, prev_shape, prev_local_span) { + let arg_text = safe_slice(input, prev_local_span); + let trimmed_arg = arg_text.trim(); + // Don't count flags (starting with -) or empty arguments + if !trimmed_arg.is_empty() + && !trimmed_arg.starts_with('-') + { + arg_count += 1; + } + } + } + } + + console_log!( + "[completion] Right after command {cmd_name:?}, setting CommandArgument context with arg_index: {arg_count}" + ); + + return Some(CompletionContext::CommandArgument { + prefix: String::new(), + span: Span::new(byte_pos, byte_pos), + command_name: cmd_name, + arg_index: arg_count, + }); + } else { + // No positional arguments, don't show any completions + console_log!( + "[completion] Command {cmd_name:?} has no positional args, not showing completions" + ); + // Leave context as None to show no completions + return None; + } + } else { + // Couldn't find signature, don't show completions + console_log!( + "[completion] Could not find signature for {cmd_name:?}, not showing completions" + ); + // Leave context as None to show no completions + return None; + } + } else { + // Not right after command, complete the command itself + console_log!("[completion] Set Command context with prefix: {cmd:?}"); + return Some(CompletionContext::Command { + prefix: cmd.to_string(), + span: local_span, + }); + } + } + } + break; + } + } + + // No command found before, check context from tokens + console_log!("[completion] No command found before cursor, checking tokens"); + // No command before, check context from tokens + let (tokens, _) = lex(input.as_bytes(), 0, &[], &[], true); + let last_token = tokens.iter().filter(|t| t.span.end <= byte_pos).last(); + + let is_cmd_context = if let Some(token) = last_token { + let matches = matches!( + token.contents, + TokenContents::Pipe + | TokenContents::PipePipe + | TokenContents::Semicolon + | TokenContents::Eol + ); + console_log!( + "[completion] Last token: {contents:?}, is_cmd_context from token={matches}", + contents = token.contents + ); + matches + } else { + console_log!( + "[completion] No last token found, assuming start of input (is_cmd_context=true)" + ); + true // Start of input + }; + + // Look for the last non-whitespace token before cursor + let text_before = &input[..byte_pos]; + + // Also check if we're inside a block - if the last non-whitespace char before cursor is '{' + let text_before_trimmed = text_before.trim_end(); + let is_inside_block = text_before_trimmed.ends_with('{'); + // If we found a separator after a command, we're starting a new command + let is_cmd_context = is_cmd_context || is_inside_block || has_separator_after_command; + console_log!( + "[completion] is_inside_block={is_inside_block}, has_separator_after_command={has_separator_after_command}, final is_cmd_context={is_cmd_context}" + ); + + // Find the last word before cursor + let last_word_start = text_before + .rfind(|c: char| c.is_whitespace() || is_separator_char(c)) + .map(|i| i + 1) + .unwrap_or(0); + + let last_word = text_before[last_word_start..].trim_start(); + console_log!("[completion] last_word_start={last_word_start}, last_word={last_word:?}"); + + if is_cmd_context { + Some(CompletionContext::Command { + prefix: last_word.to_string(), + span: Span::new(last_word_start, byte_pos), + }) + } else { + // Check if this is a variable or cell path (starts with $) + let trimmed_word = last_word.trim(); + if trimmed_word.starts_with('$') { + // Check if this is a cell path (contains a dot after $) + if let Some((var_name, path_so_far, cell_prefix)) = parse_cell_path(trimmed_word) { + let var_id = lookup_variable_id(&var_name, working_set); + + if let Some(var_id) = var_id { + let prefix_byte_len = cell_prefix.len(); + let cell_span_start = byte_pos.saturating_sub(prefix_byte_len); + Some(CompletionContext::CellPath { + prefix: cell_prefix.to_string(), + span: Span::new(cell_span_start, byte_pos), + var_id, + path_so_far: path_so_far.iter().map(|s| s.to_string()).collect(), + }) + } else { + let var_prefix = trimmed_word[1..].to_string(); + Some(CompletionContext::Variable { + prefix: var_prefix, + span: Span::new(last_word_start, byte_pos), + }) + } + } else { + // Simple variable completion + let var_prefix = trimmed_word[1..].to_string(); + Some(CompletionContext::Variable { + prefix: var_prefix, + span: Span::new(last_word_start, byte_pos), + }) + } + } else if trimmed_word.starts_with('-') { + // Try to find command by looking backwards through shapes + let mut found_cmd = None; + for (span, shape) in shapes.iter().rev() { + let local_span = to_local_span(*span, global_offset); + if local_span.end <= byte_pos && is_command_shape(input, shape, local_span) { + let cmd_text = safe_slice(input, local_span); + let cmd_name = extract_command_name(cmd_text).to_string(); + found_cmd = Some(cmd_name); + break; + } + } + if let Some(cmd_name) = found_cmd { + Some(CompletionContext::Flag { + prefix: trimmed_word.to_string(), + span: Span::new(last_word_start, byte_pos), + command_name: cmd_name, + }) + } else { + Some(CompletionContext::Argument { + prefix: last_word.to_string(), + span: Span::new(last_word_start, byte_pos), + }) + } + } else { + // Try to find command and argument index + let mut found_cmd = None; + let mut arg_count = 0; + for (span, shape) in shapes.iter().rev() { + let local_span = to_local_span(*span, global_offset); + if local_span.end <= byte_pos { + if is_command_shape(input, shape, local_span) { + let cmd_text = safe_slice(input, local_span); + let cmd_name = extract_command_name(cmd_text).to_string(); + found_cmd = Some(cmd_name); + break; + } else { + let arg_text = safe_slice(input, local_span); + let trimmed_arg = arg_text.trim(); + if !trimmed_arg.is_empty() && !trimmed_arg.starts_with('-') { + arg_count += 1; + } + } + } + } + if let Some(cmd_name) = found_cmd { + Some(CompletionContext::CommandArgument { + prefix: trimmed_word.to_string(), + span: Span::new(last_word_start, byte_pos), + command_name: cmd_name, + arg_index: arg_count, + }) + } else { + Some(CompletionContext::Argument { + prefix: last_word.to_string(), + span: Span::new(last_word_start, byte_pos), + }) + } + } + } +} + +pub fn determine_context( + input: &str, + shapes: &[(Span, FlatShape)], + working_set: &StateWorkingSet, + engine_guard: &EngineState, + byte_pos: usize, + global_offset: usize, +) -> Option { + // First try to determine context from shapes + if let Some(ctx) = + determine_context_from_shape(input, shapes, working_set, byte_pos, global_offset) + { + return Some(ctx); + } + + // Fallback to token-based context determination + determine_context_fallback( + input, + shapes, + working_set, + engine_guard, + byte_pos, + global_offset, + ) +} diff --git a/src/completion/files.rs b/src/completion/files.rs new file mode 100644 index 0000000..031ce11 --- /dev/null +++ b/src/completion/files.rs @@ -0,0 +1,51 @@ +use crate::completion::helpers::to_char_span; +use crate::completion::types::Suggestion; +use nu_protocol::Span; + +pub fn generate_file_suggestions( + prefix: &str, + span: Span, + root: &std::sync::Arc, + description: Option, + input: &str, +) -> Vec { + let (dir, file_prefix) = prefix + .rfind('/') + .map(|idx| (&prefix[..idx + 1], &prefix[idx + 1..])) + .unwrap_or(("", prefix)); + + let dir_to_join = (dir.len() > 1 && dir.ends_with('/')) + .then(|| &dir[..dir.len() - 1]) + .unwrap_or(dir); + + let target_dir = if !dir.is_empty() { + match root.join(dir_to_join) { + Ok(d) if d.is_dir().unwrap_or(false) => Some(d), + _ => None, + } + } else { + Some(root.join("").unwrap()) + }; + + let mut file_suggestions = Vec::new(); + if let Some(d) = target_dir { + if let Ok(iterator) = d.read_dir() { + let char_span = to_char_span(input, span); + for entry in iterator { + let name = entry.filename(); + if name.starts_with(file_prefix) { + let full_completion = format!("{}{}", dir, name); + file_suggestions.push(Suggestion { + name: full_completion.clone(), + description: description.clone(), + is_command: false, + rendered: full_completion, + span_start: char_span.start, + span_end: char_span.end, + }); + } + } + } + } + file_suggestions +} diff --git a/src/completion/helpers.rs b/src/completion/helpers.rs new file mode 100644 index 0000000..2f7e851 --- /dev/null +++ b/src/completion/helpers.rs @@ -0,0 +1,168 @@ +use nu_parser::FlatShape; +use nu_protocol::engine::StateWorkingSet; +use nu_protocol::{ENV_VARIABLE_ID, IN_VARIABLE_ID, NU_VARIABLE_ID, Span}; + +/// Macro for console logging that automatically converts formatted strings to JsValue +#[macro_export] +macro_rules! console_log { + ($($arg:tt)*) => { + web_sys::console::log_1(&wasm_bindgen::JsValue::from_str(&format!($($arg)*))); + }; +} + +pub fn is_separator_char(c: char) -> bool { + ['|', ';', '(', '{'].contains(&c) +} + +pub fn is_command_separator_char(c: char) -> bool { + ['|', ';'].contains(&c) +} + +pub fn has_separator_between(input: &str, start: usize, end: usize) -> bool { + if start < end && start < input.len() { + let text_between = &input[start..std::cmp::min(end, input.len())]; + text_between.chars().any(|c| is_separator_char(c)) + } else { + false + } +} + +pub fn find_last_separator_pos(text: &str) -> Option { + text.rfind(|c| is_command_separator_char(c)).map(|i| i + 1) +} + +pub fn ends_with_separator(text: &str) -> bool { + let text = text.trim_end(); + text.ends_with('|') || text.ends_with(';') +} + +pub fn to_local_span(span: Span, global_offset: usize) -> Span { + Span::new( + span.start.saturating_sub(global_offset), + span.end.saturating_sub(global_offset), + ) +} + +pub fn safe_slice(input: &str, span: Span) -> &str { + if span.start < input.len() { + let safe_end = std::cmp::min(span.end, input.len()); + &input[span.start..safe_end] + } else { + "" + } +} + +pub fn is_command_shape(input: &str, shape: &FlatShape, local_span: Span) -> bool { + matches!( + shape, + FlatShape::External(_) | FlatShape::InternalCall(_) | FlatShape::Keyword + ) || matches!(shape, FlatShape::Garbage) && { + if local_span.start < input.len() { + let prev_text = safe_slice(input, local_span); + !prev_text.trim().starts_with('-') + } else { + false + } + } +} + +pub fn handle_block_prefix(prefix: &str, span: Span) -> Option<(&str, Span, bool)> { + let mut block_prefix = prefix; + let mut block_span_start = span.start; + + // Remove leading '{' and whitespace + if block_prefix.starts_with('{') { + block_prefix = &block_prefix[1..]; + block_span_start += 1; + } + let trimmed_block_prefix = block_prefix.trim_start(); + if trimmed_block_prefix != block_prefix { + // Adjust span start to skip whitespace + block_span_start += block_prefix.len() - trimmed_block_prefix.len(); + } + + let is_empty = trimmed_block_prefix.is_empty(); + Some(( + trimmed_block_prefix, + Span::new(block_span_start, span.end), + is_empty, + )) +} + +pub fn extract_command_name(cmd_text: &str) -> &str { + cmd_text + .split_whitespace() + .next() + .unwrap_or(cmd_text) + .trim() +} + +pub fn lookup_variable_id( + var_name: &str, + working_set: &StateWorkingSet, +) -> Option { + match var_name { + "env" => Some(ENV_VARIABLE_ID), + "nu" => Some(NU_VARIABLE_ID), + "in" => Some(IN_VARIABLE_ID), + _ => working_set.find_variable(var_name.as_bytes()), + } +} + +pub fn parse_cell_path(text: &str) -> Option<(&str, Vec<&str>, &str)> { + let trimmed = text.trim(); + if !trimmed.starts_with('$') { + return None; + } + + // Check if this is a cell path (contains a dot after $) + if let Some(dot_pos) = trimmed[1..].find('.') { + let var_name = &trimmed[1..dot_pos + 1]; + let after_var = &trimmed[dot_pos + 2..]; + let parts: Vec<&str> = after_var.split('.').collect(); + let (path_so_far, cell_prefix) = if parts.is_empty() { + (vec![], "") + } else if after_var.ends_with('.') { + ( + parts.iter().filter(|s| !s.is_empty()).copied().collect(), + "", + ) + } else { + let path: Vec<&str> = parts[..parts.len().saturating_sub(1)] + .iter() + .copied() + .collect(); + let prefix = parts.last().copied().unwrap_or(""); + (path, prefix) + }; + Some((var_name, path_so_far, cell_prefix)) + } else { + None + } +} + +pub fn parse_cell_path_from_fields(text: &str) -> (Vec<&str>, &str) { + let trimmed = text.trim(); + let parts: Vec<&str> = trimmed.split('.').collect(); + if parts.is_empty() { + (vec![], "") + } else if trimmed.ends_with('.') { + ( + parts.iter().filter(|s| !s.is_empty()).copied().collect(), + "", + ) + } else { + let path: Vec<&str> = parts[..parts.len().saturating_sub(1)] + .iter() + .copied() + .collect(); + let prefix = parts.last().copied().unwrap_or(""); + (path, prefix) + } +} + +pub fn to_char_span(input: &str, span: Span) -> Span { + let char_start = input[..span.start].chars().count(); + let char_end = input[..span.end].chars().count(); + Span::new(char_start, char_end) +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs new file mode 100644 index 0000000..8e33e53 --- /dev/null +++ b/src/completion/mod.rs @@ -0,0 +1,95 @@ +use crate::console_log; +use futures::FutureExt; +use js_sys::Promise; +use nu_parser::{flatten_block, parse}; +use nu_protocol::engine::StateWorkingSet; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; + +use super::*; + +pub mod context; +pub mod files; +pub mod helpers; +pub mod suggestions; +pub mod types; +pub mod variables; + +pub use context::determine_context; +pub use suggestions::generate_suggestions; +pub use types::{CompletionContext, Suggestion}; + +#[wasm_bindgen] +pub fn completion(input: String, js_cursor_pos: usize) -> Promise { + future_to_promise(completion_impl(input, js_cursor_pos).map(|s| Ok(JsValue::from_str(&s)))) +} + +pub async fn completion_impl(input: String, js_cursor_pos: usize) -> String { + let engine_guard = read_engine_state().await; + let stack_guard = crate::read_stack().await; + let root = get_pwd(); + + // Map UTF-16 cursor position (from JS) to Byte index (for Rust) + let byte_pos = input + .char_indices() + .map(|(i, _)| i) + .nth(js_cursor_pos) + .unwrap_or(input.len()); + + let (working_set, shapes, global_offset) = { + let mut working_set = StateWorkingSet::new(&engine_guard); + let global_offset = working_set.next_span_start(); + let block = parse(&mut working_set, None, input.as_bytes(), false); + let shapes = flatten_block(&working_set, &block); + (working_set, shapes, global_offset) + }; + + // Initial state logging + console_log!( + "[completion] Input: {input:?}, JS cursor: {js_cursor_pos}, byte cursor: {byte_pos}" + ); + console_log!( + "[completion] Found {count} shapes, global_offset: {global_offset}", + count = shapes.len() + ); + for (idx, (span, shape)) in shapes.iter().enumerate() { + let (local_start, local_end) = ( + span.start.saturating_sub(global_offset), + span.end.saturating_sub(global_offset), + ); + console_log!( + "[completion] Shape {idx}: {shape:?} at [{start}, {end}] (local: [{local_start}, {local_end}])", + start = span.start, + end = span.end + ); + } + + // Determine completion context + let context = determine_context( + &input, + &shapes, + &working_set, + &engine_guard, + byte_pos, + global_offset, + ); + + // Generate suggestions based on context + let mut suggestions = generate_suggestions( + &input, + context, + &working_set, + &engine_guard, + &stack_guard, + &root, + byte_pos, + ); + + drop(working_set); + drop(engine_guard); + + suggestions.sort(); + let suggestions = serde_json::to_string(&suggestions).unwrap_or_else(|_| "[]".to_string()); + console_log!("{suggestions}"); + suggestions +} diff --git a/src/completion/suggestions.rs b/src/completion/suggestions.rs new file mode 100644 index 0000000..93a545c --- /dev/null +++ b/src/completion/suggestions.rs @@ -0,0 +1,384 @@ +use crate::completion::context::get_command_signature; +use crate::completion::files::generate_file_suggestions; +use crate::completion::helpers::to_char_span; +use crate::completion::types::{CompletionContext, Suggestion}; +use crate::completion::variables::*; +use crate::console_log; +use nu_protocol::Span; +use nu_protocol::engine::{EngineState, Stack, StateWorkingSet}; + +pub fn generate_command_suggestions( + input: &str, + working_set: &StateWorkingSet, + prefix: String, + span: Span, +) -> Vec { + console_log!("[completion] Generating Command suggestions with prefix: {prefix:?}"); + // Command completion + let cmds = + working_set.find_commands_by_predicate(|value| value.starts_with(prefix.as_bytes()), true); + + let span = to_char_span(input, span); + let mut suggestions = Vec::new(); + let mut cmd_count = 0; + + for (_, name, desc, _) in cmds { + let name_str = String::from_utf8_lossy(&name).to_string(); + suggestions.push(Suggestion { + rendered: { + let name_colored = ansi_term::Color::Green.bold().paint(&name_str); + let desc_str = desc.as_deref().unwrap_or(""); + format!("{name_colored} {desc_str}") + }, + name: name_str, + description: desc, + is_command: true, + span_start: span.start, + span_end: span.end, + }); + cmd_count += 1; + } + console_log!("[completion] Found {cmd_count} command suggestions"); + suggestions +} + +pub fn generate_argument_suggestions( + input: &str, + prefix: String, + span: Span, + root: &std::sync::Arc, +) -> Vec { + console_log!("[completion] Generating Argument suggestions with prefix: {prefix:?}"); + // File completion + let file_suggestions = generate_file_suggestions(&prefix, span, root, None, input); + let file_count = file_suggestions.len(); + console_log!("[completion] Found {file_count} file suggestions"); + file_suggestions +} + +pub fn generate_flag_suggestions( + input: &str, + engine_guard: &EngineState, + prefix: String, + span: Span, + command_name: String, +) -> Vec { + console_log!( + "[completion] Generating Flag suggestions for command: {command_name:?}, prefix: {prefix:?}" + ); + + let mut suggestions = Vec::new(); + if let Some(signature) = get_command_signature(engine_guard, &command_name) { + let span = to_char_span(input, span); + let mut flag_count = 0; + + // Get switches from signature + // Signature has a named field that contains named arguments (including switches) + for flag in &signature.named { + // Check if this is a switch (has no argument) + // Switches have arg: None, named arguments have arg: Some(SyntaxShape) + let is_switch = flag.arg.is_none(); + + if is_switch { + let long_name = format!("--{}", flag.long); + let short_name = flag.short.map(|c| format!("-{}", c)); + + // Determine which flags to show based on prefix: + // - If prefix is empty or exactly "-", show all flags (both short and long) + // - If prefix starts with "--", only show long flags that match the prefix + // - If prefix starts with "-" (but not "--"), only show short flags that match the prefix + let show_all = prefix.is_empty() || prefix == "-"; + + // Helper to create a flag suggestion + let create_flag_suggestion = |flag_name: String| -> Suggestion { + Suggestion { + name: flag_name.clone(), + description: Some(flag.desc.clone()), + is_command: false, + rendered: { + let flag_colored = ansi_term::Color::Cyan.bold().paint(&flag_name); + format!("{flag_colored} {}", flag.desc) + }, + span_start: span.start, + span_end: span.end, + } + }; + + // Add long flag if it matches + let should_show_long = if show_all { + true // Show all flags when prefix is "-" or empty + } else if prefix.starts_with("--") { + long_name.starts_with(&prefix) // Only show long flags matching prefix + } else { + false // Don't show long flags if prefix is short flag format + }; + + if should_show_long { + suggestions.push(create_flag_suggestion(long_name)); + flag_count += 1; + } + + // Add short flag if it matches + if let Some(short) = &short_name { + let should_show_short = if show_all { + true // Show all flags when prefix is "-" or empty + } else if prefix.starts_with("-") && !prefix.starts_with("--") { + short.starts_with(&prefix) // Only show short flags matching prefix + } else { + false // Don't show short flags if prefix is long flag format + }; + + if should_show_short { + suggestions.push(create_flag_suggestion(short.clone())); + flag_count += 1; + } + } + } + } + + console_log!("[completion] Found {flag_count} flag suggestions"); + } else { + console_log!("[completion] Could not find signature for command: {command_name:?}"); + } + suggestions +} + +pub fn generate_command_argument_suggestions( + input: &str, + engine_guard: &EngineState, + prefix: String, + span: Span, + command_name: String, + arg_index: usize, + root: &std::sync::Arc, +) -> Vec { + console_log!( + "[completion] Generating CommandArgument suggestions for command: {command_name:?}, arg_index: {arg_index}, prefix: {prefix:?}" + ); + + let mut suggestions = Vec::new(); + if let Some(signature) = get_command_signature(engine_guard, &command_name) { + // Get positional arguments from signature + // Combine required and optional positional arguments + let mut all_positional = Vec::new(); + all_positional.extend_from_slice(&signature.required_positional); + all_positional.extend_from_slice(&signature.optional_positional); + + // Find the argument at the given index + if let Some(arg) = all_positional.get(arg_index) { + // Check the SyntaxShape to determine completion type + // Only suggest files/dirs for Filepath type (or "any" when type is unknown) + match &arg.shape { + nu_protocol::SyntaxShape::Filepath | nu_protocol::SyntaxShape::Any => { + // File/directory completion + let file_suggestions = generate_file_suggestions( + &prefix, + span, + root, + Some(arg.desc.clone()), + input, + ); + let file_count = file_suggestions.len(); + suggestions.extend(file_suggestions); + console_log!( + "[completion] Found {file_count} file suggestions for argument {arg_index}" + ); + } + _ => { + // For other types, don't suggest files + console_log!( + "[completion] Argument {arg_index} is type {:?}, not suggesting files", + arg.shape + ); + } + } + } else { + // Argument index out of range, fall back to file completion + console_log!( + "[completion] Argument index {arg_index} out of range, using file completion" + ); + // Use the same file completion logic as Argument context + let file_suggestions = generate_file_suggestions(&prefix, span, root, None, input); + suggestions.extend(file_suggestions); + } + } else { + // No signature found, fall back to file completion + console_log!( + "[completion] Could not find signature for command: {command_name:?}, using file completion" + ); + let file_suggestions = generate_file_suggestions(&prefix, span, root, None, input); + suggestions.extend(file_suggestions); + } + suggestions +} + +pub fn generate_variable_suggestions( + input: &str, + working_set: &StateWorkingSet, + prefix: String, + span: Span, + byte_pos: usize, +) -> Vec { + console_log!("[completion] Generating Variable suggestions with prefix: {prefix:?}"); + + // Collect all available variables + let variables = collect_variables(working_set, input, byte_pos); + let span = to_char_span(input, span); + let mut suggestions = Vec::new(); + let mut var_count = 0; + + for (var_name, var_id) in variables { + // Filter by prefix (variable name includes $, so we need to check after $) + if var_name.len() > 1 && var_name[1..].starts_with(&prefix) { + // Get variable type + let var_type = working_set.get_variable(var_id).ty.to_string(); + + suggestions.push(Suggestion { + name: var_name.clone(), + description: Some(var_type.clone()), + is_command: false, + rendered: { + let var_colored = ansi_term::Color::Blue.bold().paint(&var_name); + format!("{var_colored} {var_type}") + }, + span_start: span.start, + span_end: span.end, + }); + var_count += 1; + } + } + + console_log!("[completion] Found {var_count} variable suggestions"); + suggestions +} + +pub fn generate_cell_path_suggestions( + input: &str, + working_set: &StateWorkingSet, + engine_guard: &EngineState, + stack_guard: &Stack, + prefix: String, + span: Span, + var_id: nu_protocol::VarId, + path_so_far: Vec, +) -> Vec { + console_log!( + "[completion] Generating CellPath suggestions with prefix: {prefix:?}, path: {path_so_far:?}" + ); + + let mut suggestions = Vec::new(); + // Evaluate the variable to get its value + if let Some(var_value) = + eval_variable_for_completion(var_id, working_set, engine_guard, stack_guard) + { + // Follow the path to get the value at the current level + let current_value = if path_so_far.is_empty() { + var_value + } else { + let path_refs: Vec<&str> = path_so_far.iter().map(|s| s.as_str()).collect(); + follow_cell_path(&var_value, &path_refs).unwrap_or(var_value) + }; + + // Get columns/fields from the current value + let columns = get_columns_from_value(¤t_value); + let span = to_char_span(input, span); + let mut field_count = 0; + + for (col_name, col_type) in columns { + // Filter by prefix + if col_name.starts_with(&prefix) { + let type_str = col_type.as_deref().unwrap_or("any"); + suggestions.push(Suggestion { + name: col_name.clone(), + description: Some(type_str.to_string()), + is_command: false, + rendered: { + let col_colored = ansi_term::Color::Yellow.paint(&col_name); + format!("{col_colored} {type_str}") + }, + span_start: span.start, + span_end: span.end, + }); + field_count += 1; + } + } + + console_log!("[completion] Found {field_count} cell path suggestions"); + } else { + // Variable couldn't be evaluated - this is expected for runtime variables + // We can't provide cell path completions without knowing the structure + console_log!( + "[completion] Could not evaluate variable {var_id:?} for cell path completion (runtime variable)" + ); + + // Try to get type information to provide better feedback + if let Ok(var_info) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + working_set.get_variable(var_id) + })) { + console_log!("[completion] Variable type: {ty:?}", ty = var_info.ty); + } + } + suggestions +} + +pub fn generate_suggestions( + input: &str, + context: Option, + working_set: &StateWorkingSet, + engine_guard: &EngineState, + stack_guard: &Stack, + root: &std::sync::Arc, + byte_pos: usize, +) -> Vec { + console_log!("context: {context:?}"); + + match context { + Some(CompletionContext::Command { prefix, span }) => { + generate_command_suggestions(input, working_set, prefix, span) + } + Some(CompletionContext::Argument { prefix, span }) => { + generate_argument_suggestions(input, prefix, span, root) + } + Some(CompletionContext::Flag { + prefix, + span, + command_name, + }) => generate_flag_suggestions(input, engine_guard, prefix, span, command_name), + Some(CompletionContext::CommandArgument { + prefix, + span, + command_name, + arg_index, + }) => generate_command_argument_suggestions( + input, + engine_guard, + prefix, + span, + command_name, + arg_index, + root, + ), + Some(CompletionContext::Variable { prefix, span }) => { + generate_variable_suggestions(input, working_set, prefix, span, byte_pos) + } + Some(CompletionContext::CellPath { + prefix, + span, + var_id, + path_so_far, + }) => generate_cell_path_suggestions( + input, + working_set, + engine_guard, + stack_guard, + prefix, + span, + var_id, + path_so_far, + ), + _ => { + console_log!("[completion] Context is None, no suggestions generated"); + Vec::new() + } + } +} diff --git a/src/completion/types.rs b/src/completion/types.rs new file mode 100644 index 0000000..4769ba5 --- /dev/null +++ b/src/completion/types.rs @@ -0,0 +1,62 @@ +use nu_protocol::Span; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct Suggestion { + pub name: String, + pub description: Option, + pub is_command: bool, + pub rendered: String, + pub span_start: usize, // char index (not byte) + pub span_end: usize, // char index (not byte) +} + +impl PartialEq for Suggestion { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} +impl Eq for Suggestion {} +impl PartialOrd for Suggestion { + fn partial_cmp(&self, other: &Self) -> Option { + self.name.partial_cmp(&other.name) + } +} +impl Ord for Suggestion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +#[derive(Debug)] +pub enum CompletionContext { + Command { + prefix: String, + span: Span, + }, + Argument { + prefix: String, + span: Span, + }, + Flag { + prefix: String, + span: Span, + command_name: String, + }, + CommandArgument { + prefix: String, + span: Span, + command_name: String, + arg_index: usize, + }, + Variable { + prefix: String, // without the $ prefix + span: Span, + }, + CellPath { + prefix: String, // the partial field name being typed (after the last dot) + span: Span, // replacement span + var_id: nu_protocol::VarId, // variable ID for evaluation + path_so_far: Vec, // path members accessed before current one + }, +} diff --git a/src/completion/variables.rs b/src/completion/variables.rs new file mode 100644 index 0000000..de18e4c --- /dev/null +++ b/src/completion/variables.rs @@ -0,0 +1,218 @@ +use crate::console_log; +use nu_protocol::engine::{EngineState, Stack, StateWorkingSet}; +use nu_protocol::{ENV_VARIABLE_ID, IN_VARIABLE_ID, NU_VARIABLE_ID, Span, Value}; +use std::collections::HashMap; + +pub fn eval_variable_for_completion( + var_id: nu_protocol::VarId, + working_set: &StateWorkingSet, + engine_guard: &EngineState, + stack_guard: &Stack, +) -> Option { + match var_id { + id if id == NU_VARIABLE_ID => { + // $nu - get from engine state constant + engine_guard.get_constant(id).cloned() + } + id if id == ENV_VARIABLE_ID => { + // $env - build from environment variables in engine state + // EnvVars is HashMap> (overlay -> vars) + let mut pairs: Vec<(String, Value)> = Vec::new(); + for overlay_env in engine_guard.env_vars.values() { + for (name, value) in overlay_env.iter() { + pairs.push((name.clone(), value.clone())); + } + } + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + // Deduplicate by name (later overlays override earlier ones) + pairs.dedup_by(|a, b| a.0 == b.0); + Some(Value::record(pairs.into_iter().collect(), Span::unknown())) + } + id if id == IN_VARIABLE_ID => { + // $in - typically not available at completion time + None + } + _ => { + // User-defined variable - try to get const value first + let var_info = working_set.get_variable(var_id); + if let Some(const_val) = &var_info.const_val { + Some(const_val.clone()) + } else { + // Variable doesn't have a const value (runtime value) + // Try to get the value from the stack (runtime storage) + match stack_guard.get_var(var_id, Span::unknown()) { + Ok(value) => { + console_log!("[completion] Found variable {var_id:?} value in stack"); + Some(value) + } + Err(_) => { + // Variable not in stack either + console_log!( + "[completion] Variable {var_id:?} has no const value and not in stack, type: {ty:?}", + ty = var_info.ty + ); + None + } + } + } + } + } +} + +pub fn get_columns_from_value(value: &Value) -> Vec<(String, Option)> { + match value { + Value::Record { val, .. } => val + .iter() + .map(|(name, v)| (name.to_string(), Some(v.get_type().to_string()))) + .collect(), + Value::List { vals, .. } => { + // Get common columns from list of records + if let Some(first) = vals.first() { + if let Value::Record { val, .. } = first { + return val + .iter() + .map(|(name, v)| (name.to_string(), Some(v.get_type().to_string()))) + .collect(); + } + } + vec![] + } + _ => vec![], + } +} + +pub fn follow_cell_path(value: &Value, path: &[&str]) -> Option { + let mut current = value.clone(); + for member in path { + match ¤t { + Value::Record { val, .. } => { + current = val.get(member)?.clone(); + } + Value::List { vals, .. } => { + // Try to parse as index or get from first record + if let Ok(idx) = member.parse::() { + current = vals.get(idx)?.clone(); + } else if let Some(first) = vals.first() { + if let Value::Record { val, .. } = first { + current = val.get(member)?.clone(); + } else { + return None; + } + } else { + return None; + } + } + _ => return None, + } + } + Some(current) +} + +pub fn extract_closure_params(input: &str, cursor_pos: usize) -> Vec { + let mut params = Vec::new(); + + // Find all closures in the input by looking for {|...| patterns + // We need to find closures that contain the cursor position + let mut brace_stack: Vec = Vec::new(); // Stack of opening brace positions + let mut closures: Vec<(usize, usize, Vec)> = Vec::new(); // (start, end, params) + + let mut i = 0; + let chars: Vec = input.chars().collect(); + + while i < chars.len() { + if chars[i] == '{' { + brace_stack.push(i); + } else if chars[i] == '}' { + if let Some(start) = brace_stack.pop() { + // Check if this is a closure with parameters: {|param| ...} + if start + 1 < chars.len() && chars[start + 1] == '|' { + // Find the parameter list + let param_start = start + 2; + let mut param_end = param_start; + + // Find the closing | of the parameter list + while param_end < chars.len() && chars[param_end] != '|' { + param_end += 1; + } + + if param_end < chars.len() { + // Extract parameter names + let params_text: String = chars[param_start..param_end].iter().collect(); + let param_names: Vec = params_text + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + closures.push((start, i + 1, param_names)); + } + } + } + } + i += 1; + } + + // Find closures that contain the cursor position + // A closure contains the cursor if: start <= cursor_pos < end + for (start, end, param_names) in closures { + if start <= cursor_pos && cursor_pos < end { + console_log!( + "[completion] Found closure at [{start}, {end}) containing cursor {cursor_pos}, params: {param_names:?}" + ); + params.extend(param_names); + } + } + + params +} + +pub fn collect_variables( + working_set: &StateWorkingSet, + input: &str, + cursor_pos: usize, +) -> HashMap { + let mut variables = HashMap::new(); + + // Add built-in variables + variables.insert("$nu".to_string(), NU_VARIABLE_ID); + variables.insert("$in".to_string(), IN_VARIABLE_ID); + variables.insert("$env".to_string(), ENV_VARIABLE_ID); + + // Collect closure parameters at cursor position + // We don't need real var_ids for closure parameters since they're not evaluated yet + // We'll use a placeholder var_id (using IN_VARIABLE_ID as a safe placeholder) + // The actual var_id lookup will happen when the variable is used + let closure_params = extract_closure_params(input, cursor_pos); + for param_name in closure_params { + let var_name = format!("${}", param_name); + // Use IN_VARIABLE_ID as placeholder - it's safe since we're just using it for the name + // The completion logic only needs the name, not the actual var_id + variables.insert(var_name.clone(), IN_VARIABLE_ID); + console_log!("[completion] Added closure parameter: {var_name:?}"); + } + + // Collect from working set delta scope + let mut removed_overlays = vec![]; + for scope_frame in working_set.delta.scope.iter().rev() { + for overlay_frame in scope_frame.active_overlays(&mut removed_overlays).rev() { + for (name, var_id) in &overlay_frame.vars { + let name = String::from_utf8_lossy(name).to_string(); + variables.insert(name, *var_id); + } + } + } + + // Collect from permanent state scope + for overlay_frame in working_set + .permanent_state + .active_overlays(&removed_overlays) + .rev() + { + for (name, var_id) in &overlay_frame.vars { + let name = String::from_utf8_lossy(name).to_string(); + variables.insert(name, *var_id); + } + } + + variables +} diff --git a/src/lib.rs b/src/lib.rs index ccac5cd..830f2df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,12 +7,11 @@ use nu_cmd_base::hook::eval_hook; use nu_cmd_extra::add_extra_command_context; use nu_cmd_lang::create_default_context; use nu_engine::{command_prelude::*, eval_block}; -use nu_parser::{FlatShape, TokenContents, flatten_block, lex, parse}; +use nu_parser::{FlatShape, flatten_block, parse}; use nu_protocol::{ Config, ListStream, PipelineData, Signals, Span, engine::{EngineState, Stack, StateWorkingSet}, }; -use serde::Serialize; use std::{ io::Cursor, sync::{Arc, OnceLock},