Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog.d/20260728_170000_unisay_renumber_ir_goldens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Changed

- The `.ir` golden files no longer churn on compiler-minted name indices
(#338). Every IR pass draws fresh binder names from one pipeline-global
counter, so the index a name carries records how much supply the passes
before it happened to consume: an optimizer change that mints one extra
name anywhere renumbers every name after it, and the structural goldens
then diff on lines whose only change is the counter. The golden harness now
renumbers each top-level site's minted binders in first-occurrence order
before rendering, so `m$590` prints as `m$0` and a site's names depend only
on that site's own structure. Restarting the whole pipeline's supply at
1000 leaves every golden — `.ir`, `.lua` and `eval/golden.txt` alike —
byte-identical. The digit-run classifier this shares with the Lua emission
renumberer moved to `Language.PureScript.Backend.Renumber`, carrying
Note [Supply-drawn digit runs]; structural suffixes (`f$w`, `f$p1`,
`pong$sc1Tuple`, uniquification's `x0`) are left alone as before.
Generated Lua is unchanged.
136 changes: 136 additions & 0 deletions lib/Language/PureScript/Backend/IR/Renumber.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{- | Renumbering of the compiler-minted binders of an IR module.

The IR pipeline mints names by drawing an index from one monotone supply
shared by every pass ("Language.PureScript.Backend.IR.Supply"), so the
index a binder carries records how much supply the passes that ran before
it happened to consume. Rendering a module — the @.ir@ golden files, a
pass trace — therefore shows names that shift wholesale whenever an
unrelated pass starts consuming more or fewer names, burying the
structural change under renumbered lines.

'renumberUberModule' reassigns those indices in first-occurrence order,
one allocation per top-level site, making every minted name a function of
its own site's structure: a pass that consumes extra supply while
building one site cannot renumber another. See Note [Supply-drawn digit
runs] in "Language.PureScript.Backend.Renumber" for which digit runs
qualify — the positional suffixes of uncurrying (@f$w@, @f$p1@),
call-pattern specialization (@f$sc1Tuple@) and uniquification (@x0@) are
structural already and pass through.

The renaming is applied per site as a plain name-to-name map, with no
scope threading, which the GUC discipline (@UniqueBinders@) licenses:
within one site at most one binder carries any given name, so a local
reference belongs to the binder its name matches. The one local
reference a site may leave free is the runtime lazy factory (see
Note [The PSLUA_runtime_lazy coupling] in "Language.PureScript.Names"),
whose name holds no digit run and so is never an image of the
renumbering. Top-level names are drawn from source identifiers and
structural suffixes, never from the supply, so a site's renaming cannot
reach another site.
-}
module Language.PureScript.Backend.IR.Renumber (renumberUberModule) where

import Control.Lens (foldMapOf, over)
import Data.Map qualified as Map
import Language.PureScript.Backend.IR.Linker (UberModule (..))
import Language.PureScript.Backend.IR.Names (Name (..), Qualified (Local))
import Language.PureScript.Backend.IR.Types
( Grouping
, Parameter (..)
, RawExp (..)
, bindingNames
, listGrouping
, paramName
, subexpressions
)
import Language.PureScript.Backend.Renumber
( Allocation
, Delimiter (Delimiter)
, noAllocation
, renumberedText
)

--------------------------------------------------------------------------------
-- Renumbering -----------------------------------------------------------------

{- | Renumber the minted binders of every top-level site — each binding,
foreign binding and export — independently of the others.
-}
renumberUberModule ∷ UberModule → UberModule
renumberUberModule uberModule =
uberModule
{ uberModuleBindings =
fmap renumberSite <<$>> uberModuleBindings uberModule
, uberModuleForeigns = renumberSite <<$>> uberModuleForeigns uberModule
, uberModuleExports = renumberSite <<$>> uberModuleExports uberModule
}

-- | Renumber one site: allocate for its binders, then apply the renaming.
renumberSite ∷ RawExp ann → RawExp ann
renumberSite e = rename (siteRenaming (binders e)) e

{- | The renaming of a site's minted binder names, allocated in the order
'binders' visits them. Names carrying no supply-drawn index are absent,
which leaves them — and the references resolving to them — untouched.
-}
siteRenaming ∷ [Name] → Map Name Name
siteRenaming names =
Map.fromList . catMaybes $
evaluatingState noAllocation (traverse allocated names)
where
allocated ∷ Name → State Allocation (Maybe (Name, Name))
allocated name =
case renumberedText (Delimiter "$") noneReserved (nameToText name) of
Nothing → pure Nothing
Just mint → Just . (name,) . Name <$> mint

-- Every image carries a @$@-delimited digit run, which no source
-- identifier and no structural suffix can spell, so the allocation has
-- no spelling to withhold.
noneReserved ∷ Text → Bool
noneReserved _spelling = False

-- | The site's binder names, in traversal order.
binders ∷ ∀ ann. RawExp ann → [Name]
binders = \case
AbsN _ann params body → paramNames params <> binders body
-- The RHS is outside the binders' scope (Note [Multi-value results]).
LetValues _ann params rhs body →
binders rhs <> paramNames params <> binders body
Let _ann binds body →
(bindingNames =<< toList binds)
<> foldMap boundBinders (toList binds)
<> binders body
-- No other constructor binds a name ('ForeignImport' included: its
-- name list holds the export keys of the foreign source file):
other → foldMapOf subexpressions binders other
where
paramNames ∷ NonEmpty (Parameter ann) → [Name]
paramNames = mapMaybe paramName . toList

boundBinders ∷ Grouping (ann, Name, RawExp ann) → [Name]
boundBinders = foldMap (\(_ann, _name, e) → binders e) . listGrouping

-- | Rewrite every binder and every local reference through the renaming.
rename ∷ ∀ ann. Map Name Name → RawExp ann → RawExp ann
rename renames = go
where
renamed ∷ Name → Name
renamed name = Map.findWithDefault name name renames

go ∷ RawExp ann → RawExp ann
go = \case
Ref ann (Local name) → Ref ann (Local (renamed name))
AbsN ann params body → AbsN ann (renameParam <$> params) (go body)
LetValues ann params rhs body →
LetValues ann (renameParam <$> params) (go rhs) (go body)
Let ann binds body → Let ann (fmap renameBound <$> binds) (go body)
other → over subexpressions go other

renameBound ∷ (ann, Name, RawExp ann) → (ann, Name, RawExp ann)
renameBound (ann, name, e) = (ann, renamed name, go e)

renameParam ∷ Parameter ann → Parameter ann
renameParam = \case
p@(ParamUnused _ann) → p
ParamNamed ann name → ParamNamed ann (renamed name)
131 changes: 28 additions & 103 deletions lib/Language/PureScript/Backend/Lua/Renumber.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,31 @@ monotone supply: suffix-minted as @base$N@ (uniquification, inline-paste
freshening, deep-bind flattening) or prefix-minted as @$tagN@ (CSE's
@$cse@, codegen's @$sel@\/@$a@ dispatch locals, the native-loop @$xs@\/@$f@
atomizers, …), with 'Language.PureScript.Backend.Lua.Name.makeSafe'
mangling @$@ to @_S_@ on the way into the Lua AST. The index a name carries
is pipeline history, not artifact structure: any upstream change that
shifts how much supply earlier code consumes renames every later binder,
churning emitted modules that are semantically identical.
mangling @$@ to @_S_@ on the way into the Lua AST.

'renumberChunk' erases that history immediately before printing: every
supply-drawn index occurring in a local binder is renumbered in
first-occurrence order, and the binder's references follow
scope-consistently. Emitted names become a function of the chunk's own
structure — byte-stable under any upstream change that only perturbs
supply consumption. See Note [Supply-drawn digit runs] for the domain
and the safety argument.
supply consumption. See Note [Supply-drawn digit runs] in
"Language.PureScript.Backend.Renumber" for which digit runs qualify.

PureScript identifiers cannot contain @$@, so compiler-minted names are
the only source of the renumbered shapes; a hand-written FFI local that
happens to spell one is renumbered too, which alpha-renames it
consistently and is semantically inert. Binders are renamed together
with exactly their in-scope references, so the rewrite is an
alpha-renaming; a reference the environment does not bind is a global —
an FFI file's stdlib or host-API read — and stays untouched, with
allocation skipping any index whose direct @prefix_S_index@ spelling
occurs free in the chunk, so a renamed binder cannot capture such a
global either.
-}
module Language.PureScript.Backend.Lua.Renumber (renumberChunk) where

import Data.Char qualified as Char
import Data.Map qualified as Map
import Data.Set qualified as Set
import Data.Text qualified as Text
import Language.PureScript.Backend.Lua.Name (Name)
import Language.PureScript.Backend.Lua.Name qualified as Name
import Language.PureScript.Backend.Lua.Types
Expand All @@ -38,57 +44,20 @@ import Language.PureScript.Backend.Lua.Types
, TableRowF (..)
, VarF (..)
)

