Skip to content

Value-parameterised annotations and Face primacy - #60527

Open
tecosaur wants to merge 23 commits into
JuliaLang:masterfrom
tecosaur:val-param-annot-faces
Open

tecosaur wants to merge 23 commits into
JuliaLang:masterfrom
tecosaur:val-param-annot-faces

Conversation

@tecosaur

@tecosaur tecosaur commented Jan 1, 2026 •

Copy link
Copy Markdown
Member

This is part of a large effort to overhaul StyledStrings to address the major pain points that have come up since the initial design/implementation.

This work adds value-parameterisation to annotations, making static compilation more viable, replaces symbol-based face naming with a form of hybrid face-interning (face""), overhauls the styled"" macro AST generation, adds face namespacing, and includes light/dark theming work mentioned in #59958 (though I still want to land the REPL changes separately in that PR).

I'll be fleshing out this description more soon, but for now I just want to use CI-built Julia to make it easier for friendly testers to try this out and provide feedback/bug-hunt.

@tecosaur
tecosaur force-pushed the val-param-annot-faces branch 12 times, most recently from 04651e1 to bce32f4 Compare January 2, 2026 19:54
@tecosaur tecosaur added strings "Strings!" display and printing Aesthetics and correctness of printed representations of objects. stdlib Julia's standard library labels Jan 3, 2026
@tecosaur
tecosaur force-pushed the val-param-annot-faces branch from bce32f4 to a5e0e17 Compare January 3, 2026 06:41
@tecosaur
tecosaur force-pushed the val-param-annot-faces branch 4 times, most recently from e40a123 to e514004 Compare January 4, 2026 16:47
@tecosaur

tecosaur commented Jan 5, 2026

Copy link
Copy Markdown
Member Author

There are a few oddities with CI that I'd appreciate help working out how to resolve:

      From worker 11:	┌ Info: REPL: trace compile output:
      From worker 11:	└ precompile(Tuple{typeof(Base.isempty), Base.AnnotatedString{String, StyledStrings.Face}})
REPL                                                        (11) |         failed at 2026-01-04T12:47:03.747
Test Failed at .../julia/stdlib/v1.14/REPL/test/precompilation.jl:43
  Expression: n_precompiles <= expected_precompiles
   Evaluated: 1 <= 0

I tried adding precompile(Tuple{typeof(Base.isempty), Base.AnnotatedString{String, REPL.StyledStrings.Face}}) to REPL/src/precompile.jl, but it's still there.

Error During Test at /Users/julia/.julia/scratchspaces/a66863c6-20e8-4ff4-8a62-49f30b1f605e/agent-cache/default-macmini-x64-5.0/build/default-macmini-x64-5-0/julialang/julia-master/julia-e5140044d4/share/julia/test/testdefs.jl:25
  Got exception outside of a @test
  LoadError: UndefVarError: `StyledStrings` not defined in `Main.Test74Main_REPL.REPLTests`
  Stacktrace:
    ...
  in expression starting at .../julia/stdlib/v1.14/REPL/test/repl.jl:1929
  in expression starting at .../julia/stdlib/v1.14/REPL/test/repl.jl:1923
  in expression starting at .../julia/stdlib/v1.14/REPL/test/runtests.jl:10

This confuses me, since StyledStrings is imported before the referenced lines in repl.jl.

@KristofferC

KristofferC commented Jan 7, 2026 •

Copy link
Copy Markdown
Member

I tried adding precompile(Tuple{typeof(Base.isempty), Base.AnnotatedString{String, REPL.StyledStrings.Face}}) to REPL/src/precompile.jl, but it's still there.

Try adding it to

