Skip to content

UPLC source view: source-mapped UPLC artifact for the VS Code profiler extension - #338

Open
nau wants to merge 12 commits into
masterfrom
feature/uplc-source-view
Open

UPLC source view: source-mapped UPLC artifact for the VS Code profiler extension#338
nau wants to merge 12 commits into
masterfrom
feature/uplc-source-view

Conversation

@nau

@nau nau commented Aug 3, 2026

Copy link
Copy Markdown
Member

What

Profiled runs now write a UPLC source map next to the profile reports: the pretty-printed UPLC text plus a span table mapping text ranges to Scala source positions and enclosing function names. The Scalus Profiler VS Code extension (companion PR in scalus-vscode-extension) uses it for a side-by-side, bidirectionally synced Scala/UPLC view.

  • V3 lowering stamps the enclosing function name into every UplcAnnotation (previously a dead field); the position fill passes now carry the whole annotation.
  • TermPrinter: the pretty-printer body extracted with a per-node decorator hook; default output is byte-identical (verified by a sha256 differential over 1200 terms x 5 render modes).
  • UplcSourceMapRenderer: renders via paiges zero-width markers to recover exact character offsets; the text invariant is total (render(term).uplc == term.show, degrading to zero spans on pathological marker collisions). Spans carry 0-based source ranges, function indices and post-order node indices (stable under Apply wrapping, following Aiken's unmerged source-map design).
  • ProfileReportWriter writes <key>.uplc.json and indexes it as format: "uplc" in profile-manifest.json when the profile level is Full and at least one profile file was rendered. Console-only runs stay off disk. Manifest schema stays v1; older extension versions ignore the new entry.
  • MiMa: the compiler-internal packages (scalus.compiler.sir.lowering, .linking, scalus.compiler.intrinsics, scalus.uplc.builtin.internal) are now exempt via documented wildcard filters instead of per-member entries; README and Claude.md state the policy.

Design docs

  • Spec: docs/superpowers/specs/2026-07-31-uplc-source-view-design.md
  • Plan: docs/superpowers/plans/2026-07-31-uplc-source-view.md

Testing

  • sbtn quick green after rebase on master (3462 core + 811 + 448 + 22 tests, 0 failures); sbtn mima green; JS/Native compile and scalusJS/Test/fastLinkJS verified.
  • New suites: FunctionNameAnnotationTest, FillAnnotationsTest, PrettyDecoratedTest, UplcSourceMapRendererTest (14 cases incl. marker-collision spoof), ProfileReportWriterUplcTest (JVM, 5 cases incl. console-only gating).
  • End-to-end: profiled runs of the scalus vesting example and Binocular's BitcoinValidator produced valid artifacts (6558 spans / 23 files / 70 functions for Binocular) consumed by the extension.

Notes

  • Follow-up candidates (not in this PR): producer-side normalization of duplicate file spellings and bare function-name interning in the artifact tables; the extension compensates in the meantime.

nau added 12 commits August 3, 2026 15:58
Design for showing compiled UPLC per source line/definition/function in the
Scalus Profiler VS Code extension: functionName in UplcAnnotation, span-aware
rendering via paiges zero-width markers, uplc.json artifact in the profile
manifest, and a bidirectional side-by-side view in the extension.
…ing V3 lowering

Every LoweredValue now captures the name of the user function being lowered
(LoweringContext.currentFunction, set by Lowering.lowerLet around lambda-valued
bindings) and copies it into the UplcAnnotation of every term it emits. This
gives tooling, e.g. the VS Code UPLC source view, a way to group compiled UPLC
by source function. It is pure metadata and does not change generated code.

The capture happens at LoweredValue construction, not in termInternal: term
generation is a separate pass that runs after lowering has finished, so a
LoweringContext read there would always see the restored empty name. The name
therefore lives in a thread-local, since several lowerings can run at once in
one JVM.

Binding names are reduced to their source-level form: the owner prefix that
linked top-level defs carry and the -<symbolId> suffix that the plugin appends
to local bindings are both stripped.

Two positional-fill passes in Term now key on the position alone instead of on
whole-annotation emptiness, and preserve the rest of the annotation, so a term
that knows its function but not its position still gets a position filled in.

The UplcAnnotation sites in LoweredValue.scala that were left as-is are the
ones outside a LoweredValue: none remain in that file, but ScalusRuntime.scala,
simple/BaseSimpleLowering.scala and typegens/* still build terms from helper
objects with no value to capture from. Those stay unattributed for now.

Adds two MiMa filters: the new trait val adds an abstract accessor to the
compiler-internal LoweredValue interface, which has no supported external
implementors.
…n fill from it

Review follow-up to the previous commit, three findings.

The ChooseList/ChooseData spines ended in withPosIfEmpty(pos), a term
annotation site the first pass missed because it does not spell
UplcAnnotation(. They now use withAnnotationIfEmpty(ann(pos)).

Term.withAnnotationIfEmpty still keyed on whole-annotation emptiness. With
functionName populated, a subterm that knew its function but not its position
read as already annotated, so it was skipped and the recursion into its whole
subtree stopped, losing positions that used to be filled. It now keys on the
position alone and merges instead of overwriting, matching the two
fillEmptyPos passes.

ScalusRuntime.initSupportBindings lowered every support-module def with no
function scope, so the whole prelude was unattributed. It now goes through
Lowering.loweringBinding, the same helper and the same lambda-only gate as user
bindings; simpleBindingName/loweringBinding/LocalBindingIdSuffix were widened to
private[lowering] for that. A compile of List.single(3).map(_ + 1).length now
names 83 of 167 terms (foldLeft, foldRight, length, map), up from none.

The 27 remaining UplcAnnotation sites in ScalusRuntime and typegens are all
inside ComplexLoweredValue bodies where ann(...) is in scope, contrary to the
earlier rationale, so they are converted too. Only the non-V3 simple backend
still emits bare annotations.
Generalize the two Term fill passes from ScalusSourcePos to the whole
UplcAnnotation, so a back-filled spine node inherits the enclosing
function name of the code it operates on, not only its position.

fillEmptyAnnotationsBottomUp/fillEmptyAnnotationsTopDown are the new
private[scalus] passes; fillEmptyPosBottomUp/fillEmptyPosTopDown keep
their signatures and delegate. Both fields are filled independently and
an existing one is never overwritten, so Task 1 semantics (position fill
decoupled from the function name) are preserved.
Move the Pretty[Term] printer body into a private[scalus] object TermPrinter
that takes a (Term, Doc) => Doc decorator, applied to the Doc of every printed
node. The given Pretty[Term] keeps sanitizing names exactly once and delegates
with an identity decorator, so pretty/show output is byte-identical.

prettySanitized assumes an already-sanitized term. Inner Apply nodes of a
flattened application chain are not printed individually, so they are not
decorated.
…an map

Renders a Term to the same text as Term.show plus a table mapping character
ranges of that text back to Scala source positions and enclosing function
names. Offsets are recovered by passing zero-width paiges markers through the
shared pretty-printer, so the rendered UPLC is byte-identical to what every
other Scalus output shows.

Span node indices are post-order, which keeps them stable when a compiled
program is later wrapped in Apply nodes to apply parameters. Serializes to the
schemaVersion 1 <key>.uplc.json document the VS Code UPLC source view consumes.
A string constant can contain the renderer's marker characters. The scanner's
guard only caught a spoof that arrived after the marker it imitated; a spoof
printed before that node was consumed as the real thing, which corrupted the
constant's text, broke the render(term).uplc == term.show invariant and left
spans that partially overlap – the one thing the view's innermost-span cursor
lookup relies on not happening.

Verify the stripped text against an unmarked render of the same term and, on a
mismatch, return the clean text with no spans. Degrading to a spanless view is
recoverable, wrong UPLC text is not.

Also pin the ordering of UplcSourceMap.spans in its scaladoc: marker-emission
order, children before parent, s not monotonic, so consumers must sort before
binary-searching by offset.
…ports

ProfileReportWriter.write takes the evaluated term as an optional last
parameter. At ProfileLevel.Full, when the term still carries the compiler's
source annotations, it writes a <key>.uplc.json source map next to the profile
files and indexes it in profile-manifest.json as format "uplc".

The ledger evaluator passes the script's unapplied program term (by name, so a
CBOR decode only happens when a profile was produced) and ScalusTest does the
same for test-side profiling.
…ve its test to JVM

The test used java.nio.file from the shared test tree, so Scala.js could not
link js/test; it moves to scalus-core/jvm/src/test.

The source map is now written only when the run also rendered at least one
profile file. A console-only report at ProfileLevel.Full used to write the
artifact and, because manifest runs are replaced by (scriptHash, tag, index),
rewrite an existing run's file list to the source map alone, hiding the profile
files consumers look for.
Replace em dashes with en dashes in the annotation fill-pass comments added
on this branch (Compiled.scala, Term.scala). Drop the stale duplicated
scaladoc above PlutusScriptEvaluator.renderProfile, which still referenced
the removed writeProfileManifest and the pre-refactor behavior.
Replace the two LoweredValue.functionName member filters with documented
package-level exclusions for scalus.compiler.sir.lowering, .linking,
scalus.compiler.intrinsics and scalus.uplc.builtin.internal - the packages
the stability docs already carve out of the compatibility promise. Note the
concrete list in README and Claude.md, and mark InteropSurfaceTest as planned
(M2), not existing. MIXED packages keep per-symbol filters only.
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.

1 participant