Skip to content

Implement CPython-like implicit string concatenation in parser - #4097

Merged
youknowone merged 12 commits into
RustPython:mainfrom
astral-sh:charlie/joined-str
Aug 21, 2022
Merged

youknowone merged 12 commits into
RustPython:mainfrom
astral-sh:charlie/joined-str

Conversation

@charliermarsh

Copy link
Copy Markdown
Collaborator

Summary

This PR attempts to address #4095 by modifying the parser's handling of implicit string concatenation to better match CPython's behavior. Specifically, rather than always returning a JoinedStr in the presence of multiple substrings, we return a single Constant if all the substrings are constant, and a single JoinedStr if any of the substrings are JoinedStr, but with the sub-expressions flattened as much as possible.

@charliermarsh

Copy link
Copy Markdown
Collaborator Author

(High- and low-level feedback welcome!)

@fanninpm

Copy link
Copy Markdown
Contributor

@youknowone does this conflict with #4090?

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! The approach seems convincing. I left a few comments about high-level decisions.

@fanninpm conflicts happen everyday and we always do rebase. It will not be a big deal.

Comment thread parser/src/string.rs Resolved
Comment thread parser/src/string.rs Outdated
Comment on lines +16 to +19
if values
.iter()
.all(|value| matches!(value.node, ExprKind::Constant { .. }))
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this condition seems to be handled by compiler instead of parser

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But:

import ast

expr = """(
    'Hello, '
    'world!'
)"""

print(ast.dump(ast.parse(expr)))

Returns: Module(body=[Expr(value=Constant(value='Hello, world!', kind=None))], type_ignores=[]).

So my interpretation was that the CPython AST parser was handling this condition. Unless I'm misunderstanding.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I was not understanding this part that well.
I think this is a special case handling to check if it contains an f-string or not.
The other logic part looks same to the general case.
How about having a flag to check it? Then we don't need to keep the same error handling logic for both branch.

