drmkit_input/libinput_log.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Routing libinput's own diagnostics into drmkit's log.
5//!
6//! libinput talks: device add and remove, quirk-parse failures, `client bug:
7//! event processing lagging`. Left alone it writes them to its own stderr
8//! handler, which escapes [`drmkit_log::set_log_sink`] and any sink a
9//! consumer installed. So a handler is always installed, and everything it
10//! gets is forwarded with a `[libinput]` tag.
11//!
12//! # Why this is hand-written
13//!
14//! `input-sys` blocklists `libinput_log_set_handler` -- `build.rs` line 88 --
15//! and keeps the rest of the family. That is the right call for a bindgen
16//! crate: the callback takes a `va_list`, which bindgen cannot express
17//! portably. The fix belongs on this side, and it is small.
18
19use std::ffi::{c_char, c_int, c_void};
20
21/// libinput's own thresholds, from `libinput.h`.
22const PRIORITY_DEBUG: c_int = 10;
23const PRIORITY_INFO: c_int = 20;
24const PRIORITY_ERROR: c_int = 30;
25
26/// The most of one message that is kept.
27///
28/// libinput's longest are quirk-parse failures naming a file and a line, well
29/// inside this. Matches the reference's buffer so a message that is truncated
30/// is truncated the same way.
31const BUFFER: usize = 1024;
32
33/// How much libinput should say before this is called at all.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[non_exhaustive]
36pub enum LogPriority {
37 /// Everything, including per-event detail.
38 Debug,
39 /// Device add and remove, and configuration.
40 Info,
41 /// Failures only. libinput's own default, and this one.
42 #[default]
43 Error,
44}
45
46impl LogPriority {
47 const fn raw(self) -> c_int {
48 match self {
49 Self::Debug => PRIORITY_DEBUG,
50 Self::Info => PRIORITY_INFO,
51 Self::Error => PRIORITY_ERROR,
52 }
53 }
54}
55
56// The two calls this needs. `libinput_log_set_priority` is in `input-sys`,
57// but naming it here as well keeps the pair together and this module
58// independent of which version of the bindings is resolved.
59//
60// `struct libinput*` is passed as an opaque pointer either way, and the
61// `va_list` argument is a pointer on every Linux ABI drmkit targets: on
62// x86-64 and aarch64 it is an array type and decays to one, on riscv64 it is
63// `void*` already, and on arm and i686 it is a single pointer-sized value.
64unsafe extern "C" {
65 fn libinput_log_set_handler(libinput: *mut c_void, handler: Option<Handler>);
66 fn libinput_log_set_priority(libinput: *mut c_void, priority: c_int);
67 fn vsnprintf(out: *mut c_char, len: usize, format: *const c_char, args: *mut c_void) -> c_int;
68}
69
70type Handler = unsafe extern "C" fn(
71 libinput: *mut c_void,
72 priority: c_int,
73 format: *const c_char,
74 args: *mut c_void,
75);
76
77/// Send this context's diagnostics to drmkit's log, at or above `priority`.
78///
79/// # Safety
80///
81/// `libinput` must be a live `struct libinput*`. The handler outlives every
82/// context, so it does not have to be removed before one is dropped.
83pub(crate) unsafe fn install(libinput: *mut c_void, priority: LogPriority) {
84 // SAFETY: the caller guarantees the pointer. `trampoline` matches
85 // libinput_log_handler's signature, and the pointer it is handed back is
86 // never dereferenced.
87 unsafe {
88 libinput_log_set_handler(libinput, Some(trampoline));
89 libinput_log_set_priority(libinput, priority.raw());
90 }
91}
92
93/// What libinput calls.
94///
95/// A panic here would cross a C frame. Nothing in it can panic -- the
96/// formatting is bounded and the forwarding takes a `&str` -- and an
97/// `extern "C"` function aborts rather than unwinding if one somehow did.
98unsafe extern "C" fn trampoline(
99 _libinput: *mut c_void,
100 priority: c_int,
101 format: *const c_char,
102 args: *mut c_void,
103) {
104 if format.is_null() {
105 return;
106 }
107 let mut buffer = [0_u8; BUFFER];
108 // SAFETY: `format` is libinput's own format string and `args` the matching
109 // list, both live for this call. The buffer is what its length says, and
110 // vsnprintf always terminates within it. The list is consumed once:
111 // libinput's own `va_end` follows this return, and nothing else reads it.
112 let written = unsafe {
113 vsnprintf(
114 buffer.as_mut_ptr().cast::<c_char>(),
115 buffer.len(),
116 format,
117 args,
118 )
119 };
120 let Ok(written) = usize::try_from(written) else {
121 // Negative means an output error, which for a fixed buffer means a
122 // format string vsnprintf could not handle. Nothing to report.
123 return;
124 };
125 // vsnprintf returns the length it *would* have written.
126 let written = written.min(buffer.len() - 1);
127 forward(priority, &buffer[..written]);
128}
129
130/// Hand one rendered message to drmkit's log.
131fn forward(priority: c_int, message: &[u8]) {
132 let message = String::from_utf8_lossy(message);
133 let message = trim(&message);
134 if message.is_empty() {
135 return;
136 }
137 match classify(priority) {
138 LogPriority::Debug => drmkit_log::log_debug!("[libinput] {message}"),
139 LogPriority::Info => drmkit_log::log_info!("[libinput] {message}"),
140 LogPriority::Error => drmkit_log::log_error!("[libinput] {message}"),
141 }
142}
143
144/// Which band a raw libinput priority falls in.
145///
146/// Bands rather than exact values: libinput's are spaced ten apart precisely
147/// so more can be added between them, and an unknown one should land at the
148/// nearest level rather than be dropped.
149fn classify(priority: c_int) -> LogPriority {
150 if priority <= PRIORITY_DEBUG {
151 LogPriority::Debug
152 } else if priority <= PRIORITY_INFO {
153 LogPriority::Info
154 } else {
155 LogPriority::Error
156 }
157}
158
159/// Strip the line ending libinput puts on every message.
160///
161/// A structured sink adds its own framing, and a message that arrives with a
162/// newline already on it produces a blank line in the middle of the log.
163fn trim(message: &str) -> &str {
164 message.trim_end_matches(['\n', '\r'])
165}
166
167#[cfg(test)]
168mod tests {
169 use super::{LogPriority, classify, trim};
170
171 #[test]
172 fn each_libinput_priority_lands_where_it_should() {
173 assert_eq!(classify(10), LogPriority::Debug);
174 assert_eq!(classify(20), LogPriority::Info);
175 assert_eq!(classify(30), LogPriority::Error);
176 }
177
178 #[test]
179 fn a_priority_between_the_known_ones_lands_at_the_nearest() {
180 // libinput spaced them ten apart so it could add more. One that
181 // arrives from a newer library should be reported, not dropped.
182 assert_eq!(classify(1), LogPriority::Debug);
183 assert_eq!(classify(15), LogPriority::Info);
184 assert_eq!(classify(25), LogPriority::Error);
185 assert_eq!(classify(999), LogPriority::Error);
186 }
187
188 #[test]
189 fn the_trailing_newline_comes_off() {
190 assert_eq!(trim("event5 - lagging\n"), "event5 - lagging");
191 assert_eq!(trim("windows\r\n"), "windows");
192 assert_eq!(trim("several\n\n\n"), "several");
193 assert_eq!(trim("none"), "none");
194 assert_eq!(trim("\n"), "");
195 }
196
197 #[test]
198 fn a_newline_inside_the_message_stays() {
199 assert_eq!(trim("two\nlines\n"), "two\nlines");
200 }
201}