hardcoded_precompile_statements = """
instead.

Comment thread base/strings/annotated.jl Outdated
@tecosaur
tecosaur force-pushed the val-param-annot-faces branch from 13bcc37 to 148c6fd Compare July 26, 2026 09:13

@topolarity topolarity 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.

Thanks for those responses.

I will give another round of "proper" review soon (Monday).. until then, what do you think about restoring to the "default" Face using proper "reset" ANSI codes, instead of "set" codes? I believe the terminal supports this even for individual text attributes.

I am curious if that behaves better under default Face "mismatch" (it should fix my complaint in (2) without affecting your blending argument)

@tecosaur
tecosaur force-pushed the val-param-annot-faces branch 5 times, most recently from cf7c5a8 to 054a09c Compare October 18, 2026 02:26
Annotated strings held their annotation values as `Any`. Nothing about
an `AnnotatedString{String}` said what its annotations held, so code
that read or displayed one could not dispatch on it.

Annotations now have a value type parameter: `AnnotatedString{S, V}`,
and likewise `AnnotatedChar`, `Annotation`, `RegionAnnotation` and
`AnnotatedIOBuffer`. A one-parameter form, such as
`AnnotatedString{String}`, covers every `V`, and the one-parameter
constructors infer it.

- Construction infers `V` from the annotations given. Constructing from
  an existing annotated string or char copies its annotations, while
  `convert` returns it as it is. Adding annotations to one widens `V`,
  as `vcat` does.
- `annotatedstring`, `*` and `replace` widen `V` to the union of the
  value types, up to three of them, and then to `Any`. `join` keeps a
  shared `V`, and otherwise gives `Any`. Values are not converted to a
  common type.
- `annotate!` refuses a value outside `V` with an `ArgumentError`.
  Writing or replacing into a typed buffer converts the values to its
  `V`, and reading converts them to the requested type.
- An annotated buffer merges adjacent runs only when their values are
  identical. Equal but distinct values keep their own runs.
The `invoke_in_world` barrier around every annotated write is replaced
by a trait on the value type, `Base.AnnotatedDisplay.AnnotationStyle`.
A package declares a style for its value type and implements
`awrite(textwriter, style, io, s)`; `write`, `print` and
`escape_string` all go through it. A style may add a `text/html`
method, which `showable` reports. `NoStyle` writes the plain text and
has no MIME form. Only `Any`-valued strings still work out their style
from the values they hold, behind one remaining barrier, and `showable`
does the same for them. Escaping into an `AnnotatedIOBuffer`, bare or
inside an `IOContext`, keeps the annotations, moved to cover the
escaped text. `print` goes through `write`, so printing into an
`IOContext` around such a buffer keeps them too.

When two styles meet, a rule `AnnotationStyle(a, b)` settles which
applies. It is tried in both orders, as `promote_rule` is, and a
missing rule raises an `ArgumentError` naming the method to define.

Both trait functions have `max_methods = 1`. A call whose value type
is not known at compile time then sees two applicable methods, so
inference leaves it dynamic and records no method-table edge. This
means a package's style and writer definitions cannot invalidate
Base's compiled callers, which a test checks in a fresh process.
`eachregion` built every region up front: a vector of ranges, and a
fresh vector of annotations for each region, all before yielding
anything.

The iterator now walks the annotation events as it goes, keeping a
sorted list of the annotations active over the current region. Each
region is yielded as a substring, with its annotations as a view of
that list, `ActiveAnnotations`. A view held after the iterator has
moved on still works; it finds its annotations again by position, at a
cost linear in the number of annotations.

Events are packed into integers, so sorting them is a radix sort. The
active list is updated by shifting entries within it, and `iterate` is
inlined so the sweep's state stays in registers. A region now costs
8 ns in order and 15 ns under a nested annotation, down from 20 and 35,
with nothing allocated per region.

A subregion or an annotation that starts within a character is moved
back to the start of that character, as the events are.

The views read the string's annotations when they are used, so a string
must not change while its regions are in use. If it does, reads stay in
bounds: the iterator keeps the event layout it was built with, and an
annotation index that is out of range is a `BoundsError`.

Assisted-by: Claude Code (Fable 5.1)
Stdlib: StyledStrings
URL: https://github.com/JuliaLang/StyledStrings.jl.git
Stdlib branch: face-primacy
Old commit: 41c13c2
New commit: b8c9246
Diff:
JuliaLang/StyledStrings.jl@41c13c2...b8c9246

Stdlib: JuliaSyntaxHighlighting
URL: https://github.com/JuliaLang/JuliaSyntaxHighlighting.jl.git
Stdlib branch: faces-value-parameterisation
Old commit: dae5817
New commit: 2061af0
Diff:
JuliaLang/JuliaSyntaxHighlighting.jl@dae5817...2061af0

StyledStrings displays faces through the annotation style trait, and
names faces with `Face` values held in module palettes
(JuliaLang/StyledStrings.jl#131). Links are `Link` values, and
`StyledValue` names the value type of styled strings.
JuliaSyntaxHighlighting highlights with `Face` values from its own
palette.
Markdown, Profile and the REPL named their faces with prefixed
`Symbol`s, registered with `addface!`. They now declare their faces
with `@defpalette`, register them from `__init__`, and refer to them
as `face"name"` or by bare name inside `styled""`.

The REPL's styling passes, Markdown and Profile build their strings
with `StyledValue` values, as styled strings have, so that they share
one value type and the code specialised on it, and redisplaying the
input takes the static display path. Each styling pass must return such
a string, a `StylingPasses.StyledString`. Markdown keeps only the
annotations of interpolated text that its buffer can hold, and marks
its links with `StyledStrings.Link`.

Markdown also never styled `ERROR:` in REPL examples, as the check
compared an annotated substring with a plain string. It now compares
the plain text.

The REPL's history search styled the character before each condition
by its byte offset, which threw when that character took more than one
byte. It now styles it only where a single-byte prefix or separator
can be.

Assisted-by: Claude Code (Opus 5.5)
`--trim=safe` reported two dynamic calls when loading a terminfo entry.
The parts of `TERMINFO_DIRS` are now converted to `String`s, rather than
kept as `SubString`s, and each kind of capability is inserted with its
own concrete value type.
The Trimmability program now prints a styled string with ANSI escapes
and as HTML. The string has a height, an underline colour and a link,
so the writers compare successive faces. The test checks both lines
against the same output from an untrimmed process.

Assisted-by: Claude Code (Opus 5.5)
`merge_annotations` started from a copy of the first pass's string,
then added the annotations of every pass. The first pass's
annotations were therefore stored twice. The loop now skips the
first pass.

Assisted-by: Claude Code (Opus 5.5)
`join(split(s))` of an `AnnotatedString` returned a plain `String`.
`join` asks `_isannotated` about the element type, and only the method
for instances covered `SubString{<:AnnotatedString}`. A method for the
type now sits beside it.

Assisted-by: Claude Code (Opus 5.5)
`eltype(SubString{T})` was `Char` whatever `T`, while iterating a
substring of an annotated string yields `AnnotatedChar`s. So `collect`
of such a substring gave a `Vector{Char}` and dropped the annotations.
A substring's eltype is now that of its string.

Assisted-by: Claude Code (Opus 5.5)
`annotatedstring` summed its arguments' size hints with `mapreduce`,
which compiles a fold for each combination of argument types. As the
value types of annotations are now mostly defined outside Base, these
folds were compiled into each package image that builds annotated
strings: about 440 methods across Markdown, Profile and the REPL. A
loop, as `string` uses, compiles none.

Assisted-by: Claude Code (Opus 5.5)
`shell_completions` joined the arguments it parsed from a `Vector{Any}`,
and `string` and `join` infer that an annotated string may result from
that, so path completion was also compiled for
`AnnotatedString{String, Any}`. The arguments are strings, so they are
now joined as a `Vector{String}`. Likewise, the file name that the
history search's save prompt reads lost its type through a `try`, and
so did the styled message it is interpolated into. Together these take
about 0.5 MB off the REPL image.

Assisted-by: Claude Code (Opus 5.5)
Annotated strings were displayed through a trait: each value type named
a style, a union's styles were promoted to one, and the style's writer
showed the whole string. Two types without a promotion rule could not be
shown together, and an `Any` string chose its style from its contents,
so a string could print differently from the same text held more
widely, or cut into parts.

Now each value type says how its annotations look in a MIME type, with
three functions. `context` reads what a display needs once, `reduction`
gives a region's state from that type's annotations, and `transition`
writes the change between two states. With no annotations, a reduction
gives the baseline, where display starts and ends. A type that needs no
context can leave it out of its reduction.

`render` walks the regions and writes the transitions between their
states. For plain text, each type's state changes on its own. For HTML,
the types share one stack of nested elements, and an element is kept
while its state still fits. A type without methods is not displayed, so
a string displays the same whatever union of value types holds it.

A known union's states are found by straight-line code. An `Any` string
goes through one method, compiled once, which dispatches at runtime in
the latest world, so new display methods do not invalidate its callers.
`write`, `print`, `escape_string`, and `show` for HTML use `render`.
HTML is offered for every annotated string, as `show` can always write
it.

Plain text without colour, or without annotations, is written as it is.

StyledStrings now displays faces and links through these functions
(JuliaLang/StyledStrings.jl#131), so it is bumped together:

Stdlib: StyledStrings
URL: https://github.com/JuliaLang/StyledStrings.jl.git
Stdlib branch: face-primacy
Old commit: b8c9246
New commit: 64e4cfd
Diff:
JuliaLang/StyledStrings.jl@b8c9246...64e4cfd

Assisted-by: Claude Code (Opus 5.5)
The label of an annotation restated the type of its value: `:face`
beside a `Face`, `:link` beside a `Link`. Storing both let them
disagree, and labels shared one flat namespace across packages.

An annotation is now a region and a value, `(; region, value)`. A char's
annotations, and each region's from `eachregion`, are the values
themselves. Annotations are given as `(region, value)`, `region =>
value`, or with `annotate!(str, [range], value)`. A labelled
`(region, label, value)` is refused, rather than read as a region and
a value.

Markdown and the REPL annotate with values. Markdown's
`with_output_annotations` is compiled once rather than for each closure
it is given, so its call to the closure is dynamic. Markdown's terminal
rendering already dispatches on untyped content, so this costs nothing
under `--trim` now, but would need specialising if that rendering were
made trimmable.

StyledStrings and JuliaSyntaxHighlighting annotate with values alone,
so they are bumped together:

Stdlib: StyledStrings
URL: https://github.com/JuliaLang/StyledStrings.jl.git
Stdlib branch: face-primacy
Old commit: 64e4cfd
New commit: d6479d5
Diff:
JuliaLang/StyledStrings.jl@64e4cfd...d6479d5

Stdlib: JuliaSyntaxHighlighting
URL: https://github.com/JuliaLang/JuliaSyntaxHighlighting.jl.git
Stdlib branch: faces-value-parameterisation
Old commit: 2061af0
New commit: 9ef9da7
Diff:
JuliaLang/JuliaSyntaxHighlighting.jl@2061af0...9ef9da7

Assisted-by: Claude Code (Opus 5.5)
Code written for labelled annotations keeps working. A labelled value
is kept as it is when it has the kind of its label, and otherwise
becomes `label => value`, a `Pair{Symbol, Any}`. The kind comes from
the internal `labelled_kind`, which is the label itself unless a
package gives it a type, as StyledStrings does for `:face` and `:link`.
Deletion with `nothing` removes the annotations of that kind.

Labelled writes with a value are deprecated. Deletion is not, as there
is no other way to delete yet. A vector of labelled annotations is
converted when all of it is labelled. Reading `.label` breaks.

All of it is in `deprecated.jl`, so that it can be removed as one.

StyledStrings gives `:face` and `:link` their kinds, so it is bumped
together:

Stdlib: StyledStrings
URL: https://github.com/JuliaLang/StyledStrings.jl.git
Stdlib branch: face-primacy
Old commit: d6479d5
New commit: 10c37a7
Diff:
JuliaLang/StyledStrings.jl@d6479d5...10c37a7

Assisted-by: Claude Code (Opus 5.5)
A value type with a member that isn't concrete now displays from the
types of its values, as `Any` does, so a string displays the same
whatever value type holds it. A reduction is called for each concrete
type, and the manual and its docstring say that it selects its values
with `value isa T`, so a reduction written for several types sees each
type's values apart.

Strings of `Any` values display through code compiled once. Their types
are found without compiling a set for each. They share a known union's
methods for plain text and HTML, where each type of state is checked on
its own. They call the interface through barriers, so that a new value
type's methods invalidate none of it. In HTML, a state with no fields
now closes its element when it changes, as a state with fields does.

Assisted-by: Claude Code (Opus 5.5)
The labelled forms of annotations were only read in vectors of them
alone, and their values only by the kind of their label, so old code
lost its links and some of its annotations without a word:

- A package now says what its labels' values stand for, as well as
  their kinds. StyledStrings gives `:link` strings their `Link`s.
- A string reads a labelled annotation where it reads each annotation,
  so it is read in a vector of any values too.
- Every labelled form but deletion reads its values through
  `labelled_value`, which gives the one warning.

Base builds its own chars with their value types, so a string whose
values only look labelled keeps them as they are.

Assisted-by: Claude Code (Opus 5.5)
The manual gains a section on `Base.AnnotatedDisplay`: what `context`,
`reduction` and `transition` do, the rules an implementation follows,
and a worked example, run as a doctest.

Assisted-by: Claude Code (Opus 5.5)
`replace` into an `AnnotatedIOBuffer` takes its pairs as a vararg and
only passes them on. So the body behind its `count` keyword was not
specialised on them, and it boxed every value of the replacement loop.
Taking them as `Vararg{Pair, N}` makes it specialise, as `_replace_`
does for plain strings. Replacing two characters in a short styled
string drops from 5.7 µs to 0.3 µs, and `replace` on an annotated
string from 6.5 µs to 0.9 µs.

Annotations were also built with the type of their own value, then
converted to the buffer's value type as they were pushed. That
allocated whenever the value type was a union. They are now built with
the buffer's value type, which makes inserting them nine times faster.

This removes two REVIEW comments that asked why these were slow.

Assisted-by: Claude Code (Opus 5.5)
Two replace tests appeared twice, once each in the splitting and size
tests. They are kept under count and replacing a pattern with itself.

Assisted-by: Claude Code (Opus 5.5)
A string was rendered by code compiled for its value type, so each new
union compiled the whole region walk and every reduction again, even
when the new member does not display.

`render` now finds the types that display from their baseline states,
those that are not `nothing`, and walks only their annotations. A type
that does not display adds just a small filter, and the rest of the
rendering is the code already compiled for the types that do. When
nothing displays, the text is written as it is.

The region iterator can be limited to one type of value, which it reads
in place when the string holds nothing else, and otherwise gathers as
it makes its events. Annotations that do not display no longer split
regions, so a string that mixes them with faces renders faster.

A string of `Any` values finds its types from the values, and drops
those that do not display in the same way. Every state of a type is of
its baseline's type. One of another type, which broke `transition` or
the HTML frames, now displays as the baseline. Where the state types
are known this check compiles away.

A new value type that only adds a non-displayed member now compiles in
about 55 ms rather than 190 ms. A long styled string with an `Int`
among its values prints 60% faster, and allocates 8 KB rather than 51
KB. Strings of `StyledValue` print as fast as before.

Assisted-by: Claude Code (Opus 5.5)
Under Base, note the value type parameter, how it is inferred and
widened, that it selects the display, that a type written without it is
no longer concrete, and that region iteration is lazy. Under
JuliaSyntaxHighlighting, note its palette of faces. Under Markdown, note
the renamed faces.toml section. Under StyledStrings, note face primacy,
palettes and their use, when face names are resolved, and that
`Symbol`-valued face annotations are no longer styled.
The rest of JuliaLang/StyledStrings.jl#131. Links reduce without a
context, old string links become `Link`s through `labelled_value`, and
internal styled text has the value type of styled strings. The face,
markup, and palette code is simpler, and the precompile workload covers
what a fresh session first uses. Interpolated variables are read in
order, markup keys must be identifiers, a customised default face's
resets fall back on the standard ones, and face loops through palette
variants are rejected. Interpolating a value of unknown type is faster,
and StyledStrings' image holds about 330 KB less native code.

Stdlib: StyledStrings
URL: https://github.com/JuliaLang/StyledStrings.jl.git
Stdlib branch: face-primacy
Old commit: 10c37a7
New commit: bc6d6a5
Diff:
JuliaLang/StyledStrings.jl@10c37a7...bc6d6a5

Assisted-by: Claude Code (Opus 5.5)
@tecosaur
tecosaur force-pushed the val-param-annot-faces branch from 054a09c to 6ea1e4c Compare October 18, 2026 08:15

This branch has not been deployed

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

Labels

bugfix This change fixes an existing bug display and printing Aesthetics and correctness of printed representations of objects. don't squash Don't squash merge feature Indicates new feature / enhancement requests stdlib Julia's standard library strings "Strings!"

Projects

None yet

5 participants