Skip to content

Commit

Permalink
add rtruncate, ltruncate, ctruncate for truncating strings in t…
Browse files Browse the repository at this point in the history
…erms of `textwidth` (#55351)

Co-authored-by: Timothy <[email protected]>
Co-authored-by: Steven G. Johnson <[email protected]>
  • Loading branch information
3 people authored Aug 8, 2024
1 parent 07f563e commit e439836
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 0 deletions.
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ New library features
the uniquing checking ([#53474])
* `RegexMatch` objects can now be used to construct `NamedTuple`s and `Dict`s ([#50988])
* `Lockable` is now exported ([#54595])
* New `ltruncate`, `rtruncate` and `ctruncate` functions for truncating strings to text width, accounting for char widths ([#55351])

Standard library changes
------------------------
Expand Down
3 changes: 3 additions & 0 deletions base/exports.jl
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ export
codepoint,
codeunit,
codeunits,
ctruncate,
digits,
digits!,
eachsplit,
Expand All @@ -620,6 +621,7 @@ export
join,
lpad,
lstrip,
ltruncate,
ncodeunits,
ndigits,
nextind,
Expand All @@ -632,6 +634,7 @@ export
rpad,
rsplit,
rstrip,
rtruncate,
split,
string,
strip,
Expand Down
148 changes: 148 additions & 0 deletions base/strings/util.jl
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,154 @@ function rpad(
r == 0 ? stringfn(s, p^q) : stringfn(s, p^q, first(p, r))
end

"""
rtruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = '…')
Truncate `str` to at most `maxwidth` columns (as estimated by [`textwidth`](@ref)), replacing the last characters
with `replacement` if necessary. The default replacement string is "…".
# Examples
```jldoctest
julia> s = rtruncate("🍕🍕 I love 🍕", 10)
"🍕🍕 I lo…"
julia> textwidth(s)
10
julia> rtruncate("foo", 3)
"foo"
```
!!! compat "Julia 1.12"
This function was added in Julia 1.12.
See also [`ltruncate`](@ref) and [`ctruncate`](@ref).
"""
function rtruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = '')
ret = string_truncate_boundaries(str, Int(maxwidth), replacement, Val(:right))
if isnothing(ret)
return string(str)
else
left, _ = ret::Tuple{Int,Int}
@views return str[begin:left] * replacement
end
end

"""
ltruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = '…')
Truncate `str` to at most `maxwidth` columns (as estimated by [`textwidth`](@ref)), replacing the first characters
with `replacement` if necessary. The default replacement string is "…".
# Examples
```jldoctest
julia> s = ltruncate("🍕🍕 I love 🍕", 10)
"…I love 🍕"
julia> textwidth(s)
10
julia> ltruncate("foo", 3)
"foo"
```
!!! compat "Julia 1.12"
This function was added in Julia 1.12.
See also [`rtruncate`](@ref) and [`ctruncate`](@ref).
"""
function ltruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = '')
ret = string_truncate_boundaries(str, Int(maxwidth), replacement, Val(:left))
if isnothing(ret)
return string(str)
else
_, right = ret::Tuple{Int,Int}
@views return replacement * str[right:end]
end
end

"""
ctruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = '…'; prefer_left::Bool = true)
Truncate `str` to at most `maxwidth` columns (as estimated by [`textwidth`](@ref)), replacing the middle characters
with `replacement` if necessary. The default replacement string is "…". By default, the truncation
prefers keeping chars on the left, but this can be changed by setting `prefer_left` to `false`.
# Examples
```jldoctest
julia> s = ctruncate("🍕🍕 I love 🍕", 10)
"🍕🍕 …e 🍕"
julia> textwidth(s)
10
julia> ctruncate("foo", 3)
"foo"
```
!!! compat "Julia 1.12"
This function was added in Julia 1.12.
See also [`ltruncate`](@ref) and [`rtruncate`](@ref).
"""
function ctruncate(str::AbstractString, maxwidth::Integer, replacement::Union{AbstractString,AbstractChar} = ''; prefer_left::Bool = true)
ret = string_truncate_boundaries(str, Int(maxwidth), replacement, Val(:center), prefer_left)
if isnothing(ret)
return string(str)
else
left, right = ret::Tuple{Int,Int}
@views return str[begin:left] * replacement * str[right:end]
end
end

function string_truncate_boundaries(
str::AbstractString,
maxwidth::Integer,
replacement::Union{AbstractString,AbstractChar},
::Val{mode},
prefer_left::Bool = true) where {mode}

maxwidth >= 0 || throw(ArgumentError("maxwidth $maxwidth should be non-negative"))

# check efficiently for early return if str is less wide than maxwidth
total_width = 0
for c in str
total_width += textwidth(c)
total_width > maxwidth && break
end
total_width <= maxwidth && return nothing

l0, _ = left, right = firstindex(str), lastindex(str)
width = textwidth(replacement)
# used to balance the truncated width on either side
rm_width_left, rm_width_right, force_other = 0, 0, false
@inbounds while true
if mode === :left || (mode === :center && (!prefer_left || left > l0))
rm_width = textwidth(str[right])
if mode === :left || (rm_width_right <= rm_width_left || force_other)
force_other = false
(width += rm_width) <= maxwidth || break
rm_width_right += rm_width
right = prevind(str, right)
else
force_other = true
end
end
if mode (:right, :center)
rm_width = textwidth(str[left])
if mode === :left || (rm_width_left <= rm_width_right || force_other)
force_other = false
(width += textwidth(str[left])) <= maxwidth || break
rm_width_left += rm_width
left = nextind(str, left)
else
force_other = true
end
end
end
return prevind(str, left), nextind(str, right)
end

"""
eachsplit(str::AbstractString, dlm; limit::Integer=0, keepempty::Bool=true)
eachsplit(str::AbstractString; limit::Integer=0, keepempty::Bool=false)
Expand Down
3 changes: 3 additions & 0 deletions doc/src/base/strings.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Base.:(==)(::AbstractString, ::AbstractString)
Base.cmp(::AbstractString, ::AbstractString)
Base.lpad
Base.rpad
Base.ltruncate
Base.rtruncate
Base.ctruncate
Base.findfirst(::AbstractString, ::AbstractString)
Base.findnext(::AbstractString, ::AbstractString, ::Integer)
Base.findnext(::AbstractChar, ::AbstractString, ::Integer)
Expand Down
46 changes: 46 additions & 0 deletions test/strings/util.jl
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,52 @@ end
@test rpad("⟨k|H₁|k⟩", 12) |> textwidth == 12
end

@testset "string truncation (ltruncate, rtruncate, ctruncate)" begin
@test ltruncate("foo", 4) == "foo"
@test ltruncate("foo", 3) == "foo"
@test ltruncate("foo", 2) == "…o"
@test ltruncate("🍕🍕 I love 🍕", 10) == "…I love 🍕" # handle wide emojis
@test ltruncate("🍕🍕 I love 🍕", 10, "[…]") == "[…]love 🍕"
# when the replacement string is longer than the trunc
# trust that the user wants the replacement string rather than erroring
@test ltruncate("abc", 2, "xxxxxx") == "xxxxxx"

@inferred ltruncate("xxx", 4)
@inferred ltruncate("xxx", 2)
@inferred ltruncate(@view("xxxxxxx"[1:4]), 4)
@inferred ltruncate(@view("xxxxxxx"[1:4]), 2)

@test rtruncate("foo", 4) == "foo"
@test rtruncate("foo", 3) == "foo"
@test rtruncate("foo", 2) == "f…"
@test rtruncate("🍕🍕 I love 🍕", 10) == "🍕🍕 I lo…"
@test rtruncate("🍕🍕 I love 🍕", 10, "[…]") == "🍕🍕 I […]"
@test rtruncate("abc", 2, "xxxxxx") == "xxxxxx"

@inferred rtruncate("xxx", 4)
@inferred rtruncate("xxx", 2)
@inferred rtruncate(@view("xxxxxxx"[1:4]), 4)
@inferred rtruncate(@view("xxxxxxx"[1:4]), 2)

@test ctruncate("foo", 4) == "foo"
@test ctruncate("foo", 3) == "foo"
@test ctruncate("foo", 2) == "f…"
@test ctruncate("foo", 2; prefer_left=true) == "f…"
@test ctruncate("foo", 2; prefer_left=false) == "…o"
@test ctruncate("foobar", 6) == "foobar"
@test ctruncate("foobar", 5) == "fo…ar"
@test ctruncate("foobar", 4) == "fo…r"
@test ctruncate("🍕🍕 I love 🍕", 10) == "🍕🍕 …e 🍕"
@test ctruncate("🍕🍕 I love 🍕", 10, "[…]") == "🍕🍕[…] 🍕"
@test ctruncate("abc", 2, "xxxxxx") == "xxxxxx"
@test ctruncate("🍕🍕🍕🍕🍕🍕xxxxxxxxxxx", 9) == "🍕🍕…xxxx"

@inferred ctruncate("xxxxx", 5)
@inferred ctruncate("xxxxx", 3)
@inferred ctruncate(@view("xxxxxxx"[1:5]), 5)
@inferred ctruncate(@view("xxxxxxx"[1:5]), 3)
end

# string manipulation
@testset "lstrip/rstrip/strip" begin
@test strip("") == ""
Expand Down

0 comments on commit e439836

Please sign in to comment.