From 909f4f9761e051c4515c6e01459a7d7d91793ccd Mon Sep 17 00:00:00 2001 From: Stefan Karpinski Date: Thu, 6 May 2021 11:58:05 -0400 Subject: [PATCH] show(::String): elide long strings (close #40724) --- NEWS.md | 1 + base/strings/io.jl | 48 +++++++++++++++++++++++++++++++++++++++++ test/show.jl | 54 ++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2e63729ee09cb..07db69fecf7db 100644 --- a/NEWS.md +++ b/NEWS.md @@ -55,6 +55,7 @@ New library features Standard library changes ------------------------ +* Long strings are now elided using the syntax `"head" ⋯ 12345 bytes ⋯ "tail"` when displayed in the REPL ([#40736]). * `count` and `findall` now accept an `AbstractChar` argument to search for a character in a string ([#38675]). * `range` now supports the `range(start, stop)` and `range(start, stop, length)` methods ([#39228]). * `range` now supports `start` as an optional keyword argument ([#38041]). diff --git a/base/strings/io.jl b/base/strings/io.jl index 3977f137510e3..0f877275ed759 100644 --- a/base/strings/io.jl +++ b/base/strings/io.jl @@ -190,6 +190,54 @@ print(io::IO, s::AbstractString) = for c in s; print(io, c); end write(io::IO, s::AbstractString) = (len = 0; for c in s; len += Int(write(io, c))::Int; end; len) show(io::IO, s::AbstractString) = print_quoted(io, s) +# show elided string if more than `limit` characters +function show( + io :: IO, + mime :: MIME"text/plain", + str :: AbstractString; + limit :: Union{Int, Nothing} = nothing, +) + # compute limit in default case + if limit === nothing + get(io, :limit, false) || return show(io, str) + limit = max(20, displaysize(io)[2]) + # one line in collection, seven otherwise + get(io, :typeinfo, nothing) === nothing && (limit *= 7) + end + + # early out for short strings + len = ncodeunits(str) + len ≤ limit - 2 && # quote chars + return show(io, str) + + # these don't depend on string data + units = codeunit(str) == UInt8 ? "bytes" : "code units" + skip_text(skip) = " ⋯ $skip $units ⋯ " + short = length(skip_text("")) + 4 # quote chars + chars = max(limit, short + 1) - short # at least 1 digit + + # figure out how many characters to print in elided case + chars -= d = ndigits(len - chars) # first adjustment + chars += d - ndigits(len - chars) # second if needed + chars = max(0, chars) + + # find head & tail, avoiding O(length(str)) computation + head = nextind(str, 0, 1 + (chars + 1) ÷ 2) + tail = prevind(str, len + 1, chars ÷ 2) + + # threshold: min chars skipped to make elision worthwhile + t = short + ndigits(len - chars) - 1 + n = tail - head # skipped code units + if 4t ≤ n || t ≤ n && t ≤ length(str, head, tail-1) + skip = skip_text(n) + show(io, SubString(str, 1:prevind(str, head))) + print(io, skip) # TODO: bold styled + show(io, SubString(str, tail)) + else + show(io, str) + end +end + # optimized methods to avoid iterating over chars write(io::IO, s::Union{String,SubString{String}}) = GC.@preserve s Int(unsafe_write(io, pointer(s), reinterpret(UInt, sizeof(s))))::Int diff --git a/test/show.jl b/test/show.jl index 2c8a6fd67bd4a..2dde201b06dfb 100644 --- a/test/show.jl +++ b/test/show.jl @@ -808,13 +808,20 @@ Base.methodloc_callback[] = nothing # test that no spurious visual lines are added when one element spans multiple lines v = fill!(Array{Any}(undef, 9), 0) v[1] = "look I'm wide! --- " ^ 9 - @test replstr(v) == "9-element Vector{Any}:\n \"look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- \"\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0" - @test replstr([fill(0, 9) v]) == "9×2 Matrix{Any}:\n 0 … \"look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- \"\n 0 0\n 0 0\n 0 0\n 0 0\n 0 … 0\n 0 0\n 0 0\n 0 0" + r = replstr(v) + @test startswith(r, "9-element Vector{Any}:\n \"look I'm wide! ---") + @test endswith(r, "look I'm wide! --- \"\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0") + # test vertical/diagonal ellipsis v = fill!(Array{Any}(undef, 50), 0) v[1] = "look I'm wide! --- " ^ 9 - @test replstr(v) == "50-element Vector{Any}:\n \"look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- \"\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n ⋮\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0" - @test replstr([fill(0, 50) v]) == "50×2 Matrix{Any}:\n 0 … \"look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- look I'm wide! --- \"\n 0 0\n 0 0\n 0 0\n 0 0\n 0 … 0\n 0 0\n 0 0\n 0 0\n 0 0\n ⋮ ⋱ \n 0 0\n 0 0\n 0 0\n 0 0\n 0 … 0\n 0 0\n 0 0\n 0 0\n 0 0" + r = replstr(v) + @test startswith(r, "50-element Vector{Any}:\n \"look I'm wide! ---") + @test endswith(r, "look I'm wide! --- \"\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n ⋮\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0") + + r = replstr([fill(0, 50) v]) + @test startswith(r, "50×2 Matrix{Any}:\n 0 … \"look I'm wide! ---") + @test endswith(r, "look I'm wide! --- \"\n 0 0\n 0 0\n 0 0\n 0 0\n 0 … 0\n 0 0\n 0 0\n 0 0\n 0 0\n ⋮ ⋱ \n 0 0\n 0 0\n 0 0\n 0 0\n 0 … 0\n 0 0\n 0 0\n 0 0\n 0 0") # issue #34659 @test replstr(Int32[]) == "Int32[]" @@ -825,6 +832,45 @@ Base.methodloc_callback[] = nothing @test replstr([zeros(3,0),zeros(2,0)]) == "2-element Vector{Matrix{Float64}}:\n 3×0 Matrix{Float64}\n 2×0 Matrix{Float64}" end +# string show with elision +@testset "string show with elision" begin + @testset "elision logic" begin + strs = ["A", "∀", "∀A", "A∀", "😃"] + for limit = 0:100, len = 0:100, str in strs + str = str^len + str = str[1:nextind(str, 0, len)] + out = sprint() do io + show(io, MIME"text/plain"(), str; limit) + end + lower = length("\"\" ⋯ $(ncodeunits(str)) bytes ⋯ \"\"") + limit = max(limit, lower) + if length(str) + 2 ≤ limit + @test eval(Meta.parse(out)) == str + else + @test limit-!isascii(str) <= length(out) <= limit + re = r"(\"[^\"]*\") ⋯ (\d+) bytes ⋯ (\"[^\"]*\")" + m = match(re, out) + head = eval(Meta.parse(m.captures[1])) + tail = eval(Meta.parse(m.captures[3])) + skip = parse(Int, m.captures[2]) + @test startswith(str, head) + @test endswith(str, tail) + @test ncodeunits(str) == + ncodeunits(head) + skip + ncodeunits(tail) + end + end + end + + @testset "default elision limit" begin + r = replstr("x"^1000) + @test length(r) == 7*80 + @test r == repr("x"^271) * " ⋯ 459 bytes ⋯ " * repr("x"^270) + r = replstr(["x"^1000]) + @test length(r) < 120 + @test r == "1-element Vector{String}:\n " * repr("x"^31) * " ⋯ 939 bytes ⋯ " * repr("x"^30) + end +end + # Issue 14121 @test_repr "(A'x)'"