Repository: rust-analyzer/rust-analyzer
Pull request #6587 (2020-11-17 21:44:52)

SSR: Support statement matching and replacing

For #3186

Hi!

This is a smaller initial patchset that came up while working on support for statement lists (and my first time working on RA :grin:). It has me stuck on trailing semicolons for which I hope to receive some feedback. Matching (and replacing) `let` bindings with a trailing semicolon works fine, but trying to omit these (to make patterns more ergonomic) turns out more complex than expected.

The "optional trailing semicolon solution" implemented in this PR is ugly because `Matcher::attempt_match_token` should only consume a trailing `;` when parsing `let` bindings to prevent other code from breaking. That at the same time has a nasty side-effect of `;` ending up in the matched code: any replacements on that should include the trailing semicolon as well even if it was not in the pattern. A better example is in the tests:

https://github.com/rust-analyzer/rust-analyzer/blob/3ae1649c24a689473b874c331f5f176e5839978e/crates/ssr/src/tests.rs#L178-L184

The end result to achieve is (I guess) allowing replacement of let bindings without trailing semicolon like `let x = $a ==>> let x = 1` (but including them on both sides is still fine), and should make replacement in a macro call (where `foo!(let a = 2;)` for a `$x:stmt` is invalid syntax) possible as well. That should allow to enable/fix these tests:

https://github.com/rust-analyzer/rust-analyzer/blob/3ae1649c24a689473b874c331f5f176e5839978e/crates/ssr/src/tests.rs#L201-L214