Comment thread parser/src/string.rs Resolved
Comment thread parser/src/string.rs Outdated
} else {
return Err(LexicalError {
location,
error: LexicalErrorType::OtherError(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be preferable to define a custom error type here, as with f-strings?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is not possible to hit if parser doesn't have a bug. How about panic?

Comment thread parser/src/string.rs Resolved
@youknowone

Copy link
Copy Markdown
Member

@fanninpm oh, if you wanted to asked to check if #4042 has string changes to conflict, the answer is still no. only ! conversions changed in the update.

Comment thread parser/src/string.rs Outdated
.map(|value| match value.node {
ExprKind::JoinedStr { values } => Ok(values),
ExprKind::Constant { .. } => Ok(vec![value]),
_ => Err(LexicalError {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering structuring this code such that I iterate once upfront to ensure that I only have these expression types (maybe even cast them to a custom Enum that's distinct from ExprKind?), so that I don't have to handle these mismatch errors multiple times.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggested a way to use StringKind instead of Expr. That will be helpful if you want to go.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I learned more from your comments.

Here is 2 choices of suggestions. Minimal fix or more refactoring.

Because I think the string-only branch is duplicated logic, I want to remove it.

Suggestion 1. If you want to finish this PR quickly, please remove the string-only branch and use has_fstring flag I suggested to decide to create JoinedStr. That will be good enough. And we can refactor it later.
Suggestion 2. If you want to fully refactor the string processing, please move the whole string expr handling to the new function and use the StringKind instead of ast::ExprKind matches.

Comment thread parser/src/string.rs Resolved
Comment thread parser/src/string.rs Outdated
} else {
return Err(LexicalError {
location,
error: LexicalErrorType::OtherError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is not possible to hit if parser doesn't have a bug. How about panic?

Comment thread parser/src/python.lalrpop Outdated
} else {
values.into_iter().next().unwrap()
})
parse_implicit_concatenation(values).map_err(|e| e.into())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the similar part in cpython is _PyPegen_concatenate_strings

Comment thread parser/src/string.rs Outdated
Comment on lines +16 to +19
if values
.iter()
.all(|value| matches!(value.node, ExprKind::Constant { .. }))
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I was not understanding this part that well.
I think this is a special case handling to check if it contains an f-string or not.
The other logic part looks same to the general case.
How about having a flag to check it? Then we don't need to keep the same error handling logic for both branch.

Comment thread parser/src/python.lalrpop Outdated
Comment on lines +970 to +971
let values = values.collect::<Result<Vec<_>, _>>()?;

Ok(if values.len() > 1 {
ast::Expr::new(location, ast::ExprKind::JoinedStr { values })
} else {
values.into_iter().next().unwrap()
})
parse_implicit_concatenation(values).map_err(|e| e.into())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole process(L959-975) looks like to be fit in parse_implicit_concatenation.
Creating Expr or Constant here is looking too eager now - which was previously ok but we can handle it better because you enhanced it a lot.
Having f-string marker will be easier (which can be used to decide to make JoinedStr)

Comment thread parser/src/string.rs Resolved
Comment thread parser/src/string.rs Outdated
Comment on lines +16 to +18
if values
.iter()
.all(|value| matches!(value.node, ExprKind::Constant { .. }))

@youknowone youknowone Aug 18, 2022 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you'd like to do the simpler fix,

Suggested change
if values
.iter()
.all(|value| matches!(value.node, ExprKind::Constant { .. }))
let has_fstring = values
.iter()
.any(|value| !matches!(value.node, ExprKind::Constant { .. }));

Otherwise it will be removed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I still need this has_fstring flag and logic, even after the refactor, since I need this at the end:

if has_fstring {
    Expr::new(initial_location, ExprKind::JoinedStr { values: deduped })
} else {
    deduped
        .into_iter()
        .exactly_one()
        .expect("String must be concatenated to a single element.")
}

...but correct me if I'm wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(In theory, if values at the end is just a single Expr, and it's a constant, we could just return that -- but CPython seems to return a JoinedStr with one constant child, so we'd be deviating from their behavior IIUC.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yep. Sorry for poor explanation. we need has_fstring, but not by iteration for its own. we can manage a mutable variable to update when it hits fstring branch later.

Comment thread parser/src/string.rs Resolved
@charliermarsh

Copy link
Copy Markdown
Collaborator Author

Awesome, thanks tons @youknowone, I'm learning a lot from your comments. Will do another pass on this and figure out which of the two suggestions I want to pursue.

@charliermarsh

Copy link
Copy Markdown
Collaborator Author

@youknowone - I went for Suggestion 2, attempted to refactor to use StringKind, etc.

Comment thread parser/Cargo.toml Outdated
unicode_names2 = "0.5.0"
phf = "0.10.1"
ahash = "0.7.6"
itertools = "0.10.3"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Had to add this to the parser, assume that's ok? Also, unclear if you want to keep these sorted.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorting will be good. this is also ok.

Comment thread parser/src/string.rs Resolved
Comment thread parser/src/string.rs Outdated
let mut deduped: Vec<Expr> = vec![];
let mut current: Vec<String> = vec![];
for value in values {
match value.node {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

match here is actually matching StringKind::F or not.
The entire upper match can be moved here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to consolidate but I believe I still need an inner match?

An example would be something like:

(
  "foo"
  f"bar{1}baz"
)

In this case, we want to produce JoinedStr([Constant("foobar"), FormattedValue(1), Constant("baz")]). So I have to iterate over the values of f-string within the StringKind match, IIUC.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would you also rebase it to our main branch? parser is recently updated to 3.10 compliant version.

Comment thread parser/Cargo.toml Resolved
Comment thread parser/src/string.rs Outdated
StringKind::Normal => current.push(string),
StringKind::U => current.push(string),
StringKind::F => {
if let ExprKind::JoinedStr { values } = parse_located_fstring(&string, location)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably parse_located_fstring could now return Vec<Expr>?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, that makes sense! we never use the returned JoinedStr

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do this in a separate PR.

@charliermarsh

Copy link
Copy Markdown
Collaborator Author

(Rebased!)

Comment thread parser/src/string.rs Outdated
use crate::token::StringKind;
use itertools::Itertools;

pub fn parse_implicit_concatenation(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function is not only handling concatenation but strings itself now

Suggested change
pub fn parse_implicit_concatenation(
pub fn parse_strings(

Comment thread parser/src/string.rs
pub fn parse_strings(
values: Vec<(Location, (String, StringKind))>,
) -> Result<Expr, LexicalError> {
pub fn parse_strings(values: Vec<(Location, (String, StringKind))>) -> Result<Expr, LexicalError> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Gah, thank you, forgot to cargo fmt.)

@charliermarsh

Copy link
Copy Markdown
Collaborator Author

Good changes, thanks @youknowone!

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I made a few minor changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants