Skip to content

Commit 2690ca8

Browse files
authored
Terminfo parser (#50797)
This was part of #49586, but it has been spun out so it an be discussed separately and once merged simplify #49586 a bit. This introduces a pure-Julia terminfo parser, which means that: - We can no longer depend on calling `tput` - We can start thinking about fancier terminal-adaptive functionality (see #49586) I am hoping this can be considered and (hopefully) merged in the near future 🙂. Oh, and I think it's fun to note that it looks like this may well be the most concise standards-compliant Terminfo parsers in existence.
1 parent 0d544cc commit 2690ca8

File tree

8 files changed

+1759
-29
lines changed

8 files changed

+1759
-29
lines changed

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Deprecated or removed
7474

7575
External dependencies
7676
---------------------
77+
* `tput` is no longer called to check terminal capabilities, it has been replaced with a pure-Julia terminfo parser.
7778

7879
Tooling Improvements
7980
--------------------

base/Base.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ include("filesystem.jl")
358358
using .Filesystem
359359
include("cmd.jl")
360360
include("process.jl")
361-
include("ttyhascolor.jl")
361+
include("terminfo.jl")
362362
include("secretbuffer.jl")
363363

364364
# core math functions

base/client.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
## and REPL
55

66
have_color = nothing
7+
have_truecolor = nothing
78
const default_color_warn = :yellow
89
const default_color_error = :light_red
910
const default_color_info = :cyan
@@ -413,6 +414,7 @@ function run_main_repl(interactive::Bool, quiet::Bool, banner::Symbol, history_f
413414
if interactive && isassigned(REPL_MODULE_REF)
414415
invokelatest(REPL_MODULE_REF[]) do REPL
415416
term_env = get(ENV, "TERM", @static Sys.iswindows() ? "" : "dumb")
417+
global current_terminfo = load_terminfo(term_env)
416418
term = REPL.Terminals.TTYTerminal(term_env, stdin, stdout, stderr)
417419
banner == :no || Base.banner(term, short=banner==:short)
418420
if term.term_type == "dumb"

base/terminfo.jl

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
include("terminfo_data.jl")
4+
5+
"""
6+
struct TermInfoRaw
7+
8+
A structured representation of a terminfo file, without any knowledge of
9+
particular capabilities, solely based on `term(5)`.
10+
11+
!!! warning
12+
This is not part of the public API, and thus subject to change without notice.
13+
14+
# Fields
15+
16+
- `names::Vector{String}`: The names this terminal is known by.
17+
- `flags::BitVector`: A list of 0–$(length(TERM_FLAGS)) flag values.
18+
- `numbers::Union{Vector{UInt16}, Vector{UInt32}}`: A list of 0–$(length(TERM_NUMBERS))
19+
number values. A value of `typemax(eltype(numbers))` is used to skip over
20+
unspecified capabilities while ensuring value indices are correct.
21+
- `strings::Vector{Union{String, Nothing}}`: A list of 0–$(length(TERM_STRINGS))
22+
string values. A value of `nothing` is used to skip over unspecified
23+
capabilities while ensuring value indices are correct.
24+
- `extended::Union{Nothing, Dict{Symbol, Union{Bool, Int, String}}}`: Should an
25+
extended info section exist, this gives the entire extended info as a
26+
dictionary. Otherwise `nothing`.
27+
28+
See also: `TermInfo` and `TermCapability`.
29+
"""
30+
struct TermInfoRaw
31+
names::Vector{String}
32+
flags::BitVector
33+
numbers::Union{Vector{UInt16}, Vector{UInt32}}
34+
strings::Vector{Union{String, Nothing}}
35+
extended::Union{Nothing, Dict{Symbol, Union{Bool, Int, String}}}
36+
end
37+
38+
"""
39+
struct TermInfo
40+
41+
A parsed terminfo paired with capability information.
42+
43+
!!! warning
44+
This is not part of the public API, and thus subject to change without notice.
45+
46+
# Fields
47+
48+
- `names::Vector{String}`: The names this terminal is known by.
49+
- `flags::Int`: The number of flags specified.
50+
- `numbers::BitVector`: A mask indicating which of `TERM_NUMBERS` have been
51+
specified.
52+
- `strings::BitVector`: A mask indicating which of `TERM_STRINGS` have been
53+
specified.
54+
- `extensions::Vector{Symbol}`: A list of extended capability variable names.
55+
- `capabilities::Dict{Symbol, Union{Bool, Int, String}}`: The capability values
56+
themselves.
57+
58+
See also: `TermInfoRaw` and `TermCapability`.
59+
"""
60+
struct TermInfo
61+
names::Vector{String}
62+
flags::Int
63+
numbers::BitVector
64+
strings::BitVector
65+
extensions::Vector{Symbol}
66+
capabilities::Dict{Symbol, Union{Bool, Int, String}}
67+
end
68+
69+
TermInfo() = TermInfo([], 0, [], [], [], Dict())
70+
71+
function read(data::IO, ::Type{TermInfoRaw})
72+
# Parse according to `term(5)`
73+
# Header
74+
magic = read(data, UInt16) |> ltoh
75+
NumInt = if magic == 0o0432
76+
UInt16
77+
elseif magic == 0o01036
78+
UInt32
79+
else
80+
throw(ArgumentError("Terminfo data did not start with the magic number 0o0432 or 0o01036"))
81+
end
82+
name_bytes = read(data, UInt16) |> ltoh
83+
flag_bytes = read(data, UInt16) |> ltoh
84+
numbers_count = read(data, UInt16) |> ltoh
85+
string_count = read(data, UInt16) |> ltoh
86+
table_bytes = read(data, UInt16) |> ltoh
87+
# Terminal Names
88+
term_names = split(String(read(data, name_bytes - 1)), '|') .|> String
89+
0x00 == read(data, UInt8) ||
90+
throw(ArgumentError("Terminfo data did not contain a null byte after the terminal names section"))
91+
# Boolean Flags
92+
flags = read(data, flag_bytes) .== 0x01
93+
if position(data) % 2 != 0
94+
0x00 == read(data, UInt8) ||
95+
throw(ArgumentError("Terminfo did not contain a null byte after the flag section, expected to position the start of the numbers section on an even byte"))
96+
end
97+
# Numbers, Strings, Table
98+
numbers = reinterpret(NumInt, read(data, numbers_count * sizeof(NumInt))) .|> ltoh
99+
string_indices = reinterpret(UInt16, read(data, string_count * sizeof(UInt16))) .|> ltoh
100+
strings_table = read(data, table_bytes)
101+
strings = map(string_indices) do idx
102+
if idx (0xffff, 0xfffe)
103+
len = findfirst(==(0x00), view(strings_table, 1+idx:length(strings_table)))
104+
!isnothing(len) ||
105+
throw(ArgumentError("Terminfo string table entry does not terminate with a null byte"))
106+
String(strings_table[1+idx:idx+len-1])
107+
end
108+
end
109+
TermInfoRaw(term_names, flags, numbers, strings,
110+
if !eof(data) extendedterminfo(data; NumInt) end)
111+
end
112+
113+
"""
114+
extendedterminfo(data::IO; NumInt::Union{Type{UInt16}, Type{UInt32}})
115+
116+
Read an extended terminfo section from `data`, with `NumInt` as the numbers type.
117+
118+
This will accept any terminfo content that conforms with `term(5)`.
119+
120+
See also: `read(::IO, ::Type{TermInfoRaw})`
121+
"""
122+
function extendedterminfo(data::IO; NumInt::Union{Type{UInt16}, Type{UInt32}})
123+
# Extended info
124+
if position(data) % 2 != 0
125+
0x00 == read(data, UInt8) ||
126+
throw(ArgumentError("Terminfo did not contain a null byte before the extended section, expected to position the start on an even byte"))
127+
end
128+
# Extended header
129+
flag_bytes = read(data, UInt16) |> ltoh
130+
numbers_count = read(data, UInt16) |> ltoh
131+
string_count = read(data, UInt16) |> ltoh
132+
table_count = read(data, UInt16) |> ltoh
133+
table_bytes = read(data, UInt16) |> ltoh
134+
# Extended flags/numbers/strings
135+
flags = read(data, flag_bytes) .== 0x01
136+
if flag_bytes % 2 != 0
137+
0x00 == read(data, UInt8) ||
138+
throw(ArgumentError("Terminfo did not contain a null byte after the extended flag section, expected to position the start of the numbers section on an even byte"))
139+
end
140+
numbers = reinterpret(NumInt, read(data, numbers_count * sizeof(NumInt))) .|> ltoh
141+
table_indices = reinterpret(UInt16, read(data, table_count * sizeof(UInt16))) .|> ltoh
142+
table_strings = [String(readuntil(data, 0x00)) for _ in 1:length(table_indices)]
143+
strings = table_strings[1:string_count]
144+
labels = Symbol.(table_strings[string_count+1:end])
145+
Dict{Symbol, Union{Bool, Int, String}}(
146+
labels .=> vcat(flags, numbers, strings))
147+
end
148+
149+
"""
150+
TermInfo(raw::TermInfoRaw)
151+
152+
Construct a `TermInfo` from `raw`, using known terminal capabilities (as of
153+
NCurses 6.3, see `TERM_FLAGS`, `TERM_NUMBERS`, and `TERM_STRINGS`).
154+
"""
155+
function TermInfo(raw::TermInfoRaw)
156+
capabilities = Dict{Symbol, Union{Bool, Int, String}}()
157+
sizehint!(capabilities, 2 * (length(raw.flags) + length(raw.numbers) + length(raw.strings)))
158+
for (flag, value) in zip(TERM_FLAGS, raw.flags)
159+
capabilities[flag.short] = value
160+
capabilities[flag.long] = value
161+
end
162+
for (num, value) in zip(TERM_NUMBERS, raw.numbers)
163+
if value != typemax(eltype(raw.numbers))
164+
capabilities[num.short] = Int(value)
165+
capabilities[num.long] = Int(value)
166+
end
167+
end
168+
for (str, value) in zip(TERM_STRINGS, raw.strings)
169+
if !isnothing(value)
170+
capabilities[str.short] = value
171+
capabilities[str.long] = value
172+
end
173+
end
174+
extensions = if !isnothing(raw.extended)
175+
capabilities = merge(capabilities, raw.extended)
176+
keys(raw.extended) |> collect
177+
else
178+
Symbol[]
179+
end
180+
TermInfo(raw.names, length(raw.flags),
181+
raw.numbers .!= typemax(eltype(raw.numbers)),
182+
map(!isnothing, raw.strings),
183+
extensions, capabilities)
184+
end
185+
186+
getindex(ti::TermInfo, key::Symbol) = ti.capabilities[key]
187+
get(ti::TermInfo, key::Symbol, default::D) where D<:Union{Bool, Int, String} =
188+
get(ti.capabilities, key, default)::D
189+
get(ti::TermInfo, key::Symbol, default) = get(ti.capabilities, key, default)
190+
keys(ti::TermInfo) = keys(ti.capabilities)
191+
haskey(ti::TermInfo, key::Symbol) = haskey(ti.capabilities, key)
192+
193+
function show(io::IO, ::MIME"text/plain", ti::TermInfo)
194+
print(io, "TermInfo(", ti.names, "; ", ti.flags, " flags, ",
195+
sum(ti.numbers), " numbers, ", sum(ti.strings), " strings")
196+
!isempty(ti.extensions) > 0 &&
197+
print(io, ", ", length(ti.extensions), " extended capabilities")
198+
print(io, ')')
199+
end
200+
201+
"""
202+
find_terminfo_file(term::String)
203+
204+
Locate the terminfo file for `term`, return `nothing` if none could be found.
205+
206+
The lookup policy is described in `terminfo(5)` "Fetching Compiled
207+
Descriptions".
208+
"""
209+
function find_terminfo_file(term::String)
210+
isempty(term) && return
211+
chr, chrcode = string(first(term)), string(Int(first(term)), base=16)
212+
terminfo_dirs = if haskey(ENV, "TERMINFO")
213+
[ENV["TERMINFO"]]
214+
elseif isdir(joinpath(homedir(), ".terminfo"))
215+
[joinpath(homedir(), ".terminfo")]
216+
elseif haskey(ENV, "TERMINFO_DIRS")
217+
split(ENV["TERMINFO_DIRS"], ':')
218+
elseif Sys.isunix()
219+
["/usr/share/terminfo"]
220+
else
221+
String[]
222+
end
223+
for dir in terminfo_dirs
224+
if isfile(joinpath(dir, chr, term))
225+
return joinpath(dir, chr, term)
226+
elseif isfile(joinpath(dir, chrcode, term))
227+
return joinpath(dir, chrcode, term)
228+
end
229+
end
230+
end
231+
232+
"""
233+
load_terminfo(term::String)
234+
235+
Load the `TermInfo` for `term`, falling back on a blank `TermInfo`.
236+
"""
237+
function load_terminfo(term::String)
238+
file = find_terminfo_file(term)
239+
isnothing(file) && return TermInfo()
240+
try
241+
TermInfo(read(file, TermInfoRaw))
242+
catch err
243+
if err isa ArgumentError || err isa IOError
244+
TermInfo()
245+
else
246+
rethrow()
247+
end
248+
end
249+
end
250+
251+
"""
252+
The terminfo of the current terminal.
253+
"""
254+
current_terminfo::TermInfo = TermInfo()
255+
256+
# Legacy/TTY methods and the `:color` parameter
257+
258+
if Sys.iswindows()
259+
ttyhascolor(term_type = nothing) = true
260+
else
261+
function ttyhascolor(term_type = get(ENV, "TERM", ""))
262+
startswith(term_type, "xterm") ||
263+
haskey(current_terminfo, :setaf)
264+
end
265+
end
266+
267+
"""
268+
ttyhastruecolor()
269+
270+
Return a boolean signifying whether the current terminal supports 24-bit colors.
271+
272+
This uses the `COLORTERM` environment variable if possible, returning true if it
273+
is set to either `"truecolor"` or `"24bit"`.
274+
275+
As a fallback, first on unix systems the `colors` terminal capability is checked
276+
— should more than 256 colors be reported, this is taken to signify 24-bit
277+
support.
278+
"""
279+
function ttyhastruecolor()
280+
get(ENV, "COLORTERM", "") ("truecolor", "24bit") ||
281+
@static if Sys.isunix()
282+
get(current_terminfo, :colors, 0) > 256
283+
else
284+
false
285+
end
286+
end
287+
288+
function get_have_color()
289+
global have_color
290+
have_color === nothing && (have_color = ttyhascolor())
291+
return have_color::Bool
292+
end
293+
294+
function get_have_truecolor()
295+
global have_truecolor
296+
have_truecolor === nothing && (have_truecolor = ttyhastruecolor())
297+
return have_truecolor::Bool
298+
end
299+
300+
in(key_value::Pair{Symbol,Bool}, ::TTY) = key_value.first === :color && key_value.second === get_have_color()
301+
haskey(::TTY, key::Symbol) = key === :color
302+
getindex(::TTY, key::Symbol) = key === :color ? get_have_color() : throw(KeyError(key))
303+
get(::TTY, key::Symbol, default) = key === :color ? get_have_color() : default

0 commit comments

Comments
 (0)