diff --git a/crates/forge_domain/src/attachment.rs b/crates/forge_domain/src/attachment.rs index 58019ffb3c..6e589249fd 100644 --- a/crates/forge_domain/src/attachment.rs +++ b/crates/forge_domain/src/attachment.rs @@ -128,6 +128,49 @@ pub struct FileTag { pub symbol: Option, } +/// Recognizes a balanced `[...]` group, including any nested balanced groups. +/// +/// Used to absorb bracket pairs that appear inside file paths (such as Next.js +/// dynamic route segments like `[locale]` or `[[...slug]]`) so the outer path +/// parser does not terminate prematurely on the first `]`. +fn parse_balanced_brackets(input: &str) -> nom::IResult<&str, &str> { + use nom::Parser; + use nom::branch::alt; + use nom::bytes::complete::take_while1; + use nom::character::complete::char; + use nom::combinator::recognize; + use nom::multi::many0; + use nom::sequence::delimited; + + recognize(delimited( + char('['), + many0(alt(( + parse_balanced_brackets, + take_while1(|c: char| c != '[' && c != ']'), + ))), + char(']'), + )) + .parse(input) +} + +/// Recognizes a path segment that may contain balanced `[...]` groups. +/// +/// Stops at the first `:`, `#`, or `]` that is not nested inside a balanced +/// bracket pair. Requires at least one consumed character. +fn parse_path_segment(input: &str) -> nom::IResult<&str, &str> { + use nom::Parser; + use nom::branch::alt; + use nom::bytes::complete::take_while1; + use nom::combinator::recognize; + use nom::multi::many1; + + recognize(many1(alt(( + parse_balanced_brackets, + take_while1(|c: char| c != '[' && c != ']' && c != ':' && c != '#'), + )))) + .parse(input) +} + impl FileTag { pub fn parse(input: &str) -> nom::IResult<&str, FileTag> { use nom::bytes::complete::take_while1; @@ -154,10 +197,10 @@ impl FileTag { nom::combinator::recognize(( nom::character::complete::satisfy(|c| c.is_ascii_alphabetic()), nom::character::complete::char(':'), - take_while1(|c: char| c != ':' && c != '#' && c != ']'), + parse_path_segment, )), // Fall back to regular path parsing - take_while1(|c: char| c != ':' && c != '#' && c != ']'), + parse_path_segment, )); let mut parser = delimited( tag("@["), @@ -563,4 +606,111 @@ mod tests { assert!(paths.contains(&expected_unix)); assert!(paths.contains(&expected_windows)); } + + #[test] + fn test_attachment_parse_nextjs_dynamic_route() { + let text = String::from("Open @[/src/app/[locale]/layout.tsx]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[locale]/layout.tsx".to_string(), + loc: None, + symbol: None, + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_nextjs_dynamic_route_with_location() { + let text = String::from("Open @[/src/app/[locale]/layout.tsx:10:20]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[locale]/layout.tsx".to_string(), + loc: Some(Location { start: Some(10), end: Some(20) }), + symbol: None, + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_nextjs_catch_all_route() { + let text = String::from("Open @[/src/app/[...slug]/page.tsx]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[...slug]/page.tsx".to_string(), + loc: None, + symbol: None, + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_nextjs_optional_catch_all_route() { + let text = String::from("Open @[/src/app/[[...slug]]/page.tsx#Page]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[[...slug]]/page.tsx".to_string(), + loc: None, + symbol: Some("Page".to_string()), + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_nextjs_multiple_dynamic_segments() { + let text = String::from("Open @[/src/app/[locale]/blog/[slug]/page.tsx:5#Component]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[locale]/blog/[slug]/page.tsx".to_string(), + loc: Some(Location { start: Some(5), end: None }), + symbol: Some("Component".to_string()), + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_windows_dynamic_route() { + let text = String::from("Open @[C:\\project\\src\\app\\[locale]\\layout.tsx]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "C:\\project\\src\\app\\[locale]\\layout.tsx".to_string(), + loc: None, + symbol: None, + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } + + #[test] + fn test_attachment_parse_many_square_brackets() { + // Real-world example: deeply nested or heavily-bracketed Next.js routes + let text = + String::from("Open @[/src/app/[locale]/[version]/[...path]/[[...rest]]/page.tsx:1:10]"); + let paths = Attachment::parse_all(text); + assert_eq!(paths.len(), 1); + + let expected = FileTag { + path: "/src/app/[locale]/[version]/[...path]/[[...rest]]/page.tsx".to_string(), + loc: Some(Location { start: Some(1), end: Some(10) }), + symbol: None, + }; + let actual = paths.first().unwrap(); + assert_eq!(actual, &expected); + } } diff --git a/crates/forge_main/src/zsh/plugin.rs b/crates/forge_main/src/zsh/plugin.rs index b54e3e9baa..c91ee70a9a 100644 --- a/crates/forge_main/src/zsh/plugin.rs +++ b/crates/forge_main/src/zsh/plugin.rs @@ -426,6 +426,20 @@ mod tests { assert_eq!(actual, expected); } + /// Regression: forge keybindings must survive zsh-vi-mode's `zvm_init` + /// by re-applying via `zvm_after_init_commands` (#2681). + #[test] + fn test_generated_plugin_registers_zvm_after_init_hook() { + use pretty_assertions::assert_eq; + + let fixture = generate_zsh_plugin().unwrap(); + let actual = fixture.contains("function _forge_apply_keybindings()") + && fixture.contains("typeset -ga zvm_after_init_commands") + && fixture.contains("zvm_after_init_commands+=('_forge_apply_keybindings')"); + let expected = true; + assert_eq!(actual, expected); + } + #[test] fn test_setup_zsh_integration_without_nerd_font_config() { use tempfile::TempDir; diff --git a/shell-plugin/lib/bindings.zsh b/shell-plugin/lib/bindings.zsh index 432d0cb421..0476dd43cf 100644 --- a/shell-plugin/lib/bindings.zsh +++ b/shell-plugin/lib/bindings.zsh @@ -35,11 +35,17 @@ function forge-bracketed-paste() { zle reset-prompt } -# Register the bracketed paste widget to fix highlighting on paste -zle -N bracketed-paste forge-bracketed-paste +# Re-applied after zsh-vi-mode's `zvm_init` precmd hook, which rebuilds the +# main/viins/vicmd keymaps and otherwise silently clobbers these bindings. +function _forge_apply_keybindings() { + zle -N bracketed-paste forge-bracketed-paste + bindkey '^M' forge-accept-line + bindkey '^J' forge-accept-line + bindkey '^I' forge-completion +} + +_forge_apply_keybindings -# Bind Enter to our custom accept-line that transforms :commands -bindkey '^M' forge-accept-line -bindkey '^J' forge-accept-line -# Update the Tab binding to use the new completion widget -bindkey '^I' forge-completion # Tab for both @ and :command completion +# Harmless no-op when zsh-vi-mode (jeffreytse/zsh-vi-mode) isn't loaded. +typeset -ga zvm_after_init_commands +zvm_after_init_commands+=('_forge_apply_keybindings')