A possible MVP of this PR might be to drop this optional `;' handling entirely and only allow an SSR pattern/template with semicolons on either side.

MarijnS95: Marking this PR ready for review to capture suggestions and a general (n)ack on the way this is implemented. Note that the code and commit history are still messy, that will be cleaned up in due time when the path forward is cleared up :slightly_smiling_face: 
matklad: cc @davidlattimore 
davidlattimore: Nice to see this being worked on! You're right that the optional semicolon handling is adding quite a bit of complexity. My personal feeling would be that making semicolons non-optional would be reasonable for a first cut of the feature.

If you do want to make semicolons optional, I think it could be done more simply. I'd be inclined to allow `let $a = $b` to match `let x = 10;` (including the semicolon). The semicolon is part of the let statement and trying to match only part of a syntax node is I think what's adding lots of the complexity. Allowing this would only require a couple of extra lines of code in `attempt_match_token` - if the pattern is at the end, the current code token is a semicolon and the parent node is a let-statement then accept the match.

You'd then need a little extra code in `replacing.rs` to add an extra semicolon if the replacement node is a statement, but doesn't end in a semicolon.
MarijnS95: @davidlattimore That suggestion is implemented in the first 5 commits, up to and including be6b4c379. That last commit makes sure optional trailing semicolons are only allowed for `let` patterns, otherwise tons of tests break.

With that, as you say the replacement code needs an update to insert missing trailing semicolons where necessary.

I'm hesitant to implement it that way though because we're going to need partial match ranges _anyway_ for statements lists - where one usually only wants to match a few consecutive statements in a block. At that point this arguably hacky approach can be stripped out again, and have a consistent user experience as well (ie. replacing `let x=y; ==>> let x=y` should make the semicolon disappear, replacing `let x=y ==>> let x=y;` should make a second one appear). Also, what would - and should - happen when replacing a let binding with something different entirely? If we insert a trailing semicolon for every statement that lacks one it might not be what the user intended. It can possibly be limited to instances where the matched block was a let binding with semicolon.

In the end we should start with some test cases covering these, especially where the user intends to change trailing colon rules - and compare the simplified approach with match ranges.
davidlattimore: Do you think it might make sense then to leave optional semicolons until after multi-statement support is implemented?
MarijnS95: @davidlattimore I am not sure. Implementing it the way you suggested turns out fairly trivial, but the aforementioned hypothetical test/usecases fail as expected:

https://github.com/rust-analyzer/rust-analyzer/blob/a87293b45f1679d42a9f1568ef65ff76d98c61c6/crates/ssr/src/tests.rs#L222-L227

https://github.com/rust-analyzer/rust-analyzer/blob/a87293b45f1679d42a9f1568ef65ff76d98c61c6/crates/ssr/src/tests.rs#L239-L244

Afaik this should be fixable earlier in the pipeline, when having the search pattern and template side-by-side: if the _search_ pattern includes a trailing semicolon, the replacement statement should receive one as well.

Unsurprisingly the partial match implementation implicitly succeeds all these tests, see the cleaned up version here: https://github.com/MarijnS95/rust-analyzer/compare/stmt..let-stmt-partial

Do you think these theoretical usecases are worth fixing? Otherwise, we might do this in 3 stages:

1. Disable these test cases and take this PR as-is with insertion code in `replacing.rs`;
2. Re-enable the testcases with the partial match range implementation, reviewed in a separate PR;
3. Implement and review partial statement-list matching in parallel.
davidlattimore: I probably wouldn't worry about the hypothetical use cases. SSR is really intended for transforming syntactically valid code into different syntactically valid code. I struggle to think of a case where removing semicolons from valid code would leave the code valid. Even if there is a case, it's probably pretty obscure, so probably isn't a high priority to support.

I assume the motivation for wanting to support optional trailing semicolons is to make writing the rule easier, since you don't need to remember to add the semicolons? Assuming that's the case, you could make it an error to supply a rule that has a trailing semicolon on one side, but not the other. Not sure if it's worth enforcing such a rule though. I'd probably just treat rules with / without the trailing semicolon as semantically equivalent.
MarijnS95: @davidlattimore I mostly care about consistency to the user. If they specify a semicolon on one side but not the other there must be a meaning in that. I won't be surprised if someone ends up in a situation with a single `let` statement in a block that they wish to replace with some expression _without_ trailing semicolon to use as the final expression in a block.
(Next to that I care about code cleanliness here in the codebase. Not a fan of littering some `if LET_STMT` edgecases around if it can be solved more or less "generic" with a concept needed later).

As for making it optional, yes, just convenience. Just as it might be convenient to match just the `let foo` part for renames, even though that can't be substituted for anything other than a partial `let <name>` statement or the output becomes invalid (well, assigning the RHS to something that's not a let binding is valid. That's possible now with a pattern like: `let foo = $b ==>> something_mut = $b`).

In the end, unlike expressions a let binding without trailing semicolon is invalid so we might as well treat it as such: simply disallow it from being specified (parsed, really) in the search pattern nor replacement template.
davidlattimore: Disallowing let expressions without a trailing semicolon sounds good to me :)
MarijnS95: @davidlattimore This should have been easy to solve by converting the statement parser to `expressions::StmtWithSemi::Yes` but breaks parsing the RHS in one of the simple test cases: `let $a = $b; ==>> $b`. I'm leaving it at `Optional` and have instead removed `StmtWithSemi` handling from the `let_stmt` parser. If I understand our conversations correctly thus far it should be impossible/invalid for a let statement to contain a trailing semicolon, ever.
MarijnS95: Unsurprisingly this [breaks](https://github.com/rust-analyzer/rust-analyzer/runs/1636842023) macro calls, where `let` statements without trailing semicolon are allowed. We can use `Optional` after all, and I've removed the (now failing) test case asserting `let $a = $b` to be invalid again.

Patch:
diff --git a/crates/hir_def/src/path/lower.rs b/crates/hir_def/src/path/lower.rs
index 8a01e6eead..9518ac109b 100644
--- a/crates/hir_def/src/path/lower.rs
+++ b/crates/hir_def/src/path/lower.rs
@@ -123,7 +123,7 @@ pub(super) fn lower_path(mut path: ast::Path, hygiene: &Hygiene) -> Option<Path>
     // We follow what it did anyway :)
     if segments.len() == 1 && kind == PathKind::Plain {
         if let Some(_macro_call) = path.syntax().parent().and_then(ast::MacroCall::cast) {
-            if let Some(crate_id) = hygiene.local_inner_macros() {
+            if let Some(crate_id) = hygiene.local_inner_macros(path) {
                 kind = PathKind::DollarCrate(crate_id);
             }
         }
diff --git a/crates/hir_expand/src/hygiene.rs b/crates/hir_expand/src/hygiene.rs
index 7ab0a5e52e..6042e15b22 100644
--- a/crates/hir_expand/src/hygiene.rs
+++ b/crates/hir_expand/src/hygiene.rs
@@ -2,30 +2,94 @@
 //!
 //! Specifically, `ast` + `Hygiene` allows you to create a `Name`. Note that, at
 //! this moment, this is horribly incomplete and handles only `$crate`.
+use std::sync::Arc;
+
+use arena::{Arena, Idx};
 use base_db::CrateId;
 use either::Either;
-use syntax::ast;
+use mbe::Origin;
+use syntax::{ast, AstNode};
 
 use crate::{
     db::AstDatabase,
     name::{AsName, Name},
-    HirFileId, HirFileIdRepr, MacroCallId, MacroDefKind,
+    ExpansionInfo, HirFileId, HirFileIdRepr, MacroCallId, MacroDefKind,
 };
 
 #[derive(Clone, Debug)]
 pub struct Hygiene {
-    // This is what `$crate` expands to
-    def_crate: Option<CrateId>,
+    frames: Option<Arc<HygieneFrames>>,
+}
+
+impl Hygiene {
+    pub fn new(db: &dyn AstDatabase, file_id: HirFileId) -> Hygiene {
+        Hygiene { frames: Some(Arc::new(HygieneFrames::new(db, file_id.clone()))) }
+    }
+
+    pub fn new_unhygienic() -> Hygiene {
+        Hygiene { frames: None }
+    }
+
+    // FIXME: this should just return name
+    pub fn name_ref_to_name(&self, name_ref: ast::NameRef) -> Either<Name, CrateId> {
+        if let Some(frames) = &self.frames {
+            if name_ref.text() == "$crate" {
+                if let Some(krate) = frames.root_crate(&name_ref) {
+                    return Either::Right(krate);
+                }
+            }
+        }
+
+        Either::Left(name_ref.as_name())
+    }
+
+    pub fn local_inner_macros(&self, path: ast::Path) -> Option<CrateId> {
+        let frames = self.frames.as_ref()?;
+
+        let mut token = path.syntax().first_token()?;
+        let mut current = frames.first();
+
+        while let Some((frame, data)) =
+            current.and_then(|it| Some((it, it.expansion.as_ref()?.map_token_up(&token)?)))
+        {
+            let (mapped, origin) = data;
+            if origin == Origin::Def {
+                return if frame.local_inner { frame.krate } else { None };
+            }
+            current = Some(&frames.0[frame.call_site?]);
+            token = mapped.value;
+        }
+        None
+    }
+}
+
+#[derive(Default, Debug)]
+struct HygieneFrames(Arena<HygieneFrame>);
+
+#[derive(Clone, Debug)]
+struct HygieneFrame {
+    expansion: Option<ExpansionInfo>,
 
     // Indicate this is a local inner macro
     local_inner: bool,
+    krate: Option<CrateId>,
+
+    call_site: Option<Idx<HygieneFrame>>,
+    def_site: Option<Idx<HygieneFrame>>,
 }
 
-impl Hygiene {
-    pub fn new(db: &dyn AstDatabase, file_id: HirFileId) -> Hygiene {
-        let (def_crate, local_inner) = match file_id.0 {
+impl HygieneFrames {
+    fn new(db: &dyn AstDatabase, file_id: HirFileId) -> Self {
+        let mut frames = HygieneFrames::default();
+        frames.add(db, file_id);
+        frames
+    }
+
+    fn add(&mut self, db: &dyn AstDatabase, file_id: HirFileId) -> Option<Idx<HygieneFrame>> {
+        let (krate, local_inner) = match file_id.0 {
             HirFileIdRepr::FileId(_) => (None, false),
             HirFileIdRepr::MacroFile(macro_file) => match macro_file.macro_call_id {
+                MacroCallId::EagerMacro(_id) => (None, false),
                 MacroCallId::LazyMacro(id) => {
                     let loc = db.lookup_intern_macro(id);
                     match loc.def.kind {
@@ -36,31 +100,68 @@ impl Hygiene {
                         MacroDefKind::ProcMacro(_) => (None, false),
                     }
                 }
-                MacroCallId::EagerMacro(_id) => (None, false),
             },
         };
-        Hygiene { def_crate, local_inner }
-    }
 
-    pub fn new_unhygienic() -> Hygiene {
-        Hygiene { def_crate: None, local_inner: false }
+        let expansion = file_id.expansion_info(db);
+        let expansion = match expansion {
+            None => {
+                return Some(self.0.alloc(HygieneFrame {
+                    expansion: None,
+                    local_inner,
+                    krate,
+                    call_site: None,
+                    def_site: None,
+                }));
+            }
+            Some(it) => it,
+        };
+
+        let def_site = expansion.def.clone();
+        let call_site = expansion.arg.file_id;
+        let idx = self.0.alloc(HygieneFrame {
+            expansion: Some(expansion),
+            local_inner,
+            krate,
+            call_site: None,
+            def_site: None,
+        });
+
+        self.0[idx].call_site = self.add(db, call_site);
+        self.0[idx].def_site = def_site.and_then(|it| self.add(db, it.file_id));
+
+        Some(idx)
     }
 
-    // FIXME: this should just return name
-    pub fn name_ref_to_name(&self, name_ref: ast::NameRef) -> Either<Name, CrateId> {
-        if let Some(def_crate) = self.def_crate {
-            if name_ref.text() == "$crate" {
-                return Either::Right(def_crate);
-            }
-        }
-        Either::Left(name_ref.as_name())
+    fn first(&self) -> Option<&HygieneFrame> {
+        self.0.iter().next().map(|it| it.1)
     }
 
-    pub fn local_inner_macros(&self) -> Option<CrateId> {
-        if self.local_inner {
-            self.def_crate
-        } else {
-            None
+    fn root_crate(&self, name_ref: &ast::NameRef) -> Option<CrateId> {
+        let mut token = name_ref.syntax().first_token()?;
+        let first = self.first()?;
+        let mut result = first.krate;
+        let mut current = Some(first);
+
+        while let Some((frame, (mapped, origin))) =
+            current.and_then(|it| Some((it, it.expansion.as_ref()?.map_token_up(&token)?)))
+        {
+            result = frame.krate;
+
+            let site = match origin {
+                Origin::Def => frame.def_site,
+                Origin::Call => frame.call_site,
+            };
+
+            let site = match site {
+                None => break,
+                Some(it) => it,
+            };
+
+            current = Some(&self.0[site]);
+            token = mapped.value;
         }
+
+        result
     }
 }
diff --git a/crates/hir_expand/src/lib.rs b/crates/hir_expand/src/lib.rs
index 3fa1b1d776..5b6734a5f4 100644
--- a/crates/hir_expand/src/lib.rs
+++ b/crates/hir_expand/src/lib.rs
@@ -340,11 +340,8 @@ impl ExpansionInfo {
         Some(self.expanded.with_value(token))
     }
 
-    pub fn map_token_up(
-        &self,
-        token: InFile<&SyntaxToken>,
-    ) -> Option<(InFile<SyntaxToken>, Origin)> {
-        let token_id = self.exp_map.token_by_range(token.value.text_range())?;
+    pub fn map_token_up(&self, token: &SyntaxToken) -> Option<(InFile<SyntaxToken>, Origin)> {
+        let token_id = self.exp_map.token_by_range(token.text_range())?;
 
         let (token_id, origin) = self.macro_def.0.map_id_up(token_id);
         let (token_map, tt) = match origin {
@@ -359,7 +356,7 @@ impl ExpansionInfo {
             ),
         };
 
-        let range = token_map.range_by_token(token_id)?.by_kind(token.value.kind())?;
+        let range = token_map.range_by_token(token_id)?.by_kind(token.kind())?;
         let token = algo::find_covering_element(&tt.value, range + tt.value.text_range().start())
             .into_token()?;
         Some((tt.with_value(token), origin))
@@ -495,7 +492,7 @@ fn ascend_call_token(
     expansion: &ExpansionInfo,
     token: InFile<SyntaxToken>,
 ) -> Option<InFile<SyntaxToken>> {
-    let (mapped, origin) = expansion.map_token_up(token.as_ref())?;
+    let (mapped, origin) = expansion.map_token_up(&token.value)?;
     if origin != Origin::Call {
         return None;
     }
diff --git a/crates/mbe/src/mbe_expander/matcher.rs b/crates/mbe/src/mbe_expander/matcher.rs
index ab5f87c487..385b466018 100644
--- a/crates/mbe/src/mbe_expander/matcher.rs
+++ b/crates/mbe/src/mbe_expander/matcher.rs
@@ -150,7 +150,7 @@ fn match_subtree(
                     res.add_err(err!("leftover tokens"));
                 }
             }
-            Op::Var { name, kind } => {
+            Op::Var { name, kind, .. } => {
                 let kind = match kind {
                     Some(k) => k,
                     None => {
diff --git a/crates/mbe/src/mbe_expander/transcriber.rs b/crates/mbe/src/mbe_expander/transcriber.rs
index 7205312371..57f3f104dc 100644
--- a/crates/mbe/src/mbe_expander/transcriber.rs
+++ b/crates/mbe/src/mbe_expander/transcriber.rs
@@ -100,8 +100,8 @@ fn expand_subtree(
                 err = err.or(e);
                 arena.push(tt.into());
             }
-            Op::Var { name, .. } => {
-                let ExpandResult { value: fragment, err: e } = expand_var(ctx, &name);
+            Op::Var { name, id, .. } => {
+                let ExpandResult { value: fragment, err: e } = expand_var(ctx, &name, *id);
                 err = err.or(e);
                 push_fragment(arena, fragment);
             }
@@ -118,12 +118,10 @@ fn expand_subtree(
     ExpandResult { value: tt::Subtree { delimiter: template.delimiter, token_trees: tts }, err }
 }
 
-fn expand_var(ctx: &mut ExpandCtx, v: &SmolStr) -> ExpandResult<Fragment> {
+fn expand_var(ctx: &mut ExpandCtx, v: &SmolStr, id: tt::TokenId) -> ExpandResult<Fragment> {
     if v == "crate" {
         // We simply produce identifier `$crate` here. And it will be resolved when lowering ast to Path.
-        let tt =
-            tt::Leaf::from(tt::Ident { text: "$crate".into(), id: tt::TokenId::unspecified() })
-                .into();
+        let tt = tt::Leaf::from(tt::Ident { text: "$crate".into(), id }).into();
         ExpandResult::ok(Fragment::Tokens(tt))
     } else if !ctx.bindings.contains(v) {
         // Note that it is possible to have a `$var` inside a macro which is not bound.
@@ -142,14 +140,8 @@ fn expand_var(ctx: &mut ExpandCtx, v: &SmolStr) -> ExpandResult<Fragment> {
         let tt = tt::Subtree {
             delimiter: None,
             token_trees: vec![
-                tt::Leaf::from(tt::Punct {
-                    char: '$',
-                    spacing: tt::Spacing::Alone,
-                    id: tt::TokenId::unspecified(),
-                })
-                .into(),
-                tt::Leaf::from(tt::Ident { text: v.clone(), id: tt::TokenId::unspecified() })
-                    .into(),
+                tt::Leaf::from(tt::Punct { char: '$', spacing: tt::Spacing::Alone, id }).into(),
+                tt::Leaf::from(tt::Ident { text: v.clone(), id }).into(),
             ],
         }
         .into();
diff --git a/crates/mbe/src/parser.rs b/crates/mbe/src/parser.rs
index 2f3ebc8313..77cc739b65 100644
--- a/crates/mbe/src/parser.rs
+++ b/crates/mbe/src/parser.rs
@@ -8,7 +8,7 @@ use crate::{tt_iter::TtIter, ExpandError, MetaTemplate};
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub(crate) enum Op {
-    Var { name: SmolStr, kind: Option<SmolStr> },
+    Var { name: SmolStr, kind: Option<SmolStr>, id: tt::TokenId },
     Repeat { subtree: MetaTemplate, kind: RepeatKind, separator: Option<Separator> },
     Leaf(tt::Leaf),
     Subtree(MetaTemplate),
@@ -106,18 +106,21 @@ fn next_op<'a>(first: &tt::TokenTree, src: &mut TtIter<'a>, mode: Mode) -> Resul
                         }
                         let name = UNDERSCORE.clone();
                         let kind = eat_fragment_kind(src, mode)?;
-                        Op::Var { name, kind }
+                        let id = punct.id;
+                        Op::Var { name, kind, id }
                     }
                     tt::Leaf::Ident(ident) => {
                         let name = ident.text.clone();
                         let kind = eat_fragment_kind(src, mode)?;
-                        Op::Var { name, kind }
+                        let id = ident.id;
+                        Op::Var { name, kind, id }
                     }
                     tt::Leaf::Literal(lit) => {
                         if is_boolean_literal(&lit) {
                             let name = lit.text.clone();
                             let kind = eat_fragment_kind(src, mode)?;
-                            Op::Var { name, kind }
+                            let id = lit.id;
+                            Op::Var { name, kind, id }
                         } else {
                             bail!("bad var 2");
                         }
diff --git a/crates/parser/src/grammar.rs b/crates/parser/src/grammar.rs
index 63cc900272..bb9ffea8be 100644
--- a/crates/parser/src/grammar.rs
+++ b/crates/parser/src/grammar.rs
@@ -66,6 +66,10 @@ pub(crate) mod fragments {
         expressions::stmt(p, expressions::StmtWithSemi::No)
     }
 
+    pub(crate) fn stmt_optional_semi(p: &mut Parser) {
+        expressions::stmt(p, expressions::StmtWithSemi::Optional)
+    }
+
     pub(crate) fn opt_visibility(p: &mut Parser) {
         let _ = super::opt_visibility(p);
     }
diff --git a/crates/parser/src/lib.rs b/crates/parser/src/lib.rs
index 811e740f98..9dfe63028c 100644
--- a/crates/parser/src/lib.rs
+++ b/crates/parser/src/lib.rs
@@ -88,6 +88,7 @@ pub enum FragmentKind {
     Path,
     Expr,
     Statement,
+    StatementOptionalSemi,
     Type,
     Pattern,
     Item,
@@ -118,6 +119,7 @@ pub fn parse_fragment(
         FragmentKind::Visibility => grammar::fragments::opt_visibility,
         FragmentKind::MetaItem => grammar::fragments::meta_item,
         FragmentKind::Statement => grammar::fragments::stmt,
+        FragmentKind::StatementOptionalSemi => grammar::fragments::stmt_optional_semi,
         FragmentKind::Items => grammar::fragments::macro_items,
         FragmentKind::Statements => grammar::fragments::macro_stmts,
         FragmentKind::Attr => grammar::fragments::attr,
diff --git a/crates/ssr/src/parsing.rs b/crates/ssr/src/parsing.rs
index f3b084bafe..289affe906 100644
--- a/crates/ssr/src/parsing.rs
+++ b/crates/ssr/src/parsing.rs
@@ -78,6 +78,7 @@ impl ParsedRule {
         builder.try_add(ast::Item::parse(&raw_pattern), raw_template.map(ast::Item::parse));
         builder.try_add(ast::Path::parse(&raw_pattern), raw_template.map(ast::Path::parse));
         builder.try_add(ast::Pat::parse(&raw_pattern), raw_template.map(ast::Pat::parse));
+        builder.try_add(ast::Stmt::parse(&raw_pattern), raw_template.map(ast::Stmt::parse));
         builder.build()
     }
 }
diff --git a/crates/syntax/src/lib.rs b/crates/syntax/src/lib.rs
index 4d272f367a..da151e3289 100644
--- a/crates/syntax/src/lib.rs
+++ b/crates/syntax/src/lib.rs
@@ -212,6 +212,13 @@ impl ast::Attr {
     }
 }
 
+impl ast::Stmt {
+    /// Returns `text`, parsed as statement, but only if it has no errors.
+    pub fn parse(text: &str) -> Result<Self, ()> {
+        parsing::parse_text_fragment(text, parser::FragmentKind::StatementOptionalSemi)
+    }
+}
+
 /// Matches a `SyntaxNode` against an `ast` type.
 ///
 /// # Example:


Test patch:
diff --git a/crates/hir_ty/src/tests/macros.rs b/crates/hir_ty/src/tests/macros.rs
index 1953da7beb..c64f0b5b53 100644
--- a/crates/hir_ty/src/tests/macros.rs
+++ b/crates/hir_ty/src/tests/macros.rs
@@ -370,6 +370,37 @@ expand!();
     );
 }
 
+#[test]
+fn infer_macro_with_dollar_crate_in_def_site() {
+    check_types(
+        r#"
+//- /main.rs crate:main deps:foo
+use foo::expand;
+
+macro_rules! list {
+    ($($tt:tt)*) => { $($tt)* }
+}
+
+fn test() {
+    let r = expand!();
+    r;
+  //^ u128
+}
+
+//- /lib.rs crate:foo
+#[macro_export]
+macro_rules! expand {
+    () => { list!($crate::m!()) };
+}
+
+#[macro_export]
+macro_rules! m {
+    () => { 0u128 };
+}
+"#,
+    );
+}
+
 #[test]
 fn infer_type_value_non_legacy_macro_use_as() {
     check_infer(
diff --git a/crates/ssr/src/tests.rs b/crates/ssr/src/tests.rs
index 63131f6ca5..c4149a849b 100644
--- a/crates/ssr/src/tests.rs
+++ b/crates/ssr/src/tests.rs
@@ -159,6 +159,50 @@ fn assert_match_failure_reason(pattern: &str, code: &str, snippet: &str, expecte
     assert_eq!(reasons, vec![expected_reason]);
 }
 
+#[test]
+fn ssr_let_stmt_in_macro_match() {
+    assert_matches(
+        "let a = 0",
+        r#"
+            macro_rules! m1 { ($a:stmt) => {$a}; }
+            fn f() {m1!{ let a = 0 };}"#,
+        // FIXME: Whitespace is not part of the matched block
+        &["leta=0"],
+    );
+}
+
+#[test]
+fn ssr_let_stmt_in_fn_match() {
+    assert_matches("let $a = 10;", "fn main() { let x = 10; x }", &["let x = 10;"]);
+    assert_matches("let $a = $b;", "fn main() { let x = 10; x }", &["let x = 10;"]);
+}
+
+#[test]
+fn ssr_block_expr_match() {
+    assert_matches("{ let $a = $b; }", "fn main() { let x = 10; }", &["{ let x = 10; }"]);
+    assert_matches("{ let $a = $b; $c }", "fn main() { let x = 10; x }", &["{ let x = 10; x }"]);
+}
+
+#[test]
+fn ssr_let_stmt_replace() {
+    // Pattern and template with trailing semicolon
+    assert_ssr_transform(
+        "let $a = $b; ==>> let $a = 11;",
+        "fn main() { let x = 10; x }",
+        expect![["fn main() { let x = 11; x }"]],
+    );
+}
+
+#[test]
+fn ssr_let_stmt_replace_expr() {
+    // Trailing semicolon should be dropped from the new expression
+    assert_ssr_transform(
+        "let $a = $b; ==>> $b",
+        "fn main() { let x = 10; }",
+        expect![["fn main() { 10 }"]],
+    );
+}
+
 #[test]
 fn ssr_function_to_method() {
     assert_ssr_transform(
diff --git a/crates/syntax/src/tests.rs b/crates/syntax/src/tests.rs
index 8c217dfe01..9d3433c9db 100644
--- a/crates/syntax/src/tests.rs
+++ b/crates/syntax/src/tests.rs
@@ -102,6 +102,15 @@ fn type_parser_tests() {
     );
 }
 
+#[test]
+fn stmt_parser_tests() {
+    fragment_parser_dir_test(
+        &["parser/fragments/stmt/ok"],
+        &["parser/fragments/stmt/err"],
+        crate::ast::Stmt::parse,
+    );
+}
+
 #[test]
 fn parser_fuzz_tests() {
     for (_, text) in collect_rust_files(&test_data_dir(), &["parser/fuzz-failures"]) {
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rast b/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rast
new file mode 100644
index 0000000000..5df7507e2d
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rast
@@ -0,0 +1,1 @@
+ERROR
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rs b/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rs
new file mode 100644
index 0000000000..988df07059
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_attr.rs
@@ -0,0 +1,1 @@
+#[foo]
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rast b/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rast
new file mode 100644
index 0000000000..5df7507e2d
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rast
@@ -0,0 +1,1 @@
+ERROR
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rs b/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rs
new file mode 100644
index 0000000000..7e3b2fd493
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_multiple_stmts.rs
@@ -0,0 +1,1 @@
+a(); b(); c()
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rast b/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rast
new file mode 100644
index 0000000000..5df7507e2d
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rast
@@ -0,0 +1,1 @@
+ERROR
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rs b/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rs
new file mode 100644
index 0000000000..2d06f37663
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_open_parenthesis.rs
@@ -0,0 +1,1 @@
+(
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rast b/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rast
new file mode 100644
index 0000000000..5df7507e2d
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rast
@@ -0,0 +1,1 @@
+ERROR
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rs b/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rs
new file mode 100644
index 0000000000..092bc2b041
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_semicolon.rs
@@ -0,0 +1,1 @@
+;
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rast b/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rast
new file mode 100644
index 0000000000..5df7507e2d
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rast
@@ -0,0 +1,1 @@
+ERROR
diff --git a/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rs b/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rs
new file mode 100644
index 0000000000..ca49acb079
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/err/0000_unterminated_expr.rs
@@ -0,0 +1,1 @@
+1 +
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rast
new file mode 100644
index 0000000000..274fdf16de
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rast
@@ -0,0 +1,9 @@
+EXPR_STMT@0..5
+  BIN_EXPR@0..5
+    LITERAL@0..1
+      INT_NUMBER@0..1 "1"
+    WHITESPACE@1..2 " "
+    PLUS@2..3 "+"
+    WHITESPACE@3..4 " "
+    LITERAL@4..5
+      INT_NUMBER@4..5 "1"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rs
new file mode 100644
index 0000000000..8d2f0971e2
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr.rs
@@ -0,0 +1,1 @@
+1 + 1
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rast
new file mode 100644
index 0000000000..6c946091f3
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rast
@@ -0,0 +1,69 @@
+EXPR_STMT@0..55
+  BLOCK_EXPR@0..55
+    L_CURLY@0..1 "{"
+    WHITESPACE@1..6 "\n    "
+    LET_STMT@6..20
+      LET_KW@6..9 "let"
+      WHITESPACE@9..10 " "
+      IDENT_PAT@10..11
+        NAME@10..11
+          IDENT@10..11 "x"
+      WHITESPACE@11..12 " "
+      EQ@12..13 "="
+      WHITESPACE@13..14 " "
+      CALL_EXPR@14..19
+        PATH_EXPR@14..17
+          PATH@14..17
+            PATH_SEGMENT@14..17
+              NAME_REF@14..17
+                IDENT@14..17 "foo"
+        ARG_LIST@17..19
+          L_PAREN@17..18 "("
+          R_PAREN@18..19 ")"
+      SEMICOLON@19..20 ";"
+    WHITESPACE@20..25 "\n    "
+    LET_STMT@25..39
+      LET_KW@25..28 "let"
+      WHITESPACE@28..29 " "
+      IDENT_PAT@29..30
+        NAME@29..30
+          IDENT@29..30 "y"
+      WHITESPACE@30..31 " "
+      EQ@31..32 "="
+      WHITESPACE@32..33 " "
+      CALL_EXPR@33..38
+        PATH_EXPR@33..36
+          PATH@33..36
+            PATH_SEGMENT@33..36
+              NAME_REF@33..36
+                IDENT@33..36 "bar"
+        ARG_LIST@36..38
+          L_PAREN@36..37 "("
+          R_PAREN@37..38 ")"
+      SEMICOLON@38..39 ";"
+    WHITESPACE@39..44 "\n    "
+    CALL_EXPR@44..53
+      PATH_EXPR@44..46
+        PATH@44..46
+          PATH_SEGMENT@44..46
+            NAME_REF@44..46
+              IDENT@44..46 "Ok"
+      ARG_LIST@46..53
+        L_PAREN@46..47 "("
+        BIN_EXPR@47..52
+          PATH_EXPR@47..48
+            PATH@47..48
+              PATH_SEGMENT@47..48
+                NAME_REF@47..48
+                  IDENT@47..48 "x"
+          WHITESPACE@48..49 " "
+          PLUS@49..50 "+"
+          WHITESPACE@50..51 " "
+          PATH_EXPR@51..52
+            PATH@51..52
+              PATH_SEGMENT@51..52
+                NAME_REF@51..52
+                  IDENT@51..52 "y"
+        R_PAREN@52..53 ")"
+    WHITESPACE@53..54 "\n"
+    R_CURLY@54..55 "}"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rs
new file mode 100644
index 0000000000..ffa5c1e66e
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_expr_block.rs
@@ -0,0 +1,5 @@
+{
+    let x = foo();
+    let y = bar();
+    Ok(x + y)
+}
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rast
new file mode 100644
index 0000000000..8c186da93e
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rast
@@ -0,0 +1,11 @@
+EXPR_STMT@0..6
+  CALL_EXPR@0..5
+    PATH_EXPR@0..3
+      PATH@0..3
+        PATH_SEGMENT@0..3
+          NAME_REF@0..3
+            IDENT@0..3 "foo"
+    ARG_LIST@3..5
+      L_PAREN@3..4 "("
+      R_PAREN@4..5 ")"
+  SEMICOLON@5..6 ";"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rs
new file mode 100644
index 0000000000..a280f9a5cc
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_fn_call.rs
@@ -0,0 +1,1 @@
+foo();
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rast
new file mode 100644
index 0000000000..8ab38da21c
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rast
@@ -0,0 +1,12 @@
+LET_STMT@0..11
+  LET_KW@0..3 "let"
+  WHITESPACE@3..4 " "
+  IDENT_PAT@4..5
+    NAME@4..5
+      IDENT@4..5 "x"
+  WHITESPACE@5..6 " "
+  EQ@6..7 "="
+  WHITESPACE@7..8 " "
+  LITERAL@8..10
+    INT_NUMBER@8..10 "10"
+  SEMICOLON@10..11 ";"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rs
new file mode 100644
index 0000000000..de8a7f1fc6
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_let_stmt.rs
@@ -0,0 +1,1 @@
+let x = 10;
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rast
new file mode 100644
index 0000000000..81d6df29a5
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rast
@@ -0,0 +1,21 @@
+EXPR_STMT@0..18
+  MACRO_CALL@0..17
+    PATH@0..2
+      PATH_SEGMENT@0..2
+        NAME_REF@0..2
+          IDENT@0..2 "m1"
+    BANG@2..3 "!"
+    TOKEN_TREE@3..17
+      L_CURLY@3..4 "{"
+      WHITESPACE@4..5 " "
+      LET_KW@5..8 "let"
+      WHITESPACE@8..9 " "
+      IDENT@9..10 "a"
+      WHITESPACE@10..11 " "
+      EQ@11..12 "="
+      WHITESPACE@12..13 " "
+      INT_NUMBER@13..14 "0"
+      SEMICOLON@14..15 ";"
+      WHITESPACE@15..16 " "
+      R_CURLY@16..17 "}"
+  SEMICOLON@17..18 ";"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rs
new file mode 100644
index 0000000000..075f30159b
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_let_stmt.rs
@@ -0,0 +1,1 @@
+m1!{ let a = 0; };
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rast
new file mode 100644
index 0000000000..81d6df29a5
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rast
@@ -0,0 +1,21 @@
+EXPR_STMT@0..18
+  MACRO_CALL@0..17
+    PATH@0..2
+      PATH_SEGMENT@0..2
+        NAME_REF@0..2
+          IDENT@0..2 "m1"
+    BANG@2..3 "!"
+    TOKEN_TREE@3..17
+      L_CURLY@3..4 "{"
+      WHITESPACE@4..5 " "
+      LET_KW@5..8 "let"
+      WHITESPACE@8..9 " "
+      IDENT@9..10 "a"
+      WHITESPACE@10..11 " "
+      EQ@11..12 "="
+      WHITESPACE@12..13 " "
+      INT_NUMBER@13..14 "0"
+      SEMICOLON@14..15 ";"
+      WHITESPACE@15..16 " "
+      R_CURLY@16..17 "}"
+  SEMICOLON@17..18 ";"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rs
new file mode 100644
index 0000000000..075f30159b
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_macro_unterminated_let_stmt.rs
@@ -0,0 +1,1 @@
+m1!{ let a = 0; };
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rast
new file mode 100644
index 0000000000..64c5d29691
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rast
@@ -0,0 +1,22 @@
+STRUCT@0..28
+  STRUCT_KW@0..6 "struct"
+  WHITESPACE@6..7 " "
+  NAME@7..10
+    IDENT@7..10 "Foo"
+  WHITESPACE@10..11 " "
+  RECORD_FIELD_LIST@11..28
+    L_CURLY@11..12 "{"
+    WHITESPACE@12..17 "\n    "
+    RECORD_FIELD@17..25
+      NAME@17..20
+        IDENT@17..20 "bar"
+      COLON@20..21 ":"
+      WHITESPACE@21..22 " "
+      PATH_TYPE@22..25
+        PATH@22..25
+          PATH_SEGMENT@22..25
+            NAME_REF@22..25
+              IDENT@22..25 "u32"
+    COMMA@25..26 ","
+    WHITESPACE@26..27 "\n"
+    R_CURLY@27..28 "}"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rs
new file mode 100644
index 0000000000..e5473e3ac8
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_struct_item.rs
@@ -0,0 +1,3 @@
+struct Foo {
+    bar: u32,
+}
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rast
new file mode 100644
index 0000000000..9089906bce
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rast
@@ -0,0 +1,10 @@
+EXPR_STMT@0..5
+  CALL_EXPR@0..5
+    PATH_EXPR@0..3
+      PATH@0..3
+        PATH_SEGMENT@0..3
+          NAME_REF@0..3
+            IDENT@0..3 "foo"
+    ARG_LIST@3..5
+      L_PAREN@3..4 "("
+      R_PAREN@4..5 ")"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rs
new file mode 100644
index 0000000000..eb28ef4401
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_fn_call.rs
@@ -0,0 +1,1 @@
+foo()
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rast b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rast
new file mode 100644
index 0000000000..37663671fa
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rast
@@ -0,0 +1,11 @@
+LET_STMT@0..10
+  LET_KW@0..3 "let"
+  WHITESPACE@3..4 " "
+  IDENT_PAT@4..5
+    NAME@4..5
+      IDENT@4..5 "x"
+  WHITESPACE@5..6 " "
+  EQ@6..7 "="
+  WHITESPACE@7..8 " "
+  LITERAL@8..10
+    INT_NUMBER@8..10 "10"
diff --git a/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rs b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rs
new file mode 100644
index 0000000000..78364b2a96
--- /dev/null
+++ b/crates/syntax/test_data/parser/fragments/stmt/ok/0000_unterminated_let_stmt.rs
@@ -0,0 +1,1 @@
+let x = 10