Skip to content

Commit e257c5a

Browse files
authored
Add support for help end IPython escape commands (#6358)
## Summary This PR adds support for a stricter version of help end escape commands[^1] in the parser. By stricter, I mean that the escape tokens are only at the end of the command and there are no tokens at the start. This makes it difficult to implement it in the lexer without having to do a lot of look aheads or keeping track of previous tokens. Now, as we're adding this in the parser, the lexer needs to recognize and emit a new token for `?`. So, `Question` token is added which will be recognized only in `Jupyter` mode. The conditions applied are the same as the ones in the original implementation in IPython codebase (which is a regex): * There can only be either 1 or 2 question mark(s) at the end * The node before the question mark can be a `Name`, `Attribute`, `Subscript` (only with integer constants in slice position), or any combination of the 3 nodes. ## Test Plan Added test cases for various combination of the possible nodes in the command value position and update the snapshots. fixes: #6359 fixes: #5030 (This is the final piece) [^1]: #6272 (comment)
1 parent 887a47c commit e257c5a

File tree

6 files changed

+20449
-14961
lines changed

6 files changed

+20449
-14961
lines changed

crates/ruff_python_parser/src/lexer.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,9 @@ impl<'source> Lexer<'source> {
780780

781781
self.lex_magic_command(kind)
782782
}
783+
784+
'?' if self.mode == Mode::Jupyter => Tok::Question,
785+
783786
'/' => {
784787
if self.cursor.eat_char('=') {
785788
Tok::SlashEqual

crates/ruff_python_parser/src/parser.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,15 @@ foo = %foo \
11801180
11811181
% foo
11821182
foo = %foo # comment
1183+
1184+
# Help end line magics
1185+
foo?
1186+
foo.bar??
1187+
foo.bar.baz?
1188+
foo[0]??
1189+
foo[0][1]?
1190+
foo.bar[0].baz[1]??
1191+
foo.bar[0].baz[2].egg??
11831192
"#
11841193
.trim(),
11851194
Mode::Jupyter,

crates/ruff_python_parser/src/python.lalrpop

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::{
1414
string::parse_strings,
1515
token::{self, StringKind},
1616
};
17+
use lalrpop_util::ParseError;
1718

1819
grammar(mode: Mode);
1920

@@ -89,6 +90,7 @@ SmallStatement: ast::Stmt = {
8990
AssertStatement,
9091
TypeAliasStatement,
9192
LineMagicStatement,
93+
HelpEndLineMagic,
9294
};
9395

9496
PassStatement: ast::Stmt = {
@@ -366,6 +368,78 @@ LineMagicExpr: ast::Expr = {
366368
}
367369
}
368370

371+
HelpEndLineMagic: ast::Stmt = {
372+
// We are permissive than the original implementation because we would allow whitespace
373+
// between the expression and the suffix while the IPython implementation doesn't allow it.
374+
// For example, `foo ?` would be valid in our case but invalid from IPython.
375+
<location:@L> <e:Expression<"All">> <suffix:("?")+> <end_location:@R> =>? {
376+
fn unparse_expr(expr: &ast::Expr, buffer: &mut String) -> Result<(), LexicalError> {
377+
match expr {
378+
ast::Expr::Name(ast::ExprName { id, .. }) => {
379+
buffer.push_str(id.as_str());
380+
},
381+
ast::Expr::Subscript(ast::ExprSubscript { value, slice, range, .. }) => {
382+
let ast::Expr::Constant(ast::ExprConstant { value: ast::Constant::Int(integer), .. }) = slice.as_ref() else {
383+
return Err(LexicalError {
384+
error: LexicalErrorType::OtherError("only integer constants are allowed in Subscript expressions in help end escape command".to_string()),
385+
location: range.start(),
386+
});
387+
};
388+
unparse_expr(value, buffer)?;
389+
buffer.push('[');
390+
buffer.push_str(&format!("{}", integer));
391+
buffer.push(']');
392+
},
393+
ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) => {
394+
unparse_expr(value, buffer)?;
395+
buffer.push('.');
396+
buffer.push_str(attr.as_str());
397+
},
398+
_ => {
399+
return Err(LexicalError {
400+
error: LexicalErrorType::OtherError("only Name, Subscript and Attribute expressions are allowed in help end escape command".to_string()),
401+
location: expr.range().start(),
402+
});
403+
}
404+
}
405+
Ok(())
406+
}
407+
408+
if mode != Mode::Jupyter {
409+
return Err(ParseError::User {
410+
error: LexicalError {
411+
error: LexicalErrorType::OtherError("IPython escape commands are only allowed in Jupyter mode".to_string()),
412+
location,
413+
},
414+
});
415+
}
416+
417+
let kind = match suffix.len() {
418+
1 => MagicKind::Help,
419+
2 => MagicKind::Help2,
420+
_ => {
421+
return Err(ParseError::User {
422+
error: LexicalError {
423+
error: LexicalErrorType::OtherError("maximum of 2 `?` tokens are allowed in help end escape command".to_string()),
424+
location,
425+
},
426+
});
427+
}
428+
};
429+
430+
let mut value = String::new();
431+
unparse_expr(&e, &mut value)?;
432+
433+
Ok(ast::Stmt::LineMagic(
434+
ast::StmtLineMagic {
435+
kind,
436+
value,
437+
range: (location..end_location).into()
438+
}
439+
))
440+
}
441+
}
442+
369443
CompoundStatement: ast::Stmt = {
370444
MatchStatement,
371445
IfStatement,
@@ -1732,6 +1806,7 @@ extern {
17321806
Dedent => token::Tok::Dedent,
17331807
StartModule => token::Tok::StartModule,
17341808
StartExpression => token::Tok::StartExpression,
1809+
"?" => token::Tok::Question,
17351810
"+" => token::Tok::Plus,
17361811
"-" => token::Tok::Minus,
17371812
"~" => token::Tok::Tilde,

0 commit comments

Comments
 (0)