Skip to main content

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}