Skip to main content

ane/commands/chord_engine/
parser.rs

1use anyhow::Result;
2
3use crate::data::chord_types::{
4    Action, Component, Positional, Scope, is_valid_combination, is_valid_jump_combination,
5};
6
7use super::errors::ChordError;
8use super::types::{ChordArgs, ChordQuery};
9
10pub fn parse(input: &str) -> Result<ChordQuery> {
11    let input = input.trim();
12    if input.is_empty() {
13        return Err(ChordError::parse(input, 0, "empty chord input").into());
14    }
15
16    let (chord_part, raw_args) = split_chord_and_args(input)?;
17
18    if let Some(query) = try_parse_short_form(chord_part, &raw_args, input)? {
19        return Ok(query);
20    }
21
22    if let Some(query) = try_parse_long_form(chord_part, &raw_args, input)? {
23        return Ok(query);
24    }
25
26    let suggestion = suggest_chord(chord_part);
27    match suggestion {
28        Some(sug) => Err(ChordError::parse_with_suggestion(
29            input,
30            0,
31            format!("unknown chord '{chord_part}'"),
32            sug,
33        )
34        .into()),
35        None => Err(ChordError::parse(input, 0, format!("unknown chord '{chord_part}'")).into()),
36    }
37}
38
39fn split_chord_and_args(input: &str) -> Result<(&str, Option<&str>)> {
40    let Some(paren_start) = input.find('(') else {
41        return Ok((input, None));
42    };
43    if !input.ends_with(')') {
44        return Err(ChordError::parse(
45            input,
46            paren_start,
47            "unterminated argument list (missing closing ')')",
48        )
49        .into());
50    }
51    let chord_part = &input[..paren_start];
52    let args_content = &input[paren_start + 1..input.len() - 1];
53    Ok((chord_part, Some(args_content)))
54}
55
56fn parse_args(raw_args: &Option<&str>) -> ChordArgs {
57    let mut args = ChordArgs::default();
58    let raw = match raw_args {
59        Some(s) if !s.is_empty() => *s,
60        _ => return args,
61    };
62
63    for pair in split_kv_pairs(raw) {
64        let pair = pair.trim();
65        if pair.is_empty() {
66            continue;
67        }
68        if let Some((key, val)) = pair.split_once(':') {
69            let key = key.trim();
70            let val = val.trim().trim_matches('"');
71            match key {
72                "target" if !val.is_empty() => {
73                    args.target_name = Some(val.to_string());
74                    args.target_line = val.parse().ok();
75                }
76                "parent" => {
77                    args.parent_name = Some(val.to_string());
78                }
79                "cursor" => {
80                    if let Some((l, c)) = val.split_once(',') {
81                        if let (Ok(line), Ok(col)) = (l.trim().parse(), c.trim().parse()) {
82                            args.cursor_pos = Some((line, col));
83                        }
84                    }
85                }
86                "value" => {
87                    args.value = Some(val.to_string());
88                }
89                "find" => {
90                    args.find = Some(val.to_string());
91                }
92                "replace" => {
93                    args.replace = Some(val.to_string());
94                }
95                _ => {}
96            }
97        }
98    }
99
100    args
101}
102
103fn split_kv_pairs(input: &str) -> Vec<&str> {
104    let mut pairs = Vec::new();
105    let mut depth = 0;
106    let mut in_quotes = false;
107    let mut start = 0;
108
109    for (i, ch) in input.char_indices() {
110        match ch {
111            '"' => in_quotes = !in_quotes,
112            '(' if !in_quotes => {
113                depth += 1;
114            }
115            ')' if !in_quotes => {
116                depth -= 1;
117            }
118            ',' if !in_quotes && depth == 0 => {
119                pairs.push(&input[start..i]);
120                start = i + 1;
121            }
122            _ => {}
123        }
124    }
125    if start < input.len() {
126        pairs.push(&input[start..]);
127    }
128    pairs
129}
130
131fn try_parse_short_form(
132    chord_part: &str,
133    raw_args: &Option<&str>,
134    _original_input: &str,
135) -> Result<Option<ChordQuery>> {
136    if chord_part.len() != 4 {
137        return Ok(None);
138    }
139
140    let chars: Vec<&str> = chord_part
141        .char_indices()
142        .map(|(i, c)| &chord_part[i..i + c.len_utf8()])
143        .collect();
144
145    if chars.len() != 4 {
146        return Ok(None);
147    }
148
149    let action = match Action::from_short(chars[0]) {
150        Some(a) => a,
151        None => return Ok(None),
152    };
153    let positional = match Positional::from_short(chars[1]) {
154        Some(p) => p,
155        None => return Ok(None),
156    };
157    let scope = match Scope::from_short(chars[2]) {
158        Some(s) => s,
159        None => return Ok(None),
160    };
161    let component = match Component::from_short(chars[3]) {
162        Some(c) => c,
163        None => return Ok(None),
164    };
165
166    if !is_valid_combination(scope, component) {
167        return Err(ChordError::invalid_combination(scope, component).into());
168    }
169
170    if matches!(scope, Scope::Delimiter)
171        && matches!(positional, Positional::Next | Positional::Previous)
172    {
173        return Err(ChordError::parse(
174            _original_input,
175            0,
176            "Next/Previous positional is not valid for Delimiter scope",
177        )
178        .into());
179    }
180
181    if action == Action::Jump && !is_valid_jump_combination(positional, component) {
182        let msg = if positional == Positional::Outside {
183            "Jump with Outside positional requires Beginning or End component to specify direction"
184        } else {
185            "Jump does not operate on Value, Parameters, or Arguments components"
186        };
187        return Err(ChordError::parse(_original_input, 0, msg).into());
188    }
189
190    let args = parse_args(raw_args);
191
192    if action == Action::Jump && args.value.is_some() {
193        return Err(
194            ChordError::parse(_original_input, 0, "Jump does not accept a value argument").into(),
195        );
196    }
197
198    Ok(Some(ChordQuery {
199        action,
200        positional,
201        scope,
202        component,
203        args,
204        requires_lsp: scope.requires_lsp(),
205    }))
206}
207
208fn try_parse_long_form(
209    chord_part: &str,
210    raw_args: &Option<&str>,
211    _original_input: &str,
212) -> Result<Option<ChordQuery>> {
213    let (action, rest) = match parse_long_action(chord_part) {
214        Some(r) => r,
215        None => return Ok(None),
216    };
217    let (positional, rest) = match parse_long_positional(rest) {
218        Some(r) => r,
219        None => return Ok(None),
220    };
221    let (scope, rest) = match parse_long_scope(rest) {
222        Some(r) => r,
223        None => return Ok(None),
224    };
225    let component = match parse_long_component(rest) {
226        Some(c) => c,
227        None => return Ok(None),
228    };
229
230    if !is_valid_combination(scope, component) {
231        return Err(ChordError::invalid_combination(scope, component).into());
232    }
233
234    if matches!(scope, Scope::Delimiter)
235        && matches!(positional, Positional::Next | Positional::Previous)
236    {
237        return Err(ChordError::parse(
238            _original_input,
239            0,
240            "Next/Previous positional is not valid for Delimiter scope",
241        )
242        .into());
243    }
244
245    if action == Action::Jump && !is_valid_jump_combination(positional, component) {
246        let msg = if positional == Positional::Outside {
247            "Jump with Outside positional requires Beginning or End component to specify direction"
248        } else {
249            "Jump does not operate on Value, Parameters, or Arguments components"
250        };
251        return Err(ChordError::parse(_original_input, 0, msg).into());
252    }
253
254    let args = parse_args(raw_args);
255
256    if action == Action::Jump && args.value.is_some() {
257        return Err(
258            ChordError::parse(_original_input, 0, "Jump does not accept a value argument").into(),
259        );
260    }
261
262    Ok(Some(ChordQuery {
263        action,
264        positional,
265        scope,
266        component,
267        args,
268        requires_lsp: scope.requires_lsp(),
269    }))
270}
271
272fn parse_long_action(input: &str) -> Option<(Action, &str)> {
273    let pairs = [
274        ("Change", Action::Change),
275        ("Replace", Action::Replace),
276        ("Delete", Action::Delete),
277        ("Yank", Action::Yank),
278        ("Append", Action::Append),
279        ("Prepend", Action::Prepend),
280        ("Insert", Action::Insert),
281        ("Jump", Action::Jump),
282    ];
283    for (prefix, action) in pairs {
284        if let Some(rest) = input.strip_prefix(prefix) {
285            return Some((action, rest));
286        }
287    }
288    None
289}
290
291fn parse_long_positional(input: &str) -> Option<(Positional, &str)> {
292    let pairs = [
293        ("Inside", Positional::Inside),
294        ("Until", Positional::Until),
295        ("After", Positional::After),
296        ("Before", Positional::Before),
297        ("Next", Positional::Next),
298        ("Previous", Positional::Previous),
299        ("Entire", Positional::Entire),
300        ("Outside", Positional::Outside),
301        ("To", Positional::To),
302    ];
303    for (prefix, positional) in pairs {
304        if let Some(rest) = input.strip_prefix(prefix) {
305            return Some((positional, rest));
306        }
307    }
308    None
309}
310
311fn parse_long_scope(input: &str) -> Option<(Scope, &str)> {
312    let pairs = [
313        ("Function", Scope::Function),
314        ("Variable", Scope::Variable),
315        ("Delimiter", Scope::Delimiter),
316        ("Buffer", Scope::Buffer),
317        ("Struct", Scope::Struct),
318        ("Member", Scope::Member),
319        ("Line", Scope::Line),
320    ];
321    for (prefix, scope) in pairs {
322        if let Some(rest) = input.strip_prefix(prefix) {
323            return Some((scope, rest));
324        }
325    }
326    None
327}
328
329fn parse_long_component(input: &str) -> Option<Component> {
330    match input {
331        "Beginning" => Some(Component::Beginning),
332        "Contents" => Some(Component::Contents),
333        "End" => Some(Component::End),
334        "Value" => Some(Component::Value),
335        "Parameters" => Some(Component::Parameters),
336        "Arguments" => Some(Component::Arguments),
337        "Name" => Some(Component::Name),
338        "Self" => Some(Component::Self_),
339        _ => None,
340    }
341}
342
343fn suggest_chord(input: &str) -> Option<String> {
344    let input_chars: String = input.chars().take(4).collect();
345    if input_chars.chars().count() < 4 {
346        return None;
347    }
348
349    let actions = ['c', 'r', 'd', 'y', 'a', 'p', 'i', 'j'];
350    let positionals = ['i', 'u', 'a', 'b', 'n', 'p', 'e', 'o', 't'];
351    let scopes = ['l', 'b', 'f', 'v', 's', 'm', 'd'];
352    let components = ['b', 'c', 'e', 'v', 'p', 'a', 'n', 's'];
353
354    let mut best_dist = usize::MAX;
355    let mut best = None;
356
357    for &a in &actions {
358        for &p in &positionals {
359            for &s in &scopes {
360                for &c in &components {
361                    let candidate = format!("{a}{p}{s}{c}");
362                    let scope = Scope::from_short(&s.to_string()).unwrap();
363                    let comp = Component::from_short(&c.to_string()).unwrap();
364                    if !is_valid_combination(scope, comp) {
365                        continue;
366                    }
367                    let dist = levenshtein(&input_chars, &candidate);
368                    if dist < best_dist && dist <= 2 {
369                        best_dist = dist;
370                        best = Some(candidate);
371                    }
372                }
373            }
374        }
375    }
376
377    best
378}
379
380fn levenshtein(a: &str, b: &str) -> usize {
381    let a: Vec<char> = a.chars().collect();
382    let b: Vec<char> = b.chars().collect();
383    let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
384
385    for (i, row) in dp.iter_mut().enumerate().take(a.len() + 1) {
386        row[0] = i;
387    }
388    for (j, val) in dp[0].iter_mut().enumerate().take(b.len() + 1) {
389        *val = j;
390    }
391
392    for i in 1..=a.len() {
393        for j in 1..=b.len() {
394            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
395            dp[i][j] = (dp[i - 1][j] + 1)
396                .min(dp[i][j - 1] + 1)
397                .min(dp[i - 1][j - 1] + cost);
398        }
399    }
400
401    dp[a.len()][b.len()]
402}
403
404#[cfg(test)]
405mod tests {
406    use super::parse;
407    use crate::data::chord_types::{
408        Action, Component, Positional, Scope, is_valid_combination, is_valid_jump_combination,
409    };
410
411    const ALL_ACTIONS: &[Action] = &[
412        Action::Change,
413        Action::Replace,
414        Action::Delete,
415        Action::Yank,
416        Action::Append,
417        Action::Prepend,
418        Action::Insert,
419        Action::Jump,
420    ];
421
422    const ALL_POSITIONALS: &[Positional] = &[
423        Positional::Inside,
424        Positional::Until,
425        Positional::After,
426        Positional::Before,
427        Positional::Next,
428        Positional::Previous,
429        Positional::Entire,
430        Positional::Outside,
431        Positional::To,
432    ];
433
434    const ALL_SCOPES: &[Scope] = &[
435        Scope::Line,
436        Scope::Buffer,
437        Scope::Function,
438        Scope::Variable,
439        Scope::Struct,
440        Scope::Member,
441        Scope::Delimiter,
442    ];
443
444    const ALL_COMPONENTS: &[Component] = &[
445        Component::Beginning,
446        Component::Contents,
447        Component::End,
448        Component::Value,
449        Component::Parameters,
450        Component::Arguments,
451        Component::Name,
452        Component::Self_,
453    ];
454
455    #[test]
456    fn all_valid_short_forms_parse_and_invalid_fail() {
457        for &action in ALL_ACTIONS {
458            for &pos in ALL_POSITIONALS {
459                for &scope in ALL_SCOPES {
460                    for &comp in ALL_COMPONENTS {
461                        let short = format!(
462                            "{}{}{}{}",
463                            action.short(),
464                            pos.short(),
465                            scope.short(),
466                            comp.short()
467                        );
468                        let result = parse(&short);
469                        let scope_comp_valid = is_valid_combination(scope, comp);
470                        let jump_valid =
471                            action != Action::Jump || is_valid_jump_combination(pos, comp);
472                        let delimiter_positional_valid = scope != Scope::Delimiter
473                            || !matches!(pos, Positional::Next | Positional::Previous);
474                        let should_parse =
475                            scope_comp_valid && jump_valid && delimiter_positional_valid;
476                        if should_parse {
477                            let q = result.unwrap_or_else(|e| {
478                                panic!("expected {short} to parse OK, got: {e}")
479                            });
480                            assert_eq!(q.action, action, "action mismatch for {short}");
481                            assert_eq!(q.positional, pos, "positional mismatch for {short}");
482                            assert_eq!(q.scope, scope, "scope mismatch for {short}");
483                            assert_eq!(q.component, comp, "component mismatch for {short}");
484                            assert_eq!(
485                                q.requires_lsp,
486                                scope.requires_lsp(),
487                                "requires_lsp mismatch for {short}"
488                            );
489                        } else {
490                            assert!(
491                                result.is_err(),
492                                "expected {short} to fail (invalid combo), but it parsed OK"
493                            );
494                        }
495                    }
496                }
497            }
498        }
499    }
500
501    #[test]
502    fn all_valid_long_forms_parse() {
503        for &action in ALL_ACTIONS {
504            for &pos in ALL_POSITIONALS {
505                for &scope in ALL_SCOPES {
506                    for &comp in ALL_COMPONENTS {
507                        if !is_valid_combination(scope, comp) {
508                            continue;
509                        }
510                        if action == Action::Jump && !is_valid_jump_combination(pos, comp) {
511                            continue;
512                        }
513                        if scope == Scope::Delimiter
514                            && matches!(pos, Positional::Next | Positional::Previous)
515                        {
516                            continue;
517                        }
518                        let long = format!("{action}{pos}{scope}{comp}");
519                        let result = parse(&long);
520                        let q = result
521                            .unwrap_or_else(|e| panic!("expected {long} to parse OK, got: {e}"));
522                        assert_eq!(q.action, action, "action mismatch for {long}");
523                        assert_eq!(q.positional, pos, "positional mismatch for {long}");
524                        assert_eq!(q.scope, scope, "scope mismatch for {long}");
525                        assert_eq!(q.component, comp, "component mismatch for {long}");
526                        assert_eq!(q.requires_lsp, scope.requires_lsp());
527                    }
528                }
529            }
530        }
531    }
532
533    #[test]
534    fn spot_check_change_inside_function_contents() {
535        let q = parse("cifc").unwrap();
536        assert_eq!(q.action, Action::Change);
537        assert_eq!(q.positional, Positional::Inside);
538        assert_eq!(q.scope, Scope::Function);
539        assert_eq!(q.component, Component::Contents);
540        assert!(q.requires_lsp);
541    }
542
543    #[test]
544    fn spot_check_delete_entire_line_self() {
545        let q = parse("dels").unwrap();
546        assert_eq!(q.action, Action::Delete);
547        assert_eq!(q.positional, Positional::Entire);
548        assert_eq!(q.scope, Scope::Line);
549        assert_eq!(q.component, Component::Self_);
550        assert!(!q.requires_lsp);
551    }
552
553    #[test]
554    fn spot_check_yank_entire_struct_self() {
555        let q = parse("yess").unwrap();
556        assert_eq!(q.action, Action::Yank);
557        assert_eq!(q.positional, Positional::Entire);
558        assert_eq!(q.scope, Scope::Struct);
559        assert_eq!(q.component, Component::Self_);
560        assert!(q.requires_lsp);
561    }
562
563    #[test]
564    fn spot_check_append_after_line_end() {
565        let q = parse("aale").unwrap();
566        assert_eq!(q.action, Action::Append);
567        assert_eq!(q.positional, Positional::After);
568        assert_eq!(q.scope, Scope::Line);
569        assert_eq!(q.component, Component::End);
570        assert!(!q.requires_lsp);
571    }
572
573    #[test]
574    fn spot_check_buffer_contents_is_invalid() {
575        assert!(parse("pbbc").is_err());
576    }
577
578    #[test]
579    fn spot_check_change_inside_function_beginning_is_invalid() {
580        assert!(parse("cifb").is_err());
581    }
582
583    #[test]
584    fn spot_check_replace_entire_variable_name() {
585        let q = parse("revn").unwrap();
586        assert_eq!(q.action, Action::Replace);
587        assert_eq!(q.positional, Positional::Entire);
588        assert_eq!(q.scope, Scope::Variable);
589        assert_eq!(q.component, Component::Name);
590    }
591
592    #[test]
593    fn spot_check_insert_until_member_value() {
594        let q = parse("iumv").unwrap();
595        assert_eq!(q.action, Action::Insert);
596        assert_eq!(q.positional, Positional::Until);
597        assert_eq!(q.scope, Scope::Member);
598        assert_eq!(q.component, Component::Value);
599    }
600
601    #[test]
602    fn short_form_and_long_form_equivalent() {
603        let short = parse("cifc").unwrap();
604        let long = parse("ChangeInsideFunctionContents").unwrap();
605        assert_eq!(short.action, long.action);
606        assert_eq!(short.positional, long.positional);
607        assert_eq!(short.scope, long.scope);
608        assert_eq!(short.component, long.component);
609    }
610
611    #[test]
612    fn args_target_key() {
613        let q = parse("cifc(target:getData)").unwrap();
614        assert_eq!(q.args.target_name.as_deref(), Some("getData"));
615        assert!(q.args.target_line.is_none());
616        assert!(q.args.cursor_pos.is_none());
617        assert!(q.args.value.is_none());
618    }
619
620    #[test]
621    fn args_target_key_works_for_all_lsp_scopes() {
622        let q = parse("cevv(target:myVar)").unwrap();
623        assert_eq!(q.args.target_name.as_deref(), Some("myVar"));
624        let q = parse("cesn(target:MyStruct)").unwrap();
625        assert_eq!(q.args.target_name.as_deref(), Some("MyStruct"));
626        let q = parse("cemn(target:myField)").unwrap();
627        assert_eq!(q.args.target_name.as_deref(), Some("myField"));
628    }
629
630    #[test]
631    fn args_old_scope_specific_keys_are_ignored() {
632        let q = parse("cifc(function:getData)").unwrap();
633        assert!(q.args.target_name.is_none());
634        let q = parse("cevv(variable:myVar)").unwrap();
635        assert!(q.args.target_name.is_none());
636        let q = parse("cesn(struct:MyStruct)").unwrap();
637        assert!(q.args.target_name.is_none());
638        let q = parse("cemn(member:myField)").unwrap();
639        assert!(q.args.target_name.is_none());
640        let q = parse("cifc(name:myFunc)").unwrap();
641        assert!(q.args.target_name.is_none());
642        let q = parse("cels(line:42)").unwrap();
643        assert!(q.args.target_line.is_none());
644    }
645
646    #[test]
647    fn args_target_line_number() {
648        let q = parse("cels(target:42)").unwrap();
649        assert_eq!(q.args.target_line, Some(42));
650        assert_eq!(q.args.target_name.as_deref(), Some("42"));
651    }
652
653    #[test]
654    fn args_cursor_position() {
655        let q = parse(r#"cels(cursor:"3,7")"#).unwrap();
656        assert_eq!(q.args.cursor_pos, Some((3, 7)));
657    }
658
659    #[test]
660    fn args_cursor_position_with_spaces() {
661        let q = parse(r#"cels(cursor:"0,12")"#).unwrap();
662        assert_eq!(q.args.cursor_pos, Some((0, 12)));
663    }
664
665    #[test]
666    fn args_value_plain() {
667        let q = parse("cels(value:hello)").unwrap();
668        assert_eq!(q.args.value.as_deref(), Some("hello"));
669    }
670
671    #[test]
672    fn args_value_quoted_with_spaces() {
673        let q = parse(r#"cifc(target:getData, value:"new body goes here")"#).unwrap();
674        assert_eq!(q.args.target_name.as_deref(), Some("getData"));
675        assert_eq!(q.args.value.as_deref(), Some("new body goes here"));
676    }
677
678    #[test]
679    fn args_value_with_parens_quoted() {
680        let q = parse(r#"cifp(target:getData, value:"(x: i32)")"#).unwrap();
681        assert_eq!(q.args.value.as_deref(), Some("(x: i32)"));
682    }
683
684    #[test]
685    fn args_extra_commas_ignored() {
686        let q = parse("cels(,target:1,,)").unwrap();
687        assert_eq!(q.args.target_line, Some(1));
688    }
689
690    #[test]
691    fn args_missing_value_for_target_is_none() {
692        let q = parse("cels(target:)").unwrap();
693        assert!(q.args.target_line.is_none());
694        assert!(q.args.target_name.is_none());
695    }
696
697    #[test]
698    fn args_unknown_key_is_ignored() {
699        let q = parse("cels(bogus:foo, target:2)").unwrap();
700        assert_eq!(q.args.target_line, Some(2));
701    }
702
703    #[test]
704    fn args_multiple_keys() {
705        let q = parse(r#"cifc(target:getData, value:"body")"#).unwrap();
706        assert_eq!(q.args.target_name.as_deref(), Some("getData"));
707        assert_eq!(q.args.value.as_deref(), Some("body"));
708    }
709
710    #[test]
711    fn invalid_combination_line_parameters_short() {
712        let result = parse("cilp");
713        assert!(result.is_err());
714        let msg = format!("{}", result.unwrap_err());
715        assert!(
716            msg.to_lowercase().contains("invalid"),
717            "expected 'invalid' in error: {msg}"
718        );
719    }
720
721    #[test]
722    fn invalid_combination_buffer_value_short() {
723        let result = parse("cibv");
724        assert!(result.is_err());
725    }
726
727    #[test]
728    fn invalid_combination_variable_parameters() {
729        let result = parse("civp");
730        assert!(result.is_err());
731    }
732
733    #[test]
734    fn invalid_combination_struct_arguments() {
735        let result = parse("cisa");
736        assert!(result.is_err());
737    }
738
739    #[test]
740    fn invalid_combination_long_form() {
741        let result = parse("ChangeInsideLineParameters");
742        assert!(result.is_err());
743    }
744
745    #[test]
746    fn invalid_combination_long_form_buffer_value() {
747        let result = parse("ChangeInsideBufferValue");
748        assert!(result.is_err());
749    }
750
751    #[test]
752    fn empty_input_errors() {
753        let result = parse("");
754        assert!(result.is_err());
755        let msg = format!("{}", result.unwrap_err());
756        assert!(msg.contains("empty"));
757    }
758
759    #[test]
760    fn whitespace_only_errors() {
761        let result = parse("   ");
762        assert!(result.is_err());
763    }
764
765    #[test]
766    fn unknown_short_chord_errors() {
767        let result = parse("zzzz");
768        assert!(result.is_err());
769    }
770
771    #[test]
772    fn near_miss_suggests_correction() {
773        let result = parse("xifv");
774        assert!(result.is_err());
775        let msg = format!("{}", result.unwrap_err());
776        assert!(
777            msg.contains("did you mean"),
778            "expected suggestion in error message: {msg}"
779        );
780    }
781
782    #[test]
783    fn whitespace_trimmed_around_chord() {
784        let q = parse("  cifc  ").unwrap();
785        assert_eq!(q.action, Action::Change);
786        assert_eq!(q.positional, Positional::Inside);
787        assert_eq!(q.scope, Scope::Function);
788        assert_eq!(q.component, Component::Contents);
789    }
790
791    #[test]
792    fn short_form_sets_requires_lsp_false_for_line_and_buffer() {
793        assert!(!parse("cels").unwrap().requires_lsp);
794        assert!(!parse("cebs").unwrap().requires_lsp);
795    }
796
797    #[test]
798    fn short_form_sets_requires_lsp_true_for_lsp_scopes() {
799        assert!(parse("cefs").unwrap().requires_lsp);
800        assert!(parse("cevs").unwrap().requires_lsp);
801        assert!(parse("cess").unwrap().requires_lsp);
802        assert!(parse("cems").unwrap().requires_lsp);
803    }
804
805    #[test]
806    fn long_form_self_component_accepted() {
807        let q = parse("ChangeEntireLineSelf").unwrap();
808        assert_eq!(q.component, Component::Self_);
809    }
810
811    #[test]
812    fn unterminated_paren_errors() {
813        let result = parse("cifv(target:1");
814        assert!(result.is_err());
815        assert!(format!("{}", result.unwrap_err()).contains("unterminated"));
816    }
817
818    #[test]
819    fn args_parent_key() {
820        let q = parse("cemv(target:x, parent:Foo)").unwrap();
821        assert_eq!(q.args.target_name.as_deref(), Some("x"));
822        assert_eq!(q.args.parent_name.as_deref(), Some("Foo"));
823    }
824
825    #[test]
826    fn args_find_replace_keys() {
827        let q = parse(r#"rels(target:0, find:"foo", replace:"bar")"#).unwrap();
828        assert_eq!(q.args.find.as_deref(), Some("foo"));
829        assert_eq!(q.args.replace.as_deref(), Some("bar"));
830    }
831
832    #[test]
833    fn unicode_input_does_not_panic_in_suggest() {
834        let result = parse("cłfv");
835        assert!(result.is_err());
836    }
837
838    // --- work item 0005: Jump / To / Delimiter ---
839
840    #[test]
841    fn jump_outside_invalid_component_rejects_with_direction_hint() {
842        // joln = Jump Outside Line Name — valid scope/component, invalid jump+outside
843        let result = parse("joln");
844        assert!(result.is_err());
845        let msg = format!("{}", result.unwrap_err());
846        assert!(
847            msg.contains("Beginning") || msg.contains("End") || msg.contains("direction"),
848            "expected direction hint in error: {msg}"
849        );
850    }
851
852    #[test]
853    fn jump_outside_beginning_and_end_are_valid() {
854        // jolb = Jump Outside Line Beginning; jole = Jump Outside Line End
855        assert!(parse("jolb").is_ok(), "jolb should parse OK");
856        assert!(parse("jole").is_ok(), "jole should parse OK");
857    }
858
859    #[test]
860    fn jump_outside_other_components_fail() {
861        // Name and Self_ are valid scope/component combos but invalid for Jump+Outside
862        assert!(parse("joln").is_err(), "joln (Name) should fail");
863        assert!(parse("jols").is_err(), "jols (Self_) should fail");
864        // Parameters is a valid Function component but invalid for Jump+Outside
865        assert!(parse("jofp").is_err(), "jofp (Parameters) should fail");
866    }
867
868    #[test]
869    fn jump_non_outside_valid_combinations() {
870        assert!(
871            parse("jtfc").is_ok(),
872            "jtfc (To Function Contents) should parse OK"
873        );
874        assert!(
875            parse("jnfn").is_ok(),
876            "jnfn (Next Function Name) should parse OK"
877        );
878        assert!(
879            parse("jifc").is_ok(),
880            "jifc (Inside Function Contents) should parse OK"
881        );
882    }
883
884    #[test]
885    fn jump_with_value_argument_rejects() {
886        let result = parse(r#"jtfc(value:"text")"#);
887        assert!(result.is_err());
888        let msg = format!("{}", result.unwrap_err());
889        assert!(
890            msg.contains("value") || msg.contains("Jump"),
891            "expected value/Jump in error: {msg}"
892        );
893    }
894
895    #[test]
896    fn jump_bare_short_form_no_args_required() {
897        assert!(parse("jtfc").is_ok());
898        assert!(parse("jolb").is_ok());
899        assert!(parse("jefc").is_ok());
900    }
901
902    #[test]
903    fn delimiter_scope_next_positional_rejects() {
904        // cnds = Change Next Delimiter Self_ — Delimiter does not support Next
905        let result = parse("cnds");
906        assert!(result.is_err());
907        let msg = format!("{}", result.unwrap_err());
908        assert!(
909            msg.contains("Delimiter") || msg.contains("Next") || msg.contains("Previous"),
910            "expected Delimiter/Next in error: {msg}"
911        );
912    }
913
914    #[test]
915    fn delimiter_scope_previous_positional_rejects() {
916        // cpds = Change Previous Delimiter Self_
917        let result = parse("cpds");
918        assert!(result.is_err());
919    }
920}