Repository: agda/agda
Pull request #7783: Don't inline constructors into inline functions
Author: login: plt-amy
Labels: name: inlining
description: Inlining optimisation
name: record-expression-to-copatterns
description: Concerning the translation of rhss which are record expressions to copattern matching.
name: pr: preserve commits
description: PR should be merged via rebase, preserving the commits
Created: 2025-04-03T13:32:23Z | Merged: 2025-04-08T11:11:47Z

Skips inlining record constructors on the RHS of a function marked `INLINE`, to allow defining helpers for constructing records which get inlined at the use site. Example (from [the test case](https://github.com/agda/agda/blob/b6fba05b4178623aefd7a772583aca7b3beb65a5/test/Fail/NestedRecConstrInline.agda)):

```agda
to : make → Bar
{-# INLINE to #-}
to f = record { foo = record { foo1 = f .foo1 ; foo2 = f .foo1 } ; bar3 = f .bar3 }

x : Bar
x = to record where
  foo1 = 1
  foo2 = 2
  bar3 = 3
```

Here if we did constructor inlining in the RHS of `to`, *it* would become a definition by copattern matching, and then we would have `x = to ...`. But if we skip inlining in the RHS of `to`, the elaborator will inline it into `x`'s RHS, so that its RHS is then manifestly a record construction, and then and *`x`* will become a copattern definition instead.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df0d6782912..f80fc601e43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -210,6 +210,22 @@ Changes to type checker and other components defining the Agda language.
   the PR removing it from the library, and
   [#7652](https://github.com/agda/agda/pull/7652) for the compiler.
 
+* Inlining constructors no longer happens on the right-hand-sides of
+  `INLINE` functions. This allows using `INLINE` functions to define
+  "smart constructors" for record types which have the same reduction
+  behaviour as using the actual constructor would. Small example:
+
+  ```agda
+  triple : Nat → Nat → Nat → Nat × Nat × Nat
+  {-# INLINE triple #-}
+  triple x y z = record { fst = x ; snd = y , z }
+
+  ex = triple 1 2 3
+  ```
+
+  Here, constructor inlining happens on the right hand side *of `ex`*
+  rather than of `triple`.
+
 Reflection
 ----------
 
diff --git a/src/full/Agda/Compiler/ToTreeless.hs b/src/full/Agda/Compiler/ToTreeless.hs
index f542223a902..112b852e645 100644
--- a/src/full/Agda/Compiler/ToTreeless.hs
+++ b/src/full/Agda/Compiler/ToTreeless.hs
@@ -74,7 +74,7 @@ getCompiledClauses q = do
   reportSDoc "treeless.convert" 70 $
     caseMaybe mst "-- not using split tree" $ \st ->
       "-- using split tree" $$ pretty st
-  CC.compileClauses' translate cs mst
+  CC.compileClauses' q translate cs mst
 
 -- ** Types of pipelines; different backends might use their own custom pipeline.
 type BuildPipeline = Int -> QName -> Pipeline
diff --git a/src/full/Agda/TypeChecking/CompiledClause/Compile.hs b/src/full/Agda/TypeChecking/CompiledClause/Compile.hs
index 180aeb8360b..962a80bbaee 100644
--- a/src/full/Agda/TypeChecking/CompiledClause/Compile.hs
+++ b/src/full/Agda/TypeChecking/CompiledClause/Compile.hs
@@ -37,14 +37,14 @@ import Agda.Utils.Impossible
 data RunRecordPatternTranslation = RunRecordPatternTranslation | DontRunRecordPatternTranslation
   deriving (Eq)
 
-compileClauses' :: RunRecordPatternTranslation -> [Clause] -> Maybe SplitTree -> TCM CompiledClauses
-compileClauses' recpat cs mSplitTree = do
+compileClauses' :: QName -> RunRecordPatternTranslation -> [Clause] -> Maybe SplitTree -> TCM CompiledClauses
+compileClauses' q recpat cs mSplitTree = do
 
   -- Throw away the unreachable clauses (#2723).
   let notUnreachable = (Just True /=) . clauseUnreachable
   cs <- map unBruijn <$> normaliseProjP (filter notUnreachable cs)
 
-  let translate | recpat == RunRecordPatternTranslation = runIdentityT . translateCompiledClauses
+  let translate | recpat == RunRecordPatternTranslation = runIdentityT . translateCompiledClauses q
                 | otherwise                             = return
 
   translate $ caseMaybe mSplitTree (compile cs) $ \splitTree ->
@@ -87,12 +87,15 @@ compileClauses mt cs = do
           map (prettyTCM . map unArg . clPats) cls
       reportSDoc "tc.cc" 50 $
         "clauses of " <+> prettyTCM q <+> " before compilation" <?> pretty cs
+
       let cc = compileWithSplitTree splitTree cls
       reportSDoc "tc.cc" 20 $ sep
         [ "compiled clauses of " <+> prettyTCM q <+> " (still containing record splits)"
         , nest 2 $ return $ P.pretty cc
         ]
-      (cc, becameCopatternLHS) <- runChangeT $ translateCompiledClauses cc
+
+      (cc, becameCopatternLHS) <- runChangeT $ translateCompiledClauses q cc
+
       reportSDoc "tc.cc" 12 $ sep
         [ "compiled clauses of " <+> prettyTCM q
         , nest 2 $ return $ P.pretty cc
diff --git a/src/full/Agda/TypeChecking/RecordPatterns.hs b/src/full/Agda/TypeChecking/RecordPatterns.hs
index e7152eb30d3..c4fdfed22a2 100644
--- a/src/full/Agda/TypeChecking/RecordPatterns.hs
+++ b/src/full/Agda/TypeChecking/RecordPatterns.hs
@@ -37,6 +37,7 @@ import Agda.TypeChecking.Telescope
 
 import Agda.Interaction.Options
 
+import qualified Agda.Utils.SmallSet as SmallSet
 import Agda.Utils.Either
 import Agda.Utils.Function
 import Agda.Utils.Functor
@@ -131,25 +132,35 @@ getEtaAndArity SplitCatchall = return (False, 1)
 
 translateCompiledClauses
   :: forall m. (HasConstInfo m, MonadChange m)
-  => CompiledClauses -> m CompiledClauses
-translateCompiledClauses cc = ignoreAbstractMode $ do
+  => QName
+  -> CompiledClauses
+  -> m CompiledClauses
+translateCompiledClauses defn cc = ignoreAbstractMode $ do
   reportSDoc "tc.cc.record" 20 $ vcat
     [ "translate record patterns in compiled clauses"
     , nest 2 $ return $ pretty cc
     ]
+
   cc <- loop cc
   reportSDoc "tc.cc.record" 20 $ vcat
     [ "translated compiled clauses (no eta record patterns):"
     , nest 2 $ return $ pretty cc
     ]
-  cc <- recordExpressionsToCopatterns cc
-  reportSDoc "tc.cc.record" 20 $ vcat
-    [ "translated compiled clauses (record expressions to copatterns):"
-    , nest 2 $ return $ pretty cc
-    ]
-  return cc
-  where
 
+  -- If the function these clauses belong to is marked INLINE then we do
+  -- not perform record expression to copattern translation.
+  isinl <- isInlineFun . theDef <$> getConstInfo defn
+  if
+    | isinl -> cc <$ do
+      reportSDoc "tc.cc.record" 20 $ "record expressions to copatterns skipped because" <+> prettyTCM defn <+> "is inline"
+    | otherwise -> do
+      cc <- recordExpressionsToCopatterns cc
+      reportSDoc "tc.cc.record" 20 $ vcat
+        [ "translated compiled clauses (record expressions to copatterns):"
+        , nest 2 $ return $ pretty cc
+        ]
+      pure cc
+  where
     loop :: CompiledClauses -> m (CompiledClauses)
     loop cc = case cc of
       Fail{}    -> return cc
@@ -263,6 +274,12 @@ recordRHSToCopatterns cl = do
 
               mt' :: Maybe (Arg Type) <- fuse <$> traverse (traverse inst) mt
 
+              reportSDoc "tc.inline.con" 50 $ vcat
+                [ "for field" <+> prettyTCM (unArg f) <+> ": "
+                , "  mt  =" <+> prettyTCM mt
+                , "  mt' =" <+> prettyTCM mt'
+                ]
+
               -- Make clause ... .f = v
               return cl
                 { namedClausePats = ps ++ [ unnamed . ProjP ProjSystem <$> f ]
@@ -347,7 +364,7 @@ translateSplitTree = snd <.> loop
         return (xs, t')
 
     -- @loops i ts = return (x, xs, ts')@ cf. @loop@
-    -- @x@ says wether at arg @i@ we have a record pattern split
+    -- @x@ says whether at arg @i@ we have a record pattern split
     -- that can be removed
     loops :: Int -> SplitTrees -> TCM (Bool, [Bool], SplitTrees)
     loops i ts = do
diff --git a/src/full/Agda/TypeChecking/Rules/Decl.hs b/src/full/Agda/TypeChecking/Rules/Decl.hs
index d5af16e303b..c4e5e5237a3 100644
--- a/src/full/Agda/TypeChecking/Rules/Decl.hs
+++ b/src/full/Agda/TypeChecking/Rules/Decl.hs
@@ -316,7 +316,7 @@ revisitRecordPatternTranslation qs = do
   -- qccs: compiled clauses of definitions
   (rs, qccs) <- partitionEithers . catMaybes <$> mapM classify qs
   unless (null rs) $ forM_ qccs $ \(q,cc) -> do
-    (cc, recordExpressionBecameCopatternLHS) <- runChangeT $ translateCompiledClauses cc
+    (cc, recordExpressionBecameCopatternLHS) <- runChangeT $ translateCompiledClauses q cc
     modifySignature $ updateDefinition q
       $ updateTheDef (updateCompiledClauses $ const $ Just cc)
       . updateDefCopatternLHS (|| recordExpressionBecameCopatternLHS)
diff --git a/src/full/Agda/TypeChecking/Rules/Def.hs b/src/full/Agda/TypeChecking/Rules/Def.hs
index a06726214c2..5ad39169852 100644
--- a/src/full/Agda/TypeChecking/Rules/Def.hs
+++ b/src/full/Agda/TypeChecking/Rules/Def.hs
@@ -310,10 +310,6 @@ checkFunDefS t ai extlam with i name withSubAndLets cs = do
         reportSDoc "tc.def.fun" 70 $ inTopContext $ do
           sep $ "checked clauses:" : map (nest 2 . text . show) cs
 
-        -- After checking, remove the clauses again.
-        -- (Otherwise, @checkInjectivity@ loops for issue 801).
-        modifyFunClauses name (const [])
-
         reportSDoc "tc.cc" 25 $ inTopContext $ do
           sep [ "clauses before injectivity test"
               , nest 2 $ prettyTCM $ map (QNamed name) cs  -- broken, reify (QNamed n cl) expect cl to live at top level
@@ -348,16 +344,24 @@ checkFunDefS t ai extlam with i name withSubAndLets cs = do
                        }
                  return (cs ++ [c], pure sys)
 
+        -- The macro or inline tags might be on the type signature
+        info <- getConstInfo name
+        let
+          ismacro  = isMacro (theDef info)
+          isinline = isInlineFun (theDef info)
+
         -- Annotate the clauses with which arguments are actually used.
         cs <- instantiateFull {- =<< mapM rebindClause -} cs
+
         -- Andreas, 2010-11-12
         -- rebindClause is the identity, and instantiateFull eta-contracts
         -- removing this eta-contraction fixes issue 361
         -- however, Data.Star.Decoration.gmapAll no longer type-checks
         -- possibly due to missing eta-contraction!?
 
-        -- Inline copattern record constructors on demand.
-        cs <- concat <$> do
+        -- Inline copattern record constructors on demand, unless the
+        -- function is marked inline.
+        cs <- if isinline then pure cs else concat <$> do
           forM cs $ \ cl -> do
             (cls, nonExactSplit) <- runChangeT $ recordRHSToCopatterns cl
             when nonExactSplit do
@@ -366,6 +370,15 @@ checkFunDefS t ai extlam with i name withSubAndLets cs = do
               warning $ InlineNoExactSplit name cl
             return cls
 
+        -- After checking, remove the clauses again.
+        -- (Otherwise, @checkInjectivity@ loops for issue 801).
+        --
+        -- Amy, 2025-04-03: We can't remove the clauses before doing
+        -- record→copattern translation, since we might need the
+        -- previous clauses to come up with the types of the projected
+        -- fields; see 'test/Succeed/IApplyRecConstrInline'.
+        modifyFunClauses name (const [])
+
         -- Check if the function is injective.
         -- Andreas, 2015-07-01 we do it here in order to resolve metas
         -- in mutual definitions, e.g. the U/El definition in succeed/Issue439.agda
@@ -415,9 +428,6 @@ checkFunDefS t ai extlam with i name withSubAndLets cs = do
               , nest 2 $ pretty cc
               ]
 
-        -- The macro tag might be on the type signature
-        ismacro <- isMacro . theDef <$> getConstInfo name
-
         covering <- funCovering . theDef <$> getConstInfo name
 
         -- Add the definition
@@ -430,8 +440,11 @@ checkFunDefS t ai extlam with i name withSubAndLets cs = do
 
           -- If there was a pragma for this definition, we can set the
           -- funTerminates field directly.
+          --
+          -- Amy, 2025-04-03: If the function was marked INLINE before
+          -- the clauses were checked the result should also be INLINE.
           fun  <- emptyFunctionData
-          defn <- autoInline $ FunctionDefn $
+          defn <- autoInline $ set funInline isinline $ FunctionDefn $
            set funMacro_ (ismacro || Info.defMacro i == MacroDef) $
            set funAbstr_ (Info.defAbstract i) $
            fun
diff --git a/test/Fail/NestedRecConstrInline.agda b/test/Fail/NestedRecConstrInline.agda
new file mode 100644
index 00000000000..b73cb9be41f
--- /dev/null
+++ b/test/Fail/NestedRecConstrInline.agda
@@ -0,0 +1,49 @@
+module NestedRecConstrInline where
+
+open import Agda.Builtin.Equality
+open import Agda.Builtin.Nat
+
+record Foo : Set where
+  constructor inc
+  field foo1 foo2 : Nat
+
+record Bar : Set where
+  no-eta-equality
+  constructor inc
+  field
+    foo  : Foo
+    bar3 : Nat
+
+record make : Set where
+  constructor inc
+  field
+    foo1 foo2 bar3 : Nat
+
+open Foo
+open Bar
+open make
+
+{-# INLINE Bar.constructor #-}
+
+-- This function is not turned into a copattern match, because it is
+-- marked INLINE
+to : make → Bar
+{-# INLINE to #-}
+
+to f = record { foo = record { foo1 = f .foo1 ; foo2 = f .foo2 } ; bar3 = f .bar3 }
+
+-- ... so this works:
+_ : to (inc 1 2 3) ≡ inc (inc 1 2) 3
+_ = refl
+
+-- And here we inline 'to' in the RHS of x, which then becomes a record
+-- constructor, and *that* constructor gets inlined
+x : Bar
+x = to record where
+  foo1 = 1
+  foo2 = 2
+  bar3 = 3
+
+-- ... so this fails (x ≠ ...):
+_ : x ≡ inc (inc 1 2) 3
+_ = refl
diff --git a/test/Fail/NestedRecConstrInline.err b/test/Fail/NestedRecConstrInline.err
new file mode 100644
index 00000000000..5250199171d
--- /dev/null
+++ b/test/Fail/NestedRecConstrInline.err
@@ -0,0 +1,3 @@
+NestedRecConstrInline.agda:49.5-9: error: [UnequalTerms]
+x != inc (inc 1 2) 3 of type Bar
+when checking that the expression refl has type x ≡ inc (inc 1 2) 3
diff --git a/test/Succeed/IApplyRecConstrInline.agda b/test/Succeed/IApplyRecConstrInline.agda
new file mode 100644
index 00000000000..e0c1944ae76
--- /dev/null
+++ b/test/Succeed/IApplyRecConstrInline.agda
@@ -0,0 +1,51 @@
+{-# OPTIONS --cubical #-}
+
+-- This test case justifies why the clauses for a record value
+-- definition need to be available in the state (signature)
+-- during the record-to-copatterns translation,
+-- at least for Cubical Agda.
+--
+-- For more context, see the implementation of Agda.TypeChecking.Rules.Def.
+
+module IApplyRecConstrInline where
+
+open import Agda.Builtin.Nat
+
+record Category : Set₁ where
+  field
+    Ob  : Set
+    Hom : Ob → Ob → Set
+    id  : ∀ x → Hom x x
+
+postulate X : Set
+
+record H (a b : X) : Set where
+  no-eta-equality
+  field x y : Nat
+
+{-# INLINE H.constructor #-}
+
+open Category
+
+-- Was: IApplyConfluence.hs __IMPOSSIBLE__
+--
+-- To inline a constructor in cubical code (even code that doesn't use
+-- cubical features) all the resulting fields must be properly tagged
+-- with their types.
+--
+-- The constructor inlining code can only figure out what the type of a
+-- field should be if the type of the original clause reduces to the
+-- record being constructed.
+--
+-- The coverage checker does not reduce the type that the original
+-- clause is tagged with.
+
+c : Category
+c .Ob   = X
+c .Hom  = H
+
+-- The coverage checker says the following clause has type c .Hom,
+-- but the record constructs H; so the field inliner needs to see c's
+-- clauses for this reduction to work.
+
+c .id _ = record { x = 1 ; y = 2 }
diff --git a/test/Succeed/NestedRecConstrInline.agda b/test/Succeed/NestedRecConstrInline.agda
new file mode 100644
index 00000000000..b7362031133
--- /dev/null
+++ b/test/Succeed/NestedRecConstrInline.agda
@@ -0,0 +1,34 @@
+module NestedRecConstrInline where
+
+open import Agda.Builtin.Equality
+open import Agda.Builtin.Nat
+
+record Foo : Set where
+  constructor inc
+  field foo1 foo2 : Nat
+
+record Bar : Set where
+  no-eta-equality
+  constructor inc
+  field
+    foo  : Foo
+    bar3 : Nat
+
+record make : Set where
+  constructor inc
+  field
+    foo1 foo2 bar3 : Nat
+
+open Foo
+open Bar
+open make
+
+{-# INLINE Bar.constructor #-}
+
+to : make → Bar
+{-# INLINE to #-}
+
+to f = record { foo = record { foo1 = f .foo1 ; foo2 = f .foo2 } ; bar3 = f .bar3 }
+
+_ : to (inc 1 2 3) ≡ inc (inc 1 2) 3
+_ = refl


Reviews:
user: login: andreasabel
body: Thanks!

This looks ok---I am not a user of INLINE, really.

I just have some change request concerning the tests.

If this gets accepted it needs a CHANGELOG entry.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-03T16:27:22Z
user: login: plt-amy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T16:41:37Z
user: login: andreasabel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T06:06:18Z
user: login: andreasabel
body: I added a small commit with some text that should be squashed into the existing commits.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T06:07:25Z

Review comments:
diff_hunk: @@ -0,0 +1,34 @@
+{-# OPTIONS --cubical #-}
+module IApplyRecConstrInline where
+
+open import Agda.Builtin.Nat
+
+record Category : Set₁ where
+  field
+    Ob  : Set
+    Hom : Ob → Ob → Set
+    id  : ∀ x → Hom x x
+
+postulate X : Set
+
+record H (a b : X) : Set where
+  no-eta-equality
+  field x y : Nat
+
+{-# INLINE H.constructor #-}
+
+open Category
+
+-- The type of the field id is `c .Hom` when we're generating the new
+-- clauses, which, if `c .Hom` does not reduce to a record type, we can
+-- not project types from --- so the clauses have empty types, and if
+-- IApplyConfluence runs into a clause with no type, it explodes.
+--
+-- Inlining record constructors used to run in an environment that
+-- believes the function being defined has no clauses; so the type of
+-- `id` does not reduce; so the clauses generated for `id` have no type;
+-- so IApplyConfluence explodes.
path: test/Succeed/IApplyRecConstrInline.agda
user: login: andreasabel
body: Frankly, I do not understand this explanation, maybe provide more context and details...
created_at: 2025-04-03T16:22:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,49 @@
+module NestedRecConstrInline where
+
+open import Agda.Builtin.Equality
+open import Agda.Builtin.Nat
+
+record Foo : Set where
+  constructor inc
+  field foo1 foo2 : Nat
+
+record Bar : Set where
+  no-eta-equality
+  constructor inc
+  field
+    foo  : Foo
+    bar3 : Nat
+
+record make : Set where
+  constructor inc
+  field
+    foo1 foo2 bar3 : Nat
+
+open Foo
+open Bar
+open make
+
+{-# INLINE Bar.constructor #-}
+
+-- This function is not turned into a copattern match, because it is
+-- marked INLINE
+to : make → Bar
+{-# INLINE to #-}
+
+to f = record { foo = record { foo1 = f .foo1 ; foo2 = f .foo2 } ; bar3 = f .bar3 }
+
+-- ... so this works:
+_ : to (inc 1 2 3) ≡ inc (inc 1 2) 3
+_ = refl
path: test/Fail/NestedRecConstrInline.agda
user: login: andreasabel
body: We usually do not put successful tests into a failing testcase  because it is easier to miss when the successful test breaks.  (One needs to be extra careful about checking the actual error message.)
Please move the successful test into its own file under `Succeed/`.
created_at: 2025-04-03T16:25:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,34 @@
+{-# OPTIONS --cubical #-}
+module IApplyRecConstrInline where
+
+open import Agda.Builtin.Nat
+
+record Category : Set₁ where
+  field
+    Ob  : Set
+    Hom : Ob → Ob → Set
+    id  : ∀ x → Hom x x
+
+postulate X : Set
+
+record H (a b : X) : Set where
+  no-eta-equality
+  field x y : Nat
+
+{-# INLINE H.constructor #-}
+
+open Category
+
+-- The type of the field id is `c .Hom` when we're generating the new
+-- clauses, which, if `c .Hom` does not reduce to a record type, we can
+-- not project types from --- so the clauses have empty types, and if
+-- IApplyConfluence runs into a clause with no type, it explodes.
+--
+-- Inlining record constructors used to run in an environment that
+-- believes the function being defined has no clauses; so the type of
+-- `id` does not reduce; so the clauses generated for `id` have no type;
+-- so IApplyConfluence explodes.
path: test/Succeed/IApplyRecConstrInline.agda
user: login: plt-amy
body: Yeah, I was really struggling to articulate this; I ran into the `__IMPOSSIBLE__` while writing some actual code and thankfully the minimised example is pretty simple. Let me know if the new comments make more sense?
created_at: 2025-04-03T16:41:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+{-# OPTIONS --cubical #-}
+module IApplyRecConstrInline where
+
+open import Agda.Builtin.Nat
+
+record Category : Set₁ where
+  field
+    Ob  : Set
+    Hom : Ob → Ob → Set
+    id  : ∀ x → Hom x x
+
+postulate X : Set
+
+record H (a b : X) : Set where
+  no-eta-equality
+  field x y : Nat
+
+{-# INLINE H.constructor #-}
+
+open Category
+
+-- The type of the field id is `c .Hom` when we're generating the new
+-- clauses, which, if `c .Hom` does not reduce to a record type, we can
+-- not project types from --- so the clauses have empty types, and if
+-- IApplyConfluence runs into a clause with no type, it explodes.
+--
+-- Inlining record constructors used to run in an environment that
+-- believes the function being defined has no clauses; so the type of
+-- `id` does not reduce; so the clauses generated for `id` have no type;
+-- so IApplyConfluence explodes.
path: test/Succeed/IApplyRecConstrInline.agda
user: login: andreasabel
body: Yes, definitely!

I added a sentence at the beginning of the test case to provide some more context.
created_at: 2025-04-04T06:06:18Z
author_association: MEMBER

PR comments:
user: login: andreasabel
created_at: 2025-04-08T06:48:14Z
body: @plt-amy It seems that it does not make sense to wait for further reviews.  Could you please add this to the Changelog?
author_association: MEMBER
user: login: plt-amy
created_at: 2025-04-08T10:28:24Z
body: @andreasabel Done; I also squashed your doc commit into 8d19e037dd29b7440f1fe7d08b53f998fb5b5060 by hand so we can keep both commits (independent) when merging this PR.
author_association: CONTRIBUTOR