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 it is an array type and decays to one, on aarch64 it is a 32-byte
63// struct, which AAPCS64 passes by reference, on riscv64 it is `void*`
64// already, and on arm and i686 it is a single pointer-sized value.
65unsafe extern "C" {
66    fn libinput_log_set_handler(libinput: *mut c_void, handler: Option<Handler>);
67    fn libinput_log_set_priority(libinput: *mut c_void, priority: c_int);
68    fn vsnprintf(out: *mut c_char, len: usize, format: *const c_char, args: *mut c_void) -> c_int;
69}
70
71type Handler = unsafe extern "C" fn(
72    libinput: *mut c_void,
73    priority: c_int,
74    format: *const c_char,
75    args: *mut c_void,
76);
77
78/// Send this context's diagnostics to drmkit's log, at or above `priority`.
79///
80/// # Safety
81///
82/// `libinput` must be a live `struct libinput*`. The handler outlives every
83/// context, so it does not have to be removed before one is dropped.
84pub(crate) unsafe fn install(libinput: *mut c_void, priority: LogPriority) {
85    // SAFETY: the caller guarantees the pointer. `trampoline` matches
86    // libinput_log_handler's signature, and the pointer it is handed back is
87    // never dereferenced.
88    unsafe {
89        libinput_log_set_handler(libinput, Some(trampoline));
90        libinput_log_set_priority(libinput, priority.raw());
91    }
92}
93
94/// What libinput calls.
95///
96/// A panic here would cross a C frame. Nothing in it can panic -- the
97/// formatting is bounded and the forwarding takes a `&str` -- and an
98/// `extern "C"` function aborts rather than unwinding if one somehow did.
99unsafe extern "C" fn trampoline(
100    _libinput: *mut c_void,
101    priority: c_int,
102    format: *const c_char,
103    args: *mut c_void,
104) {
105    if format.is_null() {
106        return;
107    }
108    let mut buffer = [0_u8; BUFFER];
109    // SAFETY: `format` is libinput's own format string and `args` the matching
110    // list, both live for this call. The buffer is what its length says, and
111    // vsnprintf always terminates within it. The list is consumed once:
112    // libinput's own `va_end` follows this return, and nothing else reads it.
113    let written = unsafe {
114        vsnprintf(
115            buffer.as_mut_ptr().cast::<c_char>(),
116            buffer.len(),
117            format,
118            args,
119        )
120    };
121    let Ok(written) = usize::try_from(written) else {
122        // Negative means an output error, which for a fixed buffer means a
123        // format string vsnprintf could not handle. Nothing to report.
124        return;
125    };
126    // vsnprintf returns the length it *would* have written.
127    let written = written.min(buffer.len() - 1);
128    forward(priority, &buffer[..written]);
129}
130
131/// Hand one rendered message to drmkit's log.
132fn forward(priority: c_int, message: &[u8]) {
133    let message = String::from_utf8_lossy(message);
134    let message = trim(&message);
135    if message.is_empty() {
136        return;
137    }
138    match classify(priority) {
139        LogPriority::Debug => drmkit_log::log_debug!("[libinput] {message}"),
140        LogPriority::Info => drmkit_log::log_info!("[libinput] {message}"),
141        LogPriority::Error => drmkit_log::log_error!("[libinput] {message}"),
142    }
143}
144
145/// Which band a raw libinput priority falls in.
146///
147/// Bands rather than exact values: libinput's are spaced ten apart precisely
148/// so more can be added between them, and an unknown one lands in the band at
149/// or above it rather than being dropped.
150fn classify(priority: c_int) -> LogPriority {
151    if priority <= PRIORITY_DEBUG {
152        LogPriority::Debug
153    } else if priority <= PRIORITY_INFO {
154        LogPriority::Info
155    } else {
156        LogPriority::Error
157    }
158}
159
160/// Strip the line ending libinput puts on every message.
161///
162/// A structured sink adds its own framing, and a message that arrives with a
163/// newline already on it produces a blank line in the middle of the log.
164fn trim(message: &str) -> &str {
165    message.trim_end_matches(['\n', '\r'])
166}
167
168#[cfg(test)]
169mod tests {
170    use super::{LogPriority, classify, trim};
171
172    #[test]
173    fn each_libinput_priority_lands_where_it_should() {
174        assert_eq!(classify(10), LogPriority::Debug);
175        assert_eq!(classify(20), LogPriority::Info);
176        assert_eq!(classify(30), LogPriority::Error);
177    }
178
179    #[test]
180    fn a_priority_between_the_known_ones_lands_at_the_nearest() {
181        // libinput spaced them ten apart so it could add more. One that
182        // arrives from a newer library should be reported, not dropped.
183        assert_eq!(classify(1), LogPriority::Debug);
184        assert_eq!(classify(15), LogPriority::Info);
185        assert_eq!(classify(25), LogPriority::Error);
186        assert_eq!(classify(999), LogPriority::Error);
187    }
188
189    #[test]
190    fn the_trailing_newline_comes_off() {
191        assert_eq!(trim("event5 - lagging\n"), "event5 - lagging");
192        assert_eq!(trim("windows\r\n"), "windows");
193        assert_eq!(trim("several\n\n\n"), "several");
194        assert_eq!(trim("none"), "none");
195        assert_eq!(trim("\n"), "");
196    }
197
198    #[test]
199    fn a_newline_inside_the_message_stays() {
200        assert_eq!(trim("two\nlines\n"), "two\nlines");
201    }
202}