Skip to content

Commit bf7faf2

Browse files
thn929bellicose100xp
authored andcommitted
feat(autocomplete): quote non-simple jq field names in suggestions
- Adds identifier check + quoting for non‑simple keys (leading digits, hyphens, etc.) - Updates array iteration suggestions to use .[]."field-name" - Supports nesting too
1 parent 6111370 commit bf7faf2

File tree

2 files changed

+206
-6
lines changed

2 files changed

+206
-6
lines changed

src/autocomplete/result_analyzer.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,38 @@ fn dot_prefix(needs_leading_dot: bool) -> &'static str {
1111
}
1212

1313
impl ResultAnalyzer {
14+
/// Check if a field name can use jq's simple dot syntax (e.g., .foo)
15+
/// According to jq manual: "The .foo syntax only works for simple, identifier-like keys,
16+
/// that is, keys that are all made of alphanumeric characters and underscore,
17+
/// and which do not start with a digit."
18+
/// (https://jqlang.org/manual/#object-identifier-index)
19+
/// Names that don't fit require quoted access: ."field-name"
20+
fn is_simple_jq_identifier(name: &str) -> bool {
21+
if name.is_empty() {
22+
return false;
23+
}
24+
let first_char = name.chars().next().unwrap();
25+
!first_char.is_numeric() && name.chars().all(|c| c.is_alphanumeric() || c == '_')
26+
}
27+
28+
/// Format a field name for jq syntax, quoting if it doesn't fit simple dot syntax
29+
fn format_field_name(prefix: &str, name: &str) -> String {
30+
if Self::is_simple_jq_identifier(name) {
31+
format!("{}{}", prefix, name)
32+
} else {
33+
format!("{}\"{}\"", prefix, name)
34+
}
35+
}
1436
fn extract_object_fields(
1537
map: &serde_json::Map<String, Value>,
1638
prefix: &str,
1739
suggestions: &mut Vec<Suggestion>,
1840
) {
1941
for (key, val) in map {
2042
let field_type = Self::detect_json_type(val);
43+
let field_text = Self::format_field_name(prefix, key);
2144
suggestions.push(Suggestion::new_with_type(
22-
format!("{}{}", prefix, key),
45+
field_text,
2346
SuggestionType::Field,
2447
Some(field_type),
2548
));
@@ -67,9 +90,16 @@ impl ResultAnalyzer {
6790
for (key, val) in map {
6891
let field_type = Self::detect_json_type(val);
6992
let field_text = if suppress_array_brackets {
70-
format!("{}{}", prefix, key)
93+
Self::format_field_name(prefix, key)
7194
} else {
72-
format!("{}[].{}", prefix, key)
95+
// For array iteration syntax, quote non-simple identifiers
96+
if Self::is_simple_jq_identifier(key) {
97+
// Simple identifier: .[].fieldname
98+
format!("{}[].{}", prefix, key)
99+
} else {
100+
// Non-simple identifier (starts with digit, contains special chars): .[]."fieldname"
101+
format!("{}[].\"{}\"", prefix, key)
102+
}
73103
};
74104
suggestions.push(Suggestion::new_with_type(
75105
field_text,
@@ -135,11 +165,16 @@ impl ResultAnalyzer {
135165
for (key, val) in map {
136166
let field_type = Self::detect_json_type(val);
137167
// When suppressing brackets, suggest ".field"
138-
// Otherwise, suggest ".[].field"
168+
// Otherwise, suggest ".[].field" with quoting if needed
139169
let field_text = if suppress_array_brackets {
140-
format!("{}{}", prefix, key)
170+
Self::format_field_name(prefix, key)
141171
} else {
142-
format!("{}[].{}", prefix, key)
172+
// Apply quoting logic for array iteration
173+
if Self::is_simple_jq_identifier(key) {
174+
format!("{}[].{}", prefix, key)
175+
} else {
176+
format!("{}[].\"{}\"", prefix, key)
177+
}
143178
};
144179
suggestions.push(Suggestion::new_with_type(
145180
field_text,

src/autocomplete/result_analyzer_tests.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,3 +822,168 @@ mod analyze_value_tests {
822822
assert_eq!(suggestions[0].text, ".field");
823823
}
824824
}
825+
826+
// ============================================================================
827+
// Nonsimple Field Name Quoting Tests
828+
// ============================================================================
829+
830+
#[test]
831+
fn test_field_starting_with_digit_gets_quoted() {
832+
let json: Value = serde_json::from_str(r#"{"1numeric_key": "value"}"#).unwrap();
833+
let suggestions = ResultAnalyzer::analyze_value(&json, true, false);
834+
835+
assert_eq!(suggestions.len(), 1);
836+
assert_eq!(suggestions[0].text, r#"."1numeric_key""#);
837+
}
838+
839+
#[test]
840+
fn test_field_with_hyphen_gets_quoted() {
841+
let json: Value = serde_json::from_str(r#"{"my-field": "value"}"#).unwrap();
842+
let suggestions = ResultAnalyzer::analyze_value(&json, true, false);
843+
844+
assert_eq!(suggestions.len(), 1);
845+
assert_eq!(suggestions[0].text, r#"."my-field""#);
846+
}
847+
848+
#[test]
849+
fn test_valid_field_name_not_quoted() {
850+
let json: Value = serde_json::from_str(r#"{"simple_key": "value"}"#).unwrap();
851+
let suggestions = ResultAnalyzer::analyze_value(&json, true, false);
852+
853+
assert_eq!(suggestions.len(), 1);
854+
assert_eq!(suggestions[0].text, ".simple_key");
855+
}
856+
857+
#[test]
858+
fn test_multiple_fields_with_mixed_identifier_types() {
859+
let json: Value =
860+
serde_json::from_str(r#"{"simple_key": 1, "1numeric_key": 2, "hyphen-key": 3}"#).unwrap();
861+
let suggestions = ResultAnalyzer::analyze_value(&json, true, false);
862+
863+
assert_eq!(suggestions.len(), 3);
864+
let suggestion_texts: Vec<_> = suggestions.iter().map(|s| s.text.as_str()).collect();
865+
assert!(suggestion_texts.contains(&".simple_key"));
866+
assert!(suggestion_texts.contains(&r#"."1numeric_key""#));
867+
assert!(suggestion_texts.contains(&r#"."hyphen-key""#));
868+
}
869+
870+
#[test]
871+
fn test_array_of_objects_with_nonsimple_field_names() {
872+
let json: Value = serde_json::from_str(
873+
r#"[{"1numeric_key": "value1", "simple_key": "value2"}, {"1numeric_key": "value3", "simple_key": "value4"}]"#,
874+
)
875+
.unwrap();
876+
let suggestions = ResultAnalyzer::analyze_value(&json, true, false);
877+
878+
assert_eq!(suggestions.len(), 3); // .[], .[].1numeric_key, .[].simple_key
879+
let suggestion_texts: Vec<_> = suggestions.iter().map(|s| s.text.as_str()).collect();
880+
assert!(suggestion_texts.contains(&".[]"));
881+
assert!(suggestion_texts.contains(&r#".[]."1numeric_key""#));
882+
assert!(suggestion_texts.contains(&".[].simple_key"));
883+
}
884+
885+
#[test]
886+
fn test_no_leading_dot_with_nonsimple_field() {
887+
let json: Value = serde_json::from_str(r#"{"1numeric_key": "value"}"#).unwrap();
888+
let suggestions = ResultAnalyzer::analyze_value(&json, false, false);
889+
890+
assert_eq!(suggestions.len(), 1);
891+
assert_eq!(suggestions[0].text, r#""1numeric_key""#);
892+
}
893+
894+
// ============================================================================
895+
// Complex Nested Scenarios Tests
896+
// ============================================================================
897+
898+
#[test]
899+
fn test_nested_array_name_quoting_across_levels() {
900+
// Scenario: ."hyphen-array"[]."nested-items"[] with nested array names
901+
let json: Value = serde_json::from_str(
902+
r#"{
903+
"hyphen-array": [
904+
{
905+
"nested-items": [
906+
{"simple_key": "value"}
907+
]
908+
}
909+
]
910+
}"#,
911+
)
912+
.unwrap();
913+
914+
let top_level = ResultAnalyzer::analyze_value(&json, true, false);
915+
assert!(top_level.iter().any(|s| s.text == r#"."hyphen-array""#));
916+
assert!(!top_level.iter().any(|s| s.text == ".hyphen-array"));
917+
918+
let outer_array = json
919+
.get("hyphen-array")
920+
.and_then(Value::as_array)
921+
.expect("outer array should exist");
922+
let outer_obj = outer_array
923+
.first()
924+
.and_then(Value::as_object)
925+
.expect("outer array should contain object");
926+
let outer_obj_value = Value::Object(outer_obj.clone());
927+
let nested_level = ResultAnalyzer::analyze_value(&outer_obj_value, true, false);
928+
assert!(nested_level.iter().any(|s| s.text == r#"."nested-items""#));
929+
assert!(!nested_level.iter().any(|s| s.text == ".nested-items"));
930+
}
931+
932+
#[test]
933+
fn test_nested_field_quoting_with_iteration() {
934+
// Scenario: .outer[]."inner-array"[]."hyphen-key" and .[]."1numeric_key"
935+
let json: Value = serde_json::from_str(
936+
r#"{
937+
"outer": [
938+
{
939+
"inner-array": [
940+
{"hyphen-key": "v1", "1numeric_key": "v2", "simple_key": "v3"}
941+
]
942+
}
943+
]
944+
}"#,
945+
)
946+
.unwrap();
947+
948+
let outer_array = json
949+
.get("outer")
950+
.and_then(Value::as_array)
951+
.expect("outer array should exist");
952+
let outer_obj = outer_array
953+
.first()
954+
.and_then(Value::as_object)
955+
.expect("outer array should contain object");
956+
let outer_obj_value = Value::Object(outer_obj.clone());
957+
let outer_suggestions = ResultAnalyzer::analyze_value(&outer_obj_value, true, false);
958+
assert!(
959+
outer_suggestions
960+
.iter()
961+
.any(|s| s.text == r#"."inner-array""#)
962+
);
963+
964+
let inner_array = outer_obj_value
965+
.get("inner-array")
966+
.and_then(Value::as_array)
967+
.expect("inner array should exist");
968+
let inner_array_value = Value::Array(inner_array.clone());
969+
let inner_suggestions = ResultAnalyzer::analyze_value(&inner_array_value, true, false);
970+
971+
assert!(inner_suggestions.iter().any(|s| s.text == ".[]"));
972+
assert!(
973+
inner_suggestions
974+
.iter()
975+
.any(|s| s.text == r#".[]."hyphen-key""#)
976+
);
977+
assert!(
978+
inner_suggestions
979+
.iter()
980+
.any(|s| s.text == r#".[]."1numeric_key""#)
981+
);
982+
assert!(inner_suggestions.iter().any(|s| s.text == ".[].simple_key"));
983+
assert!(!inner_suggestions.iter().any(|s| s.text == ".[].hyphen-key"));
984+
assert!(
985+
!inner_suggestions
986+
.iter()
987+
.any(|s| s.text == ".[].1numeric_key")
988+
);
989+
}

0 commit comments

Comments
 (0)