Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ jiq looks for a configuration file at `~/.config/jiq/config.toml` (or the platfo
# - osc52: use terminal escape sequences (works in most modern terminals over SSH)
backend = "auto"

[autocomplete]
# Number of array elements to sample for field suggestions (default: 10, range: 1-1000)
# Increase for heterogeneous arrays where fields vary across elements
array_sample_size = 10

[ai]
# Enable AI assistant
# For faster responses, prefer lightweight models:
Expand Down Expand Up @@ -449,7 +454,7 @@ profile = "default" # Optional: AWS profile name (uses default credential chain

## Known Limitations

- **Autocomplete** - Editing in the middle of a query falls back to root-level suggestions; for arrays, only the first element is used for field autocomplete, so fields missing from the first element or present only in other elements of a heterogeneous array will not appear as suggestions.
- **Autocomplete** - Editing in the middle of a query falls back to root-level suggestions; for arrays, a configurable number of elements are sampled to build field suggestions (default: 10, configurable via `array_sample_size` in `[autocomplete]` config section).
- **Syntax highlighting** - Basic keyword-based only, does not analyze structure like tree-sitter.

## Contributing
Expand Down
7 changes: 6 additions & 1 deletion src/app/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub struct App {
pub frame_count: u64,
pub needs_render: bool,
pub layout_regions: LayoutRegions,
pub array_sample_size: usize,
}

impl App {
Expand Down Expand Up @@ -161,6 +162,7 @@ impl App {
frame_count: 0,
needs_render: true,
layout_regions: LayoutRegions::new(),
array_sample_size: config.autocomplete.array_sample_size,
}
}

Expand All @@ -172,7 +174,10 @@ impl App {
self.mark_dirty();
match result {
Ok(json_input) => {
self.query = Some(QueryState::new(json_input.clone()));
self.query = Some(QueryState::new_with_sample_size(
json_input.clone(),
self.array_sample_size,
));

let schema_input = crate::json::extract_first_json_value(&json_input)
.unwrap_or_else(|| json_input.clone());
Expand Down
2 changes: 2 additions & 0 deletions src/autocomplete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub fn update_suggestions(
original_json: Option<Arc<Value>>,
all_field_names: Arc<HashSet<String>>,
brace_tracker: &BraceTracker,
array_sample_size: usize,
) {
if query.trim().len() < MIN_CHARS_FOR_AUTOCOMPLETE {
autocomplete.hide();
Expand All @@ -65,6 +66,7 @@ pub fn update_suggestions(
original_json,
all_field_names,
brace_tracker,
array_sample_size,
);
autocomplete.update_suggestions(suggestions);
}
1 change: 1 addition & 0 deletions src/autocomplete/autocomplete_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub fn update_suggestions_from_app(app: &mut App) {
original_json,
all_field_names,
&app.input.brace_tracker,
app.array_sample_size,
);
}

Expand Down
31 changes: 24 additions & 7 deletions src/autocomplete/context.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::autocomplete_state::{JsonFieldType, Suggestion, SuggestionType};
use super::brace_tracker::{BraceTracker, BraceType};
use super::jq_functions::filter_builtins;
use super::json_navigator::navigate;
use super::json_navigator::navigate_multi;
use super::path_parser::{PathSegment, parse_path};
use super::result_analyzer::ResultAnalyzer;
use super::scan_state::ScanState;
Expand Down Expand Up @@ -206,13 +206,15 @@ fn get_field_suggestions(
result_type: Option<ResultType>,
needs_leading_dot: bool,
suppress_array_brackets: bool,
array_sample_size: usize,
) -> Vec<Suggestion> {
if let (Some(result), Some(typ)) = (result_parsed, result_type) {
ResultAnalyzer::analyze_parsed_result(
&result,
typ,
needs_leading_dot,
suppress_array_brackets,
array_sample_size,
)
} else {
Vec::new()
Expand Down Expand Up @@ -611,6 +613,7 @@ fn inject_entry_field_suggestions(suggestions: &mut Vec<Suggestion>, needs_leadi
);
}

#[allow(clippy::too_many_arguments)]
pub fn get_suggestions(
query: &str,
cursor_pos: usize,
Expand All @@ -619,6 +622,7 @@ pub fn get_suggestions(
original_json: Option<Arc<Value>>,
all_field_names: Arc<HashSet<String>>,
brace_tracker: &BraceTracker,
array_sample_size: usize,
) -> Vec<Suggestion> {
let before_cursor = &query[..cursor_pos.min(query.len())];
let (context, partial) = analyze_context(before_cursor, brace_tracker);
Expand Down Expand Up @@ -659,6 +663,7 @@ pub fn get_suggestions(
suppress_array_brackets, // is_in_element_context == suppress_array_brackets
is_after_pipe,
result_type.as_ref(),
array_sample_size,
) {
nested_suggestions
} else if let Some(ref orig) = original_json {
Expand All @@ -671,6 +676,7 @@ pub fn get_suggestions(
suppress_array_brackets,
is_after_pipe,
result_type.as_ref(),
array_sample_size,
)
.unwrap_or_else(|| {
// Non-deterministic: show all fields from original JSON
Expand All @@ -697,6 +703,7 @@ pub fn get_suggestions(
suppress_array_brackets,
is_after_pipe,
result_type.as_ref(),
array_sample_size,
)
.unwrap_or_else(|| {
// Non-deterministic: show all fields from original JSON
Expand All @@ -714,6 +721,7 @@ pub fn get_suggestions(
result_type.clone(),
needs_dot,
suppress_array_brackets,
array_sample_size,
)
};

Expand All @@ -736,7 +744,8 @@ pub fn get_suggestions(
return Vec::new();
}

let suggestions = get_field_suggestions(result_parsed, result_type, false, true);
let suggestions =
get_field_suggestions(result_parsed, result_type, false, true, array_sample_size);
filter_suggestions_by_partial(suggestions, &partial)
}
SuggestionContext::VariableContext => {
Expand Down Expand Up @@ -921,6 +930,7 @@ fn extract_path_context_with_pipe_info(

/// Get nested field suggestions by navigating the JSON tree.
/// This is the core Phase 3 integration point.
#[allow(clippy::too_many_arguments)]
fn get_nested_field_suggestions(
json: &Value,
path_context: &str,
Expand All @@ -929,6 +939,7 @@ fn get_nested_field_suggestions(
is_in_element_context: bool,
is_after_pipe: bool,
result_type: Option<&ResultType>,
array_sample_size: usize,
) -> Option<Vec<Suggestion>> {
let mut parsed_path = parse_path(path_context);

Expand All @@ -950,12 +961,18 @@ fn get_nested_field_suggestions(
return None;
}

// Navigate to the target value
let navigated = navigate(json, &parsed_path.segments)?;
// Navigate with fan-out to collect values from multiple array elements
let navigated_values = navigate_multi(json, &parsed_path.segments, array_sample_size);
if navigated_values.is_empty() {
return None;
}

// Get suggestions from the navigated value
let suggestions =
ResultAnalyzer::analyze_value(navigated, needs_leading_dot, suppress_array_brackets);
let suggestions = ResultAnalyzer::analyze_multi_values(
&navigated_values,
needs_leading_dot,
suppress_array_brackets,
array_sample_size,
);

Some(suggestions)
}
Expand Down
5 changes: 3 additions & 2 deletions src/autocomplete/context_tests/common.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub use crate::autocomplete::json_navigator::DEFAULT_ARRAY_SAMPLE_SIZE;
use crate::autocomplete::*;
use crate::query::ResultType;
use serde_json::Value;
Expand Down Expand Up @@ -30,8 +31,8 @@ fn collect_fields_recursive(value: &Value, fields: &mut HashSet<String>) {
}
}
Value::Array(arr) => {
if let Some(first) = arr.first() {
collect_fields_recursive(first, fields);
for element in arr.iter().take(10) {
collect_fields_recursive(element, fields);
}
}
_ => {}
Expand Down
22 changes: 21 additions & 1 deletion src/autocomplete/context_tests/edge_case_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
///
/// Tests for transforming functions, complex expressions,
/// and other edge cases that require special handling.
use super::common::{empty_field_names, field_names_from, tracker_for};
use super::common::{DEFAULT_ARRAY_SAMPLE_SIZE, empty_field_names, field_names_from, tracker_for};
use crate::autocomplete::*;
use crate::query::ResultType;
use serde_json::Value;
Expand Down Expand Up @@ -48,6 +48,7 @@ mod optional_field_access {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Optional fields should still navigate correctly in non-executing context
Expand Down Expand Up @@ -81,6 +82,7 @@ mod bracket_notation {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Bracket notation should navigate like dot notation
Expand Down Expand Up @@ -110,6 +112,7 @@ mod array_index_access {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Specific index access should work
Expand Down Expand Up @@ -139,6 +142,7 @@ mod array_index_access {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Negative index should access from end
Expand Down Expand Up @@ -168,6 +172,7 @@ mod pipe_chaining {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Identity pipe should still work in non-executing context
Expand All @@ -193,6 +198,7 @@ mod pipe_chaining {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Multiple pipes with field access should work
Expand Down Expand Up @@ -230,6 +236,7 @@ mod mixed_contexts {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Inside map, navigation should work with element context prepending
Expand All @@ -254,6 +261,7 @@ mod mixed_contexts {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Select with comparison should work
Expand Down Expand Up @@ -300,6 +308,7 @@ mod deep_nesting {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Deep nesting should work
Expand Down Expand Up @@ -330,6 +339,7 @@ mod middle_of_query_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Middle-of-query should navigate from original_json to .user
Expand Down Expand Up @@ -361,6 +371,7 @@ mod middle_of_query_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Should suggest fields of order elements: id, items, status
Expand Down Expand Up @@ -391,6 +402,7 @@ mod middle_of_query_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Should filter suggestions by partial "pro"
Expand All @@ -417,6 +429,7 @@ mod middle_of_query_tests {
Some(parsed.clone()),
field_names_from(&parsed),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Navigation fails, should fall back to all field names from original_json
Expand All @@ -442,6 +455,7 @@ mod middle_of_query_tests {
Some(parsed.clone()),
field_names_from(&parsed),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Inside map(), middle of query, should navigate from original
Expand All @@ -468,6 +482,7 @@ mod middle_of_query_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Inside array builder, middle of query
Expand All @@ -493,6 +508,7 @@ mod middle_of_query_tests {
Some(parsed.clone()),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Cursor in middle (after ".user.")
Expand All @@ -504,6 +520,7 @@ mod middle_of_query_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);

// Results should be different - end shows name's type, middle shows user's fields
Expand Down Expand Up @@ -562,6 +579,7 @@ mod performance_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);
let elapsed = start.elapsed();

Expand All @@ -588,6 +606,7 @@ mod performance_tests {
Some(parsed),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);
let elapsed = start.elapsed();

Expand Down Expand Up @@ -620,6 +639,7 @@ mod performance_tests {
Some(parsed.clone()),
empty_field_names(),
&tracker,
DEFAULT_ARRAY_SAMPLE_SIZE,
);
}
let elapsed = start.elapsed();
Expand Down
Loading