From 5aed3c24678abe465bc39141ba95141e1719ba32 Mon Sep 17 00:00:00 2001 From: Cody Tapscott Date: Thu, 27 Mar 2025 16:36:02 -0400 Subject: [PATCH 1/2] Move `AnnotatedIOBuffer` implementation to `annotated_io.jl` Re-organizing these definitions - no changes to the code. --- base/strings/annotated.jl | 199 ---------------------------------- base/strings/annotated_io.jl | 201 +++++++++++++++++++++++++++++++++++ base/strings/strings.jl | 1 + 3 files changed, 202 insertions(+), 199 deletions(-) create mode 100644 base/strings/annotated_io.jl diff --git a/base/strings/annotated.jl b/base/strings/annotated.jl index 5142c32e1b70d..de984a0aac97a 100644 --- a/base/strings/annotated.jl +++ b/base/strings/annotated.jl @@ -459,202 +459,3 @@ function annotated_chartransform(f::Function, str::AnnotatedString, state=nothin end AnnotatedString(String(take!(outstr)), annots) end - -## AnnotatedIOBuffer - -struct AnnotatedIOBuffer <: AbstractPipe - io::IOBuffer - annotations::Vector{RegionAnnotation} -end - -AnnotatedIOBuffer(io::IOBuffer) = AnnotatedIOBuffer(io, Vector{RegionAnnotation}()) -AnnotatedIOBuffer() = AnnotatedIOBuffer(IOBuffer()) - -function show(io::IO, aio::AnnotatedIOBuffer) - show(io, AnnotatedIOBuffer) - size = filesize(aio.io) - print(io, '(', size, " byte", ifelse(size == 1, "", "s"), ", ", - length(aio.annotations), " annotation", ifelse(length(aio.annotations) == 1, "", "s"), ")") -end - -pipe_reader(io::AnnotatedIOBuffer) = io.io -pipe_writer(io::AnnotatedIOBuffer) = io.io - -# Useful `IOBuffer` methods that we don't get from `AbstractPipe` -position(io::AnnotatedIOBuffer) = position(io.io) -seek(io::AnnotatedIOBuffer, n::Integer) = (seek(io.io, n); io) -seekend(io::AnnotatedIOBuffer) = (seekend(io.io); io) -skip(io::AnnotatedIOBuffer, n::Integer) = (skip(io.io, n); io) -copy(io::AnnotatedIOBuffer) = AnnotatedIOBuffer(copy(io.io), copy(io.annotations)) - -annotations(io::AnnotatedIOBuffer) = io.annotations - -annotate!(io::AnnotatedIOBuffer, range::UnitRange{Int}, label::Symbol, @nospecialize(val::Any)) = - (_annotate!(io.annotations, range, label, val); io) - -function write(io::AnnotatedIOBuffer, astr::Union{AnnotatedString, SubString{<:AnnotatedString}}) - astr = AnnotatedString(astr) - offset = position(io.io) - eof(io) || _clear_annotations_in_region!(io.annotations, offset+1:offset+ncodeunits(astr)) - _insert_annotations!(io, astr.annotations) - write(io.io, String(astr)) -end - -write(io::AnnotatedIOBuffer, c::AnnotatedChar) = - write(io, AnnotatedString(string(c), [(region=1:ncodeunits(c), a...) for a in c.annotations])) -write(io::AnnotatedIOBuffer, x::AbstractString) = write(io.io, x) -write(io::AnnotatedIOBuffer, s::Union{SubString{String}, String}) = write(io.io, s) -write(io::AnnotatedIOBuffer, b::UInt8) = write(io.io, b) - -function write(dest::AnnotatedIOBuffer, src::AnnotatedIOBuffer) - destpos = position(dest) - isappending = eof(dest) - srcpos = position(src) - nb = write(dest.io, src.io) - isappending || _clear_annotations_in_region!(dest.annotations, destpos:destpos+nb) - srcannots = [setindex(annot, max(1 + srcpos, first(annot.region)):last(annot.region), :region) - for annot in src.annotations if first(annot.region) >= srcpos] - _insert_annotations!(dest, srcannots, destpos - srcpos) - nb -end - -# So that read/writes with `IOContext` (and any similar `AbstractPipe` wrappers) -# work as expected. -function write(io::AbstractPipe, s::Union{AnnotatedString, SubString{<:AnnotatedString}}) - if pipe_writer(io) isa AnnotatedIOBuffer - write(pipe_writer(io), s) - else - invoke(write, Tuple{IO, typeof(s)}, io, s) - end::Int -end -# Can't be part of the `Union` above because it introduces method ambiguities -function write(io::AbstractPipe, c::AnnotatedChar) - if pipe_writer(io) isa AnnotatedIOBuffer - write(pipe_writer(io), c) - else - invoke(write, Tuple{IO, typeof(c)}, io, c) - end::Int -end - -""" - _clear_annotations_in_region!(annotations::Vector{$RegionAnnotation}, span::UnitRange{Int}) - -Erase the presence of `annotations` within a certain `span`. - -This operates by removing all elements of `annotations` that are entirely -contained in `span`, truncating ranges that partially overlap, and splitting -annotations that subsume `span` to just exist either side of `span`. -""" -function _clear_annotations_in_region!(annotations::Vector{RegionAnnotation}, span::UnitRange{Int}) - # Clear out any overlapping pre-existing annotations. - filter!(ann -> first(ann.region) < first(span) || last(ann.region) > last(span), annotations) - extras = Tuple{Int, RegionAnnotation}[] - for i in eachindex(annotations) - annot = annotations[i] - region = annot.region - # Test for partial overlap - if first(region) <= first(span) <= last(region) || first(region) <= last(span) <= last(region) - annotations[i] = - setindex(annot, - if first(region) < first(span) - first(region):first(span)-1 - else - last(span)+1:last(region) - end, - :region) - # If `span` fits exactly within `region`, then we've only copied over - # the beginning overhang, but also need to conserve the end overhang. - if first(region) < first(span) && last(span) < last(region) - push!(extras, (i, setindex(annot, last(span)+1:last(region), :region))) - end - end - end - # Insert any extra entries in the appropriate position - for (offset, (i, entry)) in enumerate(extras) - insert!(annotations, i + offset, entry) - end - annotations -end - -""" - _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{$RegionAnnotation}, offset::Int = position(io)) - -Register new `annotations` in `io`, applying an `offset` to their regions. - -The largely consists of simply shifting the regions of `annotations` by `offset` -and pushing them onto `io`'s annotations. However, when it is possible to merge -the new annotations with recent annotations in accordance with the semantics -outlined in [`AnnotatedString`](@ref), we do so. More specifically, when there -is a run of the most recent annotations that are also present as the first -`annotations`, with the same value and adjacent regions, the new annotations are -merged into the existing recent annotations by simply extending their range. - -This is implemented so that one can say write an `AnnotatedString` to an -`AnnotatedIOBuffer` one character at a time without needlessly producing a -new annotation for each character. -""" -function _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{RegionAnnotation}, offset::Int = position(io)) - run = 0 - if !isempty(io.annotations) && last(last(io.annotations).region) == offset - for i in reverse(axes(annotations, 1)) - annot = annotations[i] - first(annot.region) == 1 || continue - i <= length(io.annotations) || continue - if annot.label == last(io.annotations).label && annot.value == last(io.annotations).value - valid_run = true - for runlen in 1:i - new = annotations[begin+runlen-1] - old = io.annotations[end-i+runlen] - if last(old.region) != offset || first(new.region) != 1 || old.label != new.label || old.value != new.value - valid_run = false - break - end - end - if valid_run - run = i - break - end - end - end - end - for runindex in 0:run-1 - old_index = lastindex(io.annotations) - run + 1 + runindex - old = io.annotations[old_index] - new = annotations[begin+runindex] - io.annotations[old_index] = setindex(old, first(old.region):last(new.region)+offset, :region) - end - for index in run+1:lastindex(annotations) - annot = annotations[index] - start, stop = first(annot.region), last(annot.region) - push!(io.annotations, setindex(annotations[index], start+offset:stop+offset, :region)) - end -end - -function read(io::AnnotatedIOBuffer, ::Type{AnnotatedString{T}}) where {T <: AbstractString} - if (start = position(io)) == 0 - AnnotatedString(read(io.io, T), copy(io.annotations)) - else - annots = [setindex(annot, UnitRange{Int}(max(1, first(annot.region) - start), last(annot.region)-start), :region) - for annot in io.annotations if last(annot.region) > start] - AnnotatedString(read(io.io, T), annots) - end -end -read(io::AnnotatedIOBuffer, ::Type{AnnotatedString{AbstractString}}) = read(io, AnnotatedString{String}) -read(io::AnnotatedIOBuffer, ::Type{AnnotatedString}) = read(io, AnnotatedString{String}) - -function read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar{T}}) where {T <: AbstractChar} - pos = position(io) - char = read(io.io, T) - annots = [NamedTuple{(:label, :value)}(annot) for annot in io.annotations if pos+1 in annot.region] - AnnotatedChar(char, annots) -end -read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar{AbstractChar}}) = read(io, AnnotatedChar{Char}) -read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar}) = read(io, AnnotatedChar{Char}) - -function truncate(io::AnnotatedIOBuffer, size::Integer) - truncate(io.io, size) - filter!(ann -> first(ann.region) <= size, io.annotations) - map!(ann -> setindex(ann, first(ann.region):min(size, last(ann.region)), :region), - io.annotations, io.annotations) - io -end diff --git a/base/strings/annotated_io.jl b/base/strings/annotated_io.jl new file mode 100644 index 0000000000000..87db57b8030c9 --- /dev/null +++ b/base/strings/annotated_io.jl @@ -0,0 +1,201 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +## AnnotatedIOBuffer + +struct AnnotatedIOBuffer <: AbstractPipe + io::IOBuffer + annotations::Vector{RegionAnnotation} +end + +AnnotatedIOBuffer(io::IOBuffer) = AnnotatedIOBuffer(io, Vector{RegionAnnotation}()) +AnnotatedIOBuffer() = AnnotatedIOBuffer(IOBuffer()) + +function show(io::IO, aio::AnnotatedIOBuffer) + show(io, AnnotatedIOBuffer) + size = filesize(aio.io) + print(io, '(', size, " byte", ifelse(size == 1, "", "s"), ", ", + length(aio.annotations), " annotation", ifelse(length(aio.annotations) == 1, "", "s"), ")") +end + +pipe_reader(io::AnnotatedIOBuffer) = io.io +pipe_writer(io::AnnotatedIOBuffer) = io.io + +# Useful `IOBuffer` methods that we don't get from `AbstractPipe` +position(io::AnnotatedIOBuffer) = position(io.io) +seek(io::AnnotatedIOBuffer, n::Integer) = (seek(io.io, n); io) +seekend(io::AnnotatedIOBuffer) = (seekend(io.io); io) +skip(io::AnnotatedIOBuffer, n::Integer) = (skip(io.io, n); io) +copy(io::AnnotatedIOBuffer) = AnnotatedIOBuffer(copy(io.io), copy(io.annotations)) + +annotations(io::AnnotatedIOBuffer) = io.annotations + +annotate!(io::AnnotatedIOBuffer, range::UnitRange{Int}, label::Symbol, @nospecialize(val::Any)) = + (_annotate!(io.annotations, range, label, val); io) + +function write(io::AnnotatedIOBuffer, astr::Union{AnnotatedString, SubString{<:AnnotatedString}}) + astr = AnnotatedString(astr) + offset = position(io.io) + eof(io) || _clear_annotations_in_region!(io.annotations, offset+1:offset+ncodeunits(astr)) + _insert_annotations!(io, astr.annotations) + write(io.io, String(astr)) +end + +write(io::AnnotatedIOBuffer, c::AnnotatedChar) = + write(io, AnnotatedString(string(c), [(region=1:ncodeunits(c), a...) for a in c.annotations])) +write(io::AnnotatedIOBuffer, x::AbstractString) = write(io.io, x) +write(io::AnnotatedIOBuffer, s::Union{SubString{String}, String}) = write(io.io, s) +write(io::AnnotatedIOBuffer, b::UInt8) = write(io.io, b) + +function write(dest::AnnotatedIOBuffer, src::AnnotatedIOBuffer) + destpos = position(dest) + isappending = eof(dest) + srcpos = position(src) + nb = write(dest.io, src.io) + isappending || _clear_annotations_in_region!(dest.annotations, destpos:destpos+nb) + srcannots = [setindex(annot, max(1 + srcpos, first(annot.region)):last(annot.region), :region) + for annot in src.annotations if first(annot.region) >= srcpos] + _insert_annotations!(dest, srcannots, destpos - srcpos) + nb +end + +# So that read/writes with `IOContext` (and any similar `AbstractPipe` wrappers) +# work as expected. +function write(io::AbstractPipe, s::Union{AnnotatedString, SubString{<:AnnotatedString}}) + if pipe_writer(io) isa AnnotatedIOBuffer + write(pipe_writer(io), s) + else + invoke(write, Tuple{IO, typeof(s)}, io, s) + end::Int +end + +# Can't be part of the `Union` above because it introduces method ambiguities +function write(io::AbstractPipe, c::AnnotatedChar) + if pipe_writer(io) isa AnnotatedIOBuffer + write(pipe_writer(io), c) + else + invoke(write, Tuple{IO, typeof(c)}, io, c) + end::Int +end + +function read(io::AnnotatedIOBuffer, ::Type{AnnotatedString{T}}) where {T <: AbstractString} + if (start = position(io)) == 0 + AnnotatedString(read(io.io, T), copy(io.annotations)) + else + annots = [setindex(annot, UnitRange{Int}(max(1, first(annot.region) - start), last(annot.region)-start), :region) + for annot in io.annotations if last(annot.region) > start] + AnnotatedString(read(io.io, T), annots) + end +end +read(io::AnnotatedIOBuffer, ::Type{AnnotatedString{AbstractString}}) = read(io, AnnotatedString{String}) +read(io::AnnotatedIOBuffer, ::Type{AnnotatedString}) = read(io, AnnotatedString{String}) + +function read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar{T}}) where {T <: AbstractChar} + pos = position(io) + char = read(io.io, T) + annots = [NamedTuple{(:label, :value)}(annot) for annot in io.annotations if pos+1 in annot.region] + AnnotatedChar(char, annots) +end +read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar{AbstractChar}}) = read(io, AnnotatedChar{Char}) +read(io::AnnotatedIOBuffer, ::Type{AnnotatedChar}) = read(io, AnnotatedChar{Char}) + +function truncate(io::AnnotatedIOBuffer, size::Integer) + truncate(io.io, size) + filter!(ann -> first(ann.region) <= size, io.annotations) + map!(ann -> setindex(ann, first(ann.region):min(size, last(ann.region)), :region), + io.annotations, io.annotations) + io +end + +""" + _clear_annotations_in_region!(annotations::Vector{$RegionAnnotation}, span::UnitRange{Int}) + +Erase the presence of `annotations` within a certain `span`. + +This operates by removing all elements of `annotations` that are entirely +contained in `span`, truncating ranges that partially overlap, and splitting +annotations that subsume `span` to just exist either side of `span`. +""" +function _clear_annotations_in_region!(annotations::Vector{RegionAnnotation}, span::UnitRange{Int}) + # Clear out any overlapping pre-existing annotations. + filter!(ann -> first(ann.region) < first(span) || last(ann.region) > last(span), annotations) + extras = Tuple{Int, RegionAnnotation}[] + for i in eachindex(annotations) + annot = annotations[i] + region = annot.region + # Test for partial overlap + if first(region) <= first(span) <= last(region) || first(region) <= last(span) <= last(region) + annotations[i] = + setindex(annot, + if first(region) < first(span) + first(region):first(span)-1 + else + last(span)+1:last(region) + end, + :region) + # If `span` fits exactly within `region`, then we've only copied over + # the beginning overhang, but also need to conserve the end overhang. + if first(region) < first(span) && last(span) < last(region) + push!(extras, (i, setindex(annot, last(span)+1:last(region), :region))) + end + end + end + # Insert any extra entries in the appropriate position + for (offset, (i, entry)) in enumerate(extras) + insert!(annotations, i + offset, entry) + end + annotations +end + +""" + _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{$RegionAnnotation}, offset::Int = position(io)) + +Register new `annotations` in `io`, applying an `offset` to their regions. + +The largely consists of simply shifting the regions of `annotations` by `offset` +and pushing them onto `io`'s annotations. However, when it is possible to merge +the new annotations with recent annotations in accordance with the semantics +outlined in [`AnnotatedString`](@ref), we do so. More specifically, when there +is a run of the most recent annotations that are also present as the first +`annotations`, with the same value and adjacent regions, the new annotations are +merged into the existing recent annotations by simply extending their range. + +This is implemented so that one can say write an `AnnotatedString` to an +`AnnotatedIOBuffer` one character at a time without needlessly producing a +new annotation for each character. +""" +function _insert_annotations!(io::AnnotatedIOBuffer, annotations::Vector{RegionAnnotation}, offset::Int = position(io)) + run = 0 + if !isempty(io.annotations) && last(last(io.annotations).region) == offset + for i in reverse(axes(annotations, 1)) + annot = annotations[i] + first(annot.region) == 1 || continue + i <= length(io.annotations) || continue + if annot.label == last(io.annotations).label && annot.value == last(io.annotations).value + valid_run = true + for runlen in 1:i + new = annotations[begin+runlen-1] + old = io.annotations[end-i+runlen] + if last(old.region) != offset || first(new.region) != 1 || old.label != new.label || old.value != new.value + valid_run = false + break + end + end + if valid_run + run = i + break + end + end + end + end + for runindex in 0:run-1 + old_index = lastindex(io.annotations) - run + 1 + runindex + old = io.annotations[old_index] + new = annotations[begin+runindex] + io.annotations[old_index] = setindex(old, first(old.region):last(new.region)+offset, :region) + end + for index in run+1:lastindex(annotations) + annot = annotations[index] + start, stop = first(annot.region), last(annot.region) + push!(io.annotations, setindex(annotations[index], start+offset:stop+offset, :region)) + end +end diff --git a/base/strings/strings.jl b/base/strings/strings.jl index 8dae311f475b4..32975b6ea3fc7 100644 --- a/base/strings/strings.jl +++ b/base/strings/strings.jl @@ -11,3 +11,4 @@ import .Iterators: PartitionIterator include("strings/util.jl") include("strings/io.jl") +include("strings/annotated_io.jl") From 50f0bd0ccb9a01ae18f73cf52d37626c78ca3d76 Mon Sep 17 00:00:00 2001 From: Cody Tapscott Date: Thu, 27 Mar 2025 16:39:51 -0400 Subject: [PATCH 2/2] Add `eachregion(::AnnotatedString)` implementation to `Base` Base needs this functionality so that it can iterate its own `AnnotatedString`s. --- base/strings/annotated.jl | 107 ++++++++++++++++++++++++++++++++++++++ test/strings/annotated.jl | 48 +++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/base/strings/annotated.jl b/base/strings/annotated.jl index de984a0aac97a..0da6c26751618 100644 --- a/base/strings/annotated.jl +++ b/base/strings/annotated.jl @@ -459,3 +459,110 @@ function annotated_chartransform(f::Function, str::AnnotatedString, state=nothin end AnnotatedString(String(take!(outstr)), annots) end + +struct RegionIterator{S <: AbstractString} + str::S + regions::Vector{UnitRange{Int}} + annotations::Vector{Vector{Annotation}} +end + +Base.length(si::RegionIterator) = length(si.regions) + +Base.@propagate_inbounds function Base.iterate(si::RegionIterator, i::Integer=1) + if i <= length(si.regions) + @inbounds ((SubString(si.str, si.regions[i]), si.annotations[i]), i+1) + end +end + +Base.eltype(::RegionIterator{S}) where { S <: AbstractString} = + Tuple{SubString{S}, Vector{Annotation}} + +""" + eachregion(s::AnnotatedString{S}) + eachregion(s::SubString{AnnotatedString{S}}) + +Identify the contiguous substrings of `s` with a constant annotations, and return +an iterator which provides each substring and the applicable annotations as a +`Tuple{SubString{S}, Vector{$Annotation}}`. + +# Examples + +```jldoctest; setup=:(using Base: AnnotatedString, eachregion) +julia> collect(eachregion(AnnotatedString( + "hey there", [(1:3, :face, :bold), + (5:9, :face, :italic)]))) +3-element Vector{Tuple{SubString{String}, Vector{$Annotation}}}: + ("hey", [$Annotation((:face, :bold))]) + (" ", []) + ("there", [$Annotation((:face, :italic))]) +``` +""" +function eachregion(s::AnnotatedString, subregion::UnitRange{Int}=firstindex(s):lastindex(s)) + isempty(s) || isempty(subregion) && + return RegionIterator(s.string, UnitRange{Int}[], Vector{Annotation}[]) + events = annotation_events(s, subregion) + isempty(events) && return RegionIterator(s.string, [subregion], [Annotation[]]) + annotvals = Annotation[ + (; label, value) for (; label, value) in annotations(s)] + regions = Vector{UnitRange{Int}}() + annots = Vector{Vector{Annotation}}() + pos = first(events).pos + if pos > first(subregion) + push!(regions, thisind(s, first(subregion)):prevind(s, pos)) + push!(annots, []) + end + activelist = Int[] + for event in events + if event.pos != pos + push!(regions, pos:prevind(s, event.pos)) + push!(annots, annotvals[activelist]) + pos = event.pos + end + if event.active + insert!(activelist, searchsortedfirst(activelist, event.index), event.index) + else + deleteat!(activelist, searchsortedfirst(activelist, event.index)) + end + end + if last(events).pos < nextind(s, last(subregion)) + push!(regions, last(events).pos:thisind(s, last(subregion))) + push!(annots, []) + end + RegionIterator(s.string, regions, annots) +end + +function eachregion(s::SubString{<:AnnotatedString}, pos::UnitRange{Int}=firstindex(s):lastindex(s)) + if isempty(s) + RegionIterator(s.string, Vector{UnitRange{Int}}(), Vector{Vector{Annotation}}()) + else + eachregion(s.string, first(pos)+s.offset:last(pos)+s.offset) + end +end + +""" + annotation_events(string::AbstractString, annots::Vector{$RegionAnnotation}, subregion::UnitRange{Int}) + annotation_events(string::AnnotatedString, subregion::UnitRange{Int}) + +Find all annotation "change events" that occur within a `subregion` of `annots`, +with respect to `string`. When `string` is styled, `annots` is inferred. + +Each change event is given in the form of a `@NamedTuple{pos::Int, active::Bool, +index::Int}` where `pos` is the position of the event, `active` is a boolean +indicating whether the annotation is being activated or deactivated, and `index` +is the index of the annotation in question. +""" +function annotation_events(s::AbstractString, annots::Vector{RegionAnnotation}, subregion::UnitRange{Int}) + events = Vector{NamedTuple{(:pos, :active, :index), Tuple{Int, Bool, Int}}}() # Position, Active?, Annotation index + for (i, (; region)) in enumerate(annots) + if !isempty(intersect(subregion, region)) + start, stop = max(first(subregion), first(region)), min(last(subregion), last(region)) + start <= stop || continue # Currently can't handle empty regions + push!(events, (pos=thisind(s, start), active=true, index=i)) + push!(events, (pos=nextind(s, stop), active=false, index=i)) + end + end + sort(events, by=e -> e.pos) +end + +annotation_events(s::AnnotatedString, subregion::UnitRange{Int}) = + annotation_events(s.string, annotations(s), subregion) diff --git a/test/strings/annotated.jl b/test/strings/annotated.jl index 8658c1b52a2ab..7f53740b9eec1 100644 --- a/test/strings/annotated.jl +++ b/test/strings/annotated.jl @@ -258,3 +258,51 @@ end write(aio, Base.AnnotatedString("hello", [(1:5, :tag, 1)])) @test sprint(show, aio) == "Base.AnnotatedIOBuffer(5 bytes, 1 annotation)" end + +@testset "Eachregion" begin + annregions(str::String, annots::Vector{<:Tuple{UnitRange{Int}, Symbol, <:Any}}) = + [(s, Tuple.(a)) for (s, a) in Base.eachregion(Base.AnnotatedString(str, annots))] + # Regions that do/don't extend to the left/right edges + @test annregions(" abc ", [(2:4, :face, :bold)]) == + [(" ", []), + ("abc", [(:face, :bold)]), + (" ", [])] + @test annregions(" x ", [(2:2, :face, :bold)]) == + [(" ", []), + ("x", [(:face, :bold)]), + (" ", [])] + @test annregions(" x", [(2:2, :face, :bold)]) == + [(" ", []), + ("x", [(:face, :bold)])] + @test annregions("x ", [(1:1, :face, :bold)]) == + [("x", [(:face, :bold)]), + (" ", [])] + @test annregions("x", [(1:1, :face, :bold)]) == + [("x", [(:face, :bold)])] + # Overlapping/nested regions + @test annregions(" abc ", [(2:4, :face, :bold), (3:3, :face, :italic)]) == + [(" ", []), + ("a", [(:face, :bold)]), + ("b", [(:face, :bold), (:face, :italic)]), + ("c", [(:face, :bold)]), + (" ", [])] + @test annregions("abc-xyz", [(1:7, :face, :bold), (1:3, :face, :green), (4:4, :face, :yellow), (4:7, :face, :italic)]) == + [("abc", [(:face, :bold), (:face, :green)]), + ("-", [(:face, :bold), (:face, :yellow), (:face, :italic)]), + ("xyz", [(:face, :bold), (:face, :italic)])] + # Preserving annotation order + @test annregions("abcd", [(1:3, :face, :red), (2:2, :face, :yellow), (2:3, :face, :green), (2:4, :face, :blue)]) == + [("a", [(:face, :red)]), + ("b", [(:face, :red), (:face, :yellow), (:face, :green), (:face, :blue)]), + ("c", [(:face, :red), (:face, :green), (:face, :blue)]), + ("d", [(:face, :blue)])] + @test annregions("abcd", [(2:4, :face, :blue), (1:3, :face, :red), (2:3, :face, :green), (2:2, :face, :yellow)]) == + [("a", [(:face, :red)]), + ("b", [(:face, :blue), (:face, :red), (:face, :green), (:face, :yellow)]), + ("c", [(:face, :blue), (:face, :red), (:face, :green)]), + ("d", [(:face, :blue)])] + # Region starting after a character spanning multiple codepoints. + @test annregions("𝟏x", [(1:4, :face, :red)]) == + [("𝟏", [(:face, :red)]), + ("x", [])] +end