{- Note [Supply-drawn digit runs]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A maximal digit run inside a (mangled) name is /supply-drawn/ — an index
that leaks pipeline history — in exactly two shapes, mirroring the two
minting grammars:

* it forms a whole @_S_@-delimited segment: @x_S_223@, and the same
run embedded deeper in a derived name, @b_S_5_S_loop@ (the dispatcher
a recursive group's leader @b$5@ lends its name to);

* it terminates the tag of a prefix-minted name — one starting with
@_S_@ — as in @$cse1413@ → @_S_cse1413@ or @$a1@ → @_S_a1@.

Digit runs anywhere else are spelling, not supply: @add3@ keeps its @3@,
and SpecConstr's positional @…_S_sc1Tuple@ keeps its @1@. PureScript
identifiers cannot contain @$@, so compiler-minted names are the only
source of these shapes; a hand-written FFI local that happens to spell
one is renumbered too, which alpha-renames it consistently and is
semantically inert.

Renumbering assigns each distinct (piece-prefix, run) pair a fresh index
per prefix, counted up from 0 in first-occurrence order. Keying by the
run's original spelling keeps a derived name in step with the binder it
embeds: @b_S_5@ and @b_S_5_S_loop@ renumber to @b_S_0@ and
@b_S_0_S_loop@. The mapping is injective (same prefix → distinct
indices; different prefixes → images differ outside their digit runs),
and an image can never collide with a name the pass leaves alone,
because every image contains a supply-drawn run and untouched names by
definition contain none. Binders are renamed together with exactly their
in-scope references, so the rewrite is an alpha-renaming; a reference
the environment does not bind is a global — an FFI file's stdlib or
host-API read — and stays untouched, with allocation skipping any index
whose direct @prefix_S_index@ spelling occurs free in the chunk, so a
renamed binder cannot capture such a global either.
-}
import Language.PureScript.Backend.Renumber
( Allocation
, Delimiter (Delimiter)
, noAllocation
, renumberedText
)

--------------------------------------------------------------------------------
-- Renumbering pass ------------------------------------------------------------

-- | Original binder name → renumbered name, for the binders in scope.
type Env = Map Name Name

data Supply = Supply
{ assigned ∷ Map (Text, Text) Natural
-- ^ (piece-prefix, original digit run) → allocated index.
, counters ∷ Map Text Natural
-- ^ Next index to allocate, per piece-prefix.
}

type M = State Supply
type M = State Allocation

{- | Renumber every supply-drawn digit run occurring in a local binder —
'Local', 'LocalFunction', 'ForNum', 'ForIn' and function parameters — in
Expand All @@ -99,7 +68,7 @@ image of the pass is a fixpoint.
-}
renumberChunk ∷ Chunk → Chunk
renumberChunk chunk =
evaluatingState (Supply mempty mempty) (goStatements Map.empty chunk)
evaluatingState noAllocation (goStatements Map.empty chunk)
where
free = freeVarNames chunk

Expand Down Expand Up @@ -207,11 +176,12 @@ renumberChunk chunk =
VarField e n → (`VarField` n) <$> goExpA env e

bindName ∷ Env → Name → M (Env, Name)
bindName env n = case renumberedText (Name.toText n) of
Nothing → pure (env, n)
Just mintText → do
n' ← Name.unsafeName <$> mintText
pure (Map.insert n n' env, n')
bindName env n =
case renumberedText (Delimiter "_S_") (`Set.member` free) (Name.toText n) of
Nothing → pure (env, n)
Just mintText → do
n' ← Name.unsafeName <$> mintText
pure (Map.insert n n' env, n')

bindNames ∷ Env → NonEmpty Name → M (Env, NonEmpty Name)
bindNames env (n :| ns) = do
Expand Down Expand Up @@ -239,51 +209,6 @@ renumberChunk chunk =
(envFinal, rest') ← bindParams env' rest
pure (envFinal, (c, param') : rest')

-- 'Just' only when the text contains a supply-drawn run (see Note
-- [Supply-drawn digit runs]); the action allocates the new indices.
renumberedText ∷ Text → Maybe (M Text)
renumberedText whole
| any supplyDrawn piecesInContext =
Just $
Text.concat <$> forM piecesInContext \piece@(run, prefix, _next) →
if supplyDrawn piece then allocate prefix run else pure run
| otherwise = Nothing
where
-- Maximal runs of digits alternating with runs of non-digits.
pieces = Text.groupBy (\a b → Char.isDigit a == Char.isDigit b) whole
piecesInContext =
[ (p, Text.concat (take i pieces), pieces !!? (i + 1))
| (i, p) ← zip [0 ..] pieces
]
supplyDrawn (p, prefix, next)
| not (Text.all Char.isDigit p) = False
| otherwise =
atSegmentEnd
&& ( "_S_" `Text.isSuffixOf` prefix -- whole segment: base$N
|| ( "_S_" `Text.isPrefixOf` whole -- prefix-minted: $tagN
&& not ("_S_" `Text.isInfixOf` Text.drop 3 prefix)
)
)
where
atSegmentEnd = maybe True ("_S_" `Text.isPrefixOf`) next

allocate ∷ Text → Text → M Text
allocate prefix run = do
Supply {assigned, counters} ← get
show <$> case Map.lookup (prefix, run) assigned of
Just i → pure i
Nothing → do
let nextClear c
| (prefix <> show c) `Set.member` free = nextClear (c + 1)
| otherwise = c
i = nextClear (Map.findWithDefault 0 prefix counters)
put
Supply
{ assigned = Map.insert (prefix, run) i assigned
, counters = Map.insert prefix (i + 1) counters
}
pure i

--------------------------------------------------------------------------------
-- Free variables --------------------------------------------------------------

Expand Down
Loading