diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c520dd6d..421a40f8 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,6 +3,7 @@ on: push: branches: - main + - julia1-compat tags: ['*'] pull_request: concurrency: @@ -18,9 +19,17 @@ jobs: fail-fast: false matrix: version: - # - '1.0' - # - '1.6' - # - '1' + - '1.0' + - '1.1' + - '1.2' + - '1.3' + - '1.4' + - '1.5' + - '1.6' + - '1.7' + - '1.8' + - '1.9' + - '1.10' - 'nightly' os: - ubuntu-latest diff --git a/Project.toml b/Project.toml index 56789cb3..c83c90d4 100644 --- a/Project.toml +++ b/Project.toml @@ -1,10 +1,13 @@ name = "StyledStrings" uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" authors = ["TEC "] -version = "1.11.0" +version = "1.0.0" + +[deps] +Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" [compat] -julia = "1.11" +julia = "1.0" [extras] Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/src/StyledStrings.jl b/src/StyledStrings.jl index f198aff4..6b888fdd 100644 --- a/src/StyledStrings.jl +++ b/src/StyledStrings.jl @@ -2,11 +2,15 @@ module StyledStrings -import Base: AnnotatedString, AnnotatedChar, annotations, annotate!, - annotatedstring, convert, merge, show, print, write +import Base: convert, merge, show, print, write export @styled_str -public Face, addface!, SimpleColor + +include("compat.jl") +include("strings/strings.jl") + +import .AnnotatedStrings: AnnotatedString, AnnotatedChar, annotations, annotate!, + annotatedstring, annotatedstring_optimize! include("faces.jl") include("regioniterator.jl") @@ -20,7 +24,7 @@ function __init__() Legacy.load_env_colors!() end -if Base.generating_output() +if generating_output() include("precompile.jl") end diff --git a/src/compat.jl b/src/compat.jl new file mode 100644 index 00000000..955f1a5a --- /dev/null +++ b/src/compat.jl @@ -0,0 +1,134 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +@static if VERSION < v"1.1" + isnothing(x) = x === nothing +end + +@static if VERSION < v"1.2" + ncodeunits(s::AbstractString) = Base.ncodeunits(s) + ncodeunits(c::AbstractChar) = ncodeunits(string(c)) +else + const ncodeunits = Base.ncodeunits +end + +@static if VERSION < v"1.3" + function _str_sizehint(x) + if x isa Float64 + return 20 + elseif x isa Float32 + return 12 + elseif x isa String || x isa SubString{String} + return sizeof(x) + elseif x isa Char + return ncodeunits(x) + else + return 8 + end + end +elseif VERSION < v"1.11" + import Base._str_sizehint +end + +@static if VERSION < v"1.3" + function unescape_string(io::IO, s::AbstractString, keep = ()) + a = Iterators.Stateful(s) + for c in a + if !isempty(a) && c == '\\' + c = popfirst!(a) + if c in keep + print(io, '\\', c) + elseif c == 'x' || c == 'u' || c == 'U' + n = k = 0 + m = c == 'x' ? 2 : + c == 'u' ? 4 : 8 + while (k += 1) <= m && !isempty(a) + nc = peek(a) + n = '0' <= nc <= '9' ? n<<4 + (nc-'0') : + 'a' <= nc <= 'f' ? n<<4 + (nc-'a'+10) : + 'A' <= nc <= 'F' ? n<<4 + (nc-'A'+10) : break + popfirst!(a) + end + if k == 1 || n > 0x10ffff + u = m == 4 ? 'u' : 'U' + throw(ArgumentError("invalid $(m == 2 ? "hex (\\x)" : + "unicode (\\$u)") escape sequence")) + end + if m == 2 # \x escape sequence + write(io, UInt8(n)) + else + print(io, Char(n)) + end + elseif '0' <= c <= '7' + k = 1 + n = c-'0' + while (k += 1) <= 3 && !isempty(a) + c = peek(a) + n = ('0' <= c <= '7') ? n<<3 + c-'0' : break + popfirst!(a) + end + if n > 255 + throw(ArgumentError("octal escape sequence out of range")) + end + write(io, UInt8(n)) + else + print(io, c == 'a' ? '\a' : + c == 'b' ? '\b' : + c == 't' ? '\t' : + c == 'n' ? '\n' : + c == 'v' ? '\v' : + c == 'f' ? '\f' : + c == 'r' ? '\r' : + c == 'e' ? '\e' : + (c == '\\' || c == '"') ? c : + throw(ArgumentError("invalid escape sequence \\$c"))) + end + else + print(io, c) + end + end + end + unescape_string(s::AbstractString, keep = ()) = + sprint(unescape_string, s, keep; sizehint=lastindex(s)) +end + +@static if VERSION < v"1.4" + function takewhile(f::Function, itr) + taken = Vector{eltype(itr)}() + next = iterate(itr) + while !isnothing(next) && ((item, state) = next) |> first |> f + push!(taken, item) + next = iterate(itr, state) + end + taken + end +else + const takewhile = Iterators.takewhile +end + +@static if VERSION < v"1.5" + function peek(s::Iterators.Stateful, sentinel=nothing) + ns = s.nextvalstate + return ns !== nothing ? ns[1] : sentinel + end +end + +@static if VERSION < v"1.6" + function parseatom(text::AbstractString, pos::Integer) + ex, Δ = Meta.parse(text, pos, greedy = false) + ex, pos + Δ - 1 + end +else + const parseatom = Meta.parseatom +end + +@static if VERSION < v"1.11" + function generating_output(incremental::Union{Bool,Nothing}=nothing) + ccall(:jl_generating_output, Cint, ()) == 0 && return false + if incremental !== nothing + Base.JLOptions().incremental == incremental || return false + end + return true + end +else + const generating_output = Base.generating_output +end diff --git a/src/faces.jl b/src/faces.jl index 2fe4e4a9..bf6462d5 100644 --- a/src/faces.jl +++ b/src/faces.jl @@ -27,7 +27,7 @@ end SimpleColor(r::Integer, g::Integer, b::Integer) = SimpleColor((; r=UInt8(r), g=UInt8(g), b=UInt8(b))) SimpleColor(rgb::UInt32) = SimpleColor(reverse(reinterpret(UInt8, [rgb]))[2:end]...) -convert(::Type{SimpleColor}, (; r, g, b)::RGBTuple) = SimpleColor((; r, g, b)) +convert(::Type{SimpleColor}, rgb::RGBTuple) = SimpleColor(rgb) convert(::Type{SimpleColor}, namedcolor::Symbol) = SimpleColor(namedcolor) convert(::Type{SimpleColor}, rgb::UInt32) = SimpleColor(rgb) @@ -331,7 +331,7 @@ const FACES = let default = Dict{Symbol, Face}( :repl_prompt_pkg => Face(inherit=[:blue, :repl_prompt]), :repl_prompt_beep => Face(inherit=[:shadow, :repl_prompt]), ) - (; default, current=ScopedValue(copy(default)), lock=ReentrantLock()) + (; default=default, current=Ref(copy(default)), lock=ReentrantLock()) end ## Adding and resetting faces ## @@ -355,12 +355,14 @@ Face (sample) ``` """ function addface!((name, default)::Pair{Symbol, Face}) - @lock FACES.lock if !haskey(FACES.default, name) - FACES.default[name] = default - FACES.current[][name] = if haskey(FACES.current[], name) - merge(deepcopy(default), FACES.current[][name]) - else - deepcopy(default) + lock(FACES.lock) do + if !haskey(FACES.default, name) + FACES.default[name] = default + FACES.current[][name] = if haskey(FACES.current[], name) + merge(deepcopy(default), FACES.current[][name]) + else + deepcopy(default) + end end end end @@ -371,7 +373,7 @@ end Reset the current global face dictionary to the default value. """ function resetfaces!() - @lock FACES.lock begin + lock(FACES.lock) do current = FACES.current[] empty!(current) for (key, val) in FACES.default @@ -391,13 +393,15 @@ In the unlikely event that the face `name` does not have a default value, it is deleted, a warning message is printed, and `nothing` returned. """ function resetfaces!(name::Symbol) - @lock FACES.lock if !haskey(FACES.current[], name) - elseif haskey(FACES.default, name) - FACES.current[][name] = deepcopy(FACES.default[name]) - else # This shouldn't happen - delete!(FACES.current[], name) - @warn """The face $name was reset, but it had no default value, and so has been deleted instead!, - This should not have happened, perhaps the face was added without using `addface!`?""" + lock(FACES.lock) do + if !haskey(FACES.current[], name) + elseif haskey(FACES.default, name) + FACES.current[][name] = deepcopy(FACES.default[name]) + else # This shouldn't happen + delete!(FACES.current[], name) + @warn """The face $name was reset, but it had no default value, and so has been deleted instead!, + This should not have happened, perhaps the face was added without using `addface!`?""" + end end end @@ -410,6 +414,9 @@ Execute `f` with `FACES``.current` temporarily modified by zero or more temporarily unset an face (if if has been set). When `withfaces` returns, the original `FACES``.current` has been restored. + !!! warning + Changing faces is not thread-safe. + # Examples ```jldoctest; setup = :(import StyledStrings: Face, withfaces) @@ -420,17 +427,28 @@ red and blue mixed make purple ``` """ function withfaces(f, keyvals::Pair{Symbol, <:Union{Face, Symbol, Nothing}}...) - newfaces = copy(FACES.current[]) + old = Dict{Symbol, Union{Face, Nothing}}() for (name, face) in keyvals + old[name] = get(FACES.current[], name, nothing) if face isa Face - newfaces[name] = face + FACES.current[][name] = face elseif face isa Symbol - newfaces[name] =get(FACES.current[], face, Face()) - elseif haskey(newfaces, name) - delete!(newfaces, name) + FACES.current[][name] = + something(get(old, face, nothing), get(FACES.current[], face, Face())) + elseif haskey(FACES.current[], name) + delete!(FACES.current[], name) + end + end + try f() + finally + for (name, face) in old + if isnothing(face) + delete!(FACES.current[], name) + else + FACES.current[][name] = face + end end end - @with(FACES.current => newfaces, f()) end """ @@ -438,9 +456,16 @@ end Execute `f` with `FACES``.current` temporarily swapped out with `altfaces` When `withfaces` returns, the original `FACES``.current` has been restored. + + !!! warning + Changing faces is not thread-safe. """ function withfaces(f, altfaces::Dict{Symbol, Face}) - @with(FACES.current => altfaces, f()) + oldfaces, FACES.current[] = FACES.current[], altfaces + try f() + finally + FACES.current[] = oldfaces + end end withfaces(f) = f() @@ -570,10 +595,12 @@ Face (sample) ``` """ function loadface!((name, update)::Pair{Symbol, Face}) - @lock FACES.lock if haskey(FACES.current[], name) - FACES.current[][name] = merge(FACES.current[][name], update) - else - FACES.current[][name] = update + lock(FACES.lock) do + if haskey(FACES.current[], name) + FACES.current[][name] = merge(FACES.current[][name], update) + else + FACES.current[][name] = update + end end end diff --git a/src/io.jl b/src/io.jl index 0ecc9763..1ad7ec76 100644 --- a/src/io.jl +++ b/src/io.jl @@ -53,7 +53,8 @@ end Print to `io` the best 8-bit SGR color code that sets the `category` color to be close to `color`. """ -function termcolor8bit(io::IO, (; r, g, b)::RGBTuple, category::Char) +function termcolor8bit(io::IO, rgb::RGBTuple, category::Char) + r, g, b = rgb.r, rgb.g, rgb.b # Magic numbers? Lots. cdistsq(r1, g1, b1) = (r1 - r)^2 + (g1 - g)^2 + (b1 - b)^2 to6cube(value) = if value < 48; 1 @@ -246,9 +247,13 @@ write(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = print(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}) = (write(io, s); nothing) -escape_string(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}, - esc = ""; keep = ()) = - (_ansi_writer(io, s, (io, s) -> escape_string(io, s, esc; keep)); nothing) +@static if VERSION < v"1.3" + escape_string(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}, esc = "") = + (_ansi_writer(io, s, (io, s) -> escape_string(io, s, esc)); nothing) +else + escape_string(io::IO, s::Union{<:AnnotatedString, SubString{<:AnnotatedString}}, esc = ""; keep = ()) = + (_ansi_writer(io, s, (io, s) -> escape_string(io, s, esc; keep=keep)); nothing) +end function write(io::IO, c::AnnotatedChar) if get(io, :color, false) == true @@ -306,7 +311,7 @@ function htmlcolor(io::IO, color::SimpleColor) htmlcolor(io, get(HTML_BASIC_COLORS, color.value, SimpleColor(:default))) end else - (; r, g, b) = color.value + r, g, b = color.value.r, color.value.g, color.value.b print(io, '#') r < 0x10 && print(io, '0') print(io, string(r, base=16)) diff --git a/src/strings/annotated.jl b/src/strings/annotated.jl new file mode 100644 index 00000000..7f5ef680 --- /dev/null +++ b/src/strings/annotated.jl @@ -0,0 +1,436 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +""" + AnnotatedString{S <: AbstractString} <: AbstractString + +A string with metadata, in the form of annotated regions. + +More specifically, this is a simple wrapper around any other +[`AbstractString`](@ref) that allows for regions of the wrapped string to be +annotated with labeled values. + +```text + C + ┌──────┸─────────┐ + "this is an example annotated string" + └──┰────────┼─────┘ │ + A └─────┰─────────┘ + B +``` + +The above diagram represents a `AnnotatedString` where three ranges have been +annotated (labeled `A`, `B`, and `C`). Each annotation holds a label (`Symbol`) +and a value (`Any`), paired together as a `Pair{Symbol, <:Any}`. + +Labels do not need to be unique, the same region can hold multiple annotations +with the same label. + +See also [`AnnotatedChar`](@ref), [`annotatedstring`](@ref), +[`annotations`](@ref), and [`annotate!`](@ref). + +!!! warning + While the constructors are part of the Base public API, the fields + of `AnnotatedString` are not. This is to allow for potential future + changes in the implementation of this type. Instead use the + [`annotations`](@ref), and [`annotate!`](@ref) getter/setter + functions. + +# Constructors + +```julia +AnnotatedString(s::S<:AbstractString) -> AnnotatedString{S} +AnnotatedString(s::S<:AbstractString, annotations::Vector{Tuple{UnitRange{Int}, Pair{Symbol, <:Any}}}) +``` + +A AnnotatedString can also be created with [`annotatedstring`](@ref), which acts much +like [`string`](@ref) but preserves any annotations present in the arguments. + +# Example + +```julia-repl +julia> AnnotatedString("this is an example annotated string", + [(1:18, :A => 1), (12:28, :B => 2), (18:35, :C => 3)]) +"this is an example annotated string" +``` +""" +struct AnnotatedString{S <: AbstractString} <: AbstractString + string::S + annotations::Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}} +end + +""" + AnnotatedChar{S <: AbstractChar} <: AbstractChar + +A Char with annotations. + +More specifically, this is a simple wrapper around any other +[`AbstractChar`](@ref), which holds a list of arbitrary labeled annotations +(`Pair{Symbol, <:Any}`) with the wrapped character. + +See also: [`AnnotatedString`](@ref), [`annotatedstring`](@ref), `annotations`, +and `annotate!`. + +!!! warning + While the constructors are part of the Base public API, the fields + of `AnnotatedChar` are not. This it to allow for potential future + changes in the implementation of this type. Instead use the + [`annotations`](@ref), and [`annotate!`](@ref) getter/setter + functions. + +# Constructors + +```julia +AnnotatedChar(s::S) -> AnnotatedChar{S} +AnnotatedChar(s::S, annotations::Vector{Pair{Symbol, <:Any}}) +``` + +# Examples + +```julia-repl +julia> AnnotatedChar('j', :label => 1) +'j': ASCII/Unicode U+006A (category Ll: Letter, lowercase) +``` +""" +struct AnnotatedChar{C <: AbstractChar} <: AbstractChar + char::C + annotations::Vector{Pair{Symbol, Any}} +end + +## Constructors ## + +# When called with overly-specialised arguments + +AnnotatedString(s::AbstractString, annots::Vector{<:Tuple{UnitRange{Int}, <:Pair{Symbol, <:Any}}}) = + AnnotatedString(s, Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}(annots)) + +AnnotatedChar(c::AbstractChar, annots::Vector{<:Pair{Symbol, <:Any}}) = + AnnotatedChar(c, Vector{Pair{Symbol, Any}}(annots)) + +# Constructors to avoid recursive wrapping + +AnnotatedString(s::AnnotatedString, annots::Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}) = + AnnotatedString(s.string, vcat(s.annotations, annots)) + +AnnotatedChar(c::AnnotatedChar, annots::Vector{Pair{Symbol, Any}}) = + AnnotatedChar(c.char, vcat(s.annotations, annots)) + +String(s::AnnotatedString{String}) = s.string # To avoid pointless overhead + +## Conversion/promotion ## + +convert(::Type{AnnotatedString}, s::AnnotatedString) = s +convert(::Type{AnnotatedString{S}}, s::S) where {S <: AbstractString} = + AnnotatedString(s, Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}()) +convert(::Type{AnnotatedString}, s::S) where {S <: AbstractString} = + convert(AnnotatedString{S}, s) +AnnotatedString(s::S) where {S <: AbstractString} = convert(AnnotatedString{S}, s) + +convert(::Type{AnnotatedChar}, c::AnnotatedChar) = c +convert(::Type{AnnotatedChar{C}}, c::C) where { C <: AbstractChar } = + AnnotatedChar{C}(c, Vector{Pair{Symbol, Any}}()) +convert(::Type{AnnotatedChar}, c::C) where { C <: AbstractChar } = + convert(AnnotatedChar{C}, c) + +AnnotatedChar(c::AbstractChar) = convert(AnnotatedChar, c) +AnnotatedChar(c::UInt32) = convert(AnnotatedChar, Char(c)) +AnnotatedChar{C}(c::UInt32) where {C <: AbstractChar} = convert(AnnotatedChar, C(c)) + +promote_rule(::Type{<:AnnotatedString}, ::Type{<:AbstractString}) = AnnotatedString + +## AbstractString interface ## + +ncodeunits(s::AnnotatedString) = ncodeunits(s.string) +codeunits(s::AnnotatedString) = codeunits(s.string) +codeunit(s::AnnotatedString) = codeunit(s.string) +codeunit(s::AnnotatedString, i::Integer) = codeunit(s.string, i) +isvalid(s::AnnotatedString, i::Integer) = isvalid(s.string, i) +@propagate_inbounds iterate(s::AnnotatedString, i::Integer=firstindex(s)) = + if i <= lastindex(s.string); (s[i], nextind(s, i)) end +eltype(::Type{<:AnnotatedString{S}}) where {S} = AnnotatedChar{eltype(S)} +firstindex(s::AnnotatedString) = firstindex(s.string) +lastindex(s::AnnotatedString) = lastindex(s.string) + +function getindex(s::AnnotatedString, i::Integer) + @boundscheck checkbounds(s, i) + @inbounds if isvalid(s, i) + AnnotatedChar(s.string[i], annotations(s, i)) + else + string_index_err(s, i) + end +end + +## AbstractChar interface ## + +ncodeunits(c::AnnotatedChar) = ncodeunits(c.char) +codepoint(c::AnnotatedChar) = codepoint(c.char) + +# Avoid the iteration fallback with comparison +cmp(a::AnnotatedString, b::AbstractString) = cmp(a.string, b) +cmp(a::AbstractString, b::AnnotatedString) = cmp(a, b.string) +# To avoid method ambiguity +cmp(a::AnnotatedString, b::AnnotatedString) = cmp(a.string, b.string) + +==(a::AnnotatedString, b::AnnotatedString) = + a.string == b.string && a.annotations == b.annotations + +==(a::AnnotatedString, b::AbstractString) = isempty(a.annotations) && a.string == b +==(a::AbstractString, b::AnnotatedString) = isempty(b.annotations) && a == b.string + +""" + annotatedstring(values...) + +Create a `AnnotatedString` from any number of `values` using their +[`print`](@ref)ed representation. + +This acts like [`string`](@ref), but takes care to preserve any annotations +present (in the form of [`AnnotatedString`](@ref) or [`AnnotatedChar`](@ref) values). + +See also [`AnnotatedString`](@ref) and [`AnnotatedChar`](@ref). + +## Examples + +```julia-repl +julia> annotatedstring("now a AnnotatedString") +"now a AnnotatedString" + +julia> annotatedstring(AnnotatedString("annotated", [(1:9, :label => 1)]), ", and unannotated") +"annotated, and unannotated" +``` +""" +function annotatedstring(xs...) + isempty(xs) && return AnnotatedString("") + size = mapreduce(_str_sizehint, +, xs) + s = IOContext(IOBuffer(sizehint=size), :color => true) + annotations = Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}() + for x in xs + if x isa AnnotatedString + for (region, annot) in x.annotations + push!(annotations, (s.io.size .+ (region), annot)) + end + print(s, x.string) + elseif x isa SubString{<:AnnotatedString} + for (region, annot) in x.string.annotations + start, stop = first(region), last(region) + if start <= x.offset + x.ncodeunits && stop > x.offset + rstart = s.io.size + max(0, start - x.offset - 1) + 1 + rstop = s.io.size + min(stop, x.offset + x.ncodeunits) - x.offset + push!(annotations, (rstart:rstop, annot)) + end + end + print(s, SubString(x.string.string, x.offset, x.ncodeunits, Val(:noshift))) + elseif x isa AnnotatedChar + for annot in x.annotations + push!(annotations, (1+s.io.size:1+s.io.size, annot)) + end + print(s, x.char) + else + print(s, x) + end + end + str = String(resize!(s.io.data, s.io.size)) + AnnotatedString(str, annotations) +end + +annotatedstring(s::AnnotatedString) = s +annotatedstring(c::AnnotatedChar) = + AnnotatedString(string(c.char), [(1:ncodeunits(c), annot) for annot in c.annotations]) + +AnnotatedString(s::SubString{<:AnnotatedString}) = annotatedstring(s) + +""" + annotatedstring_optimize!(str::AnnotatedString) + +Merge contiguous identical annotations in `str`. +""" +function annotatedstring_optimize!(s::AnnotatedString) + last_seen = Dict{Pair{Symbol, Any}, Int}() + i = 1 + while i <= length(s.annotations) + region, keyval = s.annotations[i] + prev = get(last_seen, keyval, 0) + if prev > 0 + lregion, _ = s.annotations[prev] + if last(lregion) + 1 == first(region) + s.annotations[prev] = + setindex(s.annotations[prev], + first(lregion):last(region), + 1) + deleteat!(s.annotations, i) + else + delete!(last_seen, keyval) + end + else + last_seen[keyval] = i + i += 1 + end + end + s +end + +function repeat(str::AnnotatedString, r::Integer) + r == 0 && return one(AnnotatedString) + r == 1 && return str + unannot = repeat(str.string, r) + annotations = Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}() + len = ncodeunits(str) + fullregion = firstindex(str):lastindex(str) + for (region, annot) in str.annotations + if region == fullregion + push!(annotations, (firstindex(unannot):lastindex(unannot), annot)) + end + end + for offset in 0:len:(r-1)*len + for (region, annot) in str.annotations + if region != fullregion + push!(annotations, (region .+ offset, annot)) + end + end + end + AnnotatedString(unannot, annotations) |> annotatedstring_optimize! +end + +repeat(str::SubString{<:AnnotatedString}, r::Integer) = + repeat(AnnotatedString(str), r) + +function repeat(c::AnnotatedChar, r::Integer) + str = repeat(c.char, r) + fullregion = firstindex(str):lastindex(str) + AnnotatedString(str, [(fullregion, annot) for annot in c.annotations]) +end + +function reverse(s::AnnotatedString) + lastind = lastindex(s) + AnnotatedString(reverse(s.string), + [(UnitRange(1 + lastind - last(region), + 1 + lastind - first(region)), + annot) + for (region, annot) in s.annotations]) +end + +# TODO optimise? +reverse(s::SubString{<:AnnotatedString}) = reverse(AnnotatedString(s)) + +# TODO implement `replace(::AnnotatedString, ...)` + +## End AbstractString interface ## + +""" + annotate!(str::AnnotatedString, [range::UnitRange{Int}], label::Symbol => value) + annotate!(str::SubString{AnnotatedString}, [range::UnitRange{Int}], label::Symbol => value) + +Annotate a `range` of `str` (or the entire string) with a labeled value (`label` => `value`). +To remove existing `label` annotations, use a value of `nothing`. +""" +function annotate!(s::AnnotatedString, range::UnitRange{Int}, @nospecialize(labelval::Pair{Symbol, <:Any})) + label, val = labelval + indices = searchsorted(s.annotations, (range,), by=first) + if val === nothing + labelindex = filter(i -> first(s.annotations[i][2]) === label, indices) + for index in Iterators.reverse(labelindex) + deleteat!(s.annotations, index) + end + else + splice!(s.annotations, indices, [(range, Pair{Symbol, Any}(label, val))]) + end + s +end + +annotate!(ss::AnnotatedString, @nospecialize(labelval::Pair{Symbol, <:Any})) = + annotate!(ss, firstindex(ss):lastindex(ss), labelval) + +annotate!(s::SubString{<:AnnotatedString}, range::UnitRange{Int}, @nospecialize(labelval::Pair{Symbol, <:Any})) = + (annotate!(s.string, s.offset .+ (range), labelval); s) + +annotate!(s::SubString{<:AnnotatedString}, @nospecialize(labelval::Pair{Symbol, <:Any})) = + (annotate!(s.string, s.offset .+ (1:s.ncodeunits), labelval); s) + +""" + annotate!(char::AnnotatedChar, label::Symbol => value) + +Annotate `char` with the pair `label => value`. +""" +annotate!(c::AnnotatedChar, @nospecialize(labelval::Pair{Symbol, <:Any})) = + (push!(c.annotations, labelval); c) + +""" + annotations(str::AnnotatedString, [position::Union{Integer, UnitRange}]) + annotations(str::SubString{AnnotatedString}, [position::Union{Integer, UnitRange}]) + +Get all annotations that apply to `str`. Should `position` be provided, only +annotations that overlap with `position` will be returned. + +See also: `annotate!`. +""" +annotations(s::AnnotatedString) = s.annotations + +annotations(s::SubString{<:AnnotatedString}) = + annotations(s, s.offset+1:s.offset+s.ncodeunits) + +function annotations(s::AnnotatedString, pos::UnitRange{<:Integer}) + # TODO optimise + annots = filter(label -> !isempty(intersect(pos, first(label))), + s.annotations) + last.(annots) +end + +annotations(s::AnnotatedString, pos::Integer) = annotations(s, pos:pos) + +annotations(s::SubString{<:AnnotatedString}, pos::Integer) = + annotations(s.string, s.offset + pos) +annotations(s::SubString{<:AnnotatedString}, pos::UnitRange{<:Integer}) = + annotations(s.string, first(pos)+s.offset:last(pos)+s.offset) + +""" + annotations(chr::AnnotatedChar) + +Get all annotations of `chr`. +""" +annotations(c::AnnotatedChar) = c.annotations + +## AnnotatedIOBuffer + +struct AnnotatedIOBuffer <: IO + io::IOBuffer + annotations::Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}} +end + +AnnotatedIOBuffer(io::IOBuffer) = AnnotatedIOBuffer(io, Vector{Tuple{UnitRange{Int}, Pair{Symbol, Any}}}()) +AnnotatedIOBuffer() = AnnotatedIOBuffer(IOBuffer()) + +function show(io::IO, annio::AnnotatedIOBuffer) + show(io, AnnotatedIOBuffer) + print(io, '(', annio.io.size, " bytes)") +end + +position(io::AnnotatedIOBuffer) = position(io.io) +lock(io::AnnotatedIOBuffer) = lock(io.io) +unlock(io::AnnotatedIOBuffer) = unlock(io.io) + +function write(io::AnnotatedIOBuffer, astr::Union{AnnotatedString, SubString{<:AnnotatedString}}) + astr = AnnotatedString(astr) + offset = position(io.io) + for (region, annot) in astr.annotations + start, stop = first(region), last(region) + push!(io.annotations, (start+offset:stop+offset, annot)) + end + write(io.io, astr) +end +write(io::AnnotatedIOBuffer, achr::AnnotatedChar) = write(io, AnnotatedString(achr)) +write(io::AnnotatedIOBuffer, x::AbstractString) = write(io.io, x) +write(io::AnnotatedIOBuffer, s::Union{SubString{String}, String}) = write(io.io, x) +write(io::AnnotatedIOBuffer, x::UInt8) = write(io.io, x) + +""" + read(io::AnnotatedIOBuffer, AnnotatedString) + +Read the entirety of `io`, as an `AnnotatedString`. This preserves the +annotations of any `AnnotatedString`s written to `io` and otherwise acts like +`read(io::IO, String)`. +""" +function read(io::AnnotatedIOBuffer, ::Type{AnnotatedString{>:String}}) + str = String(take!(io.io)) + annots = copy(io.annotations) + empty!(io.annotations) + seekstart(io.io) + AnnotatedString(str, annotss) +end diff --git a/src/strings/basic.jl b/src/strings/basic.jl new file mode 100644 index 00000000..73559197 --- /dev/null +++ b/src/strings/basic.jl @@ -0,0 +1,5 @@ +_isannotated(S::Type) = S != Union{} && (S <: AnnotatedString || S <: AnnotatedChar) +_isannotated(s) = _isannotated(typeof(s)) + +# actually from substring.jl +_isannotated(::SubString{T}) where {T} = _isannotated(T) diff --git a/src/strings/io.jl b/src/strings/io.jl new file mode 100644 index 00000000..5bfd843d --- /dev/null +++ b/src/strings/io.jl @@ -0,0 +1,49 @@ + +# TODO: If/when we have `AnnotatedIO`, we can revisit this and +# implement it more nicely. +function join_annotated(iterator, delim="", last=delim) + xs = zip(iterator, Iterators.repeated(delim)) |> Iterators.flatten |> collect + xs = xs[1:end-1] + if length(xs) > 1 + xs[end-1] = last + end + annotatedstring(xs...)::AnnotatedString{String} +end + +function _join_maybe_annotated(args...) + if any(function (arg) + t = eltype(arg) + !(t == Union{}) && (t <: AnnotatedString || t <: AnnotatedChar) + end, args) + join_annotated(args...) + else + sprint(join, args...) + end +end + +join(iterator) = _join_maybe_annotated(iterator) +join(iterator, delim) = _join_maybe_annotated(iterator, delim) +join(iterator, delim, last) = _join_maybe_annotated(iterator, delim, last) + +function AnnotatedString(chars::AbstractVector{C}) where {C<:AbstractChar} + str = if C <: AnnotatedChar + String(getfield.(chars, :char)) + else + sprint(sizehint=length(chars)) do io + for c in chars + print(io, c) + end + end + end + props = Tuple{UnitRange{Int}, Pair{Symbol, Any}}[] + point = 1 + for c in chars + if c isa AnnotatedChar + for prop in c.properties + push!(props, (point:point, prop)) + end + end + point += ncodeunits(c) + end + AnnotatedString(str, props) +end diff --git a/src/strings/regex.jl b/src/strings/regex.jl new file mode 100644 index 00000000..a48ef9be --- /dev/null +++ b/src/strings/regex.jl @@ -0,0 +1,45 @@ +@static if VERSION < v"1.6" + struct AnnotatedRegexMatch{S<:AbstractString} + match::SubString{S} + captures::Vector{Union{Nothing, SubString{S}}} + offset::Int64 + offsets::Vector{Int64} + regex::Regex + end +else + struct AnnotatedRegexMatch{S<:AbstractString} <: AbstractMatch + match::SubString{S} + captures::Vector{Union{Nothing, SubString{S}}} + offset::Int64 + offsets::Vector{Int64} + regex::Regex + end +end + +function _annotatedmatch(m::RegexMatch, str::AnnotatedString{S}) where {S<:AbstractString} + AnnotatedRegexMatch{AnnotatedString{S}}( + # It's surprisingly annoying to clone a substring, + # thanks to that pesky inner constructor. + eval(Expr(:new, SubString{AnnotatedString{S}}( + str, m.match.offset, m.match.ncodeunits))), + Union{Nothing,SubString{AnnotatedString{S}}}[ + if !isnothing(cap) + eval(Expr(:new, SubString{AnnotatedString{S}}( + str, cap.offset, cap.ncodeunits))) + end for cap in m.captures], + m.offset, m.offsets, m.regex) +end + +function match(re::Regex, str::AnnotatedString) + m = match(re, str.string) + if !isnothing(m) + _annotatedmatch(m, str) + end +end + +function match(re::Regex, str::AnnotatedString, idx::Integer, add_opts::UInt32=UInt32(0)) + m = match(re, str.string, idx, add_opts) + if !isnothing(m) + _annotatedmatch(m, str) + end +end diff --git a/src/strings/strings.jl b/src/strings/strings.jl new file mode 100644 index 00000000..372811db --- /dev/null +++ b/src/strings/strings.jl @@ -0,0 +1,17 @@ +module AnnotatedStrings + +import Base: @propagate_inbounds, TTY, String, cmp, codepoint, codeunit, + codeunits, convert, eltype, empty, firstindex, get, getindex, haskey, in, + isvalid, iterate, join, keys, keys, lastindex, length, merge, parse, print, + promote_rule, read, repeat, reverse, show, tryparse, write, *, == + +include(joinpath(dirname(@__DIR__), "compat.jl")) +include("annotated.jl") + +function __init__() + # Method overwriting + eval(:(include(joinpath($@__DIR__, "io.jl")))) + eval(:(include(joinpath($@__DIR__, "regex.jl")))) +end + +end diff --git a/src/strings/util.jl b/src/strings/util.jl new file mode 100644 index 00000000..ff80c23a --- /dev/null +++ b/src/strings/util.jl @@ -0,0 +1,29 @@ +function lpad( + s::Union{AbstractChar,AbstractString}, + n::Integer, + p::Union{AbstractChar,AbstractString}=' ', +) + stringfn = if any(isa.((s, p), Union{AnnotatedString, AnnotatedChar, SubString{<:AnnotatedString}})) + annotatedstring else string end + n = Int(n)::Int + m = signed(n) - Int(textwidth(s))::Int + m ≤ 0 && return stringfn(s) + l = textwidth(p) + q, r = divrem(m, l) + r == 0 ? stringfn(p^q, s) : stringfn(p^q, first(p, r), s) +end + +function rpad( + s::Union{AbstractChar,AbstractString}, + n::Integer, + p::Union{AbstractChar,AbstractString}=' ', +) + stringfn = if any(isa.((s, p), Union{AnnotatedString, AnnotatedChar, SubString{<:AnnotatedString}})) + annotatedstring else string end + n = Int(n)::Int + m = signed(n) - Int(textwidth(s))::Int + m ≤ 0 && return stringfn(s) + l = textwidth(p) + q, r = divrem(m, l) + r == 0 ? stringfn(s, p^q) : stringfn(s, p^q, first(p, r)) +end diff --git a/src/stylemacro.jl b/src/stylemacro.jl index fcd63cc8..419fc8e9 100644 --- a/src/stylemacro.jl +++ b/src/stylemacro.jl @@ -16,8 +16,7 @@ interpolated with `\$`. # Example ```julia -styled"The {bold:{italic:quick} {(foreground=#cd853f):brown} fox} jumped over \ -the {link={https://en.wikipedia.org/wiki/Laziness}:lazy} dog" +styled"The {bold:{italic:quick} {(foreground=#cd853f):brown} fox} jumped over the {link={https://en.wikipedia.org/wiki/Laziness}:lazy} dog" ``` # Extended help @@ -110,14 +109,14 @@ macro styled_str(raw_content::String) # Instead we'll just use a `NamedTuple` state = let content = unescape_string(raw_content, ('{', '}', ':', '$', '\n', '\r')) - (; content, bytes = Vector{UInt8}(content), + (; content=content, bytes = Vector{UInt8}(content), s = Iterators.Stateful(pairs(content)), parts = Any[], active_styles = Vector{Tuple{Int, Int, Union{Symbol, Expr, Pair{Symbol, Any}}}}[], pending_styles = Tuple{UnitRange{Int}, Union{Symbol, Expr, Pair{Symbol, Any}}}[], offset = Ref(0), point = Ref(1), escape = Ref(false), interpolated = Ref(false), - errors = Vector{NamedTuple{(:message, :position, :hint), - Tuple{AnnotatedString{String}, <:Union{Int, Nothing}, String}}}()) + errors = Vector{Union{NamedTuple{(:message, :position, :hint), Tuple{AnnotatedString{String}, Int, String}}, + NamedTuple{(:message, :position, :hint), Tuple{AnnotatedString{String}, Nothing, String}}}}()) end # Value restrictions we can check against @@ -141,11 +140,11 @@ macro styled_str(raw_content::String) end, -position) end - push!(state.errors, (; message=AnnotatedString(message), position, hint)) + push!(state.errors, (; message=AnnotatedString(message), position=position, hint=hint)) nothing end - hygienic_eval(expr) = Core.eval(__module__, Expr(:var"hygienic-scope", expr, @__MODULE__)) + hygienic_eval(expr) = Core.eval(__module__, Expr(Symbol("hygienic-scope"), expr, @__MODULE__)) function addpart!(state, stop::Int) if state.point[] > stop+state.offset[]+ncodeunits(state.content[stop])-1 @@ -244,7 +243,7 @@ macro styled_str(raw_content::String) -1, "right here") return "", pos end - expr, nextpos = Meta.parseatom(state.content, pos) + expr, nextpos = parseatom(state.content, pos) nchars = length(state.content[pos:prevind(state.content, nextpos)]) for _ in 1:nchars isempty(state.s) && break @@ -676,20 +675,22 @@ macro styled_str(raw_content::String) elseif state.interpolated[] :(annotatedstring($(state.parts...))) else - annotatedstring(map(hygienic_eval, state.parts)...) |> Base.annotatedstring_optimize! + annotatedstring(map(hygienic_eval, state.parts)...) |> annotatedstring_optimize! end end struct MalformedStylingMacro <: Exception raw::String - problems::Vector{NamedTuple{(:message, :position, :hint), Tuple{AnnotatedString{String}, <:Union{Int, Nothing}, String}}} + problems::Vector{Union{NamedTuple{(:message, :position, :hint), Tuple{AnnotatedString{String}, Int, String}}, + NamedTuple{(:message, :position, :hint), Tuple{AnnotatedString{String}, Nothing, String}}}} end function Base.showerror(io::IO, err::MalformedStylingMacro) # We would do "{error:┌ ERROR\:} MalformedStylingMacro" but this is already going # to be prefixed with "ERROR: LoadError", so it's better not to. println(io, "MalformedStylingMacro") - for (; message, position, hint) in err.problems + for prob in err.problems + message, position, hint = prob.message, prob.position, prob.hint posinfo = if isnothing(position) println(io, styled"{error:│} $message.") else diff --git a/test/runtests.jl b/test/runtests.jl index 97db4ede..2ed8ffed 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,6 +3,7 @@ using Test, StyledStrings import StyledStrings: SimpleColor, Face +import StyledStrings: AnnotatedString @testset "SimpleColor" begin @test SimpleColor(:hey).value == :hey # no error @@ -151,34 +152,34 @@ end @testset "Styled string macro" begin # Preservation of an unstyled string - @test styled"some string" == Base.AnnotatedString("some string") + @test styled"some string" == AnnotatedString("some string") # Basic styled constructs - @test styled"{thing=val:some} string" == Base.AnnotatedString("some string", [(1:4, :thing => "val")]) - @test styled"some {thing=val:string}" == Base.AnnotatedString("some string", [(6:11, :thing => "val")]) - @test styled"some {a=1:s}trin{b=2:g}" == Base.AnnotatedString("some string", [(6:6, :a => "1"), (11:11, :b => "2")]) - @test styled"{thing=val with spaces:some} string" == Base.AnnotatedString("some string", [(1:4, :thing => "val with spaces")]) - @test styled"{aface:some} string" == Base.AnnotatedString("some string", [(1:4, :face => :aface)]) + @test styled"{thing=val:some} string" == AnnotatedString("some string", [(1:4, :thing => "val")]) + @test styled"some {thing=val:string}" == AnnotatedString("some string", [(6:11, :thing => "val")]) + @test styled"some {a=1:s}trin{b=2:g}" == AnnotatedString("some string", [(6:6, :a => "1"), (11:11, :b => "2")]) + @test styled"{thing=val with spaces:some} string" == AnnotatedString("some string", [(1:4, :thing => "val with spaces")]) + @test styled"{aface:some} string" == AnnotatedString("some string", [(1:4, :face => :aface)]) @test styled"{aface,bface:some} string" == - Base.AnnotatedString("some string", [(1:4, :face => :aface), (1:4, :face => :bface)]) + AnnotatedString("some string", [(1:4, :face => :aface), (1:4, :face => :bface)]) # Inline face attributes @test styled"{(slant=italic):some} string" == - Base.AnnotatedString("some string", [(1:4, :face => Face(slant=:italic))]) + AnnotatedString("some string", [(1:4, :face => Face(slant=:italic))]) @test styled"{(foreground=magenta,background=#555555):some} string" == - Base.AnnotatedString("some string", [(1:4, :face => Face(foreground=:magenta, background=0x555555))]) + AnnotatedString("some string", [(1:4, :face => Face(foreground=:magenta, background=0x555555))]) # Curly bracket escaping - @test styled"some \{string" == Base.AnnotatedString("some {string") - @test styled"some string\}" == Base.AnnotatedString("some string}") - @test styled"some \{string\}" == Base.AnnotatedString("some {string}") - @test styled"some \{str:ing\}" == Base.AnnotatedString("some {str:ing}") - @test styled"some \{{bold:string}\}" == Base.AnnotatedString("some {string}", [(7:12, :face => :bold)]) - @test styled"some {bold:string \{other\}}" == Base.AnnotatedString("some string {other}", [(6:19, :face => :bold)]) + @test styled"some \{string" == AnnotatedString("some {string") + @test styled"some string\}" == AnnotatedString("some string}") + @test styled"some \{string\}" == AnnotatedString("some {string}") + @test styled"some \{str:ing\}" == AnnotatedString("some {str:ing}") + @test styled"some \{{bold:string}\}" == AnnotatedString("some {string}", [(7:12, :face => :bold)]) + @test styled"some {bold:string \{other\}}" == AnnotatedString("some string {other}", [(6:19, :face => :bold)]) # Nesting @test styled"{bold:nest{italic:ed st{red:yling}}}" == - Base.AnnotatedString( + AnnotatedString( "nested styling", [(1:14, :face => :bold), (5:14, :face => :italic), (10:14, :face => :red)]) - # Production of a `(Base.AnnotatedString)` value instead of an expression when possible - @test Base.AnnotatedString("val") == @macroexpand styled"val" - @test Base.AnnotatedString("val", [(1:3, :face => :style)]) == @macroexpand styled"{style:val}" + # Production of a `(AnnotatedString)` value instead of an expression when possible + @test AnnotatedString("val") == @macroexpand styled"val" + @test AnnotatedString("val", [(1:3, :face => :style)]) == @macroexpand styled"{style:val}" # Interpolation let annotatedstring = GlobalRef(StyledStrings, :annotatedstring) AnnotatedString = GlobalRef(StyledStrings, :AnnotatedString)