Skip to content

Commit a7ab66d

Browse files
bartlomiejuclaude
andcommitted
fix(jupyter): implement is_complete_request with bracket balancing
Instead of always replying "complete", check whether the code has balanced brackets, braces, parens, and terminated strings/comments. Returns "incomplete" for unterminated constructs and "invalid" for mismatched delimiters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21301ed commit a7ab66d

File tree

1 file changed

+187
-4
lines changed

1 file changed

+187
-4
lines changed

cli/tools/jupyter/server.rs

Lines changed: 187 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,9 @@ impl JupyterServer {
433433
.await?;
434434
}
435435

436-
JupyterMessageContent::IsCompleteRequest(_) => {
437-
connection
438-
.send(messaging::IsCompleteReply::complete().as_child_of(parent))
439-
.await?;
436+
JupyterMessageContent::IsCompleteRequest(req) => {
437+
let reply = check_is_complete(&req.code);
438+
connection.send(reply.as_child_of(parent)).await?;
440439
}
441440
JupyterMessageContent::KernelInfoRequest(_) => {
442441
connection.send(kernel_info().as_child_of(parent)).await?;
@@ -755,6 +754,190 @@ async fn publish_result(
755754
Ok(None)
756755
}
757756

757+
/// Check whether code is complete (all brackets/braces/parens balanced
758+
/// and no trailing backslash or unterminated template literal).
759+
/// This is a heuristic — it ignores delimiters inside strings and comments
760+
/// but covers the common interactive cases.
761+
fn check_is_complete(code: &str) -> messaging::IsCompleteReply {
762+
let mut stack: Vec<char> = Vec::new();
763+
let mut chars = code.chars().peekable();
764+
while let Some(ch) = chars.next() {
765+
match ch {
766+
// Skip single-line comments
767+
'/' if chars.peek() == Some(&'/') => {
768+
for c in chars.by_ref() {
769+
if c == '\n' {
770+
break;
771+
}
772+
}
773+
}
774+
// Skip multi-line comments
775+
'/' if chars.peek() == Some(&'*') => {
776+
chars.next(); // consume '*'
777+
let mut closed = false;
778+
while let Some(c) = chars.next() {
779+
if c == '*' && chars.peek() == Some(&'/') {
780+
chars.next();
781+
closed = true;
782+
break;
783+
}
784+
}
785+
if !closed {
786+
return messaging::IsCompleteReply::incomplete("".into());
787+
}
788+
}
789+
// Skip string literals
790+
'\'' | '"' | '`' => {
791+
let quote = ch;
792+
let mut escaped = false;
793+
let mut closed = false;
794+
for c in chars.by_ref() {
795+
if escaped {
796+
escaped = false;
797+
continue;
798+
}
799+
if c == '\\' {
800+
escaped = true;
801+
continue;
802+
}
803+
if c == quote {
804+
closed = true;
805+
break;
806+
}
807+
}
808+
if !closed {
809+
return messaging::IsCompleteReply::incomplete("".into());
810+
}
811+
}
812+
'(' | '[' | '{' => stack.push(ch),
813+
')' => {
814+
if stack.pop() != Some('(') {
815+
return messaging::IsCompleteReply::invalid();
816+
}
817+
}
818+
']' => {
819+
if stack.pop() != Some('[') {
820+
return messaging::IsCompleteReply::invalid();
821+
}
822+
}
823+
'}' => {
824+
if stack.pop() != Some('{') {
825+
return messaging::IsCompleteReply::invalid();
826+
}
827+
}
828+
_ => {}
829+
}
830+
}
831+
if stack.is_empty() {
832+
messaging::IsCompleteReply::complete()
833+
} else {
834+
messaging::IsCompleteReply::incomplete(" ".into())
835+
}
836+
}
837+
838+
#[cfg(test)]
839+
mod tests {
840+
use jupyter_protocol::messaging::IsCompleteReplyStatus;
841+
842+
use super::*;
843+
844+
#[test]
845+
fn test_complete_simple_statement() {
846+
let reply = check_is_complete("const x = 1");
847+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
848+
}
849+
850+
#[test]
851+
fn test_incomplete_open_brace() {
852+
let reply = check_is_complete("const x = {");
853+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
854+
}
855+
856+
#[test]
857+
fn test_incomplete_open_paren() {
858+
let reply = check_is_complete("function foo(");
859+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
860+
}
861+
862+
#[test]
863+
fn test_incomplete_open_bracket() {
864+
let reply = check_is_complete("const arr = [1, 2,");
865+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
866+
}
867+
868+
#[test]
869+
fn test_complete_balanced_braces() {
870+
let reply = check_is_complete("if (true) { console.log(1) }");
871+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
872+
}
873+
874+
#[test]
875+
fn test_complete_multiline() {
876+
let reply = check_is_complete("function foo() {\n return 1;\n}");
877+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
878+
}
879+
880+
#[test]
881+
fn test_invalid_extra_close() {
882+
let reply = check_is_complete("const x = 1 }");
883+
assert_eq!(reply.status, IsCompleteReplyStatus::Invalid);
884+
}
885+
886+
#[test]
887+
fn test_invalid_mismatched() {
888+
let reply = check_is_complete("const x = (]");
889+
assert_eq!(reply.status, IsCompleteReplyStatus::Invalid);
890+
}
891+
892+
#[test]
893+
fn test_ignores_brackets_in_strings() {
894+
let reply = check_is_complete(r#"const x = "hello { world""#);
895+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
896+
}
897+
898+
#[test]
899+
fn test_ignores_brackets_in_single_line_comment() {
900+
let reply = check_is_complete("const x = 1 // {");
901+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
902+
}
903+
904+
#[test]
905+
fn test_ignores_brackets_in_multiline_comment() {
906+
let reply = check_is_complete("const x = 1 /* { */");
907+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
908+
}
909+
910+
#[test]
911+
fn test_incomplete_unterminated_string() {
912+
let reply = check_is_complete(r#"const x = "hello"#);
913+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
914+
}
915+
916+
#[test]
917+
fn test_incomplete_unterminated_template_literal() {
918+
let reply = check_is_complete("const x = `hello ${name}");
919+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
920+
}
921+
922+
#[test]
923+
fn test_incomplete_unterminated_multiline_comment() {
924+
let reply = check_is_complete("/* this comment never ends");
925+
assert_eq!(reply.status, IsCompleteReplyStatus::Incomplete);
926+
}
927+
928+
#[test]
929+
fn test_complete_empty() {
930+
let reply = check_is_complete("");
931+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
932+
}
933+
934+
#[test]
935+
fn test_escaped_quote_in_string() {
936+
let reply = check_is_complete(r#"const x = "hello \" world""#);
937+
assert_eq!(reply.status, IsCompleteReplyStatus::Complete);
938+
}
939+
}
940+
758941
// TODO(bartlomieju): dedup with repl::editor
759942
fn get_expr_from_line_at_pos(line: &str, cursor_pos: usize) -> &str {
760943
let start = line[..cursor_pos].rfind(is_word_boundary).unwrap_or(0);

0 commit comments

Comments
 (0)