All of lore.kernel.org
 help / color / mirror / Atom feed
From: Sasha Levin <sashal@kernel.org>
To: linux-api@vger.kernel.org, linux-kernel@vger.kernel.org
Cc: Sasha Levin <sashal@kernel.org>,
	linux-doc@vger.kernel.org, linux-fsdevel@vger.kernel.org,
	linux-kbuild@vger.kernel.org, linux-kselftest@vger.kernel.org,
	workflows@vger.kernel.org, tools@kernel.org, x86@kernel.org,
	Thomas Gleixner <tglx@kernel.org>,
	"Paul E . McKenney" <paulmck@kernel.org>,
	Greg Kroah-Hartman <gregkh@linuxfoundation.org>,
	Jonathan Corbet <corbet@lwn.net>,
	Dmitry Vyukov <dvyukov@google.com>,
	Randy Dunlap <rdunlap@infradead.org>,
	Cyril Hrubis <chrubis@suse.cz>, Kees Cook <kees@kernel.org>,
	Jake Edge <jake@lwn.net>,
	David Laight <david.laight.linux@gmail.com>,
	Gabriele Paoloni <gpaoloni@redhat.com>,
	Mauro Carvalho Chehab <mchehab@kernel.org>,
	Christian Brauner <brauner@kernel.org>,
	Alexander Viro <viro@zeniv.linux.org.uk>,
	Andrew Morton <akpm@linux-foundation.org>,
	Masahiro Yamada <masahiroy@kernel.org>,
	Shuah Khan <skhan@linuxfoundation.org>,
	Arnd Bergmann <arnd@arndb.de>,
	Nathan Chancellor <nathan@kernel.org>,
	Steven Rostedt <rostedt@goodmis.org>,
	Masami Hiramatsu <mhiramat@kernel.org>,
	Mathieu Desnoyers <mathieu.desnoyers@efficios.com>
Subject: [PATCH v5 04/11] tools/kapi: add kernel API specification extraction tool
Date: Thu,  8 Oct 2026 04:49:44 -0400	[thread overview]
Message-ID: <20261008084956.2911790-5-sashal@kernel.org> (raw)
In-Reply-To: <20261008084956.2911790-1-sashal@kernel.org>

The kapi tool extracts and renders kernel API specifications from
three input sources and emits them in one of three output formats:

  Input modes:
    --source PATH    parse kerneldoc blocks from a C source file or
                     directory
    --vmlinux PATH   decode the `.kapi_specs` ELF section from a
                     compiled kernel binary
    --debugfs PATH   read the spec dumps exposed under
                     /sys/kernel/debug/kapi/ on a running kernel

  Output formats: plain, json, rst

The tool is written in Rust and builds with cargo from the crates
pinned in Cargo.lock; the resulting binary has no runtime
dependencies. It ships alongside the kernel to give documentation
tools, static analyzers, and IDE integrations a single entry point for
querying the spec data produced by the framework.

Assisted-by: LLM
Signed-off-by: Sasha Levin <sashal@kernel.org>
---
 Documentation/dev-tools/kernel-api-spec.rst   |   74 +-
 tools/kapi/.gitignore                         |    3 +
 tools/kapi/Cargo.lock                         |  679 ++++
 tools/kapi/Cargo.toml                         |   20 +
 tools/kapi/Makefile                           |   33 +
 tools/kapi/README.md                          |   33 +
 tools/kapi/src/extractor/debugfs.rs           | 1704 +++++++++
 tools/kapi/src/extractor/kerneldoc_parser.rs  | 3335 +++++++++++++++++
 tools/kapi/src/extractor/mod.rs               |  442 +++
 tools/kapi/src/extractor/source_parser.rs     |  531 +++
 .../src/extractor/vmlinux/binary_utils.rs     |  461 +++
 tools/kapi/src/extractor/vmlinux/mod.rs       | 1160 ++++++
 tools/kapi/src/formatter/json.rs              |  659 ++++
 tools/kapi/src/formatter/mod.rs               |  220 ++
 tools/kapi/src/formatter/plain.rs             |  679 ++++
 tools/kapi/src/formatter/rst.rs               |  802 ++++
 tools/kapi/src/main.rs                        |  123 +
 17 files changed, 10886 insertions(+), 72 deletions(-)
 create mode 100644 tools/kapi/.gitignore
 create mode 100644 tools/kapi/Cargo.lock
 create mode 100644 tools/kapi/Cargo.toml
 create mode 100644 tools/kapi/Makefile
 create mode 100644 tools/kapi/README.md
 create mode 100644 tools/kapi/src/extractor/debugfs.rs
 create mode 100644 tools/kapi/src/extractor/kerneldoc_parser.rs
 create mode 100644 tools/kapi/src/extractor/mod.rs
 create mode 100644 tools/kapi/src/extractor/source_parser.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/binary_utils.rs
 create mode 100644 tools/kapi/src/extractor/vmlinux/mod.rs
 create mode 100644 tools/kapi/src/formatter/json.rs
 create mode 100644 tools/kapi/src/formatter/mod.rs
 create mode 100644 tools/kapi/src/formatter/plain.rs
 create mode 100644 tools/kapi/src/formatter/rst.rs
 create mode 100644 tools/kapi/src/main.rs

diff --git a/Documentation/dev-tools/kernel-api-spec.rst b/Documentation/dev-tools/kernel-api-spec.rst
index fa8aabb92a503..95b72060f8ef8 100644
--- a/Documentation/dev-tools/kernel-api-spec.rst
+++ b/Documentation/dev-tools/kernel-api-spec.rst
@@ -31,7 +31,8 @@ The framework aims to:
    common programming errors during development and testing.
 
 3. **Support Tooling**: Export API specifications in machine-readable formats for
-   use by static analyzers, documentation generators, and development tools.
+   use by static analyzers, documentation generators, and development tools. See
+   `The kapi Tool`_.
 
 4. **Formalize Contracts**: Explicitly document API contracts including parameter
    constraints, execution contexts, locking requirements, and side effects.
@@ -573,77 +574,6 @@ The tool supports all KAPI specification types:
 - System calls (kerneldoc annotations)
 - Kernel functions (kerneldoc annotations with KAPI tags)
 
-IDE Integration
----------------
-
-Modern IDEs can use the specification data for:
-
-- Parameter hints
-- Type checking
-- Context validation
-- Error code documentation
-
-Best Practices
-==============
-
-Writing Specifications
-----------------------
-
-1. **Be Comprehensive**: Document all parameters, errors, and side effects
-2. **Keep Updated**: Update specs when API behavior changes
-3. **Use Examples**: Include usage examples in descriptions
-4. **Validate Constraints**: Define realistic constraints for parameters
-5. **Document Context**: Clearly specify allowed execution contexts
-
-Maintenance
------------
-
-1. **Version Specifications**: Increment version when API changes
-2. **Deprecation**: Mark deprecated APIs and suggest replacements
-3. **Cross-reference**: Link related APIs in descriptions
-4. **Test Specifications**: Verify specs match implementation
-
-Common Patterns
----------------
-
-**Optional Parameters**:
-
-.. code-block:: c
-
-    /**
-     * @optional_arg: Optional argument (may be NULL)
-     *
-     * param: optional_arg
-     *   type: KAPI_TYPE_PTR
-     *   flags: KAPI_PARAM_IN | KAPI_PARAM_OPTIONAL
-     */
-
-**Buffer with Size Parameter**:
-
-.. code-block:: c
-
-    /**
-     * @buf: User-space buffer
-     *
-     * param: buf
-     *   type: KAPI_TYPE_USER_PTR
-     *   flags: KAPI_PARAM_OUT | KAPI_PARAM_USER
-     *   constraint-type: KAPI_CONSTRAINT_BUFFER
-     *   size-param: 2
-     */
-
-**Callback Functions**:
-
-.. code-block:: c
-
-    /**
-     * @callback: Callback function
-     *
-     * param: callback
-     *   type: KAPI_TYPE_FUNC_PTR
-     *   flags: KAPI_PARAM_IN
-     */
-
 Troubleshooting
 ===============
 
diff --git a/tools/kapi/.gitignore b/tools/kapi/.gitignore
new file mode 100644
index 0000000000000..fa95032ed6c2a
--- /dev/null
+++ b/tools/kapi/.gitignore
@@ -0,0 +1,3 @@
+# Rust build artifacts
+/target/
+**/*.rs.bk
diff --git a/tools/kapi/Cargo.lock b/tools/kapi/Cargo.lock
new file mode 100644
index 0000000000000..23d4ef8b910d2
--- /dev/null
+++ b/tools/kapi/Cargo.lock
@@ -0,0 +1,679 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "goblin"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "kapi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "goblin",
+ "regex",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "walkdir",
+]
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.185"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scroll"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/tools/kapi/Cargo.toml b/tools/kapi/Cargo.toml
new file mode 100644
index 0000000000000..159e65d324468
--- /dev/null
+++ b/tools/kapi/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "kapi"
+version = "0.1.0"
+edition = "2021"
+rust-version = "1.85"
+authors = ["Sasha Levin <sashal@kernel.org>"]
+description = "Tool for extracting and displaying kernel API specifications"
+license = "GPL-2.0"
+
+[dependencies]
+goblin = "0.10"
+clap = { version = "4.4", features = ["derive"] }
+anyhow = "1.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+regex = "1.10"
+walkdir = "2.4"
+
+[dev-dependencies]
+tempfile = "3.8"
diff --git a/tools/kapi/Makefile b/tools/kapi/Makefile
new file mode 100644
index 0000000000000..d4234538e4eee
--- /dev/null
+++ b/tools/kapi/Makefile
@@ -0,0 +1,33 @@
+# SPDX-License-Identifier: GPL-2.0
+# Makefile wrapper for the kapi tool (Rust userspace binary).
+#
+# See Documentation/dev-tools/kernel-api-spec.rst for details.
+
+PREFIX ?= /usr/local
+
+.PHONY: all build release debug clean install test fmt clippy
+
+all: release
+
+release:
+	cargo build --release
+
+build: release
+
+debug:
+	cargo build
+
+test:
+	cargo test
+
+fmt:
+	cargo fmt --all -- --check
+
+clippy:
+	cargo clippy --all-targets --all-features -- -D warnings
+
+clean:
+	cargo clean
+
+install: release
+	install -D -m 0755 target/release/kapi $(DESTDIR)$(PREFIX)/bin/kapi
diff --git a/tools/kapi/README.md b/tools/kapi/README.md
new file mode 100644
index 0000000000000..3839f3ff2964a
--- /dev/null
+++ b/tools/kapi/README.md
@@ -0,0 +1,33 @@
+# kapi — Kernel API Specification Extractor
+
+Userspace utility that extracts and displays kernel API specifications from
+three sources:
+
+- `--source PATH` — parse kerneldoc blocks in a C source file or tree
+- `--vmlinux PATH` — decode the `.kapi_specs` ELF section of a compiled vmlinux
+- `--debugfs PATH` — read the live specs from `/sys/kernel/debug/kapi/` on a
+  running kernel (this is also the mode used when no input option is given, in
+  which case PATH is `/sys/kernel/debug`)
+
+Output formats: `plain` (default), `json`, `rst`.
+
+See `Documentation/dev-tools/kernel-api-spec.rst` for the full user guide,
+including the kerneldoc DSL reference and the surrounding framework design.
+
+## Build
+
+```
+make -C tools/kapi
+```
+
+(wraps `cargo build --release`; the binary is produced at
+`tools/kapi/target/release/kapi`).
+
+## Usage
+
+```
+tools/kapi/target/release/kapi --help
+tools/kapi/target/release/kapi --source fs/open.c sys_open
+tools/kapi/target/release/kapi --vmlinux vmlinux -f json
+tools/kapi/target/release/kapi --debugfs /sys/kernel/debug
+```
diff --git a/tools/kapi/src/extractor/debugfs.rs b/tools/kapi/src/extractor/debugfs.rs
new file mode 100644
index 0000000000000..0a7e41e1c40ca
--- /dev/null
+++ b/tools/kapi/src/extractor/debugfs.rs
@@ -0,0 +1,1704 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::formatter::OutputFormatter;
+use anyhow::{bail, Context, Result};
+use serde::Deserialize;
+use std::fs;
+use std::io::Write;
+use std::path::PathBuf;
+
+use super::{
+    display_api_spec, ApiExtractor, ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec,
+    ParamSpec, ReturnSpec, SignalMaskSpec, StateTransitionSpec, StructFieldSpec, StructSpec,
+};
+
+// Schema matching what kapi_export_json() in the kernel emits.  The kernel
+// serialises several enum-like fields as hex strings ("0x%x") or token
+// strings ("exact", "process"); we keep them as Option<String> here and
+// interpret them during conversion.
+#[derive(Deserialize)]
+struct KernelApiJson {
+    name: String,
+    #[serde(default)]
+    api_type: Option<String>,
+    #[serde(default)]
+    version: Option<u32>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    long_description: Option<String>,
+    #[serde(default)]
+    context_flags: Option<String>,
+    #[serde(default)]
+    examples: Option<String>,
+    #[serde(default)]
+    notes: Option<String>,
+    #[serde(default)]
+    capabilities: Option<Vec<KernelCapabilityJson>>,
+    #[serde(default)]
+    parameters: Option<Vec<KernelParamJson>>,
+    #[serde(default)]
+    errors: Option<Vec<KernelErrorJson>>,
+    #[serde(default, rename = "return")]
+    return_spec: Option<KernelReturnJson>,
+    #[serde(default)]
+    locks: Option<Vec<KernelLockJson>>,
+    #[serde(default)]
+    constraints: Option<Vec<KernelConstraintJson>>,
+    #[serde(default)]
+    signals: Option<Vec<KernelSignalJson>>,
+    #[serde(default)]
+    side_effects: Option<Vec<KernelSideEffectJson>>,
+    #[serde(default)]
+    state_transitions: Option<Vec<KernelStateTransitionJson>>,
+    #[serde(default)]
+    signal_masks: Option<Vec<KernelSignalMaskJson>>,
+    #[serde(default)]
+    struct_specs: Option<Vec<KernelStructJson>>,
+}
+
+#[derive(Deserialize)]
+struct KernelStateTransitionJson {
+    #[serde(default)]
+    object: Option<String>,
+    #[serde(default)]
+    from_state: Option<String>,
+    #[serde(default)]
+    to_state: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSignalMaskJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    signals: Vec<i32>,
+}
+
+#[derive(Deserialize)]
+struct KernelStructFieldJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    offset: usize,
+    #[serde(default)]
+    size: usize,
+    #[serde(default)]
+    flags: Option<String>,
+    #[serde(default)]
+    constraint_type: Option<String>,
+    #[serde(default)]
+    min_value: i64,
+    #[serde(default)]
+    max_value: i64,
+    #[serde(default)]
+    valid_mask: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelStructJson {
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    size: usize,
+    #[serde(default)]
+    alignment: usize,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    fields: Vec<KernelStructFieldJson>,
+}
+
+#[derive(Deserialize)]
+struct KernelConstraintJson {
+    name: String,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    expression: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSignalJson {
+    #[serde(default)]
+    signal_num: i32,
+    #[serde(default)]
+    signal_name: Option<String>,
+    #[serde(default)]
+    direction: Option<String>,
+    #[serde(default)]
+    action: u32,
+    #[serde(default)]
+    target: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    restartable: bool,
+    #[serde(default)]
+    sa_flags_required: Option<String>,
+    #[serde(default)]
+    sa_flags_forbidden: Option<String>,
+    #[serde(default)]
+    error_on_signal: i32,
+    #[serde(default)]
+    transform_to: i32,
+    #[serde(default)]
+    timing: Option<String>,
+    #[serde(default)]
+    priority: u32,
+    #[serde(default)]
+    interruptible: bool,
+    #[serde(default)]
+    queue_behavior: Option<String>,
+    #[serde(default)]
+    state_required: Option<String>,
+    #[serde(default)]
+    state_forbidden: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelSideEffectJson {
+    #[serde(rename = "type", default)]
+    type_hex: Option<String>,
+    #[serde(default)]
+    target: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    reversible: bool,
+}
+
+#[derive(Deserialize)]
+struct KernelParamJson {
+    name: String,
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    flags: Option<String>,
+    #[serde(default)]
+    constraint_type: Option<String>,
+    #[serde(default)]
+    constraint_desc: Option<String>,
+    #[serde(default)]
+    min_value: Option<i64>,
+    #[serde(default)]
+    max_value: Option<i64>,
+    #[serde(default)]
+    valid_mask: Option<String>,
+    #[serde(default)]
+    enum_values: Vec<i64>,
+    #[serde(default)]
+    size: Option<u64>,
+    #[serde(default)]
+    alignment: Option<u64>,
+    #[serde(default)]
+    size_param_idx: Option<u32>,
+}
+
+#[derive(Deserialize)]
+struct KernelErrorJson {
+    #[serde(rename = "code")]
+    error_code: i32,
+    #[serde(default)]
+    name: Option<String>,
+    #[serde(default)]
+    condition: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelReturnJson {
+    #[serde(rename = "type", default)]
+    type_name: Option<String>,
+    #[serde(default)]
+    type_class: Option<String>,
+    #[serde(default)]
+    check_type: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    success_value: Option<i64>,
+    #[serde(default)]
+    success_min: Option<i64>,
+    #[serde(default)]
+    success_max: Option<i64>,
+    #[serde(default)]
+    error_values: Vec<i64>,
+}
+
+#[derive(Deserialize)]
+struct KernelLockJson {
+    name: String,
+    #[serde(rename = "type", default)]
+    lock_type: Option<String>,
+    #[serde(default)]
+    scope: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+}
+
+#[derive(Deserialize)]
+struct KernelCapabilityJson {
+    capability: i32,
+    name: String,
+    action: String,
+    allows: String,
+    without_cap: String,
+    check_condition: Option<String>,
+    priority: Option<u8>,
+    alternatives: Option<Vec<i32>>,
+}
+
+/// Free-text fields of the text dump that may span several lines.
+#[derive(Clone, Copy)]
+enum TextField {
+    Description,
+    LongDescription,
+    Examples,
+    Notes,
+}
+
+impl TextField {
+    /// Recognise the header line of a text field, returning the field and
+    /// whatever follows the colon on the same line.
+    fn start(line: &str) -> Option<(Self, &str)> {
+        [
+            ("Description:", Self::Description),
+            ("Long description:", Self::LongDescription),
+            ("Examples:", Self::Examples),
+            ("Notes:", Self::Notes),
+        ]
+        .into_iter()
+        .find_map(|(header, field)| {
+            line.strip_prefix(header)
+                .map(|rest| (field, rest.trim_start()))
+        })
+    }
+
+    fn store(self, spec: &mut ApiSpec, lines: &[String]) {
+        let text = lines.join("\n");
+        let text = text.trim_matches('\n').trim_end().to_string();
+        let slot = match self {
+            Self::Description => &mut spec.description,
+            Self::LongDescription => &mut spec.long_description,
+            Self::Examples => &mut spec.examples,
+            Self::Notes => &mut spec.notes,
+        };
+        *slot = Some(text);
+    }
+}
+
+/// Extractor for kernel API specifications from debugfs
+pub struct DebugfsExtractor {
+    debugfs_path: PathBuf,
+}
+
+impl DebugfsExtractor {
+    /// Create a new debugfs extractor with the specified debugfs path
+    pub fn new(debugfs_path: Option<String>) -> Result<Self> {
+        let path = match debugfs_path {
+            Some(p) => PathBuf::from(p),
+            None => PathBuf::from("/sys/kernel/debug"),
+        };
+
+        // Check if the debugfs path exists
+        if !path.exists() {
+            bail!("Debugfs path does not exist: {}", path.display());
+        }
+
+        // Check if kapi directory exists
+        let kapi_path = path.join("kapi");
+        if !kapi_path.exists() {
+            bail!(
+                "Kernel API debugfs interface not found at: {}",
+                kapi_path.display()
+            );
+        }
+
+        Ok(Self { debugfs_path: path })
+    }
+
+    /// Parse the list file to get all available API names
+    fn parse_list_file(&self) -> Result<Vec<String>> {
+        let list_path = self.debugfs_path.join("kapi/list");
+        let content = fs::read_to_string(&list_path)
+            .with_context(|| format!("Failed to read {}", list_path.display()))?;
+
+        let mut apis = Vec::new();
+        let mut in_list = false;
+
+        for line in content.lines() {
+            if line.contains("===") {
+                in_list = true;
+                continue;
+            }
+
+            if in_list && line.starts_with("Total:") {
+                break;
+            }
+
+            if in_list && !line.trim().is_empty() {
+                // Extract API name from lines like "sys_read - Read from a file descriptor"
+                if let Some(name) = line.split(" - ").next() {
+                    apis.push(name.trim().to_string());
+                }
+            }
+        }
+
+        Ok(apis)
+    }
+
+    /// Convert context flags (emitted by the kernel as a hex string like
+    /// "0x21") into the token list consumed by the formatter.
+    fn parse_context_flags(flags: &str) -> Vec<String> {
+        let mut result = Vec::new();
+        let bits = flags
+            .strip_prefix("0x")
+            .or_else(|| flags.strip_prefix("0X"))
+            .unwrap_or(flags);
+        let Ok(flags) = u32::from_str_radix(bits, 16) else {
+            return result;
+        };
+
+        // These values should match KAPI_CTX_* flags from kernel
+        if flags & (1 << 0) != 0 {
+            result.push("KAPI_CTX_PROCESS".to_string());
+        }
+        if flags & (1 << 1) != 0 {
+            result.push("KAPI_CTX_SOFTIRQ".to_string());
+        }
+        if flags & (1 << 2) != 0 {
+            result.push("KAPI_CTX_HARDIRQ".to_string());
+        }
+        if flags & (1 << 3) != 0 {
+            result.push("KAPI_CTX_NMI".to_string());
+        }
+        if flags & (1 << 4) != 0 {
+            result.push("KAPI_CTX_ATOMIC".to_string());
+        }
+        if flags & (1 << 5) != 0 {
+            result.push("KAPI_CTX_SLEEPABLE".to_string());
+        }
+        if flags & (1 << 6) != 0 {
+            result.push("KAPI_CTX_PREEMPT_DISABLED".to_string());
+        }
+        if flags & (1 << 7) != 0 {
+            result.push("KAPI_CTX_IRQ_DISABLED".to_string());
+        }
+
+        result
+    }
+
+    /// Parse a hex-string like "0x123" into u64, returning 0 on failure.
+    fn parse_hex_u64(value: &str) -> u64 {
+        let bits = value
+            .strip_prefix("0x")
+            .or_else(|| value.strip_prefix("0X"))
+            .unwrap_or(value);
+        u64::from_str_radix(bits, 16).unwrap_or(0)
+    }
+
+    /// Parse a hex-string like "0x123" into u32, returning 0 on failure.
+    fn parse_hex_u32(value: &str) -> u32 {
+        u32::try_from(Self::parse_hex_u64(value)).unwrap_or(0)
+    }
+
+    /// Map the type-class token emitted by the kernel (param_type_to_string)
+    /// back to the numeric enum kapi_param_type.
+    fn parse_type_class(token: &str) -> u32 {
+        match token {
+            "void" => 0,
+            "int" => 1,
+            "uint" => 2,
+            "pointer" => 3,
+            "struct" => 4,
+            "union" => 5,
+            "enum" => 6,
+            "function_pointer" => 7,
+            "array" => 8,
+            "file_descriptor" => 9,
+            "user_pointer" => 10,
+            "pathname" => 11,
+            "custom" => 12,
+            _ => 0,
+        }
+    }
+
+    /// Map the constraint-type token emitted by the kernel
+    /// (constraint_type_to_string) back to the numeric enum
+    /// kapi_constraint_type.
+    fn parse_constraint_type(token: &str) -> u32 {
+        match token {
+            "none" => 0,
+            "range" => 1,
+            "mask" => 2,
+            "enum" => 3,
+            "alignment" => 4,
+            "power_of_two" => 5,
+            "page_aligned" => 6,
+            "nonzero" => 7,
+            "user_string" => 8,
+            "user_path" => 9,
+            "user_ptr" => 10,
+            "buffer" => 11,
+            "custom" => 12,
+            _ => 0,
+        }
+    }
+
+    /// Derive the API type from the symbol name, the same way the vmlinux
+    /// extractor does.
+    fn api_type_from_name(name: &str) -> &'static str {
+        if name.starts_with("sys_") {
+            "syscall"
+        } else if name.ends_with("_ioctl") {
+            "ioctl"
+        } else if name.contains("sysfs") {
+            "sysfs"
+        } else {
+            "function"
+        }
+    }
+
+    /// Map the check-type token emitted by the kernel
+    /// (return_check_type_to_string) back to the u32 enum the formatter wants.
+    fn parse_check_type(token: &str) -> u32 {
+        match token {
+            "exact" => 0,
+            "range" => 1,
+            "error_check" => 2,
+            "file_descriptor" => 3,
+            "custom" => 4,
+            "no_return" => 5,
+            _ => 0,
+        }
+    }
+
+    /// Map the lock-type token emitted by the kernel (lock_type_to_string).
+    fn parse_lock_type(token: &str) -> u32 {
+        match token {
+            "none" => 0,
+            "mutex" => 1,
+            "spinlock" => 2,
+            "rwlock" => 3,
+            "seqlock" => 4,
+            "rcu" => 5,
+            "semaphore" => 6,
+            "custom" => 7,
+            _ => 0,
+        }
+    }
+
+    /// Map the lock-scope token emitted by the kernel (lock_scope_to_string).
+    fn parse_lock_scope(token: &str) -> u32 {
+        match token {
+            "internal" => 0,
+            "acquires" => 1,
+            "releases" => 2,
+            "caller_held" => 3,
+            _ => 0,
+        }
+    }
+
+    /// Map the signal-timing token (e.g. "during") back to the u32 enum that
+    /// the source-side parser produces. Mirrors
+    /// KerneldocParser::parse_signal_timing in kerneldoc_parser.rs so the
+    /// debugfs path and the source path agree.
+    fn parse_signal_timing(token: &str) -> u32 {
+        match token.trim().to_ascii_lowercase().as_str() {
+            "before" => 0,
+            "during" => 1,
+            "after" => 2,
+            _ => 0,
+        }
+    }
+
+    /// Convert the capability action token emitted by the kernel
+    /// (capability_action_to_string) to the KAPI_CAP_* spelling used by the
+    /// other extractors.
+    fn parse_capability_action(action: &str) -> String {
+        match action {
+            "bypass_check" => "KAPI_CAP_BYPASS_CHECK".to_string(),
+            "increase_limit" => "KAPI_CAP_INCREASE_LIMIT".to_string(),
+            "override_restriction" => "KAPI_CAP_OVERRIDE_RESTRICTION".to_string(),
+            "grant_permission" => "KAPI_CAP_GRANT_PERMISSION".to_string(),
+            "modify_behavior" => "KAPI_CAP_MODIFY_BEHAVIOR".to_string(),
+            "access_resource" => "KAPI_CAP_ACCESS_RESOURCE".to_string(),
+            "perform_operation" => "KAPI_CAP_PERFORM_OPERATION".to_string(),
+            _ => action.to_string(),
+        }
+    }
+
+    /// Try to parse as JSON first
+    fn try_parse_json(&self, content: &str) -> Result<ApiSpec, serde_json::Error> {
+        let json_data: KernelApiJson = serde_json::from_str(content)?;
+        // The kernel-side kapi_json_str() emits NULL char * as the empty
+        // string "", so normalise empty -> None to match the ApiSpec
+        // convention used by --source / --vmlinux.
+        fn opt_str(s: Option<String>) -> Option<String> {
+            s.filter(|v| !v.is_empty())
+        }
+        let api_type = json_data
+            .api_type
+            .unwrap_or_else(|| Self::api_type_from_name(&json_data.name).to_string());
+
+        let mut spec = ApiSpec {
+            name: json_data.name,
+            api_type,
+            description: opt_str(json_data.description),
+            long_description: opt_str(json_data.long_description),
+            version: json_data.version.map(|v| v.to_string()),
+            context_flags: json_data
+                .context_flags
+                .as_deref()
+                .map_or_else(Vec::new, Self::parse_context_flags),
+            param_count: None,
+            error_count: None,
+            examples: opt_str(json_data.examples),
+            notes: opt_str(json_data.notes),
+            subsystem: None,   // Not in the JSON format
+            sysfs_path: None,  // Not in the JSON format
+            permissions: None, // Not in the JSON format
+            capabilities: vec![],
+            parameters: vec![],
+            return_spec: None,
+            errors: vec![],
+            signals: vec![],
+            signal_masks: vec![],
+            side_effects: vec![],
+            state_transitions: vec![],
+            constraints: vec![],
+            locks: vec![],
+            struct_specs: vec![],
+        };
+
+        // Convert capabilities
+        if let Some(caps) = json_data.capabilities {
+            for cap in caps {
+                spec.capabilities.push(CapabilitySpec {
+                    capability: cap.capability,
+                    name: cap.name,
+                    action: Self::parse_capability_action(&cap.action),
+                    allows: cap.allows,
+                    without_cap: cap.without_cap,
+                    check_condition: opt_str(cap.check_condition),
+                    priority: cap.priority,
+                    alternatives: cap.alternatives.unwrap_or_default(),
+                });
+            }
+        }
+
+        // Convert parameters.
+        if let Some(params) = json_data.parameters {
+            for (i, p) in params.into_iter().enumerate() {
+                let flags = p.flags.as_deref().map_or(0, Self::parse_hex_u32);
+                let mut param = ParamSpec {
+                    index: i as u32,
+                    name: p.name,
+                    type_name: p.type_name.unwrap_or_default(),
+                    description: p.description.unwrap_or_default(),
+                    flags,
+                    param_type: p.type_class.as_deref().map_or(0, Self::parse_type_class),
+                    constraint_type: p
+                        .constraint_type
+                        .as_deref()
+                        .map_or(0, Self::parse_constraint_type),
+                    constraint: opt_str(p.constraint_desc),
+                    min_value: p.min_value,
+                    max_value: p.max_value,
+                    valid_mask: p.valid_mask.as_deref().map(Self::parse_hex_u64),
+                    enum_values: p.enum_values.iter().map(i64::to_string).collect(),
+                    size: p.size.map(|v| v as u32),
+                    alignment: p.alignment.map(|v| v as u32),
+                    size_param_idx: p.size_param_idx,
+                };
+                param.keep_used_numbers();
+                spec.parameters.push(param);
+            }
+            if !spec.parameters.is_empty() {
+                spec.param_count = Some(spec.parameters.len() as u32);
+            }
+        }
+
+        // Convert errors
+        if let Some(errors) = json_data.errors {
+            for e in errors {
+                spec.errors.push(ErrorSpec {
+                    error_code: e.error_code,
+                    name: e.name.unwrap_or_default(),
+                    condition: e.condition.unwrap_or_default(),
+                    description: e.description.unwrap_or_default(),
+                });
+            }
+            if !spec.errors.is_empty() {
+                spec.error_count = Some(spec.errors.len() as u32);
+            }
+        }
+
+        // Convert return spec
+        if let Some(ret) = json_data.return_spec {
+            let check_type = ret.check_type.as_deref().map_or(0, Self::parse_check_type);
+            let return_type = ret.type_class.as_deref().map_or(0, Self::parse_type_class);
+            let type_name = ret.type_name.unwrap_or_default();
+            let success_value = ret.success_value.unwrap_or(0);
+            // A spec without a return block exports as an all-zero return
+            // spec; report it as absent like the vmlinux extractor does.
+            if !(type_name.is_empty() && return_type == 0 && check_type == 0 && success_value == 0)
+            {
+                let mut ret_spec = ReturnSpec {
+                    type_name,
+                    description: ret.description.unwrap_or_default(),
+                    return_type,
+                    check_type,
+                    success_value: ret.success_value,
+                    success_min: ret.success_min,
+                    success_max: ret.success_max,
+                    error_values: ret
+                        .error_values
+                        .into_iter()
+                        .filter_map(|v| i32::try_from(v).ok())
+                        .collect(),
+                };
+                ret_spec.keep_used_success_fields();
+                spec.return_spec = Some(ret_spec);
+            }
+        }
+
+        // Convert locks
+        if let Some(locks) = json_data.locks {
+            for l in locks {
+                let lock_type = l.lock_type.as_deref().map_or(0, Self::parse_lock_type);
+                let scope = l.scope.as_deref().map_or(0, Self::parse_lock_scope);
+                spec.locks.push(LockSpec {
+                    lock_name: l.name,
+                    lock_type,
+                    scope,
+                    description: l.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        // Convert constraints.  Empty strings emitted from kapi_json_str()
+        // for NULL char * fields normalise back to None to match --source.
+        if let Some(constraints) = json_data.constraints {
+            for c in constraints {
+                spec.constraints.push(ConstraintSpec {
+                    name: c.name,
+                    description: c.description.unwrap_or_default(),
+                    expression: c.expression.filter(|v| !v.is_empty()),
+                });
+            }
+        }
+
+        // Convert signals.
+        if let Some(signals) = json_data.signals {
+            for s in signals {
+                let direction = s.direction.as_deref().map_or(0, Self::parse_hex_u32);
+                let sa_flags_required = s
+                    .sa_flags_required
+                    .as_deref()
+                    .map_or(0, Self::parse_hex_u32);
+                let sa_flags_forbidden = s
+                    .sa_flags_forbidden
+                    .as_deref()
+                    .map_or(0, Self::parse_hex_u32);
+                let state_required = s.state_required.as_deref().map_or(0, Self::parse_hex_u32);
+                let state_forbidden = s.state_forbidden.as_deref().map_or(0, Self::parse_hex_u32);
+                let timing = s.timing.as_deref().map_or(0, Self::parse_signal_timing);
+                spec.signals.push(super::SignalSpec {
+                    signal_num: s.signal_num,
+                    signal_name: s.signal_name.unwrap_or_default(),
+                    direction,
+                    action: s.action,
+                    target: opt_str(s.target),
+                    condition: opt_str(s.condition),
+                    description: opt_str(s.description),
+                    timing,
+                    priority: s.priority,
+                    restartable: s.restartable,
+                    interruptible: s.interruptible,
+                    queue: opt_str(s.queue_behavior),
+                    sa_flags: 0,
+                    sa_flags_required,
+                    sa_flags_forbidden,
+                    state_required,
+                    state_forbidden,
+                    error_on_signal: if s.error_on_signal != 0 {
+                        Some(s.error_on_signal)
+                    } else {
+                        None
+                    },
+                    transform_to: if s.transform_to != 0 {
+                        // Kernel JSON already carries the numeric value.
+                        Some(s.transform_to)
+                    } else {
+                        None
+                    },
+                });
+            }
+        }
+
+        // Convert side effects.
+        if let Some(effects) = json_data.side_effects {
+            for e in effects {
+                let effect_type = e.type_hex.as_deref().map_or(0, Self::parse_hex_u32);
+                spec.side_effects.push(super::SideEffectSpec {
+                    effect_type,
+                    target: e.target.unwrap_or_default(),
+                    condition: e.condition.filter(|v| !v.is_empty()),
+                    description: e.description.unwrap_or_default(),
+                    reversible: e.reversible,
+                });
+            }
+        }
+
+        if let Some(transitions) = json_data.state_transitions {
+            for t in transitions {
+                spec.state_transitions.push(StateTransitionSpec {
+                    object: t.object.unwrap_or_default(),
+                    from_state: t.from_state.unwrap_or_default(),
+                    to_state: t.to_state.unwrap_or_default(),
+                    condition: opt_str(t.condition),
+                    description: t.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        if let Some(masks) = json_data.signal_masks {
+            for m in masks {
+                spec.signal_masks.push(SignalMaskSpec {
+                    name: m.name.unwrap_or_default(),
+                    description: m.description.unwrap_or_default(),
+                    signals: m.signals,
+                });
+            }
+        }
+
+        if let Some(structs) = json_data.struct_specs {
+            for st in structs {
+                let fields: Vec<StructFieldSpec> = st
+                    .fields
+                    .into_iter()
+                    .map(|f| StructFieldSpec {
+                        name: f.name.unwrap_or_default(),
+                        field_type: f.type_class.as_deref().map_or(0, Self::parse_type_class),
+                        type_name: f.type_name.unwrap_or_default(),
+                        offset: f.offset,
+                        size: f.size,
+                        flags: f.flags.as_deref().map_or(0, Self::parse_hex_u32),
+                        constraint_type: f
+                            .constraint_type
+                            .as_deref()
+                            .map_or(0, Self::parse_constraint_type),
+                        min_value: f.min_value,
+                        max_value: f.max_value,
+                        valid_mask: f.valid_mask.as_deref().map_or(0, Self::parse_hex_u64),
+                        description: f.description.unwrap_or_default(),
+                    })
+                    .collect();
+                spec.struct_specs.push(StructSpec {
+                    name: st.name.unwrap_or_default(),
+                    size: st.size,
+                    alignment: st.alignment,
+                    field_count: fields.len() as u32,
+                    fields,
+                    description: st.description.unwrap_or_default(),
+                });
+            }
+        }
+
+        Ok(spec)
+    }
+
+    /// Parse a single API specification file
+    fn parse_spec_file(&self, api_name: &str) -> Result<ApiSpec> {
+        // Prefer the JSON endpoint; fall back to the plain-text dump under
+        // kapi/specs/ if it is missing or does not parse.
+        let json_path = self
+            .debugfs_path
+            .join(format!("kapi/specs-json/{}", api_name));
+        match fs::read_to_string(&json_path) {
+            Ok(content) => match self.try_parse_json(&content) {
+                Ok(spec) => return Ok(spec),
+                Err(e) => eprintln!(
+                    "Warning: invalid JSON in {}: {}; using the text dump",
+                    json_path.display(),
+                    e
+                ),
+            },
+            Err(e) if e.kind() != std::io::ErrorKind::NotFound => eprintln!(
+                "Warning: cannot read {}: {}; using the text dump",
+                json_path.display(),
+                e
+            ),
+            Err(_) => {}
+        }
+
+        let spec_path = self.debugfs_path.join(format!("kapi/specs/{}", api_name));
+        let content = fs::read_to_string(&spec_path)
+            .with_context(|| format!("Failed to read {}", spec_path.display()))?;
+
+        // The specs/ file may hold JSON as well.
+        if let Ok(spec) = self.try_parse_json(&content) {
+            return Ok(spec);
+        }
+
+        // Fall back to plain text parsing
+        let mut spec = ApiSpec {
+            name: api_name.to_string(),
+            api_type: "unknown".to_string(),
+            description: None,
+            long_description: None,
+            version: None,
+            context_flags: Vec::new(),
+            param_count: None,
+            error_count: None,
+            examples: None,
+            notes: None,
+            subsystem: None,
+            sysfs_path: None,
+            permissions: None,
+            capabilities: vec![],
+            parameters: vec![],
+            return_spec: None,
+            errors: vec![],
+            signals: vec![],
+            signal_masks: vec![],
+            side_effects: vec![],
+            state_transitions: vec![],
+            constraints: vec![],
+            locks: vec![],
+            struct_specs: vec![],
+        };
+
+        // Parse the content
+        let mut text_field: Option<TextField> = None;
+        let mut text_lines: Vec<String> = Vec::new();
+        let mut parsing_capability = false;
+        let mut in_capabilities_section = false;
+        let mut current_capability: Option<CapabilitySpec> = None;
+
+        for line in content.lines() {
+            // The kernel indents every line of a multi-line value, so a value
+            // ends at the first non-blank line that is not indented.
+            if let Some(field) = text_field {
+                if line.is_empty() {
+                    text_lines.push(String::new());
+                    continue;
+                }
+                if let Some(rest) = line.strip_prefix("  ") {
+                    text_lines.push(rest.to_string());
+                    continue;
+                }
+                field.store(&mut spec, &text_lines);
+                text_field = None;
+            }
+
+            // Handle capability sections
+            if line.starts_with("Capabilities (") {
+                in_capabilities_section = true;
+                continue;
+            }
+            // Any other top-level section header ends the capabilities section
+            // so that "  pending_signals (0):" inside "Signal handling (1):"
+            // isn't mis-parsed as a capability entry.
+            if !line.starts_with(' ') && !line.is_empty() && line.ends_with(':') {
+                in_capabilities_section = false;
+            }
+            if in_capabilities_section
+                && line.starts_with("  ")
+                && line.contains(" (")
+                && line.ends_with("):")
+            {
+                // Start of a capability entry like "  CAP_IPC_LOCK (14):"
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+
+                let parts: Vec<&str> = line.trim().split(" (").collect();
+                if parts.len() == 2 {
+                    let cap_name = parts[0].to_string();
+                    let cap_id = parts[1].trim_end_matches("):").parse().unwrap_or(0);
+                    current_capability = Some(CapabilitySpec {
+                        capability: cap_id,
+                        name: cap_name,
+                        action: String::new(),
+                        allows: String::new(),
+                        without_cap: String::new(),
+                        check_condition: None,
+                        priority: None,
+                        alternatives: Vec::new(),
+                    });
+                    parsing_capability = true;
+                }
+                continue;
+            }
+            if parsing_capability && line.starts_with("    ") {
+                // Parse capability fields
+                if let Some(ref mut cap) = current_capability {
+                    if let Some(action) = line.strip_prefix("    Action: ") {
+                        cap.action = action.to_string();
+                    } else if let Some(allows) = line.strip_prefix("    Allows: ") {
+                        cap.allows = allows.to_string();
+                    } else if let Some(without) = line.strip_prefix("    Without: ") {
+                        cap.without_cap = without.to_string();
+                    } else if let Some(cond) = line.strip_prefix("    Condition: ") {
+                        cap.check_condition = Some(cond.to_string());
+                    } else if let Some(prio) = line.strip_prefix("    Priority: ") {
+                        cap.priority = prio.parse().ok();
+                    } else if let Some(alts) = line.strip_prefix("    Alternatives: ") {
+                        cap.alternatives =
+                            alts.split(", ").filter_map(|s| s.parse().ok()).collect();
+                    }
+                }
+                continue;
+            }
+            if parsing_capability && !line.starts_with("  ") {
+                // End of capabilities section
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+                parsing_capability = false;
+            }
+
+            // Handle section headers
+            if line.starts_with("Parameters (") {
+                if let Some(count_str) = line
+                    .strip_prefix("Parameters (")
+                    .and_then(|s| s.strip_suffix("):"))
+                {
+                    spec.param_count = count_str.parse().ok();
+                }
+                continue;
+            } else if line.starts_with("Errors (") {
+                if let Some(count_str) = line
+                    .strip_prefix("Errors (")
+                    .and_then(|s| s.strip_suffix("):"))
+                {
+                    spec.error_count = count_str.parse().ok();
+                }
+                continue;
+            }
+
+            // Parse regular fields
+            if let Some((field, first)) = TextField::start(line) {
+                text_field = Some(field);
+                text_lines.clear();
+                if !first.is_empty() {
+                    text_lines.push(first.to_string());
+                }
+            } else if let Some(version) = line.strip_prefix("Version: ") {
+                spec.version = Some(version.to_string());
+            } else if let Some(flags) = line.strip_prefix("Context flags: ") {
+                spec.context_flags = flags
+                    .split_whitespace()
+                    .map(|f| format!("KAPI_CTX_{f}"))
+                    .collect();
+            } else if let Some(subsys) = line.strip_prefix("Subsystem: ") {
+                spec.subsystem = Some(subsys.to_string());
+            } else if let Some(path) = line.strip_prefix("Sysfs Path: ") {
+                spec.sysfs_path = Some(path.to_string());
+            } else if let Some(perms) = line.strip_prefix("Permissions: ") {
+                spec.permissions = Some(perms.to_string());
+            }
+        }
+
+        if let Some(field) = text_field {
+            field.store(&mut spec, &text_lines);
+        }
+
+        // Handle any remaining capability
+        if let Some(cap) = current_capability.take() {
+            spec.capabilities.push(cap);
+        }
+
+        // Determine API type based on name
+        if api_name.starts_with("sys_") {
+            spec.api_type = "syscall".to_string();
+        } else if api_name.contains("_ioctl") || api_name.starts_with("ioctl_") {
+            spec.api_type = "ioctl".to_string();
+        } else if api_name.contains("sysfs")
+            || api_name.ends_with("_show")
+            || api_name.ends_with("_store")
+        {
+            spec.api_type = "sysfs".to_string();
+        } else {
+            spec.api_type = "function".to_string();
+        }
+
+        Ok(spec)
+    }
+}
+
+impl ApiExtractor for DebugfsExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        let api_names = self.parse_list_file()?;
+        let mut specs = Vec::new();
+
+        for name in api_names {
+            match self.parse_spec_file(&name) {
+                Ok(spec) => specs.push(spec),
+                Err(e) => {
+                    eprintln!("Warning: failed to parse API spec '{}': {}", name, e);
+                }
+            }
+        }
+
+        Ok(specs)
+    }
+
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>> {
+        let api_names = self.parse_list_file()?;
+
+        if api_names.contains(&name.to_string()) {
+            Ok(Some(self.parse_spec_file(name)?))
+        } else {
+            Ok(None)
+        }
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.extract_by_name(api_name)? {
+            display_api_spec(&spec, formatter, writer)?;
+        } else {
+            writeln!(writer, "API '{api_name}' not found in debugfs")?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Shortened sys_read dump as emitted by kapi_export_json().
+    const SYS_READ_JSON: &str = r#"{
+  "name": "sys_read",
+  "version": 1,
+  "description": "Read data from a file descriptor",
+  "long_description": "",
+  "context_flags": "0x21",
+  "parameters": [
+    {
+      "name": "fd",
+      "type": "unsigned int fd",
+      "type_class": "file_descriptor",
+      "flags": "0x1",
+      "description": "File descriptor to read from ",
+      "constraint_type": "range",
+      "constraint_desc": "Must be a valid, open file descriptor",
+      "min_value": 0,
+      "max_value": 2147483647,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    },
+    {
+      "name": "buf",
+      "type": "char __user * buf",
+      "type_class": "user_pointer",
+      "flags": "0x42",
+      "description": "User-space buffer to read data into ",
+      "constraint_type": "buffer",
+      "constraint_desc": "Must point to a writable user-space region",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": 2,
+      "size_multiplier": 0
+    },
+    {
+      "name": "count",
+      "type": "size_t count",
+      "type_class": "uint",
+      "flags": "0x1",
+      "description": "Maximum number of bytes to read ",
+      "constraint_type": "none",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    }
+  ],
+  "return": {
+    "type": "KAPI_TYPE_INT",
+    "type_class": "int",
+    "check_type": "range",
+    "success_value": 0,
+    "success_min": 0,
+    "success_max": 9223372036854775807,
+    "error_values": [],
+    "description": "Number of bytes read"
+  },
+  "errors": [
+    { "code": -9, "name": "EBADF", "condition": "Bad file descriptor",
+      "description": "fd is not valid" }
+  ],
+  "locks": [
+    { "name": "file->f_pos_lock", "type": "mutex", "scope": "internal",
+      "description": "Position lock" }
+  ],
+  "capabilities": [
+    {
+      "capability": 1,
+      "name": "CAP_DAC_OVERRIDE",
+      "action": "bypass_check",
+      "allows": "Bypass read permission checks",
+      "without_cap": "Standard DAC checks are enforced",
+      "check_condition": "",
+      "priority": 0
+    }
+  ],
+  "constraints": [
+    { "name": "MAX_RW_COUNT",
+      "description": "Count is clamped", "expression": "min(count, MAX_RW_COUNT)" }
+  ],
+  "signals": [
+    {
+      "signal_num": 0,
+      "signal_name": "Any signal",
+      "direction": "0x1",
+      "action": 6,
+      "target": "",
+      "condition": "When blocked waiting for data",
+      "description": "May be interrupted",
+      "restartable": true,
+      "sa_flags_required": "0x0",
+      "sa_flags_forbidden": "0x0",
+      "error_on_signal": -4,
+      "transform_to": 0,
+      "timing": "during",
+      "priority": 0,
+      "interruptible": false,
+      "queue_behavior": "",
+      "state_required": "0x0",
+      "state_forbidden": "0x0"
+    }
+  ],
+  "side_effects": [
+    { "type": "0x10", "target": "file->f_pos", "condition": "",
+      "description": "Offset advances", "reversible": false }
+  ],
+  "state_transitions": [],
+  "signal_masks": [],
+  "struct_specs": [],
+  "examples": "",
+  "notes": ""
+}
+"#;
+
+    // Synthetic dump exercising the fields sys_read leaves empty: enum
+    // values, error values, struct specs, signal masks, state transitions.
+    const FULL_JSON: &str = r#"{
+  "name": "tmp_full",
+  "version": 1,
+  "description": "Full \"feature\" test\tline\nbreak\u0001",
+  "long_description": "",
+  "context_flags": "0x91",
+  "parameters": [
+    {
+      "name": "mode",
+      "type": "int mode",
+      "type_class": "enum",
+      "flags": "0x1",
+      "description": "Enumerated parameter",
+      "constraint_type": "enum",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0x0",
+      "enum_values": [1, 2, -3, 1000000000000],
+      "size": 4096,
+      "alignment": 64,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    },
+    {
+      "name": "bits",
+      "type": "u64 bits",
+      "type_class": "uint",
+      "flags": "0x1",
+      "description": "Masked parameter",
+      "constraint_type": "mask",
+      "constraint_desc": "",
+      "min_value": 0,
+      "max_value": 0,
+      "valid_mask": "0xffffffffffffffff",
+      "enum_values": [],
+      "size": 0,
+      "alignment": 0,
+      "size_param_idx": null,
+      "size_multiplier": 0
+    }
+  ],
+  "return": {
+    "type": "long",
+    "type_class": "int",
+    "check_type": "error_check",
+    "success_value": 0,
+    "success_min": 0,
+    "success_max": 0,
+    "error_values": [-1, -22, -4095],
+    "description": "Error-checked return"
+  },
+  "errors": [],
+  "locks": [
+    { "name": "tmp_lock", "type": "seqlock", "scope": "acquires", "description": "a seqlock" }
+  ],
+  "capabilities": [
+    {
+      "capability": 21,
+      "name": "CAP_SYS_ADMIN",
+      "action": "perform_operation",
+      "allows": "do it",
+      "without_cap": "cannot",
+      "check_condition": "always checked",
+      "priority": 3,
+      "alternatives": [21, 17]
+    }
+  ],
+  "constraints": [],
+  "signals": [],
+  "side_effects": [],
+  "state_transitions": [
+    { "object": "obj", "from_state": "a", "to_state": "b", "condition": "when asked",
+      "description": "moves a to b" }
+  ],
+  "signal_masks": [
+    { "name": "blocked", "description": "Blocked while running", "signals": [2, 15, 3] }
+  ],
+  "struct_specs": [
+    {
+      "name": "tmp_struct",
+      "size": 16,
+      "alignment": 8,
+      "description": "Test structure",
+      "fields": [
+        {
+          "name": "flags",
+          "type": "u32",
+          "type_class": "uint",
+          "offset": 0,
+          "size": 4,
+          "flags": "0x1",
+          "constraint_type": "mask",
+          "min_value": 0,
+          "max_value": 0,
+          "valid_mask": "0xff",
+          "enum_values": "",
+          "description": "Flag bits"
+        },
+        {
+          "name": "kind",
+          "type": "enum k",
+          "type_class": "enum",
+          "offset": 8,
+          "size": 4,
+          "flags": "0x0",
+          "constraint_type": "range",
+          "min_value": -5,
+          "max_value": 5,
+          "valid_mask": "0x0",
+          "enum_values": "",
+          "description": "Kind"
+        }
+      ]
+    }
+  ],
+  "examples": "",
+  "notes": "note"
+}
+"#;
+
+    // Dump without the optional per-parameter constraint fields.
+    const MINIMAL_JSON: &str = r#"{
+  "name": "sys_read",
+  "version": 1,
+  "description": "Read data from a file descriptor",
+  "long_description": "Reads.",
+  "context_flags": "0x21",
+  "parameters": [
+    {
+      "name": "buf",
+      "type": "char __user * buf",
+      "type_class": "user_pointer",
+      "flags": "0x42",
+      "description": "User-space buffer to read data into "
+    }
+  ],
+  "return": {
+    "type": "KAPI_TYPE_INT",
+    "type_class": "int",
+    "check_type": "range",
+    "success_min": 0,
+    "success_max": 9223372036854775807,
+    "description": "Bytes read"
+  },
+  "errors": [],
+  "locks": [],
+  "capabilities": [
+    {
+      "capability": 1,
+      "name": "CAP_DAC_OVERRIDE",
+      "action": "bypass_check",
+      "allows": "a",
+      "without_cap": "b",
+      "check_condition": "c",
+      "priority": 0
+    }
+  ],
+  "constraints": [],
+  "signals": [],
+  "side_effects": [],
+  "examples": "",
+  "notes": ""
+}
+"#;
+
+    fn extractor() -> DebugfsExtractor {
+        DebugfsExtractor {
+            debugfs_path: PathBuf::new(),
+        }
+    }
+
+    #[test]
+    fn json_sys_read_carries_type_and_constraint_data() {
+        let spec = extractor().try_parse_json(SYS_READ_JSON).unwrap();
+
+        assert_eq!(spec.api_type, "syscall");
+        assert_eq!(spec.version.as_deref(), Some("1"));
+        assert_eq!(spec.long_description, None);
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+        assert_eq!(spec.param_count, Some(3));
+
+        let fd = &spec.parameters[0];
+        assert_eq!((fd.param_type, fd.constraint_type), (9, 1));
+        assert_eq!((fd.min_value, fd.max_value), (Some(0), Some(2147483647)));
+        assert_eq!(fd.valid_mask, None);
+        assert_eq!(fd.size_param_idx, None);
+        assert_eq!(
+            fd.constraint.as_deref(),
+            Some("Must be a valid, open file descriptor")
+        );
+
+        let buf = &spec.parameters[1];
+        assert_eq!((buf.param_type, buf.constraint_type), (10, 11));
+        assert_eq!(buf.flags, 0x42);
+        assert_eq!(buf.size_param_idx, Some(2));
+        assert_eq!(
+            (buf.min_value, buf.max_value, buf.valid_mask),
+            (None, None, None)
+        );
+        assert_eq!((buf.size, buf.alignment), (None, None));
+        assert_eq!(
+            spec.parameters[buf.size_param_idx.unwrap() as usize].name,
+            "count"
+        );
+
+        let count = &spec.parameters[2];
+        assert_eq!((count.param_type, count.constraint_type), (2, 0));
+        assert_eq!(count.constraint, None);
+        assert_eq!(count.description, "Maximum number of bytes to read ");
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!((ret.return_type, ret.check_type), (1, 1));
+        assert_eq!(ret.success_value, None);
+        assert_eq!(ret.success_min, Some(0));
+        assert_eq!(ret.success_max, Some(i64::MAX));
+        assert!(ret.error_values.is_empty());
+
+        assert_eq!(spec.errors[0].error_code, -9);
+        assert_eq!((spec.locks[0].lock_type, spec.locks[0].scope), (1, 0));
+        assert_eq!(spec.capabilities[0].action, "KAPI_CAP_BYPASS_CHECK");
+        assert_eq!(spec.capabilities[0].check_condition, None);
+        assert_eq!(spec.signals[0].timing, 1);
+        assert_eq!(spec.signals[0].error_on_signal, Some(-4));
+        assert_eq!(spec.signals[0].target, None);
+        assert_eq!(spec.side_effects[0].effect_type, 0x10);
+        assert_eq!(spec.side_effects[0].condition, None);
+        assert_eq!(spec.constraints.len(), 1);
+    }
+
+    #[test]
+    fn json_full_carries_enum_error_struct_and_transition_data() {
+        let spec = extractor().try_parse_json(FULL_JSON).unwrap();
+
+        assert_eq!(
+            spec.description.as_deref(),
+            Some("Full \"feature\" test\tline\nbreak\u{1}")
+        );
+        assert_eq!(
+            spec.context_flags,
+            [
+                "KAPI_CTX_PROCESS",
+                "KAPI_CTX_ATOMIC",
+                "KAPI_CTX_IRQ_DISABLED"
+            ]
+        );
+
+        let mode = &spec.parameters[0];
+        assert_eq!((mode.param_type, mode.constraint_type), (6, 3));
+        assert_eq!(mode.enum_values, ["1", "2", "-3", "1000000000000"]);
+        assert_eq!((mode.size, mode.alignment), (Some(4096), Some(64)));
+        assert_eq!(
+            (mode.min_value, mode.max_value, mode.valid_mask),
+            (None, None, None)
+        );
+
+        let bits = &spec.parameters[1];
+        assert_eq!(bits.constraint_type, 2);
+        assert_eq!(bits.valid_mask, Some(u64::MAX));
+        assert_eq!((bits.min_value, bits.max_value), (None, None));
+        assert_eq!((bits.size, bits.alignment), (None, None));
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(ret.check_type, 2);
+        assert_eq!(ret.error_values, [-1, -22, -4095]);
+
+        assert_eq!(spec.capabilities[0].alternatives, [21, 17]);
+        assert_eq!(spec.capabilities[0].priority, Some(3));
+        assert_eq!((spec.locks[0].lock_type, spec.locks[0].scope), (4, 1));
+
+        let trans = &spec.state_transitions[0];
+        assert_eq!(
+            (trans.object.as_str(), trans.from_state.as_str()),
+            ("obj", "a")
+        );
+        assert_eq!(trans.to_state, "b");
+        assert_eq!(trans.condition.as_deref(), Some("when asked"));
+
+        assert_eq!(spec.signal_masks[0].name, "blocked");
+        assert_eq!(spec.signal_masks[0].signals, [2, 15, 3]);
+
+        let st = &spec.struct_specs[0];
+        assert_eq!(
+            (st.name.as_str(), st.size, st.alignment),
+            ("tmp_struct", 16, 8)
+        );
+        assert_eq!(st.field_count, 2);
+        assert_eq!(st.description, "Test structure");
+        assert_eq!(
+            (st.fields[0].field_type, st.fields[0].constraint_type),
+            (2, 2)
+        );
+        assert_eq!(st.fields[0].valid_mask, 0xff);
+        assert_eq!(st.fields[1].offset, 8);
+        assert_eq!((st.fields[1].min_value, st.fields[1].max_value), (-5, 5));
+    }
+
+    #[test]
+    fn json_without_constraint_fields_still_parses() {
+        let spec = extractor().try_parse_json(MINIMAL_JSON).unwrap();
+
+        let buf = &spec.parameters[0];
+        assert_eq!(buf.param_type, 10);
+        assert_eq!(buf.constraint_type, 0);
+        assert_eq!(buf.min_value, None);
+        assert_eq!(buf.valid_mask, None);
+        assert_eq!(buf.size_param_idx, None);
+        assert!(buf.enum_values.is_empty());
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!((ret.return_type, ret.check_type), (1, 1));
+        assert_eq!(ret.success_value, None);
+        assert_eq!(ret.success_max, Some(i64::MAX));
+        assert_eq!(spec.capabilities[0].action, "KAPI_CAP_BYPASS_CHECK");
+        assert!(spec.state_transitions.is_empty());
+        assert!(spec.struct_specs.is_empty());
+    }
+
+    #[test]
+    fn enum_tokens_match_kernel_numbering() {
+        let classes = [
+            "void",
+            "int",
+            "uint",
+            "pointer",
+            "struct",
+            "union",
+            "enum",
+            "function_pointer",
+            "array",
+            "file_descriptor",
+            "user_pointer",
+            "pathname",
+            "custom",
+        ];
+        for (n, token) in classes.iter().enumerate() {
+            assert_eq!(
+                DebugfsExtractor::parse_type_class(token),
+                n as u32,
+                "{token}"
+            );
+        }
+
+        let constraints = [
+            "none",
+            "range",
+            "mask",
+            "enum",
+            "alignment",
+            "power_of_two",
+            "page_aligned",
+            "nonzero",
+            "user_string",
+            "user_path",
+            "user_ptr",
+            "buffer",
+            "custom",
+        ];
+        for (n, token) in constraints.iter().enumerate() {
+            assert_eq!(
+                DebugfsExtractor::parse_constraint_type(token),
+                n as u32,
+                "{token}"
+            );
+        }
+    }
+
+    #[test]
+    fn api_type_follows_symbol_name() {
+        assert_eq!(DebugfsExtractor::api_type_from_name("sys_read"), "syscall");
+        assert_eq!(DebugfsExtractor::api_type_from_name("foo_ioctl"), "ioctl");
+        assert_eq!(DebugfsExtractor::api_type_from_name("kmalloc"), "function");
+    }
+
+    #[test]
+    fn truncated_json_is_rejected() {
+        let cut = &SYS_READ_JSON[..SYS_READ_JSON.len() / 2];
+        assert!(extractor().try_parse_json(cut).is_err());
+    }
+
+    fn debugfs_with(json: &str, text: &str) -> tempfile::TempDir {
+        let dir = tempfile::tempdir().unwrap();
+        let kapi = dir.path().join("kapi");
+        fs::create_dir_all(kapi.join("specs")).unwrap();
+        fs::create_dir_all(kapi.join("specs-json")).unwrap();
+        fs::write(
+            kapi.join("list"),
+            "Available Kernel API Specifications\n\
+             ===================================\n\n\
+             sys_read - Read data from a file descriptor\n\n\
+             Total: 1 specifications\n",
+        )
+        .unwrap();
+        fs::write(kapi.join("specs-json/sys_read"), json).unwrap();
+        fs::write(kapi.join("specs/sys_read"), text).unwrap();
+        dir
+    }
+
+    #[test]
+    fn reads_json_endpoint_from_debugfs_tree() {
+        let dir = debugfs_with(SYS_READ_JSON, "Name: sys_read\n");
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert_eq!(spec.parameters[1].constraint_type, 11);
+        assert_eq!(spec.parameters[1].size_param_idx, Some(2));
+    }
+
+    #[test]
+    fn truncated_json_endpoint_falls_back_to_text_dump() {
+        let text = "Name: sys_read\n\
+                    Version: 1\n\
+                    Description: Read data from a file descriptor\n\
+                    Context flags: PROCESS SLEEPABLE \n";
+        let dir = debugfs_with(&SYS_READ_JSON[..SYS_READ_JSON.len() / 2], text);
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert!(spec.parameters.is_empty());
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+    }
+
+    #[test]
+    fn text_dump_multi_line_values_stay_in_their_field() {
+        let text = "Kernel API Specification\n\
+                    ========================\n\n\
+                    Name: sys_read\n\
+                    Version: 1\n\
+                    Description: Read data from a file descriptor\n\
+                    Long description:\n  \
+                    First paragraph.\n\n  \
+                    Notes: not a header\n  \
+                    Version: 9\n\
+                    Context flags: PROCESS SLEEPABLE \n\n\
+                    Parameters (1):\n  \
+                    [0] fd:\n    \
+                    description: File descriptor\n\n\
+                    Examples:\n  \
+                    read(fd, buf, n);\n  \
+                    close(fd);\n\n\
+                    Notes:\n  \
+                    one\n\n  \
+                    two\n\n";
+        let dir = debugfs_with("", text);
+        let ex = DebugfsExtractor::new(Some(dir.path().to_string_lossy().into_owned())).unwrap();
+        let spec = ex.extract_by_name("sys_read").unwrap().unwrap();
+
+        assert_eq!(spec.version.as_deref(), Some("1"));
+        assert_eq!(
+            spec.description.as_deref(),
+            Some("Read data from a file descriptor")
+        );
+        assert_eq!(
+            spec.long_description.as_deref(),
+            Some("First paragraph.\n\nNotes: not a header\nVersion: 9")
+        );
+        assert_eq!(
+            spec.context_flags,
+            ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]
+        );
+        assert_eq!(spec.param_count, Some(1));
+        assert_eq!(
+            spec.examples.as_deref(),
+            Some("read(fd, buf, n);\nclose(fd);")
+        );
+        assert_eq!(spec.notes.as_deref(), Some("one\n\ntwo"));
+    }
+}
diff --git a/tools/kapi/src/extractor/kerneldoc_parser.rs b/tools/kapi/src/extractor/kerneldoc_parser.rs
new file mode 100644
index 0000000000000..b8fae748470b0
--- /dev/null
+++ b/tools/kapi/src/extractor/kerneldoc_parser.rs
@@ -0,0 +1,3335 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::{
+    ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec,
+    SideEffectSpec, SignalSpec, StateTransitionSpec,
+};
+use anyhow::Result;
+use std::collections::HashMap;
+
+/// Kerneldoc parser that extracts KAPI annotations
+pub struct KerneldocParser;
+
+/// What block are we currently inside?
+#[derive(Debug, Clone, PartialEq)]
+enum BlockContext {
+    None,
+    Param(String),   // param: <name>
+    Error(String),   // error: <name>
+    Signal,          // signal: <name>
+    Capability,      // capability: <name>
+    SideEffect,      // side-effect: <type>
+    StateTransition, // state-trans: ...
+    Constraint,      // constraint: <name>
+    Lock,            // lock: <name>
+    Return,          // return:
+}
+
+/// Evaluate an integer literal exactly as the compiler does on LP64 (int is
+/// 32 bits, long and long long are 64): decimal, 0x/0X hex, 0b/0B binary or
+/// leading-0 octal, an optional u/U/l/L suffix and an optional leading '-'.
+/// The literal's C type (C11 6.4.4.1) decides how '-' wraps, so `-1U` is
+/// 4294967295 and `-0x80000000` is 2147483648. The result is the 64-bit
+/// two's complement pattern the value has once stored into an s64/u64
+/// field. Returns `None` for anything that requires cpp-level constant
+/// resolution (e.g. symbolic masks like `O_RDONLY | O_WRONLY`). Callers must
+/// treat that case as "value unknown" and leave the downstream slot unset,
+/// not store it as 0, which would wrongly assert that zero bits are valid.
+fn eval_c_int_literal(s: &str) -> Option<u64> {
+    let t = s.trim();
+    let (neg, t) = match t.strip_prefix('-') {
+        Some(rest) => (true, rest.trim_start()),
+        None => (false, t),
+    };
+    let body = t.trim_end_matches(['u', 'U', 'l', 'L']);
+    let suffix = &t[body.len()..];
+    let unsigned = match suffix.matches(['u', 'U']).count() {
+        0 => false,
+        1 => true,
+        _ => return None,
+    };
+    let longs = suffix.matches(['l', 'L']).count();
+    if longs > 2 {
+        return None;
+    }
+
+    let (digits, radix) =
+        if let Some(h) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
+            (h, 16)
+        } else if let Some(b) = body.strip_prefix("0b").or_else(|| body.strip_prefix("0B")) {
+            (b, 2)
+        } else if body.len() > 1 && body.starts_with('0') {
+            (&body[1..], 8)
+        } else {
+            (body, 10)
+        };
+    if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
+        return None;
+    }
+    let v = u64::from_str_radix(digits, radix).ok()?;
+
+    // Decimal literals without 'u' only take signed types; GCC treats one
+    // too large for long long as unsigned.
+    let value = if longs == 0 && !unsigned && v <= i32::MAX as u64 {
+        let x = v as i32;
+        (if neg { x.wrapping_neg() } else { x }) as i64 as u64
+    } else if longs == 0 && (unsigned || radix != 10) && v <= u32::MAX as u64 {
+        let x = v as u32;
+        u64::from(if neg { x.wrapping_neg() } else { x })
+    } else if !unsigned && v <= i64::MAX as u64 {
+        let x = v as i64;
+        (if neg { x.wrapping_neg() } else { x }) as u64
+    } else if neg {
+        v.wrapping_neg()
+    } else {
+        v
+    };
+    Some(value)
+}
+
+fn parse_u64_literal(s: &str) -> Option<u64> {
+    eval_c_int_literal(s)
+}
+
+fn parse_i64_literal(s: &str) -> Option<i64> {
+    eval_c_int_literal(s).map(|v| v as i64)
+}
+
+/// Split a return `success:` value into (exact value, range minimum,
+/// range maximum). `>= N` is the range [N, i64::MAX]; `N`, `= N` and
+/// `== N` are an exact value, and any value that is not `>= N` leaves
+/// the default range [0, i64::MAX] a range check falls back to. Mirrors
+/// `_return_success_macro()` in kdoc_apispec.py.
+fn parse_return_success(text: &str) -> (Option<i64>, Option<i64>, Option<i64>) {
+    let t = text.trim();
+    if let Some(min) = t.strip_prefix(">=") {
+        return (
+            None,
+            Some(parse_i64_literal(min).unwrap_or(0)),
+            Some(i64::MAX),
+        );
+    }
+    let exact = t
+        .strip_prefix("==")
+        .or_else(|| t.strip_prefix('='))
+        .unwrap_or(t);
+    (parse_i64_literal(exact), Some(0), Some(i64::MAX))
+}
+
+/// Canonicalise a capability `type:` value to its KAPI_CAP_* spelling.
+fn canon_kapi_cap_action(s: &str) -> String {
+    let t = s.trim();
+    if t.starts_with("KAPI_CAP_") {
+        return t.to_string();
+    }
+    match t.to_ascii_lowercase().as_str() {
+        "bypass_check" => "KAPI_CAP_BYPASS_CHECK".to_string(),
+        "increase_limit" => "KAPI_CAP_INCREASE_LIMIT".to_string(),
+        "override_restriction" => "KAPI_CAP_OVERRIDE_RESTRICTION".to_string(),
+        "grant_permission" => "KAPI_CAP_GRANT_PERMISSION".to_string(),
+        "modify_behavior" => "KAPI_CAP_MODIFY_BEHAVIOR".to_string(),
+        "access_resource" => "KAPI_CAP_ACCESS_RESOURCE".to_string(),
+        "perform_operation" => "KAPI_CAP_PERFORM_OPERATION".to_string(),
+        _ => t.to_string(),
+    }
+}
+
+/// Types whose semantics imply `KAPI_PARAM_USER` on the param, so
+/// `type: user_ptr, input` doesn't need a separate `user` flag.
+fn type_implies_user_flag(tok: &str) -> bool {
+    matches!(tok.trim(), "KAPI_TYPE_USER_PTR" | "KAPI_TYPE_PATH")
+        || matches!(
+            tok.trim().to_ascii_lowercase().as_str(),
+            "user_ptr" | "uptr" | "path"
+        )
+}
+
+/// Subfield names each block type consumes. An indented line opens a
+/// new subfield only when it starts with one of these followed by ':';
+/// any other line continues the previous subfield. Must stay in sync
+/// with the `_*_SUBFIELDS` sets in tools/lib/python/kdoc/kdoc_apispec.py.
+fn block_subfield_keys(block: &BlockContext) -> &'static [&'static str] {
+    match block {
+        BlockContext::Param(_) => &[
+            "type",
+            "flags",
+            "size",
+            "constraint-type",
+            "constraint",
+            "cdesc",
+            "range",
+            "mask",
+            "valid-mask",
+            "valid-values",
+            "alignment",
+            "size-param",
+            "struct-type",
+            "arch-mask",
+            "desc",
+            "description",
+        ],
+        BlockContext::Error(_) => &["desc", "condition"],
+        BlockContext::Signal => &[
+            "direction",
+            "action",
+            "condition",
+            "desc",
+            "errno",
+            "timing",
+            "priority",
+            "restartable",
+            "interruptible",
+            "number",
+            "target",
+            "queue",
+            "queue_behavior",
+            "transform",
+            "transform_to",
+            "transform-to",
+            "sa_flags_required",
+            "sa-flags-required",
+            "sa_flags_forbidden",
+            "sa-flags-forbidden",
+            "state_required",
+            "state-required",
+            "state_forbidden",
+            "state-forbidden",
+        ],
+        BlockContext::Capability => &["type", "allows", "without", "condition", "priority", "desc"],
+        BlockContext::SideEffect => &["target", "desc", "condition", "reversible"],
+        BlockContext::StateTransition => &["object", "from", "to", "condition", "desc"],
+        BlockContext::Constraint => &["desc", "expr"],
+        BlockContext::Lock => &[
+            "type",
+            "scope",
+            "acquired",
+            "released",
+            "held-on-entry",
+            "held-on-exit",
+            "desc",
+        ],
+        BlockContext::Return => &[
+            "type",
+            "check-type",
+            "success",
+            "success-range",
+            "error-values",
+            "desc",
+        ],
+        BlockContext::None => &[],
+    }
+}
+
+/// True if `line` starts with one of `keys` followed by ':'.
+fn starts_subfield(line: &str, keys: &[&str]) -> bool {
+    line.split_once(':')
+        .is_some_and(|(key, _)| keys.contains(&key.trim()))
+}
+
+fn is_indented_line(line: &str) -> bool {
+    line.starts_with("  ") || line.starts_with('\t')
+}
+
+/// Fold free-form prose into paragraphs. Blank lines separate
+/// paragraphs ("\n\n"); wrapped lines inside a paragraph are joined
+/// with spaces, except that a line starting with "- " always begins a
+/// new line so bullet lists survive. Mirrors `_fold_paragraphs()` in
+/// kdoc_apispec.py.
+fn fold_paragraphs(lines: &[&str]) -> String {
+    let mut paragraphs: Vec<Vec<String>> = Vec::new();
+    let mut current: Vec<String> = Vec::new();
+    for line in lines {
+        let line = line.trim();
+        if line.is_empty() {
+            if !current.is_empty() {
+                paragraphs.push(std::mem::take(&mut current));
+            }
+        } else if line.starts_with("- ") || current.is_empty() {
+            current.push(line.to_string());
+        } else if let Some(last) = current.last_mut() {
+            last.push(' ');
+            last.push_str(line);
+        }
+    }
+    if !current.is_empty() {
+        paragraphs.push(current);
+    }
+    paragraphs
+        .iter()
+        .map(|p| p.join("\n"))
+        .collect::<Vec<_>>()
+        .join("\n\n")
+        .replace('\t', " ")
+}
+
+fn expand_tabs(line: &str) -> String {
+    let mut out = String::with_capacity(line.len());
+    let mut col = 0;
+    for c in line.chars() {
+        if c == '\t' {
+            let pad = 8 - col % 8;
+            out.extend(std::iter::repeat_n(' ', pad));
+            col += pad;
+        } else {
+            out.push(c);
+            col += 1;
+        }
+    }
+    out
+}
+
+/// Keep every line of a block on its own line. The indentation shared
+/// by the continuation lines is removed so relative indentation
+/// (nested code) is preserved; runs of blank lines collapse into one.
+/// Mirrors `_fold_lines()` in kdoc_apispec.py.
+fn fold_lines(lines: &[&str]) -> String {
+    let mut lines: Vec<String> = lines
+        .iter()
+        .map(|l| expand_tabs(l).trim_end().to_string())
+        .collect();
+    while lines.first().is_some_and(|l| l.is_empty()) {
+        lines.remove(0);
+    }
+    while lines.last().is_some_and(|l| l.is_empty()) {
+        lines.pop();
+    }
+
+    let indent = |l: &str| l.len() - l.trim_start().len();
+    let base = lines
+        .iter()
+        .skip(1)
+        .filter(|l| !l.is_empty())
+        .map(|l| indent(l))
+        .min()
+        .unwrap_or(0);
+
+    let mut out: Vec<&str> = Vec::new();
+    for line in &lines {
+        if line.is_empty() {
+            if out.last().is_some_and(|l| l.is_empty()) {
+                continue;
+            }
+            out.push("");
+        } else {
+            out.push(&line[base.min(indent(line))..]);
+        }
+    }
+    out.join("\n")
+}
+
+impl KerneldocParser {
+    pub fn new() -> Self {
+        KerneldocParser
+    }
+
+    pub fn parse_kerneldoc(
+        &self,
+        doc: &str,
+        name: &str,
+        api_type: &str,
+        signature: Option<&str>,
+    ) -> Result<ApiSpec> {
+        let mut spec = ApiSpec {
+            name: name.to_string(),
+            api_type: api_type.to_string(),
+            ..Default::default()
+        };
+
+        let lines: Vec<&str> = doc.lines().collect();
+
+        // Extract main description from function name line
+        if let Some(first_line) = lines.first() {
+            if let Some((_, desc)) = first_line.split_once(" - ") {
+                spec.description = Some(desc.trim().to_string());
+            }
+        }
+
+        // Extract type names from SYSCALL_DEFINE signature
+        let type_map = if let Some(sig) = signature {
+            self.extract_types_from_signature(sig)
+        } else {
+            HashMap::new()
+        };
+
+        // Keep track of parameters we've seen (from @param lines)
+        let mut param_map: HashMap<String, ParamSpec> = HashMap::new();
+
+        // Current block being parsed
+        let mut block = BlockContext::None;
+
+        // Temporary storage for current block items
+        let mut current_lock: Option<LockSpec> = None;
+        let mut current_signal: Option<SignalSpec> = None;
+        // Pending symbolic `transform-to:` token. Captured when the parser
+        // sees a non-numeric value, but only reported if the final
+        // `transform_to` after all lines in the signal block is still
+        // unresolved. A later numeric `transform-to:` clears this so we
+        // don't warn about a value that was subsequently overridden.
+        let mut pending_transform_warning: Option<String> = None;
+        let mut current_capability: Option<CapabilitySpec> = None;
+        let mut current_side_effect: Option<SideEffectSpec> = None;
+        let mut current_constraint: Option<ConstraintSpec> = None;
+        let mut current_error: Option<ErrorSpec> = None;
+        let mut current_return: Option<ReturnSpec> = None;
+        let mut current_state_trans: Option<StateTransitionSpec> = None;
+
+        let mut i = 0;
+
+        while i < lines.len() {
+            let line = lines[i];
+            let trimmed = line.trim();
+
+            // Skip empty lines
+            if trimmed.is_empty() {
+                i += 1;
+                continue;
+            }
+
+            // Check if this is an indented continuation line (part of current block)
+            let is_indented = is_indented_line(line);
+
+            // If indented and we're in a block, parse as block attribute.
+            // A subfield line absorbs the lines that follow it until the
+            // next known subfield key, so a value such as
+            // `constraint-type: mask(FOO | BAR |` ... `| BAZ)` or a
+            // wrapped `condition:` arrives as a single logical line.
+            if is_indented && block != BlockContext::None {
+                let keys = block_subfield_keys(&block);
+                let mut logical = trimmed.to_string();
+                if starts_subfield(trimmed, keys) {
+                    let mut j = i + 1;
+                    while j < lines.len() {
+                        let next = lines[j];
+                        let next_trim = next.trim();
+                        if next_trim.is_empty() {
+                            j += 1;
+                            continue;
+                        }
+                        if !is_indented_line(next) || starts_subfield(next_trim, keys) {
+                            break;
+                        }
+                        logical.push(' ');
+                        logical.push_str(next_trim);
+                        i = j;
+                        j += 1;
+                    }
+                }
+                self.parse_block_attribute(
+                    &logical,
+                    &block,
+                    &mut param_map,
+                    &mut current_error,
+                    &mut current_signal,
+                    &mut pending_transform_warning,
+                    &mut current_capability,
+                    &mut current_side_effect,
+                    &mut current_constraint,
+                    &mut current_lock,
+                    &mut current_return,
+                    &mut current_state_trans,
+                );
+                i += 1;
+                continue;
+            }
+
+            // Not indented or not in block: flush current block if any.
+            // If a symbolic `transform-to:` was captured and no later
+            // numeric line cleared it, surface the warning now; by
+            // construction `transform_to` is None in that case.
+            if matches!(block, BlockContext::Signal) {
+                if let Some(raw) = pending_transform_warning.take() {
+                    eprintln!(
+                        "kapi: warning: transform-to: {raw:?} is symbolic; \
+                         source-mode cannot resolve signal numbers portably. \
+                         Use --vmlinux or --debugfs to get the resolved value.",
+                    );
+                }
+            }
+            self.flush_block(
+                &mut block,
+                &mut spec,
+                &mut current_error,
+                &mut current_signal,
+                &mut current_capability,
+                &mut current_side_effect,
+                &mut current_constraint,
+                &mut current_lock,
+                &mut current_return,
+                &mut current_state_trans,
+            );
+
+            // Parse top-level annotations
+            if let Some(rest) = trimmed.strip_prefix("@") {
+                // @param: description (standard kerneldoc parameter)
+                if let Some((param_name, desc)) = rest.split_once(':') {
+                    let param_name = param_name.trim();
+                    let desc = desc.trim();
+                    if !param_name.contains('-') {
+                        let idx = param_map.len() as u32;
+                        let type_name = type_map.get(param_name).cloned().unwrap_or_default();
+                        param_map.insert(
+                            param_name.to_string(),
+                            ParamSpec {
+                                index: idx,
+                                name: param_name.to_string(),
+                                type_name,
+                                description: desc.to_string(),
+                                flags: 0,
+                                param_type: 0,
+                                constraint_type: 0,
+                                constraint: None,
+                                min_value: None,
+                                max_value: None,
+                                valid_mask: None,
+                                enum_values: vec![],
+                                size: None,
+                                alignment: None,
+                                size_param_idx: None,
+                            },
+                        );
+                    }
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("long-desc:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_paragraphs(&section);
+                spec.long_description = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            } else if let Some(rest) = trimmed.strip_prefix("context-flags:") {
+                spec.context_flags = self.parse_context_flags(rest.trim());
+            } else if let Some(rest) = trimmed.strip_prefix("contexts:") {
+                // Short form: "contexts: process, sleepable"
+                spec.context_flags = self.parse_context_list(rest.trim());
+            } else if let Some(rest) = trimmed.strip_prefix("param-count:") {
+                spec.param_count = rest.trim().parse().ok();
+            }
+            // Block-start annotations
+            else if let Some(rest) = trimmed.strip_prefix("param:") {
+                let param_name = rest.trim().to_string();
+                block = BlockContext::Param(param_name.clone());
+                // Ensure param exists in map
+                if !param_map.contains_key(&param_name) {
+                    let idx = param_map.len() as u32;
+                    let type_name = type_map
+                        .get(param_name.as_str())
+                        .cloned()
+                        .unwrap_or_default();
+                    param_map.insert(
+                        param_name.clone(),
+                        ParamSpec {
+                            index: idx,
+                            name: param_name,
+                            type_name,
+                            description: String::new(),
+                            flags: 0,
+                            param_type: 0,
+                            constraint_type: 0,
+                            constraint: None,
+                            min_value: None,
+                            max_value: None,
+                            valid_mask: None,
+                            enum_values: vec![],
+                            size: None,
+                            alignment: None,
+                            size_param_idx: None,
+                        },
+                    );
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("error:") {
+                // error: NAME, condition
+                let parts: Vec<&str> = rest.splitn(2, ',').map(|s| s.trim()).collect();
+                if !parts.is_empty() {
+                    let error_name = parts[0].to_string();
+                    let condition = if parts.len() >= 2 {
+                        parts[1].to_string()
+                    } else {
+                        String::new()
+                    };
+                    let error_code = self.error_name_to_code(&error_name);
+                    current_error = Some(ErrorSpec {
+                        error_code,
+                        name: error_name.clone(),
+                        condition,
+                        description: String::new(),
+                    });
+                    block = BlockContext::Error(error_name);
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("signal:") {
+                let signal_name = rest.trim().to_string();
+                current_signal = Some(SignalSpec {
+                    signal_num: 0,
+                    signal_name,
+                    direction: 1,
+                    action: 0,
+                    target: None,
+                    condition: None,
+                    description: None,
+                    restartable: false,
+                    timing: 0,
+                    priority: 0,
+                    interruptible: false,
+                    queue: None,
+                    sa_flags: 0,
+                    sa_flags_required: 0,
+                    sa_flags_forbidden: 0,
+                    state_required: 0,
+                    state_forbidden: 0,
+                    error_on_signal: None,
+                    transform_to: None,
+                });
+                block = BlockContext::Signal;
+            } else if let Some(rest) = trimmed.strip_prefix("capability:") {
+                let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                if !parts.is_empty() {
+                    let cap_name = parts[0].to_string();
+                    let cap_value = self.parse_capability_value(&cap_name);
+                    // If we have 3 parts, it's flat format: capability: CAP, action, name
+                    let (action, name) = if parts.len() >= 3 {
+                        (parts[1].to_string(), parts[2].to_string())
+                    } else {
+                        (String::new(), cap_name.clone())
+                    };
+                    current_capability = Some(CapabilitySpec {
+                        capability: cap_value,
+                        name,
+                        action,
+                        allows: String::new(),
+                        without_cap: String::new(),
+                        check_condition: None,
+                        priority: Some(0),
+                        alternatives: vec![],
+                    });
+                    block = BlockContext::Capability;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("side-effect:") {
+                // Could be flat format (comma-separated) or block start
+                let rest = rest.trim();
+                // Check if it's the flat format with commas
+                let comma_parts: Vec<&str> = rest.splitn(3, ',').map(|s| s.trim()).collect();
+                if comma_parts.len() >= 3 {
+                    // Flat format: side-effect: TYPE, target, desc
+                    let mut effect = SideEffectSpec {
+                        effect_type: self.parse_effect_type(comma_parts[0]),
+                        target: comma_parts[1].to_string(),
+                        condition: None,
+                        description: comma_parts[2].to_string(),
+                        reversible: false,
+                    };
+                    if comma_parts[2].contains("reversible=yes") {
+                        effect.reversible = true;
+                    }
+                    spec.side_effects.push(effect);
+                } else {
+                    // Block format: side-effect: TYPE
+                    current_side_effect = Some(SideEffectSpec {
+                        effect_type: self.parse_effect_type(rest),
+                        target: String::new(),
+                        condition: None,
+                        description: String::new(),
+                        reversible: false,
+                    });
+                    block = BlockContext::SideEffect;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("state-trans:") {
+                // Flat form: state-trans: OBJECT, FROM, TO, DESCRIPTION
+                // (the description may itself contain commas). Block
+                // form: a bare OBJECT followed by from:/to:/condition:/
+                // desc: subfields.
+                if rest.contains(',') {
+                    let mut parts = rest.splitn(4, ',').map(|s| s.trim());
+                    let mut next_part = || parts.next().unwrap_or_default().to_string();
+                    spec.state_transitions.push(StateTransitionSpec {
+                        object: next_part(),
+                        from_state: next_part(),
+                        to_state: next_part(),
+                        condition: None,
+                        description: next_part(),
+                    });
+                } else {
+                    current_state_trans = Some(StateTransitionSpec {
+                        object: rest.trim().to_string(),
+                        from_state: String::new(),
+                        to_state: String::new(),
+                        condition: None,
+                        description: String::new(),
+                    });
+                }
+                block = BlockContext::StateTransition;
+            } else if let Some(rest) = trimmed.strip_prefix("constraint:") {
+                let rest = rest.trim();
+                // Could be flat format: constraint: name, desc
+                // Or block format: constraint: name
+                let parts: Vec<&str> = rest.splitn(2, ',').map(|s| s.trim()).collect();
+                if parts.len() >= 2 {
+                    // Flat format
+                    current_constraint = Some(ConstraintSpec {
+                        name: parts[0].to_string(),
+                        description: parts[1].to_string(),
+                        expression: None,
+                    });
+                } else {
+                    // Block format
+                    current_constraint = Some(ConstraintSpec {
+                        name: rest.to_string(),
+                        description: String::new(),
+                        expression: None,
+                    });
+                }
+                block = BlockContext::Constraint;
+            } else if let Some(rest) = trimmed.strip_prefix("lock:") {
+                let rest = rest.trim();
+                // Could be flat: lock: name, type
+                // Or block: lock: name
+                let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                if parts.len() >= 2 {
+                    current_lock = Some(LockSpec {
+                        lock_name: parts[0].to_string(),
+                        lock_type: self.parse_lock_type(parts[1]),
+                        scope: super::KAPI_LOCK_INTERNAL,
+                        description: String::new(),
+                    });
+                } else {
+                    current_lock = Some(LockSpec {
+                        lock_name: rest.to_string(),
+                        lock_type: 0,
+                        scope: super::KAPI_LOCK_INTERNAL,
+                        description: String::new(),
+                    });
+                }
+                block = BlockContext::Lock;
+            }
+            // Other top-level annotations
+            else if let Some(rest) = trimmed.strip_prefix("return:") {
+                let rest = rest.trim();
+                if rest.is_empty() {
+                    // Block format
+                    current_return = Some(ReturnSpec {
+                        type_name: String::new(),
+                        description: String::new(),
+                        return_type: 0,
+                        check_type: 0,
+                        success_value: None,
+                        success_min: None,
+                        success_max: None,
+                        error_values: vec![],
+                    });
+                    block = BlockContext::Return;
+                }
+            } else if let Some(rest) = trimmed.strip_prefix("examples:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_lines(&section);
+                spec.examples = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            } else if let Some(rest) = trimmed.strip_prefix("notes:") {
+                let (section, next_i) = self.collect_section(&lines, i, rest);
+                let val = fold_paragraphs(&section);
+                spec.notes = Some(val).filter(|v| !v.is_empty());
+                i = next_i;
+                continue;
+            }
+
+            i += 1;
+        }
+
+        // Flush any remaining block. Emit a pending symbolic
+        // `transform-to:` warning if the final state still has no
+        // resolved numeric value (see per-line loop for rationale).
+        if matches!(block, BlockContext::Signal) {
+            if let Some(raw) = pending_transform_warning.take() {
+                eprintln!(
+                    "kapi: warning: transform-to: {raw:?} is symbolic; \
+                     source-mode cannot resolve signal numbers portably. \
+                     Use --vmlinux or --debugfs to get the resolved value.",
+                );
+            }
+        }
+        self.flush_block(
+            &mut block,
+            &mut spec,
+            &mut current_error,
+            &mut current_signal,
+            &mut current_capability,
+            &mut current_side_effect,
+            &mut current_constraint,
+            &mut current_lock,
+            &mut current_return,
+            &mut current_state_trans,
+        );
+
+        // Convert param_map to vec preserving order
+        let mut params: Vec<ParamSpec> = param_map.into_values().collect();
+        params.sort_by_key(|p| p.index);
+
+        // If the spec carries an explicit param-count, warn when it
+        // disagrees with the number of param: blocks we actually saw.
+        if let Some(claimed) = spec.param_count {
+            if claimed as usize != params.len() {
+                eprintln!(
+                    "kapi: {}: param-count: {} disagrees with {} param: block(s)",
+                    name,
+                    claimed,
+                    params.len(),
+                );
+            }
+        }
+
+        for param in &mut params {
+            param.drop_unset_string_limits();
+        }
+        spec.parameters = params;
+
+        Ok(spec)
+    }
+
+    /// Parse an indented attribute line within a block
+    #[allow(clippy::too_many_arguments)]
+    fn parse_block_attribute(
+        &self,
+        trimmed: &str,
+        block: &BlockContext,
+        param_map: &mut HashMap<String, ParamSpec>,
+        current_error: &mut Option<ErrorSpec>,
+        current_signal: &mut Option<SignalSpec>,
+        pending_transform_warning: &mut Option<String>,
+        current_capability: &mut Option<CapabilitySpec>,
+        current_side_effect: &mut Option<SideEffectSpec>,
+        current_constraint: &mut Option<ConstraintSpec>,
+        current_lock: &mut Option<LockSpec>,
+        current_return: &mut Option<ReturnSpec>,
+        current_state_trans: &mut Option<StateTransitionSpec>,
+    ) {
+        match block {
+            BlockContext::Param(param_name) => {
+                if let Some(param) = param_map.get_mut(param_name) {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        // Accept either:
+                        //   type: KAPI_TYPE_UINT              (long, single token)
+                        //   type: uint                        (short, single token)
+                        //   type: uint, input                 (short, type + flags)
+                        //   type: path, input                 (short, type + flags)
+                        // Single-token inputs leave flags alone; they are
+                        // set by a separate `flags:` line.
+                        //
+                        // User-space pointer types (user_ptr, path) imply
+                        // KAPI_PARAM_USER, so specs don't need to repeat
+                        // `user` after the type.
+                        let mut parts = rest.split(',').map(str::trim);
+                        let type_token = parts.next();
+                        if let Some(ty) = type_token {
+                            param.param_type = self.parse_param_type(ty);
+                        }
+                        for flag in parts {
+                            param.flags |= self.parse_param_flag_token(flag);
+                        }
+                        if type_token.map(type_implies_user_flag).unwrap_or(false) {
+                            param.flags |= 1 << 6; // KAPI_PARAM_USER
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("flags:") {
+                        param.flags = self.parse_param_flags(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("constraint-type:") {
+                        // Accepts `KAPI_CONSTRAINT_*` enum tokens or
+                        // function-call expressions like `range(0, 4096)`
+                        // / `mask(0xff)` / `buffer(2)` that also populate
+                        // the matching numeric fields on `param`.
+                        let text = rest.trim();
+                        if !self.apply_constraint_expr(param, text) {
+                            param.constraint_type = self.parse_constraint_type(text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("valid-mask:") {
+                        // Symbolic mask values need cpp-level resolution;
+                        // leave that to the binary reader.
+                        let _ = rest;
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("cdesc:")
+                        .or_else(|| trimmed.strip_prefix("constraint:"))
+                    {
+                        // Free-text constraint description.
+                        param.constraint = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("range:") {
+                        let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
+                        if parts.len() >= 2 {
+                            param.min_value = parts[0].parse().ok();
+                            param.max_value = parts[1].parse().ok();
+                            param.constraint_type = 1; // KAPI_CONSTRAINT_RANGE
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("size-param:") {
+                        param.size_param_idx = rest.trim().parse().ok();
+                    } else if let Some(rest) = trimmed.strip_prefix("description:") {
+                        param.description = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        param.description = rest.trim().to_string();
+                    } else if !trimmed.contains(':') || trimmed.starts_with("  ") {
+                        // Continuation of the previous attribute's value.
+                        if let Some(c) = param.constraint.as_mut() {
+                            c.push(' ');
+                            c.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Error(_) => {
+                if let Some(error) = current_error.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if error.description.is_empty() {
+                            error.description = text;
+                        } else {
+                            error.description.push(' ');
+                            error.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        error.condition = rest.trim().to_string();
+                    } else {
+                        // Continuation of description
+                        if !error.description.is_empty() {
+                            error.description.push(' ');
+                            error.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Signal => {
+                if let Some(signal) = current_signal.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("direction:") {
+                        signal.direction = self.parse_signal_direction(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("action:") {
+                        signal.action = self.parse_signal_action(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        signal.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if signal.description.is_none() {
+                            signal.description = Some(text);
+                        } else if let Some(d) = signal.description.as_mut() {
+                            d.push(' ');
+                            d.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("errno:") {
+                        // `error:` cannot be used here because kerneldoc
+                        // promotes it to a top-level section header.
+                        //
+                        // Accepted forms:
+                        //   errno: -4         -> numeric literal, stored as-is
+                        //   errno: -EINTR     -> kernel convention; resolve
+                        //                       the symbol and negate
+                        //   errno: EINTR      -> bare symbol; resolved value
+                        //                       is already negative
+                        let value = rest.trim();
+                        signal.error_on_signal = if let Ok(code) = value.parse::<i32>() {
+                            Some(code)
+                        } else if let Some(name) = value.strip_prefix('-') {
+                            // `error_name_to_code` already returns the negated
+                            // code (e.g. "EINTR" -> -4), so `-EINTR` resolves
+                            // to -4 too; the leading `-` on the symbolic form
+                            // is kernel-source convention, not a second negation.
+                            Some(self.error_name_to_code(name))
+                        } else {
+                            Some(self.error_name_to_code(value))
+                        };
+                    } else if let Some(rest) = trimmed.strip_prefix("timing:") {
+                        signal.timing = self.parse_signal_timing(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("restartable:") {
+                        let val = rest.trim().to_lowercase();
+                        signal.restartable = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else if let Some(rest) = trimmed.strip_prefix("interruptible:") {
+                        let val = rest.trim().to_lowercase();
+                        signal.interruptible = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else if let Some(rest) = trimmed.strip_prefix("priority:") {
+                        signal.priority = rest.trim().parse().unwrap_or(0);
+                    } else if let Some(rest) = trimmed.strip_prefix("target:") {
+                        signal.target = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("queue:")
+                        .or_else(|| trimmed.strip_prefix("queue_behavior:"))
+                    {
+                        signal.queue = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("number:") {
+                        signal.signal_num = rest.trim().parse().unwrap_or(0);
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("transform-to:")
+                        .or_else(|| trimmed.strip_prefix("transform_to:"))
+                        .or_else(|| trimmed.strip_prefix("transform:"))
+                    {
+                        // transform-to: takes a signal constant (e.g.
+                        // SIGKILL) or a numeric literal. Only a numeric
+                        // literal fills `transform_to`; symbolic values
+                        // cannot be resolved portably in userspace
+                        // because signal numbers are arch-dependent and
+                        // we have no access to the target arch's
+                        // <asm/signal.h>. Report such cases to stderr so
+                        // they are not silently lost, and point the user
+                        // at --vmlinux / --debugfs, which consult the
+                        // compiled struct where the C preprocessor has
+                        // already baked in the correct value.
+                        //
+                        // Assign unconditionally so the last line in
+                        // the kerneldoc wins and an intended symbolic
+                        // override doesn't silently leave a stale
+                        // numeric value from an earlier line. The
+                        // warning is deferred until flush_block() so a
+                        // subsequent numeric line can cancel it; if the
+                        // last line was still symbolic we report it
+                        // then.
+                        let v = rest.trim();
+                        let parsed = v.parse::<i32>().ok();
+                        signal.transform_to = parsed;
+                        if parsed.is_some() {
+                            *pending_transform_warning = None;
+                        } else if !v.is_empty() {
+                            *pending_transform_warning = Some(v.to_string());
+                        }
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("sa-flags-required:")
+                        .or_else(|| trimmed.strip_prefix("sa_flags_required:"))
+                    {
+                        signal.sa_flags_required = self.parse_hex_or_bitmask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("sa-flags-forbidden:")
+                        .or_else(|| trimmed.strip_prefix("sa_flags_forbidden:"))
+                    {
+                        signal.sa_flags_forbidden = self.parse_hex_or_bitmask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("state-required:")
+                        .or_else(|| trimmed.strip_prefix("state_required:"))
+                    {
+                        signal.state_required = self.parse_signal_state_mask(rest.trim());
+                    } else if let Some(rest) = trimmed
+                        .strip_prefix("state-forbidden:")
+                        .or_else(|| trimmed.strip_prefix("state_forbidden:"))
+                    {
+                        signal.state_forbidden = self.parse_signal_state_mask(rest.trim());
+                    } else {
+                        // Continuation of description
+                        if let Some(d) = signal.description.as_mut() {
+                            d.push(' ');
+                            d.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Capability => {
+                if let Some(cap) = current_capability.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        cap.action = canon_kapi_cap_action(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("allows:") {
+                        cap.allows = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("without:") {
+                        cap.without_cap = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        cap.check_condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("priority:") {
+                        cap.priority = rest.trim().parse().ok();
+                    }
+                }
+            }
+            BlockContext::SideEffect => {
+                if let Some(effect) = current_side_effect.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("target:") {
+                        effect.target = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        effect.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if effect.description.is_empty() {
+                            effect.description = text;
+                        } else {
+                            effect.description.push(' ');
+                            effect.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("reversible:") {
+                        let val = rest.trim().to_lowercase();
+                        effect.reversible = matches!(val.as_str(), "yes" | "true" | "1");
+                    } else {
+                        // Continuation of description
+                        if !effect.description.is_empty() {
+                            effect.description.push(' ');
+                            effect.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Constraint => {
+                if let Some(constraint) = current_constraint.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if constraint.description.is_empty() {
+                            constraint.description = text;
+                        } else {
+                            constraint.description.push(' ');
+                            constraint.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("expr:") {
+                        constraint.expression = Some(rest.trim().to_string());
+                    } else {
+                        // Continuation of description
+                        if !constraint.description.is_empty() {
+                            constraint.description.push(' ');
+                            constraint.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Lock => {
+                if let Some(lock) = current_lock.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        lock.lock_type = self.parse_lock_type(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("scope:") {
+                        lock.scope = match rest.trim() {
+                            "internal" => super::KAPI_LOCK_INTERNAL,
+                            "acquires" => super::KAPI_LOCK_ACQUIRES,
+                            "releases" => super::KAPI_LOCK_RELEASES,
+                            "caller_held" => super::KAPI_LOCK_CALLER_HELD,
+                            _ => super::KAPI_LOCK_INTERNAL,
+                        };
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if lock.description.is_empty() {
+                            lock.description = text;
+                        } else {
+                            lock.description.push(' ');
+                            lock.description.push_str(&text);
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("acquired:") {
+                        // Same rule as kdoc_apispec.py: a lock that is both
+                        // acquired and released stays KAPI_LOCK_INTERNAL.
+                        if matches!(rest.trim().to_lowercase().as_str(), "true" | "yes") {
+                            lock.scope = if lock.scope == super::KAPI_LOCK_RELEASES {
+                                super::KAPI_LOCK_INTERNAL
+                            } else {
+                                super::KAPI_LOCK_ACQUIRES
+                            };
+                        }
+                    } else if let Some(rest) = trimmed.strip_prefix("released:") {
+                        if matches!(rest.trim().to_lowercase().as_str(), "true" | "yes") {
+                            lock.scope = if lock.scope == super::KAPI_LOCK_ACQUIRES {
+                                super::KAPI_LOCK_INTERNAL
+                            } else {
+                                super::KAPI_LOCK_RELEASES
+                            };
+                        }
+                    } else {
+                        // Continuation of description
+                        if !lock.description.is_empty() {
+                            lock.description.push(' ');
+                            lock.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::Return => {
+                if let Some(ret) = current_return.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("type:") {
+                        let raw = rest.trim();
+                        ret.type_name = raw.to_string();
+                        ret.return_type = self.parse_param_type(raw);
+                    } else if let Some(rest) = trimmed.strip_prefix("check-type:") {
+                        ret.check_type = self.parse_return_check_type(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("success:") {
+                        (ret.success_value, ret.success_min, ret.success_max) =
+                            parse_return_success(rest.trim());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        let text = rest.trim().to_string();
+                        if ret.description.is_empty() {
+                            ret.description = text;
+                        } else {
+                            ret.description.push(' ');
+                            ret.description.push_str(&text);
+                        }
+                    } else {
+                        // Continuation of description
+                        if !ret.description.is_empty() {
+                            ret.description.push(' ');
+                            ret.description.push_str(trimmed);
+                        }
+                    }
+                }
+            }
+            BlockContext::StateTransition => {
+                if let Some(trans) = current_state_trans.as_mut() {
+                    if let Some(rest) = trimmed.strip_prefix("object:") {
+                        trans.object = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("from:") {
+                        trans.from_state = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("to:") {
+                        trans.to_state = rest.trim().to_string();
+                    } else if let Some(rest) = trimmed.strip_prefix("condition:") {
+                        trans.condition = Some(rest.trim().to_string());
+                    } else if let Some(rest) = trimmed.strip_prefix("desc:") {
+                        trans.description = rest.trim().to_string();
+                    }
+                }
+            }
+            BlockContext::None => {}
+        }
+    }
+
+    /// Flush the current block, pushing items into the spec
+    #[allow(clippy::too_many_arguments)]
+    fn flush_block(
+        &self,
+        block: &mut BlockContext,
+        spec: &mut ApiSpec,
+        current_error: &mut Option<ErrorSpec>,
+        current_signal: &mut Option<SignalSpec>,
+        current_capability: &mut Option<CapabilitySpec>,
+        current_side_effect: &mut Option<SideEffectSpec>,
+        current_constraint: &mut Option<ConstraintSpec>,
+        current_lock: &mut Option<LockSpec>,
+        current_return: &mut Option<ReturnSpec>,
+        current_state_trans: &mut Option<StateTransitionSpec>,
+    ) {
+        match block {
+            BlockContext::Error(_) => {
+                if let Some(error) = current_error.take() {
+                    spec.errors.push(error);
+                }
+            }
+            BlockContext::Signal => {
+                if let Some(signal) = current_signal.take() {
+                    spec.signals.push(signal);
+                }
+            }
+            BlockContext::Capability => {
+                if let Some(cap) = current_capability.take() {
+                    spec.capabilities.push(cap);
+                }
+            }
+            BlockContext::SideEffect => {
+                if let Some(effect) = current_side_effect.take() {
+                    spec.side_effects.push(effect);
+                }
+            }
+            BlockContext::Constraint => {
+                if let Some(constraint) = current_constraint.take() {
+                    spec.constraints.push(constraint);
+                }
+            }
+            BlockContext::Lock => {
+                if let Some(lock) = current_lock.take() {
+                    spec.locks.push(lock);
+                }
+            }
+            BlockContext::Return => {
+                if let Some(mut ret) = current_return.take() {
+                    ret.keep_used_success_fields();
+                    spec.return_spec = Some(ret);
+                }
+            }
+            BlockContext::StateTransition => {
+                if let Some(trans) = current_state_trans.take() {
+                    spec.state_transitions.push(trans);
+                }
+            }
+            _ => {}
+        }
+        *block = BlockContext::None;
+    }
+
+    /// Extract parameter type names from SYSCALL_DEFINE signature
+    fn extract_types_from_signature(&self, sig: &str) -> HashMap<String, String> {
+        let mut types = HashMap::new();
+
+        // Find content between outermost parens
+        let content = if let Some(start) = sig.find('(') {
+            let end = sig.rfind(')').unwrap_or(sig.len());
+            &sig[start + 1..end]
+        } else {
+            return types;
+        };
+
+        // Split by comma and process type/name pairs
+        // SYSCALL_DEFINE format: (syscall_name, type1, name1, type2, name2, ...)
+        let parts: Vec<&str> = content.split(',').map(|s| s.trim()).collect();
+
+        // Skip first part (syscall name), then process pairs
+        let mut i = 1;
+        while i + 1 < parts.len() {
+            let type_part = parts[i].trim();
+            let name_part = parts[i + 1].trim();
+
+            // Build the type_name string: "type name"
+            let type_name = format!("{} {}", type_part, name_part);
+            types.insert(name_part.to_string(), type_name);
+
+            i += 2;
+        }
+
+        types
+    }
+
+    /// Collect the raw lines of a free-form section: the text after the
+    /// key plus every following indented or blank line, up to the next
+    /// annotation line.
+    fn collect_section<'a>(
+        &self,
+        lines: &[&'a str],
+        start_idx: usize,
+        first_part: &'a str,
+    ) -> (Vec<&'a str>, usize) {
+        let mut section = vec![first_part];
+        let mut i = start_idx + 1;
+
+        while i < lines.len() {
+            let line = lines[i];
+            if self.is_annotation_line(line) {
+                break;
+            }
+            if !line.trim().is_empty() && !is_indented_line(line) {
+                break;
+            }
+            section.push(line);
+            i += 1;
+        }
+
+        (section, i)
+    }
+
+    fn is_annotation_line(&self, line: &str) -> bool {
+        let trimmed = line.trim_start();
+        if !trimmed.contains(':') {
+            return false;
+        }
+        let annotations = [
+            "param:",
+            "param-count:",
+            "error:",
+            "lock:",
+            "signal:",
+            "side-effect:",
+            "state-trans:",
+            "capability:",
+            "constraint:",
+            "return:",
+            "examples:",
+            "notes:",
+            "context-",
+            "long-desc:",
+            "api-type:",
+        ];
+
+        for ann in &annotations {
+            if trimmed.starts_with(ann) {
+                return true;
+            }
+        }
+        false
+    }
+
+    /// Parse a constraint expression and apply it to `param`.
+    /// Shapes:
+    ///   NAME                         (e.g. "user_path", "nonzero")
+    ///   NAME ( ARG (, ARG)* )        (e.g. "range(0, 4096)", "buffer(2)")
+    /// Returns true if the expression matched a known constraint kind,
+    /// populating `param`'s numeric fields. Returns false if the text
+    /// is free-form, leaving `param` untouched.
+    fn apply_constraint_expr(&self, param: &mut ParamSpec, text: &str) -> bool {
+        let t = text.trim();
+        if t.is_empty() {
+            return false;
+        }
+        // Split NAME ( ARGS ); no nesting, no escaping.
+        let (name, args): (&str, Option<&str>) = match (t.find('('), t.rfind(')')) {
+            (Some(lp), Some(rp)) if rp > lp => (t[..lp].trim(), Some(t[lp + 1..rp].trim())),
+            _ => (t, None),
+        };
+        // Bail out on anything that looks like free text (spaces inside the
+        // name part).
+        if name.contains(char::is_whitespace) || name.is_empty() {
+            return false;
+        }
+        let name_lc = name.to_ascii_lowercase();
+        let split_args = || -> Vec<String> {
+            args.map(|a| a.split(',').map(|s| s.trim().to_string()).collect())
+                .unwrap_or_default()
+        };
+        match name_lc.as_str() {
+            "range" => {
+                let a = split_args();
+                if a.len() != 2 {
+                    return false;
+                }
+                param.min_value = parse_i64_literal(&a[0]);
+                param.max_value = parse_i64_literal(&a[1]);
+                param.constraint_type = 1; // KAPI_CONSTRAINT_RANGE
+                true
+            }
+            "mask" => {
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                // Symbolic masks (e.g. "O_RDONLY | O_WRONLY | ...") can't
+                // be resolved at parse time; leave valid_mask as None so
+                // downstream consumers treat the mask as unknown, matching
+                // the long-form `valid-mask:` handler (which also leaves
+                // the slot untouched when the value isn't a literal).
+                param.valid_mask = parse_u64_literal(&a[0]);
+                param.constraint_type = 2; // KAPI_CONSTRAINT_MASK
+                true
+            }
+            "enum" => {
+                let a = split_args();
+                if a.is_empty() {
+                    return false;
+                }
+                // --vmlinux and --debugfs report each value in decimal, so
+                // numeric literals are normalised; symbolic names stay.
+                param.enum_values = a
+                    .into_iter()
+                    .map(|v| parse_i64_literal(&v).map_or(v, |n| n.to_string()))
+                    .collect();
+                param.constraint_type = 3; // KAPI_CONSTRAINT_ENUM
+                true
+            }
+            "alignment" | "align" => {
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                param.alignment = parse_u64_literal(&a[0]).and_then(|n| u32::try_from(n).ok());
+                param.constraint_type = 4; // KAPI_CONSTRAINT_ALIGNMENT
+                true
+            }
+            "power_of_two" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 5; // KAPI_CONSTRAINT_POWER_OF_TWO
+                true
+            }
+            "page_aligned" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 6; // KAPI_CONSTRAINT_PAGE_ALIGNED
+                true
+            }
+            "nonzero" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 7; // KAPI_CONSTRAINT_NONZERO
+                true
+            }
+            "user_string" => {
+                // Optional size argument: user_string(N)
+                if let Some(arg) = args {
+                    if let Some(n) = parse_u64_literal(arg).and_then(|n| u32::try_from(n).ok()) {
+                        param.size = Some(n);
+                    }
+                }
+                param.constraint_type = 8; // KAPI_CONSTRAINT_USER_STRING
+                true
+            }
+            "user_path" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 9; // KAPI_CONSTRAINT_USER_PATH
+                true
+            }
+            "user_ptr" => {
+                if args.is_some() {
+                    return false;
+                }
+                param.constraint_type = 10; // KAPI_CONSTRAINT_USER_PTR
+                true
+            }
+            "buffer" => {
+                // buffer(size_param_idx): capture the index into
+                // param.size_param_idx so it matches the long-form
+                // `size-param: N` handler below (and the C struct
+                // field populated by KAPI_PARAM_SIZE_PARAM()).
+                let a = split_args();
+                if a.len() != 1 {
+                    return false;
+                }
+                param.size_param_idx = a[0].parse().ok();
+                param.constraint_type = 11; // KAPI_CONSTRAINT_BUFFER
+                true
+            }
+            "custom" => {
+                // custom(fn_name): record function name as free-text constraint
+                // so downstream tooling can wire it up.
+                if let Some(arg) = args {
+                    param.constraint = Some(arg.trim().to_string());
+                }
+                param.constraint_type = 12; // KAPI_CONSTRAINT_CUSTOM
+                true
+            }
+            _ => false,
+        }
+    }
+
+    fn parse_context_flags(&self, flags: &str) -> Vec<String> {
+        flags
+            .split('|')
+            .map(|f| self.ctx_alias(f.trim()).to_string())
+            .filter(|f| !f.is_empty())
+            .collect()
+    }
+
+    /// Parse a comma-separated short-form context list
+    /// (e.g. "process, sleepable" -> ["KAPI_CTX_PROCESS", "KAPI_CTX_SLEEPABLE"]).
+    /// Tokens that already look like KAPI_CTX_* are passed through.
+    fn parse_context_list(&self, flags: &str) -> Vec<String> {
+        flags
+            .split(',')
+            .map(|f| self.ctx_alias(f.trim()).to_string())
+            .filter(|f| !f.is_empty())
+            .collect()
+    }
+
+    /// Canonicalise a single context token to its KAPI_CTX_* spelling.
+    /// Short aliases are case-insensitive. Unknown tokens pass through
+    /// verbatim so mixed/long-form input keeps working.
+    fn ctx_alias(&self, tok: &str) -> String {
+        let t = tok.trim();
+        if t.is_empty() {
+            return String::new();
+        }
+        match t.to_ascii_lowercase().as_str() {
+            "process" => "KAPI_CTX_PROCESS".to_string(),
+            "softirq" => "KAPI_CTX_SOFTIRQ".to_string(),
+            "hardirq" => "KAPI_CTX_HARDIRQ".to_string(),
+            "nmi" => "KAPI_CTX_NMI".to_string(),
+            "atomic" => "KAPI_CTX_ATOMIC".to_string(),
+            "sleepable" => "KAPI_CTX_SLEEPABLE".to_string(),
+            "preempt_disabled" => "KAPI_CTX_PREEMPT_DISABLED".to_string(),
+            "irq_disabled" => "KAPI_CTX_IRQ_DISABLED".to_string(),
+            _ => t.to_string(),
+        }
+    }
+
+    fn error_name_to_code(&self, name: &str) -> i32 {
+        match name {
+            "EPERM" => -1,
+            "ENOENT" => -2,
+            "ESRCH" => -3,
+            "EINTR" => -4,
+            "EIO" => -5,
+            "ENXIO" => -6,
+            "E2BIG" => -7,
+            "ENOEXEC" => -8,
+            "EBADF" => -9,
+            "ECHILD" => -10,
+            "EAGAIN" | "EWOULDBLOCK" => -11,
+            "ENOMEM" => -12,
+            "EACCES" => -13,
+            "EFAULT" => -14,
+            "ENOTBLK" => -15,
+            "EBUSY" => -16,
+            "EEXIST" => -17,
+            "EXDEV" => -18,
+            "ENODEV" => -19,
+            "ENOTDIR" => -20,
+            "EISDIR" => -21,
+            "EINVAL" => -22,
+            "ENFILE" => -23,
+            "EMFILE" => -24,
+            "ENOTTY" => -25,
+            "ETXTBSY" => -26,
+            "EFBIG" => -27,
+            "ENOSPC" => -28,
+            "ESPIPE" => -29,
+            "EROFS" => -30,
+            "EMLINK" => -31,
+            "EPIPE" => -32,
+            "EDOM" => -33,
+            "ERANGE" => -34,
+            "EDEADLK" => -35,
+            "ENAMETOOLONG" => -36,
+            "ENOLCK" => -37,
+            "ENOSYS" => -38,
+            "ENOTEMPTY" => -39,
+            "ELOOP" => -40,
+            "ENOMSG" => -42,
+            "ENODATA" => -61,
+            "ENOLINK" => -67,
+            "EPROTO" => -71,
+            "EOVERFLOW" => -75,
+            "ELIBBAD" => -80,
+            "EILSEQ" => -84,
+            "ENOTSOCK" => -88,
+            "EDESTADDRREQ" => -89,
+            "EMSGSIZE" => -90,
+            "EPROTOTYPE" => -91,
+            "ENOPROTOOPT" => -92,
+            "EPROTONOSUPPORT" => -93,
+            "EOPNOTSUPP" | "ENOTSUP" => -95,
+            "EADDRINUSE" => -98,
+            "EADDRNOTAVAIL" => -99,
+            "ENETDOWN" => -100,
+            "ENETUNREACH" => -101,
+            "ENETRESET" => -102,
+            "ECONNABORTED" => -103,
+            "ECONNRESET" => -104,
+            "ENOBUFS" => -105,
+            "EISCONN" => -106,
+            "ENOTCONN" => -107,
+            "ETIMEDOUT" => -110,
+            "ECONNREFUSED" => -111,
+            "EALREADY" => -114,
+            "EINPROGRESS" => -115,
+            "ESTALE" => -116,
+            "EDQUOT" => -122,
+            "ENOMEDIUM" => -123,
+            "ENOKEY" => -126,
+            "EHWPOISON" => -133,
+            "ERESTARTSYS" => -512,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_TYPE_* token (or its short-form alias) to the numeric
+    /// value declared in `enum kapi_param_type` in
+    /// `include/linux/kernel_api_spec.h`.
+    fn parse_param_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_TYPE_VOID" => 0,
+            "KAPI_TYPE_INT" => 1,
+            "KAPI_TYPE_UINT" => 2,
+            "KAPI_TYPE_PTR" => 3,
+            "KAPI_TYPE_STRUCT" => 4,
+            "KAPI_TYPE_UNION" => 5,
+            "KAPI_TYPE_ENUM" => 6,
+            "KAPI_TYPE_FUNC_PTR" => 7,
+            "KAPI_TYPE_ARRAY" => 8,
+            "KAPI_TYPE_FD" => 9,
+            "KAPI_TYPE_USER_PTR" => 10,
+            "KAPI_TYPE_PATH" => 11,
+            "KAPI_TYPE_CUSTOM" => 12,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "void" => 0,
+                "int" => 1,
+                "uint" => 2,
+                "ptr" => 3,
+                "struct" => 4,
+                "union" => 5,
+                "enum" => 6,
+                "func_ptr" => 7,
+                "array" => 8,
+                "fd" => 9,
+                "user_ptr" | "uptr" => 10,
+                "path" => 11,
+                "custom" => 12,
+                _ => 0,
+            },
+        }
+    }
+
+    /// Map a KAPI_CONSTRAINT_* token to the numeric value declared in
+    /// `enum kapi_constraint_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_constraint_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_CONSTRAINT_NONE" => 0,
+            "KAPI_CONSTRAINT_RANGE" => 1,
+            "KAPI_CONSTRAINT_MASK" => 2,
+            "KAPI_CONSTRAINT_ENUM" => 3,
+            "KAPI_CONSTRAINT_ALIGNMENT" => 4,
+            "KAPI_CONSTRAINT_POWER_OF_TWO" => 5,
+            "KAPI_CONSTRAINT_PAGE_ALIGNED" => 6,
+            "KAPI_CONSTRAINT_NONZERO" => 7,
+            "KAPI_CONSTRAINT_USER_STRING" => 8,
+            "KAPI_CONSTRAINT_USER_PATH" => 9,
+            "KAPI_CONSTRAINT_USER_PTR" => 10,
+            "KAPI_CONSTRAINT_BUFFER" => 11,
+            "KAPI_CONSTRAINT_CUSTOM" => 12,
+            _ => 0,
+        }
+    }
+
+    fn parse_param_flags(&self, flags: &str) -> u32 {
+        flags
+            .split('|')
+            .map(|f| self.parse_param_flag_token(f.trim()))
+            .fold(0, |acc, bit| acc | bit)
+    }
+
+    /// Parse one flag token (long or short form, case-insensitive for
+    /// short form). Returns 0 for unknown tokens.
+    fn parse_param_flag_token(&self, tok: &str) -> u32 {
+        let t = tok.trim();
+        // KAPI_PARAM_* names and upper-case short forms first.
+        match t {
+            "KAPI_PARAM_IN" | "IN" => return 1,
+            "KAPI_PARAM_OUT" | "OUT" => return 2,
+            "KAPI_PARAM_INOUT" | "INOUT" => return 3,
+            "KAPI_PARAM_OPTIONAL" | "OPTIONAL" => return 1 << 3,
+            "KAPI_PARAM_CONST" | "CONST" => return 1 << 4,
+            "KAPI_PARAM_VOLATILE" | "VOLATILE" => return 1 << 5,
+            "KAPI_PARAM_USER" | "USER" => return 1 << 6,
+            "KAPI_PARAM_DMA" | "DMA" => return 1 << 7,
+            "KAPI_PARAM_ALIGNED" | "ALIGNED" => return 1 << 8,
+            _ => {}
+        }
+        // English short aliases (case-insensitive).
+        match t.to_ascii_lowercase().as_str() {
+            "input" => 1,
+            "output" => 2,
+            "inout" => 3,
+            "optional" => 1 << 3,
+            "const" => 1 << 4,
+            "volatile" => 1 << 5,
+            "user" => 1 << 6,
+            "dma" => 1 << 7,
+            "aligned" => 1 << 8,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_LOCK_* token to the numeric value declared in
+    /// `enum kapi_lock_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_lock_type(&self, type_str: &str) -> u32 {
+        let s = type_str.trim();
+        match s {
+            "KAPI_LOCK_NONE" => 0,
+            "KAPI_LOCK_MUTEX" => 1,
+            "KAPI_LOCK_SPINLOCK" => 2,
+            "KAPI_LOCK_RWLOCK" => 3,
+            "KAPI_LOCK_SEQLOCK" => 4,
+            "KAPI_LOCK_RCU" => 5,
+            "KAPI_LOCK_SEMAPHORE" => 6,
+            "KAPI_LOCK_CUSTOM" => 7,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "none" => 0,
+                "mutex" => 1,
+                "spinlock" => 2,
+                "rwlock" => 3,
+                "seqlock" => 4,
+                "rcu" => 5,
+                "semaphore" => 6,
+                "custom" => 7,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_direction(&self, dir: &str) -> u32 {
+        let s = dir.trim();
+        match s {
+            "KAPI_SIGNAL_RECEIVE" => 1,
+            "KAPI_SIGNAL_SEND" => 2,
+            "KAPI_SIGNAL_HANDLE" => 4,
+            "KAPI_SIGNAL_BLOCK" => 8,
+            "KAPI_SIGNAL_IGNORE" => 16,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "receive" => 1,
+                "send" => 2,
+                "handle" => 4,
+                "block" => 8,
+                "ignore" => 16,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_action(&self, action: &str) -> u32 {
+        let s = action.trim();
+        match s {
+            "KAPI_SIGNAL_ACTION_DEFAULT" => 0,
+            "KAPI_SIGNAL_ACTION_TERMINATE" => 1,
+            "KAPI_SIGNAL_ACTION_COREDUMP" => 2,
+            "KAPI_SIGNAL_ACTION_STOP" => 3,
+            "KAPI_SIGNAL_ACTION_CONTINUE" => 4,
+            "KAPI_SIGNAL_ACTION_CUSTOM" => 5,
+            "KAPI_SIGNAL_ACTION_RETURN" => 6,
+            "KAPI_SIGNAL_ACTION_RESTART" => 7,
+            "KAPI_SIGNAL_ACTION_QUEUE" => 8,
+            "KAPI_SIGNAL_ACTION_DISCARD" => 9,
+            "KAPI_SIGNAL_ACTION_TRANSFORM" => 10,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "default" => 0,
+                "terminate" => 1,
+                "coredump" => 2,
+                "stop" => 3,
+                "continue" => 4,
+                "custom" => 5,
+                "return" => 6,
+                "restart" => 7,
+                "queue" => 8,
+                "discard" => 9,
+                "transform" => 10,
+                _ => 0,
+            },
+        }
+    }
+
+    fn parse_signal_timing(&self, timing: &str) -> u32 {
+        let s = timing.trim();
+        match s {
+            "KAPI_SIGNAL_TIME_BEFORE" => 0,
+            "KAPI_SIGNAL_TIME_DURING" => 1,
+            "KAPI_SIGNAL_TIME_AFTER" => 2,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "before" => 0,
+                "during" => 1,
+                "after" => 2,
+                _ => 0,
+            },
+        }
+    }
+
+    /// Accept a hex literal ("0x4"), a decimal literal ("4"), or a '|'-separated
+    /// bitmask expression. Unknown tokens contribute 0.
+    fn parse_hex_or_bitmask(&self, value: &str) -> u32 {
+        let v = value.trim();
+        if let Some(hex) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
+            if let Ok(n) = u32::from_str_radix(hex, 16) {
+                return n;
+            }
+        }
+        if let Ok(n) = v.parse::<u32>() {
+            return n;
+        }
+        let mut acc = 0u32;
+        for part in v.split(['|', ',']) {
+            let t = part.trim();
+            if t.is_empty() {
+                continue;
+            }
+            if let Some(hex) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
+                if let Ok(n) = u32::from_str_radix(hex, 16) {
+                    acc |= n;
+                    continue;
+                }
+            }
+            if let Ok(n) = t.parse::<u32>() {
+                acc |= n;
+            }
+        }
+        acc
+    }
+
+    /// Parse a '|'-separated list of KAPI_SIGNAL_STATE_* tokens (or short
+    /// names like "RUNNING") and OR their bit values together. Matches the
+    /// BIT(N) definitions in kernel_api_spec.h.
+    fn parse_signal_state_mask(&self, value: &str) -> u32 {
+        let mut acc = 0u32;
+        for part in value.split(['|', ',']) {
+            let t = part.trim().trim_start_matches("KAPI_SIGNAL_STATE_");
+            let bit = match t.to_ascii_uppercase().as_str() {
+                "RUNNING" => 1 << 0,
+                "SLEEPING" => 1 << 1,
+                "STOPPED" => 1 << 2,
+                "TRACED" => 1 << 3,
+                "ZOMBIE" => 1 << 4,
+                "DEAD" => 1 << 5,
+                _ => 0,
+            };
+            acc |= bit;
+        }
+        acc
+    }
+
+    /// Bitmask of `KAPI_EFFECT_*` values joined by '|' or ','.
+    /// Values match `enum kapi_side_effect_type` in
+    /// `include/linux/kernel_api_spec.h`.
+    fn parse_effect_type(&self, type_str: &str) -> u32 {
+        let sep = if type_str.contains('|') || !type_str.contains(',') {
+            '|'
+        } else {
+            ','
+        };
+        let mut result = 0;
+        for flag in type_str.split(sep) {
+            let t = flag.trim();
+            let bit = match t {
+                "KAPI_EFFECT_NONE" => 0,
+                "KAPI_EFFECT_ALLOC_MEMORY" => 1 << 0,
+                "KAPI_EFFECT_FREE_MEMORY" => 1 << 1,
+                "KAPI_EFFECT_MODIFY_STATE" => 1 << 2,
+                "KAPI_EFFECT_SIGNAL_SEND" => 1 << 3,
+                "KAPI_EFFECT_FILE_POSITION" => 1 << 4,
+                "KAPI_EFFECT_LOCK_ACQUIRE" => 1 << 5,
+                "KAPI_EFFECT_LOCK_RELEASE" => 1 << 6,
+                "KAPI_EFFECT_RESOURCE_CREATE" => 1 << 7,
+                "KAPI_EFFECT_RESOURCE_DESTROY" => 1 << 8,
+                "KAPI_EFFECT_SCHEDULE" => 1 << 9,
+                "KAPI_EFFECT_HARDWARE" => 1 << 10,
+                "KAPI_EFFECT_NETWORK" => 1 << 11,
+                "KAPI_EFFECT_FILESYSTEM" => 1 << 12,
+                "KAPI_EFFECT_PROCESS_STATE" => 1 << 13,
+                "KAPI_EFFECT_IRREVERSIBLE" => 1 << 14,
+                _ => match t.to_ascii_lowercase().as_str() {
+                    "none" => 0,
+                    "alloc_memory" => 1 << 0,
+                    "free_memory" => 1 << 1,
+                    "modify_state" => 1 << 2,
+                    "signal_send" => 1 << 3,
+                    "file_position" => 1 << 4,
+                    "lock_acquire" => 1 << 5,
+                    "lock_release" => 1 << 6,
+                    "resource_create" => 1 << 7,
+                    "resource_destroy" => 1 << 8,
+                    "schedule" => 1 << 9,
+                    "hardware" => 1 << 10,
+                    "network" => 1 << 11,
+                    "filesystem" => 1 << 12,
+                    "process_state" => 1 << 13,
+                    "irreversible" => 1 << 14,
+                    _ => 0,
+                },
+            };
+            result |= bit;
+        }
+        result
+    }
+
+    fn parse_capability_value(&self, cap: &str) -> i32 {
+        match cap {
+            "CAP_CHOWN" => 0,
+            "CAP_DAC_OVERRIDE" => 1,
+            "CAP_DAC_READ_SEARCH" => 2,
+            "CAP_FOWNER" => 3,
+            "CAP_FSETID" => 4,
+            "CAP_KILL" => 5,
+            "CAP_SETGID" => 6,
+            "CAP_SETUID" => 7,
+            "CAP_SETPCAP" => 8,
+            "CAP_LINUX_IMMUTABLE" => 9,
+            "CAP_NET_BIND_SERVICE" => 10,
+            "CAP_NET_BROADCAST" => 11,
+            "CAP_NET_ADMIN" => 12,
+            "CAP_NET_RAW" => 13,
+            "CAP_IPC_LOCK" => 14,
+            "CAP_IPC_OWNER" => 15,
+            "CAP_SYS_MODULE" => 16,
+            "CAP_SYS_RAWIO" => 17,
+            "CAP_SYS_CHROOT" => 18,
+            "CAP_SYS_PTRACE" => 19,
+            "CAP_SYS_PACCT" => 20,
+            "CAP_SYS_ADMIN" => 21,
+            "CAP_SYS_BOOT" => 22,
+            "CAP_SYS_NICE" => 23,
+            "CAP_SYS_RESOURCE" => 24,
+            "CAP_SYS_TIME" => 25,
+            "CAP_SYS_TTY_CONFIG" => 26,
+            "CAP_MKNOD" => 27,
+            "CAP_LEASE" => 28,
+            "CAP_AUDIT_WRITE" => 29,
+            "CAP_AUDIT_CONTROL" => 30,
+            "CAP_SETFCAP" => 31,
+            "CAP_MAC_OVERRIDE" => 32,
+            "CAP_MAC_ADMIN" => 33,
+            "CAP_SYSLOG" => 34,
+            "CAP_WAKE_ALARM" => 35,
+            "CAP_BLOCK_SUSPEND" => 36,
+            "CAP_AUDIT_READ" => 37,
+            "CAP_PERFMON" => 38,
+            "CAP_BPF" => 39,
+            "CAP_CHECKPOINT_RESTORE" => 40,
+            _ => 0,
+        }
+    }
+
+    /// Map a KAPI_RETURN_* token to the numeric value declared in
+    /// `enum kapi_return_check_type` in `include/linux/kernel_api_spec.h`.
+    fn parse_return_check_type(&self, check: &str) -> u32 {
+        let s = check.trim();
+        match s {
+            "KAPI_RETURN_EXACT" => 0,
+            "KAPI_RETURN_RANGE" => 1,
+            "KAPI_RETURN_ERROR_CHECK" => 2,
+            "KAPI_RETURN_FD" => 3,
+            "KAPI_RETURN_CUSTOM" => 4,
+            "KAPI_RETURN_NO_RETURN" => 5,
+            _ => match s.to_ascii_lowercase().as_str() {
+                "exact" => 0,
+                "range" => 1,
+                "error_check" => 2,
+                "fd" => 3,
+                "custom" => 4,
+                "no_return" => 5,
+                _ => 0,
+            },
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn parser() -> KerneldocParser {
+        KerneldocParser::new()
+    }
+
+    #[test]
+    fn parse_minimal_kerneldoc() {
+        let doc = "\
+sys_foo - Do something useful
+context-flags: KAPI_CTX_PROCESS
+param-count: 1
+@fd: The file descriptor
+error: EBADF, Bad file descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_foo", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.name, "sys_foo");
+        assert_eq!(spec.api_type, "syscall");
+        assert_eq!(spec.description.as_deref(), Some("Do something useful"));
+        assert_eq!(spec.param_count, Some(1));
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].name, "fd");
+        assert_eq!(spec.parameters[0].description, "The file descriptor");
+        assert_eq!(spec.errors.len(), 1);
+        assert_eq!(spec.errors[0].name, "EBADF");
+        assert_eq!(spec.errors[0].error_code, -9);
+    }
+
+    #[test]
+    fn parse_multiple_param_types() {
+        let doc = "\
+sys_bar - Multiple params
+@fd: file descriptor arg
+@buf: user buffer
+@count: byte count
+@flags: option flags
+param: fd
+  type: KAPI_TYPE_FD
+param: buf
+  type: KAPI_TYPE_USER_PTR
+param: count
+  type: KAPI_TYPE_UINT
+param: flags
+  type: KAPI_TYPE_UINT
+";
+        let sig = "(bar, int, fd, char __user *, buf, size_t, count, unsigned long, flags)";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_bar", "syscall", Some(sig))
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 4);
+
+        let fd_param = spec.parameters.iter().find(|p| p.name == "fd").unwrap();
+        assert_eq!(fd_param.param_type, 9); // FD (kernel enum)
+
+        let buf_param = spec.parameters.iter().find(|p| p.name == "buf").unwrap();
+        assert_eq!(buf_param.param_type, 10); // USER_PTR (kernel enum)
+        assert_eq!(buf_param.type_name, "char __user * buf");
+
+        let count_param = spec.parameters.iter().find(|p| p.name == "count").unwrap();
+        assert_eq!(count_param.param_type, 2); // UINT
+
+        let flags_param = spec.parameters.iter().find(|p| p.name == "flags").unwrap();
+        assert_eq!(flags_param.param_type, 2); // UINT
+    }
+
+    #[test]
+    fn parse_error_codes_with_descriptions() {
+        let doc = "\
+sys_err - Error test
+error: EBADF
+  desc: Bad file descriptor
+  condition: fd < 0
+error: EFAULT
+  desc: Bad user pointer
+  condition: buf is NULL
+error: EINVAL
+  desc: Invalid argument
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_err", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.errors.len(), 3);
+
+        assert_eq!(spec.errors[0].name, "EBADF");
+        assert_eq!(spec.errors[0].error_code, -9);
+        assert_eq!(spec.errors[0].description, "Bad file descriptor");
+        assert_eq!(spec.errors[0].condition, "fd < 0");
+
+        assert_eq!(spec.errors[1].name, "EFAULT");
+        assert_eq!(spec.errors[1].error_code, -14);
+        assert_eq!(spec.errors[1].description, "Bad user pointer");
+
+        assert_eq!(spec.errors[2].name, "EINVAL");
+        assert_eq!(spec.errors[2].error_code, -22);
+        assert_eq!(spec.errors[2].description, "Invalid argument");
+    }
+
+    #[test]
+    fn parse_context_flags() {
+        let doc = "\
+sys_ctx - Context test
+context-flags: KAPI_CTX_PROCESS|KAPI_CTX_SLEEPABLE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.context_flags.len(), 2);
+        assert_eq!(spec.context_flags[0], "KAPI_CTX_PROCESS");
+        assert_eq!(spec.context_flags[1], "KAPI_CTX_SLEEPABLE");
+    }
+
+    #[test]
+    fn parse_context_list_short() {
+        // "contexts: process, sleepable" -> KAPI_CTX_PROCESS | SLEEPABLE
+        let doc = "\
+sys_ctx - Context test
+contexts: process, sleepable
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_context_list_mixed() {
+        // Short tokens intermixed with explicit KAPI_CTX_* still work.
+        let doc = "\
+sys_ctx - Context test
+contexts: process, KAPI_CTX_SLEEPABLE, softirq
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+                "KAPI_CTX_SOFTIRQ".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_context_flags_long_with_short_token() {
+        // Long-form "context-flags:" accepts "|"-joined short aliases.
+        let doc = "\
+sys_ctx - Context test
+context-flags: process | KAPI_CTX_SLEEPABLE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ctx", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.context_flags,
+            vec![
+                "KAPI_CTX_PROCESS".to_string(),
+                "KAPI_CTX_SLEEPABLE".to_string(),
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_param_type_short_combined() {
+        // "type: uint, input" combines the type and flag aliases.
+        let doc = "\
+sys_t - Short type test
+param: size
+  type: uint, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_t", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].param_type, 2); // KAPI_TYPE_UINT
+        assert_eq!(spec.parameters[0].flags, 1); // KAPI_PARAM_IN
+    }
+
+    #[test]
+    fn parse_param_type_short_multi_flag() {
+        // "type: path, input, user" sets both the IN and USER flags.
+        let doc = "\
+sys_t - Short type test
+param: filename
+  type: path, input, user
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_t", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(spec.parameters[0].param_type, 11); // PATH (kernel enum)
+        assert_eq!(spec.parameters[0].flags, 1 | (1 << 6)); // IN | USER
+    }
+
+    #[test]
+    fn parse_constraint_type_range_expr() {
+        // Short form: "constraint-type: range(0, 4096)" replaces the
+        // two-line long form "constraint-type: KAPI_CONSTRAINT_RANGE"
+        // + "range: 0, 4096".
+        let doc = "\
+sys_c - Constraint test
+param: count
+  type: uint, input
+  constraint-type: range(0, 4096)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 1); // KAPI_CONSTRAINT_RANGE
+        assert_eq!(p.min_value, Some(0));
+        assert_eq!(p.max_value, Some(4096));
+    }
+
+    #[test]
+    fn parse_constraint_type_mask_expr() {
+        let doc = "\
+sys_c - Constraint test
+param: flags
+  type: uint, input
+  constraint-type: mask(0xff)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2); // KAPI_CONSTRAINT_MASK
+        assert_eq!(p.valid_mask, Some(0xff));
+    }
+
+    #[test]
+    fn parse_constraint_type_enum_expr() {
+        let doc = "\
+sys_c - Constraint test
+param: mode
+  type: int, input
+  constraint-type: enum(0, 0x10, -3, MODE_FAST)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 3); // KAPI_CONSTRAINT_ENUM
+        assert_eq!(p.enum_values, ["0", "16", "-3", "MODE_FAST"]);
+    }
+
+    #[test]
+    fn c_integer_literals() {
+        assert_eq!(parse_u64_literal("0"), Some(0));
+        assert_eq!(parse_u64_literal("010"), Some(8));
+        assert_eq!(parse_u64_literal("0755"), Some(0o755));
+        assert_eq!(parse_u64_literal("0x1F"), Some(31));
+        assert_eq!(parse_u64_literal("0b101"), Some(5));
+        assert_eq!(parse_u64_literal("4096UL"), Some(4096));
+        assert_eq!(parse_u64_literal("0xffULL"), Some(255));
+        assert_eq!(parse_u64_literal("08"), None);
+        assert_eq!(parse_u64_literal("0x"), None);
+        assert_eq!(parse_u64_literal("+5"), None);
+        assert_eq!(parse_u64_literal("PAGE_SIZE"), None);
+        assert_eq!(parse_i64_literal("-010"), Some(-8));
+        assert_eq!(parse_i64_literal("-1U"), Some(4294967295));
+        assert_eq!(parse_i64_literal("-0x80000000"), Some(2147483648));
+        assert_eq!(parse_i64_literal("-2147483648"), Some(-2147483648));
+        assert_eq!(parse_i64_literal("-1UL"), Some(-1));
+        assert_eq!(parse_i64_literal("0xFFFFFFFFFFFFFFFF"), Some(-1));
+        assert_eq!(parse_i64_literal("18446744073709551615"), Some(-1));
+        assert_eq!(parse_i64_literal("-0x8000000000000000"), Some(i64::MIN));
+        assert_eq!(parse_u64_literal("-1"), Some(u64::MAX));
+        assert_eq!(parse_u64_literal("1uu"), None);
+        assert_eq!(parse_u64_literal("1lll"), None);
+    }
+
+    #[test]
+    fn parse_constraint_c_literals() {
+        let doc = "\
+sys_c - Constraint test
+param: mode
+  type: int, input
+  constraint-type: enum(010, 0x10, -07, 0b11, 5U)
+param: perms
+  type: uint, input
+  constraint-type: mask(0755)
+param: len
+  type: uint, input
+  constraint-type: range(01, 0x1000UL)
+param: align
+  type: uint, input
+  constraint-type: alignment(0x10)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters;
+        assert_eq!(p[0].enum_values, ["8", "16", "-7", "3", "5"]);
+        assert_eq!(p[1].valid_mask, Some(0o755));
+        assert_eq!((p[2].min_value, p[2].max_value), (Some(1), Some(4096)));
+        assert_eq!(p[3].alignment, Some(16));
+    }
+
+    #[test]
+    fn zero_user_string_limits_are_unset() {
+        let doc = "\
+sys_s - String test
+param: name
+  type: user_ptr, input
+  range: 0, 255
+  constraint-type: user_string
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_s", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 8);
+        assert_eq!((p.min_value, p.max_value), (None, Some(255)));
+    }
+
+    #[test]
+    fn user_ptr_type_implies_user_flag() {
+        let doc = "\
+sys_u - Implicit user flag test
+param: buf
+  type: user_ptr, output
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_u", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 10); // KAPI_TYPE_USER_PTR
+        assert_eq!(
+            p.flags,
+            (1 << 1) | (1 << 6), // OUT | USER
+            "user_ptr type must imply KAPI_PARAM_USER"
+        );
+    }
+
+    #[test]
+    fn fd_type_does_not_imply_user_flag() {
+        // Only user_ptr / path imply KAPI_PARAM_USER. fd, int, uint,
+        // and every other non-user-space type must leave flags alone.
+        let doc = "\
+sys_fd - fd has no implicit user flag
+param: fd
+  type: fd, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_fd", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 9);
+        assert_eq!(p.flags, 1, "fd must not auto-set KAPI_PARAM_USER");
+    }
+
+    #[test]
+    fn path_type_implies_user_flag() {
+        let doc = "\
+sys_p - Path implicit user flag
+param: filename
+  type: path, input
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_p", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.param_type, 11);
+        assert_eq!(
+            p.flags,
+            1 | (1 << 6), // IN | USER
+            "path type must imply KAPI_PARAM_USER"
+        );
+    }
+
+    #[test]
+    fn short_form_enum_equivalence() {
+        // Short-form and long-form renderings of the same spec must
+        // produce identical ApiSpec output across every enum family:
+        // context flags, param type+flags, constraint type, lock type,
+        // signal direction/action/timing, capability action, side-effect
+        // bitmask, return check type.
+        let long = "\
+sys_x - Enum short form test
+context-flags: KAPI_CTX_PROCESS | KAPI_CTX_SLEEPABLE
+
+param: fd
+  type: KAPI_TYPE_FD
+  flags: KAPI_PARAM_IN
+
+lock: files->file_lock
+  type: KAPI_LOCK_SPINLOCK
+  scope: acquires
+  desc: table lock
+
+signal: pending_signals
+  direction: KAPI_SIGNAL_RECEIVE
+  action: KAPI_SIGNAL_ACTION_RETURN
+  timing: KAPI_SIGNAL_TIME_DURING
+  desc: sig
+
+capability: CAP_SYS_ADMIN
+  type: KAPI_CAP_BYPASS_CHECK
+
+return:
+  type: KAPI_TYPE_INT
+  check-type: KAPI_RETURN_FD
+  desc: fd or errno
+
+side-effect: KAPI_EFFECT_RESOURCE_CREATE | KAPI_EFFECT_ALLOC_MEMORY
+  target: t
+  desc: d
+";
+        let short = "\
+sys_x - Enum short form test
+contexts: process, sleepable
+
+param: fd
+  type: fd, input
+
+lock: files->file_lock
+  type: spinlock
+  scope: acquires
+  desc: table lock
+
+signal: pending_signals
+  direction: receive
+  action: return
+  timing: during
+  desc: sig
+
+capability: CAP_SYS_ADMIN
+  type: bypass_check
+
+return:
+  type: int
+  check-type: fd
+  desc: fd or errno
+
+side-effect: resource_create | alloc_memory
+  target: t
+  desc: d
+";
+        let mut sp_l = parser()
+            .parse_kerneldoc(long, "sys_x", "syscall", None)
+            .unwrap();
+        let mut sp_s = parser()
+            .parse_kerneldoc(short, "sys_x", "syscall", None)
+            .unwrap();
+        // The return type name is the human spelling, kept as written.
+        for sp in [&mut sp_l, &mut sp_s] {
+            sp.return_spec.as_mut().unwrap().type_name.clear();
+        }
+        assert_eq!(
+            format!("{:#?}", sp_l),
+            format!("{:#?}", sp_s),
+            "long-form and short-form of every enum family must normalise identically"
+        );
+    }
+
+    #[test]
+    fn parse_buffer_short_captures_size_param_idx() {
+        let doc = "\
+sys_b - Buffer test
+param: buf
+  type: user_ptr, output, user
+  constraint-type: buffer(2)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_b", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters[0].constraint_type, 11);
+        assert_eq!(spec.parameters[0].size_param_idx, Some(2));
+    }
+
+    #[test]
+    fn buffer_short_and_size_param_long_are_symmetric() {
+        let short = "\
+sys_b - Symmetric buffer test
+param: buf
+  type: user_ptr, output, user
+  constraint-type: buffer(2)
+";
+        let long = "\
+sys_b - Symmetric buffer test
+param: buf
+  type: KAPI_TYPE_USER_PTR
+  flags: KAPI_PARAM_OUT | KAPI_PARAM_USER
+  constraint-type: KAPI_CONSTRAINT_BUFFER
+  size-param: 2
+";
+        let sp_s = parser()
+            .parse_kerneldoc(short, "sys_b", "syscall", None)
+            .unwrap();
+        let sp_l = parser()
+            .parse_kerneldoc(long, "sys_b", "syscall", None)
+            .unwrap();
+        assert_eq!(format!("{:#?}", sp_s), format!("{:#?}", sp_l));
+    }
+
+    #[test]
+    fn parse_constraint_type_bare_user_path() {
+        let doc = "\
+sys_c - Constraint test
+param: filename
+  type: path, input, user
+  constraint-type: user_path
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters[0].constraint_type, 9); // USER_PATH
+    }
+
+    #[test]
+    fn starts_subfield_requires_known_key() {
+        let keys = block_subfield_keys(&BlockContext::SideEffect);
+        assert!(super::starts_subfield("target: file table", keys));
+        assert!(super::starts_subfield("condition:", keys));
+        assert!(super::starts_subfield("reversible: yes", keys));
+        // A colon inside prose does not open a subfield.
+        assert!(!super::starts_subfield(
+            "this lock mid-operation: the handler",
+            keys
+        ));
+        assert!(!super::starts_subfield("Careful: it is dangerous", keys));
+        // A key that belongs to another block type does not count.
+        assert!(!super::starts_subfield("direction: receive", keys));
+        assert!(!super::starts_subfield("O_RDONLY | O_WRONLY |", keys));
+        assert!(!super::starts_subfield(")", keys));
+    }
+
+    #[test]
+    fn multiline_fold_stops_at_sibling_block_attribute() {
+        // The constraint-type's continuation must not greedily eat the
+        // next subfield of the same block.
+        let doc = "\
+sys_y - Fold stop test
+param: f
+  type: int, input
+  constraint-type: mask(FOO |
+                        BAR)
+  cdesc: something about f
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_y", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        // If the fold over-consumed, `cdesc:` would have been swallowed
+        // into the mask expression and param.constraint_type would be 0.
+        assert_eq!(spec.parameters[0].constraint_type, 2);
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("something about f")
+        );
+    }
+
+    #[test]
+    fn parse_constraint_type_mask_expr_multiline() {
+        // Real-world sys_open/flags case: a symbolic mask split across
+        // four continuation lines. The parser must fold the continuation
+        // lines before running the function-call match, otherwise the
+        // constraint type silently decays to 0.
+        let doc = "\
+sys_x - Multi-line mask test
+param: f
+  type: int, input
+  constraint-type: mask(O_RDONLY | O_WRONLY | O_RDWR | O_CREAT | O_EXCL | O_NOCTTY |
+                        O_TRUNC | O_APPEND | O_NONBLOCK | O_DSYNC | O_SYNC | FASYNC |
+                        O_DIRECT | O_LARGEFILE | O_DIRECTORY | O_NOFOLLOW | O_NOATIME |
+                        O_CLOEXEC | O_PATH | O_TMPFILE)
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_x", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2, "multi-line mask must set MASK type");
+        // Symbolic mask: must stay unresolved rather than becoming Some(0).
+        assert_eq!(
+            p.valid_mask, None,
+            "symbolic mask values must remain None, not Some(0)"
+        );
+    }
+
+    #[test]
+    fn parse_constraint_long_form() {
+        let doc = "\
+sys_c - Constraint test
+param: foo
+  type: uint, input
+  constraint-type: KAPI_CONSTRAINT_MASK
+  valid-mask: 0xff
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 2); // KAPI_CONSTRAINT_MASK
+    }
+
+    #[test]
+    fn parse_constraint_free_text() {
+        // `constraint:` carries free-text constraint description;
+        // function-call short form lives on `constraint-type:`.
+        let doc = "\
+sys_c - Constraint test
+param: foo
+  type: uint, input
+  constraint: must be a valid page descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_c", "syscall", None)
+            .unwrap();
+
+        let p = &spec.parameters[0];
+        assert_eq!(p.constraint_type, 0);
+        assert_eq!(
+            p.constraint.as_deref(),
+            Some("must be a valid page descriptor")
+        );
+    }
+
+    #[test]
+    fn parse_description_alias_overrides_kerneldoc() {
+        // `description:` inside a `param:` block is an alias for `desc:`
+        // and overrides the @param description.
+        let doc = "\
+sys_d - Description alias test
+@size: kerneldoc short description
+param: size
+  type: uint, input
+  description: The new long form description.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_d", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        assert_eq!(
+            spec.parameters[0].description,
+            "The new long form description."
+        );
+    }
+
+    #[test]
+    fn canonical_equivalence_short_vs_long() {
+        // Two spellings of the same spec must produce identical ApiSpec
+        // JSON.
+        let long = "\
+sys_open - open a file
+context-flags: KAPI_CTX_PROCESS | KAPI_CTX_SLEEPABLE
+
+param: filename
+  type: KAPI_TYPE_PATH
+  flags: KAPI_PARAM_IN | KAPI_PARAM_USER
+  constraint-type: KAPI_CONSTRAINT_USER_PATH
+  desc: Pathname to open
+
+param: count
+  type: KAPI_TYPE_UINT
+  flags: KAPI_PARAM_IN
+  constraint-type: KAPI_CONSTRAINT_RANGE
+  range: 0, 4096
+  desc: Byte count
+";
+        let short = "\
+sys_open - open a file
+contexts: process, sleepable
+
+param: filename
+  type: path, input, user
+  constraint-type: user_path
+  description: Pathname to open
+
+param: count
+  type: uint, input
+  constraint-type: range(0, 4096)
+  description: Byte count
+";
+        let long_spec = parser()
+            .parse_kerneldoc(long, "sys_open", "syscall", None)
+            .unwrap();
+        let short_spec = parser()
+            .parse_kerneldoc(short, "sys_open", "syscall", None)
+            .unwrap();
+
+        // ApiSpec isn't Serialize as a whole, so compare the Debug
+        // rendering, which still proves every field canonicalises
+        // identically.
+        let d_long = format!("{:#?}", long_spec);
+        let d_short = format!("{:#?}", short_spec);
+        assert_eq!(
+            d_long, d_short,
+            "short-form and long-form specs must normalise identically"
+        );
+    }
+
+    #[test]
+    fn parse_capability_block() {
+        let doc = "\
+sys_cap - Capability test
+capability: CAP_SYS_ADMIN
+  type: required
+  allows: Full system administration
+  without: Operation not permitted
+  condition: always
+  priority: 5
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cap", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.capabilities.len(), 1);
+        let cap = &spec.capabilities[0];
+        assert_eq!(cap.capability, 21); // CAP_SYS_ADMIN
+        assert_eq!(cap.action, "required");
+        assert_eq!(cap.allows, "Full system administration");
+        assert_eq!(cap.without_cap, "Operation not permitted");
+        assert_eq!(cap.check_condition.as_deref(), Some("always"));
+        assert_eq!(cap.priority, Some(5));
+    }
+
+    #[test]
+    fn parse_lock_block() {
+        let doc = "\
+sys_lock - Lock test
+lock: files_lock, KAPI_LOCK_MUTEX
+  scope: acquires
+  desc: Protects file table
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_lock", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.locks.len(), 1);
+        let lock = &spec.locks[0];
+        assert_eq!(lock.lock_name, "files_lock");
+        assert_eq!(lock.lock_type, 1); // MUTEX
+        assert_eq!(lock.scope, super::super::KAPI_LOCK_ACQUIRES);
+        assert_eq!(lock.description, "Protects file table");
+    }
+
+    #[test]
+    fn parse_lock_acquired_released_flags() {
+        let doc = "\
+sys_lock - Lock test
+lock: a_lock, KAPI_LOCK_MUTEX
+  acquired: true
+  released: true
+lock: b_lock, KAPI_LOCK_MUTEX
+  acquired: true
+lock: c_lock, KAPI_LOCK_MUTEX
+  released: yes
+lock: d_lock, KAPI_LOCK_MUTEX
+  acquired: conditional
+  released: false
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_lock", "syscall", None)
+            .unwrap();
+
+        let scopes: Vec<u32> = spec.locks.iter().map(|l| l.scope).collect();
+        assert_eq!(
+            scopes,
+            vec![
+                super::super::KAPI_LOCK_INTERNAL,
+                super::super::KAPI_LOCK_ACQUIRES,
+                super::super::KAPI_LOCK_RELEASES,
+                super::super::KAPI_LOCK_INTERNAL,
+            ]
+        );
+    }
+
+    #[test]
+    fn parse_signal_block() {
+        let doc = "\
+sys_sig - Signal test
+signal: SIGKILL
+  direction: KAPI_SIGNAL_RECEIVE
+  action: KAPI_SIGNAL_ACTION_TERMINATE
+  timing: KAPI_SIGNAL_TIME_DURING
+  priority: 3
+  restartable: yes
+  interruptible: yes
+  desc: Process termination signal
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_sig", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.signals.len(), 1);
+        let sig = &spec.signals[0];
+        assert_eq!(sig.signal_name, "SIGKILL");
+        assert_eq!(sig.direction, 1); // RECEIVE
+        assert_eq!(sig.action, 1); // TERMINATE
+        assert_eq!(sig.timing, 1); // DURING
+        assert_eq!(sig.priority, 3);
+        assert!(sig.restartable);
+        assert!(sig.interruptible);
+        assert_eq!(
+            sig.description.as_deref(),
+            Some("Process termination signal")
+        );
+    }
+
+    #[test]
+    fn parse_signal_errno_shapes() {
+        // All three accepted spellings of the signal errno field must
+        // produce the same negative kernel return code.
+        for (form, label) in [
+            ("errno: -EINTR", "-EINTR symbolic"),
+            ("errno: EINTR", "bare symbolic"),
+            ("errno: -4", "numeric literal"),
+        ] {
+            let doc = format!(
+                "sys_s - Signal errno test\n\
+                 signal: SIGINT\n\
+                 \x20 direction: receive\n\
+                 \x20 action: return\n\
+                 \x20 {}\n",
+                form,
+            );
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_s", "syscall", None)
+                .unwrap();
+            assert_eq!(spec.signals.len(), 1, "{label}");
+            assert_eq!(
+                spec.signals[0].error_on_signal,
+                Some(-4),
+                "errno form {label:?} must resolve to -EINTR (-4)",
+            );
+        }
+    }
+
+    #[test]
+    fn parse_side_effect_flat() {
+        let doc = "\
+sys_se - Side effect test
+side-effect: KAPI_EFFECT_MODIFY_STATE, file_table, Allocates a new file descriptor
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_se", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.side_effects.len(), 1);
+        let se = &spec.side_effects[0];
+        assert_eq!(se.effect_type, 1 << 2); // KAPI_EFFECT_MODIFY_STATE
+        assert_eq!(se.target, "file_table");
+        assert_eq!(se.description, "Allocates a new file descriptor");
+    }
+
+    #[test]
+    fn parse_side_effect_block() {
+        let doc = "\
+sys_se2 - Side effect block test
+side-effect: KAPI_EFFECT_ALLOC_MEMORY
+  target: kernel_heap
+  desc: Allocates kernel memory
+  reversible: yes
+  condition: size > 0
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_se2", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.side_effects.len(), 1);
+        let se = &spec.side_effects[0];
+        assert_eq!(se.effect_type, 1 << 0); // KAPI_EFFECT_ALLOC_MEMORY
+        assert_eq!(se.target, "kernel_heap");
+        assert_eq!(se.description, "Allocates kernel memory");
+        assert!(se.reversible);
+        assert_eq!(se.condition.as_deref(), Some("size > 0"));
+    }
+
+    #[test]
+    fn parse_empty_doc_no_error() {
+        let doc = "";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_empty", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.name, "sys_empty");
+        assert!(spec.description.is_none());
+        assert!(spec.parameters.is_empty());
+        assert!(spec.errors.is_empty());
+        assert!(spec.signals.is_empty());
+        assert!(spec.capabilities.is_empty());
+        assert!(spec.locks.is_empty());
+        assert!(spec.side_effects.is_empty());
+        assert!(spec.context_flags.is_empty());
+    }
+
+    #[test]
+    fn parse_missing_sections_no_error() {
+        // Only has a description, no KAPI annotations
+        let doc = "\
+sys_simple - Just a simple syscall
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_simple", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.description.as_deref(), Some("Just a simple syscall"));
+        assert!(spec.parameters.is_empty());
+        assert!(spec.errors.is_empty());
+        assert!(spec.context_flags.is_empty());
+    }
+
+    #[test]
+    fn parse_constraint_block() {
+        let doc = "\
+sys_cst - Constraint test
+constraint: valid_fd
+  desc: File descriptor must be valid and open
+  expr: fd >= 0 && fd < NR_OPEN
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cst", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.constraints.len(), 1);
+        let cst = &spec.constraints[0];
+        assert_eq!(cst.name, "valid_fd");
+        assert_eq!(cst.description, "File descriptor must be valid and open");
+        assert_eq!(cst.expression.as_deref(), Some("fd >= 0 && fd < NR_OPEN"));
+    }
+
+    #[test]
+    fn parse_state_transition_flat() {
+        let doc = "\
+sys_st - State transition test
+state-trans: fd, open, closed, File descriptor is closed
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_st", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.state_transitions.len(), 1);
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.object, "fd");
+        assert_eq!(st.from_state, "open");
+        assert_eq!(st.to_state, "closed");
+        assert_eq!(st.description, "File descriptor is closed");
+    }
+
+    #[test]
+    fn parse_param_block_with_range() {
+        let doc = "\
+sys_rng - Range test
+@count: byte count
+param: count
+  type: KAPI_TYPE_UINT
+  flags: IN
+  range: 0, 4096
+  constraint-type: KAPI_CONSTRAINT_RANGE
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_rng", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.parameters.len(), 1);
+        let p = &spec.parameters[0];
+        assert_eq!(p.name, "count");
+        assert_eq!(p.param_type, 2); // UINT
+        assert_eq!(p.flags, 1); // IN
+        assert_eq!(p.min_value, Some(0));
+        assert_eq!(p.max_value, Some(4096));
+        assert_eq!(p.constraint_type, 1); // RANGE
+    }
+
+    #[test]
+    fn parse_return_block() {
+        let doc = "\
+sys_ret - Return test
+return:
+  type: KAPI_TYPE_INT
+  check-type: KAPI_RETURN_FD
+  success: 0
+  desc: Returns file descriptor on success
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ret", "syscall", None)
+            .unwrap();
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(ret.type_name, "KAPI_TYPE_INT");
+        assert_eq!(ret.return_type, 1); // INT
+        assert_eq!(ret.check_type, 3); // FD
+        assert_eq!(ret.success_value, None); // FD checks carry no success value
+        assert_eq!(ret.description, "Returns file descriptor on success");
+    }
+
+    #[test]
+    fn colon_in_continuation_line_does_not_start_subfield() {
+        let doc = "\
+sys_col - Colon continuation test
+param: x
+  type: int
+  cdesc: Must be sane. Note: this is a continuation
+    with a colon: in the middle.
+
+lock: mylock
+  type: mutex
+  desc: Taken at entry; if the caller drops
+    this lock mid-operation: the handler waits
+    until released.
+
+side-effect: modify_state
+  target: stuff
+  condition: only when a
+    special case applies: for example
+    on Tuesdays
+  desc: Does things.
+    Careful: it is dangerous.
+  reversible: no
+
+signal: SIGINT
+  direction: receive
+  condition: while blocked, unless
+    flagged: see below
+  desc: Interrupts the wait.
+
+capability: CAP_SYS_ADMIN
+  type: bypass_check
+  allows: Things
+    that need: privilege
+  condition: Always
+
+constraint: limit
+  desc: A limit
+    of note: nothing.
+  expr: a &&
+    b
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_col", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("Must be sane. Note: this is a continuation with a colon: in the middle.")
+        );
+        assert_eq!(
+            spec.locks[0].description,
+            "Taken at entry; if the caller drops this lock mid-operation: the handler waits until released."
+        );
+        let se = &spec.side_effects[0];
+        assert_eq!(
+            se.condition.as_deref(),
+            Some("only when a special case applies: for example on Tuesdays")
+        );
+        assert_eq!(se.description, "Does things. Careful: it is dangerous.");
+        assert!(!se.reversible);
+        let sig = &spec.signals[0];
+        assert_eq!(
+            sig.condition.as_deref(),
+            Some("while blocked, unless flagged: see below")
+        );
+        assert_eq!(sig.description.as_deref(), Some("Interrupts the wait."));
+        let cap = &spec.capabilities[0];
+        assert_eq!(cap.allows, "Things that need: privilege");
+        assert_eq!(cap.check_condition.as_deref(), Some("Always"));
+        let cst = &spec.constraints[0];
+        assert_eq!(cst.description, "A limit of note: nothing.");
+        assert_eq!(cst.expression.as_deref(), Some("a && b"));
+    }
+
+    #[test]
+    fn multiline_side_effect_condition_is_kept_whole() {
+        let doc = "\
+sys_mc - Multi-line condition test
+side-effect: modify_state
+  target: userfaultfd event queue
+  condition: MADV_DONTNEED, MADV_FREE
+    on a VMA whose userfaultfd context negotiated
+    UFFD_FEATURE_EVENT_REMOVE
+  desc: Generates a notification.
+    The monitor cannot veto it.
+  reversible: no
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_mc", "syscall", None)
+            .unwrap();
+
+        let se = &spec.side_effects[0];
+        assert_eq!(
+            se.condition.as_deref(),
+            Some(
+                "MADV_DONTNEED, MADV_FREE on a VMA whose userfaultfd context \
+                 negotiated UFFD_FEATURE_EVENT_REMOVE"
+            )
+        );
+        assert_eq!(
+            se.description,
+            "Generates a notification. The monitor cannot veto it."
+        );
+    }
+
+    #[test]
+    fn blank_line_inside_block_continues_subfield() {
+        let doc = "\
+sys_bl - Blank line test
+lock: l
+  type: mutex
+  desc: First half
+
+    second half.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_bl", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.locks[0].description, "First half second half.");
+    }
+
+    #[test]
+    fn param_cdesc_is_the_constraint_text() {
+        let doc = "\
+sys_cd - cdesc test
+param: fd
+  type: int, input
+  cdesc: Must be open.
+    Zero is allowed.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_cd", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.parameters[0].constraint.as_deref(),
+            Some("Must be open. Zero is allowed.")
+        );
+    }
+
+    #[test]
+    fn state_transition_block_keeps_condition_separate() {
+        let doc = "\
+sys_stb - State transition block test
+state-trans: file_descriptor
+  from: open
+  to: closed/free
+  condition: Valid fd passed to close
+  desc: The fd becomes unusable.
+    It may be reused.
+
+state-trans: refcount
+  from: n
+  to: n-1
+  desc: Decremented.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_stb", "syscall", None)
+            .unwrap();
+
+        assert_eq!(spec.state_transitions.len(), 2);
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.object, "file_descriptor");
+        assert_eq!(st.from_state, "open");
+        assert_eq!(st.to_state, "closed/free");
+        assert_eq!(st.condition.as_deref(), Some("Valid fd passed to close"));
+        assert_eq!(st.description, "The fd becomes unusable. It may be reused.");
+        let st = &spec.state_transitions[1];
+        assert_eq!(st.object, "refcount");
+        assert_eq!(st.condition, None);
+        assert_eq!(st.description, "Decremented.");
+    }
+
+    #[test]
+    fn state_transition_flat_description_keeps_commas() {
+        let doc = "\
+sys_stc - Flat state transition test
+state-trans: fd, open, closed, File is closed, and the number is free
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_stc", "syscall", None)
+            .unwrap();
+
+        let st = &spec.state_transitions[0];
+        assert_eq!(st.description, "File is closed, and the number is free");
+        assert_eq!(st.condition, None);
+    }
+
+    #[test]
+    fn return_type_name_is_kept_as_written() {
+        for (written, ty) in [("int", 1), ("long", 0), ("KAPI_TYPE_UINT", 2)] {
+            let doc = format!("sys_rt - Return type test\nreturn:\n  type: {written}\n");
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_rt", "syscall", None)
+                .unwrap();
+            let ret = spec.return_spec.as_ref().unwrap();
+            assert_eq!(ret.type_name, written);
+            assert_eq!(ret.return_type, ty);
+        }
+    }
+
+    #[test]
+    fn return_success_follows_check_type() {
+        let cases = [
+            // (check-type, success, value, min, max)
+            ("exact", "0", Some(0), None, None),
+            ("exact", "= 0", Some(0), None, None),
+            ("exact", "== -1", Some(-1), None, None),
+            ("exact", "0x10", Some(16), None, None),
+            ("exact", ">= 0", None, None, None),
+            ("range", ">= 0", None, Some(0), Some(i64::MAX)),
+            ("range", ">= 1", None, Some(1), Some(i64::MAX)),
+            ("range", "0", None, Some(0), Some(i64::MAX)),
+            ("fd", ">= 0", None, None, None),
+            ("error_check", "0", None, None, None),
+        ];
+        for (check, success, value, min, max) in cases {
+            let doc = format!(
+                "sys_rs - Return success test\nreturn:\n  type: int\n  \
+                 success: {success}\n  check-type: {check}\n  desc: ok\n"
+            );
+            let spec = parser()
+                .parse_kerneldoc(&doc, "sys_rs", "syscall", None)
+                .unwrap();
+            let ret = spec.return_spec.as_ref().unwrap();
+            assert_eq!(ret.success_value, value, "{check} {success}");
+            assert_eq!(ret.success_min, min, "{check} {success}");
+            assert_eq!(ret.success_max, max, "{check} {success}");
+        }
+    }
+
+    #[test]
+    fn return_description_continuation_with_colon() {
+        let doc = "\
+sys_rd - Return description test
+return:
+  type: int
+  check-type: exact
+  success: 0
+  desc: Returns zero on success. Note: this is
+    a continuation line with a colon.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_rd", "syscall", None)
+            .unwrap();
+
+        let ret = spec.return_spec.as_ref().unwrap();
+        assert_eq!(
+            ret.description,
+            "Returns zero on success. Note: this is a continuation line with a colon."
+        );
+        assert_eq!(ret.success_value, Some(0));
+    }
+
+    #[test]
+    fn examples_keep_one_example_per_line() {
+        let doc = "\
+sys_ex - Examples test
+examples: fd = open(\"/etc/passwd\", O_RDONLY);  // Read existing file
+  fd = open(\"/tmp/new\", O_WRONLY | O_CREAT, 0644);  // Create
+  // Handle short writes:
+  while (total < len) {
+    n = write(fd, buf + total, len - total);
+    if (n < 0) break;
+  }
+
+  close(fd);
+
+notes: After the examples.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_ex", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.examples.as_deref(),
+            Some(
+                "fd = open(\"/etc/passwd\", O_RDONLY);  // Read existing file\n\
+                 fd = open(\"/tmp/new\", O_WRONLY | O_CREAT, 0644);  // Create\n\
+                 // Handle short writes:\n\
+                 while (total < len) {\n  \
+                 n = write(fd, buf + total, len - total);\n  \
+                 if (n < 0) break;\n\
+                 }\n\
+                 \n\
+                 close(fd);"
+            )
+        );
+        assert_eq!(spec.notes.as_deref(), Some("After the examples."));
+    }
+
+    #[test]
+    fn notes_and_long_desc_keep_paragraphs_and_bullets() {
+        let doc = "\
+sys_nt - Notes test
+long-desc: First paragraph wraps
+  over two lines.
+
+  Second paragraph introduces a list:
+  - item one wraps
+    onto a second line
+  - item two
+
+  Last paragraph.
+notes: The behavior varies:
+
+  - Regular files: reads
+    from the current position.
+
+  - Pipes: block.
+
+  Trailing: text.
+";
+        let spec = parser()
+            .parse_kerneldoc(doc, "sys_nt", "syscall", None)
+            .unwrap();
+
+        assert_eq!(
+            spec.long_description.as_deref(),
+            Some(
+                "First paragraph wraps over two lines.\n\n\
+                 Second paragraph introduces a list:\n\
+                 - item one wraps onto a second line\n\
+                 - item two\n\n\
+                 Last paragraph."
+            )
+        );
+        assert_eq!(
+            spec.notes.as_deref(),
+            Some(
+                "The behavior varies:\n\n\
+                 - Regular files: reads from the current position.\n\n\
+                 - Pipes: block.\n\n\
+                 Trailing: text."
+            )
+        );
+    }
+
+    #[test]
+    fn fold_lines_handles_leading_blank_and_tabs() {
+        assert_eq!(
+            super::fold_lines(&["", "  a();", "  b();", "    c();", ""]),
+            "a();\nb();\n  c();"
+        );
+        assert_eq!(super::fold_lines(&["x();", "\ty();"]), "x();\ny();");
+        assert_eq!(super::fold_lines(&[""]), "");
+        assert_eq!(super::fold_lines(&["a", "  b", "", "", "  c"]), "a\nb\n\nc");
+    }
+
+    #[test]
+    fn fold_paragraphs_joins_wrapped_lines_only() {
+        assert_eq!(
+            super::fold_paragraphs(&["one", "  two", "", "", "  three", "- four", "- five"]),
+            "one two\n\nthree\n- four\n- five"
+        );
+        assert_eq!(super::fold_paragraphs(&["", "  "]), "");
+    }
+}
diff --git a/tools/kapi/src/extractor/mod.rs b/tools/kapi/src/extractor/mod.rs
new file mode 100644
index 0000000000000..d7ada57924d7a
--- /dev/null
+++ b/tools/kapi/src/extractor/mod.rs
@@ -0,0 +1,442 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::formatter::OutputFormatter;
+use anyhow::Result;
+use std::io::Write;
+
+pub mod debugfs;
+pub mod kerneldoc_parser;
+pub mod source_parser;
+pub mod vmlinux;
+
+pub use debugfs::DebugfsExtractor;
+pub use source_parser::SourceExtractor;
+pub use vmlinux::VmlinuxExtractor;
+
+/// Capability specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct CapabilitySpec {
+    pub capability: i32,
+    pub name: String,
+    pub action: String,
+    pub allows: String,
+    pub without_cap: String,
+    pub check_condition: Option<String>,
+    pub priority: Option<u8>,
+    pub alternatives: Vec<i32>,
+}
+
+/// Parameter specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ParamSpec {
+    pub index: u32,
+    pub name: String,
+    pub type_name: String,
+    pub description: String,
+    pub flags: u32,
+    pub param_type: u32,
+    pub constraint_type: u32,
+    pub constraint: Option<String>,
+    pub min_value: Option<i64>,
+    pub max_value: Option<i64>,
+    pub valid_mask: Option<u64>,
+    pub enum_values: Vec<String>,
+    pub size: Option<u32>,
+    pub alignment: Option<u32>,
+    /// Index of the parameter that carries this parameter's byte count
+    /// (for KAPI_CONSTRAINT_BUFFER). Populated by either
+    /// `size-param: N` (long form) or `constraint-type: buffer(N)`
+    /// (short form).
+    pub size_param_idx: Option<u32>,
+}
+
+/// Constraint type enum values matching kernel enum kapi_constraint_type
+pub const KAPI_CONSTRAINT_RANGE: u32 = 1;
+pub const KAPI_CONSTRAINT_MASK: u32 = 2;
+pub const KAPI_CONSTRAINT_USER_STRING: u32 = 8;
+
+impl ParamSpec {
+    /// Clear the numeric fields the constraint type does not use. The
+    /// compiled struct always carries them, so an unset value reads as 0.
+    pub fn keep_used_numbers(&mut self) {
+        match self.constraint_type {
+            KAPI_CONSTRAINT_RANGE => {}
+            KAPI_CONSTRAINT_USER_STRING => self.drop_unset_string_limits(),
+            _ => {
+                self.min_value = None;
+                self.max_value = None;
+            }
+        }
+        if self.constraint_type != KAPI_CONSTRAINT_MASK {
+            self.valid_mask = None;
+        }
+        self.size = self.size.filter(|&n| n != 0);
+        self.alignment = self.alignment.filter(|&n| n != 0);
+    }
+
+    /// The kernel treats a user_string length limit of 0 as no limit.
+    pub fn drop_unset_string_limits(&mut self) {
+        if self.constraint_type == KAPI_CONSTRAINT_USER_STRING {
+            self.min_value = self.min_value.filter(|&n| n > 0);
+            self.max_value = self.max_value.filter(|&n| n > 0);
+        }
+    }
+}
+
+/// Return value specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ReturnSpec {
+    pub type_name: String,
+    pub description: String,
+    pub return_type: u32,
+    pub check_type: u32,
+    pub success_value: Option<i64>,
+    pub success_min: Option<i64>,
+    pub success_max: Option<i64>,
+    pub error_values: Vec<i32>,
+}
+
+impl ReturnSpec {
+    /// Drop the success fields the check type does not use, the way the
+    /// generated KAPI_RETURN_SUCCESS()/KAPI_RETURN_SUCCESS_RANGE() macros
+    /// only set the one that applies.
+    pub fn keep_used_success_fields(&mut self) {
+        match self.check_type {
+            0 => {
+                self.success_min = None;
+                self.success_max = None;
+            }
+            1 => self.success_value = None,
+            _ => {
+                self.success_value = None;
+                self.success_min = None;
+                self.success_max = None;
+            }
+        }
+    }
+}
+
+/// Error specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ErrorSpec {
+    pub error_code: i32,
+    pub name: String,
+    pub condition: String,
+    pub description: String,
+}
+
+/// Signal specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SignalSpec {
+    pub signal_num: i32,
+    pub signal_name: String,
+    pub direction: u32,
+    pub action: u32,
+    pub target: Option<String>,
+    pub condition: Option<String>,
+    pub description: Option<String>,
+    pub timing: u32,
+    pub priority: u32,
+    pub restartable: bool,
+    pub interruptible: bool,
+    pub queue: Option<String>,
+    pub sa_flags: u32,
+    pub sa_flags_required: u32,
+    pub sa_flags_forbidden: u32,
+    pub state_required: u32,
+    pub state_forbidden: u32,
+    pub error_on_signal: Option<i32>,
+    /// Signal number to transform to (e.g. `SIGKILL` -> 9 on x86).
+    /// Always an integer or null in JSON -- the schema never widens to
+    /// a string. Extractors reading the compiled struct (`--vmlinux`,
+    /// `--debugfs`) populate this directly. The source-kerneldoc parser
+    /// populates it only when the `transform-to:` subfield is a numeric
+    /// literal; symbolic signal names are arch-dependent and cannot be
+    /// resolved portably in userspace, so they are reported via an
+    /// stderr warning and leave this field `None`. Consumers that need
+    /// the resolved number for a symbolic spec should use `--vmlinux`
+    /// or `--debugfs` against a kernel built for the target arch.
+    pub transform_to: Option<i32>,
+}
+
+/// Signal mask specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SignalMaskSpec {
+    pub name: String,
+    pub description: String,
+    pub signals: Vec<i32>,
+}
+
+/// Side effect specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct SideEffectSpec {
+    pub effect_type: u32,
+    pub target: String,
+    pub condition: Option<String>,
+    pub description: String,
+    pub reversible: bool,
+}
+
+/// State transition specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StateTransitionSpec {
+    pub object: String,
+    pub from_state: String,
+    pub to_state: String,
+    pub condition: Option<String>,
+    pub description: String,
+}
+
+/// Constraint specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ConstraintSpec {
+    pub name: String,
+    pub description: String,
+    pub expression: Option<String>,
+}
+
+/// Lock scope enum values matching kernel enum kapi_lock_scope
+pub const KAPI_LOCK_INTERNAL: u32 = 0;
+pub const KAPI_LOCK_ACQUIRES: u32 = 1;
+pub const KAPI_LOCK_RELEASES: u32 = 2;
+pub const KAPI_LOCK_CALLER_HELD: u32 = 3;
+
+/// Lock specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct LockSpec {
+    pub lock_name: String,
+    pub lock_type: u32,
+    pub scope: u32,
+    pub description: String,
+}
+
+/// Struct field specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StructFieldSpec {
+    pub name: String,
+    pub field_type: u32,
+    pub type_name: String,
+    pub offset: usize,
+    pub size: usize,
+    pub flags: u32,
+    pub constraint_type: u32,
+    pub min_value: i64,
+    pub max_value: i64,
+    pub valid_mask: u64,
+    pub description: String,
+}
+
+/// Struct specification
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct StructSpec {
+    pub name: String,
+    pub size: usize,
+    pub alignment: usize,
+    pub field_count: u32,
+    pub fields: Vec<StructFieldSpec>,
+    pub description: String,
+}
+
+/// Common API specification information that all extractors should provide
+#[derive(Debug, Clone, Default)]
+pub struct ApiSpec {
+    pub name: String,
+    pub api_type: String,
+    pub description: Option<String>,
+    pub long_description: Option<String>,
+    pub version: Option<String>,
+    pub context_flags: Vec<String>,
+    pub param_count: Option<u32>,
+    pub error_count: Option<u32>,
+    pub examples: Option<String>,
+    pub notes: Option<String>,
+    // Sysfs-specific fields
+    pub subsystem: Option<String>,
+    pub sysfs_path: Option<String>,
+    pub permissions: Option<String>,
+    pub capabilities: Vec<CapabilitySpec>,
+    pub parameters: Vec<ParamSpec>,
+    pub return_spec: Option<ReturnSpec>,
+    pub errors: Vec<ErrorSpec>,
+    pub signals: Vec<SignalSpec>,
+    pub signal_masks: Vec<SignalMaskSpec>,
+    pub side_effects: Vec<SideEffectSpec>,
+    pub state_transitions: Vec<StateTransitionSpec>,
+    pub constraints: Vec<ConstraintSpec>,
+    pub locks: Vec<LockSpec>,
+    pub struct_specs: Vec<StructSpec>,
+}
+
+/// Trait for extracting API specifications from different sources
+pub trait ApiExtractor {
+    /// Extract all API specifications from the source
+    fn extract_all(&self) -> Result<Vec<ApiSpec>>;
+
+    /// Extract a specific API specification by name
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>>;
+
+    /// Display detailed information about a specific API
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()>;
+}
+
+/// Helper function to display an ApiSpec using a formatter
+pub fn display_api_spec(
+    spec: &ApiSpec,
+    formatter: &mut dyn OutputFormatter,
+    writer: &mut dyn Write,
+) -> Result<()> {
+    formatter.begin_api_details(writer, &spec.name)?;
+
+    if let Some(desc) = &spec.description {
+        formatter.description(writer, desc)?;
+    }
+
+    if let Some(long_desc) = &spec.long_description {
+        formatter.long_description(writer, long_desc)?;
+    }
+
+    if !spec.context_flags.is_empty() {
+        formatter.begin_context_flags(writer)?;
+        for flag in &spec.context_flags {
+            formatter.context_flag(writer, flag)?;
+        }
+        formatter.end_context_flags(writer)?;
+    }
+
+    if !spec.parameters.is_empty() {
+        formatter.begin_parameters(writer, spec.parameters.len().try_into().unwrap_or(u32::MAX))?;
+        for param in &spec.parameters {
+            formatter.parameter(writer, param)?;
+        }
+        formatter.end_parameters(writer)?;
+    }
+
+    if let Some(ret) = &spec.return_spec {
+        formatter.return_spec(writer, ret)?;
+    }
+
+    if !spec.errors.is_empty() {
+        formatter.begin_errors(writer, spec.errors.len().try_into().unwrap_or(u32::MAX))?;
+        for error in &spec.errors {
+            formatter.error(writer, error)?;
+        }
+        formatter.end_errors(writer)?;
+    }
+
+    if let Some(notes) = &spec.notes {
+        formatter.notes(writer, notes)?;
+    }
+
+    if let Some(examples) = &spec.examples {
+        formatter.examples(writer, examples)?;
+    }
+
+    // Display sysfs-specific fields
+    if spec.api_type == "sysfs" {
+        if let Some(subsystem) = &spec.subsystem {
+            formatter.sysfs_subsystem(writer, subsystem)?;
+        }
+        if let Some(path) = &spec.sysfs_path {
+            formatter.sysfs_path(writer, path)?;
+        }
+        if let Some(perms) = &spec.permissions {
+            formatter.sysfs_permissions(writer, perms)?;
+        }
+    }
+
+    if !spec.capabilities.is_empty() {
+        formatter.begin_capabilities(writer)?;
+        for cap in &spec.capabilities {
+            formatter.capability(writer, cap)?;
+        }
+        formatter.end_capabilities(writer)?;
+    }
+
+    // Display signals
+    if !spec.signals.is_empty() {
+        formatter.begin_signals(writer, spec.signals.len().try_into().unwrap_or(u32::MAX))?;
+        for signal in &spec.signals {
+            formatter.signal(writer, signal)?;
+        }
+        formatter.end_signals(writer)?;
+    }
+
+    // Display signal masks
+    if !spec.signal_masks.is_empty() {
+        formatter.begin_signal_masks(
+            writer,
+            spec.signal_masks.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for mask in &spec.signal_masks {
+            formatter.signal_mask(writer, mask)?;
+        }
+        formatter.end_signal_masks(writer)?;
+    }
+
+    // Display side effects
+    if !spec.side_effects.is_empty() {
+        formatter.begin_side_effects(
+            writer,
+            spec.side_effects.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for effect in &spec.side_effects {
+            formatter.side_effect(writer, effect)?;
+        }
+        formatter.end_side_effects(writer)?;
+    }
+
+    // Display state transitions
+    if !spec.state_transitions.is_empty() {
+        formatter.begin_state_transitions(
+            writer,
+            spec.state_transitions.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for trans in &spec.state_transitions {
+            formatter.state_transition(writer, trans)?;
+        }
+        formatter.end_state_transitions(writer)?;
+    }
+
+    // Display constraints
+    if !spec.constraints.is_empty() {
+        formatter.begin_constraints(
+            writer,
+            spec.constraints.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for constraint in &spec.constraints {
+            formatter.constraint(writer, constraint)?;
+        }
+        formatter.end_constraints(writer)?;
+    }
+
+    // Display locks
+    if !spec.locks.is_empty() {
+        formatter.begin_locks(writer, spec.locks.len().try_into().unwrap_or(u32::MAX))?;
+        for lock in &spec.locks {
+            formatter.lock(writer, lock)?;
+        }
+        formatter.end_locks(writer)?;
+    }
+
+    // Display struct specs
+    if !spec.struct_specs.is_empty() {
+        formatter.begin_struct_specs(
+            writer,
+            spec.struct_specs.len().try_into().unwrap_or(u32::MAX),
+        )?;
+        for struct_spec in &spec.struct_specs {
+            formatter.struct_spec(writer, struct_spec)?;
+        }
+        formatter.end_struct_specs(writer)?;
+    }
+
+    formatter.end_api_details(writer)?;
+
+    Ok(())
+}
diff --git a/tools/kapi/src/extractor/source_parser.rs b/tools/kapi/src/extractor/source_parser.rs
new file mode 100644
index 0000000000000..cc50e15f16c83
--- /dev/null
+++ b/tools/kapi/src/extractor/source_parser.rs
@@ -0,0 +1,531 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::kerneldoc_parser::KerneldocParser;
+use super::{display_api_spec, ApiExtractor, ApiSpec};
+use crate::formatter::OutputFormatter;
+use anyhow::{bail, Context, Result};
+use regex::Regex;
+use std::fs;
+use std::io::Write;
+use std::path::Path;
+use walkdir::WalkDir;
+
+/// Extractor for kernel source files with KAPI-annotated kerneldoc
+pub struct SourceExtractor {
+    path: String,
+    parser: KerneldocParser,
+    syscall_regex: Regex,
+    ioctl_regex: Regex,
+    function_regex: Regex,
+}
+
+impl SourceExtractor {
+    pub fn new(path: &str) -> Result<Self> {
+        if !Path::new(path).exists() {
+            bail!("Source path does not exist: {path}");
+        }
+
+        Ok(SourceExtractor {
+            path: path.to_string(),
+            parser: KerneldocParser::new(),
+            syscall_regex: Regex::new(r"SYSCALL_DEFINE\d+\((\w+)")?,
+            ioctl_regex: Regex::new(r"(?:static\s+)?long\s+(\w+_ioctl)\s*\(")?,
+            function_regex: Regex::new(concat!(
+                r"(?m)^(?:static\s+)?(?:inline\s+)?",
+                r"(?:(?:unsigned\s+)?",
+                r"(?:long|int|void|char|short",
+                r"|struct\s+\w+\s*\*?",
+                r"|[\w_]+_t)",
+                r"\s*\*?\s+)?",
+                r"(\w+)\s*\([^)]*\)",
+            ))?,
+        })
+    }
+
+    fn extract_from_file(&self, path: &Path) -> Result<Vec<ApiSpec>> {
+        let content = fs::read_to_string(path)
+            .with_context(|| format!("Failed to read file: {}", path.display()))?;
+
+        self.extract_from_content(&content)
+    }
+
+    /// Same rule as has-apispec in scripts/Makefile.build: a contexts: (or
+    /// context-flags:) line plus one more KAPI section.
+    fn has_context_line(doc: &str) -> bool {
+        const SECTIONS: &[&str] = &[
+            "api-type:",
+            "param:",
+            "error:",
+            "capability:",
+            "signal:",
+            "lock:",
+            "state-trans:",
+            "constraint:",
+            "side-effect:",
+            "long-desc:",
+        ];
+        let has_line = |keys: &[&str]| {
+            doc.lines()
+                .map(str::trim_start)
+                .any(|l| keys.iter().any(|k| l.starts_with(k)))
+        };
+
+        has_line(&["contexts:", "context-flags:"]) && has_line(SECTIONS)
+    }
+
+    fn extract_from_content(&self, content: &str) -> Result<Vec<ApiSpec>> {
+        let mut specs = Vec::new();
+        let mut in_kerneldoc = false;
+        let mut current_doc = String::new();
+        let lines: Vec<&str> = content.lines().collect();
+        let mut i = 0;
+
+        while i < lines.len() {
+            let line = lines[i];
+
+            // Start of kerneldoc comment
+            if line.trim_start().starts_with("/**") {
+                in_kerneldoc = true;
+                current_doc.clear();
+                i += 1;
+                continue;
+            }
+
+            // Inside kerneldoc comment
+            if in_kerneldoc {
+                if line.contains("*/") {
+                    in_kerneldoc = false;
+
+                    // Check if this kerneldoc has KAPI annotations
+                    if Self::has_context_line(&current_doc) {
+                        // Look ahead for the function declaration
+                        if let Some((name, api_type, signature)) =
+                            self.find_function_after(&lines, i + 1)
+                        {
+                            if let Ok(spec) = self.parser.parse_kerneldoc(
+                                &current_doc,
+                                &name,
+                                &api_type,
+                                Some(&signature),
+                            ) {
+                                specs.push(spec);
+                            }
+                        }
+                    }
+                } else {
+                    // Remove leading asterisk and preserve content
+                    let cleaned = if let Some(stripped) = line.trim_start().strip_prefix("*") {
+                        if let Some(no_space) = stripped.strip_prefix(' ') {
+                            no_space
+                        } else {
+                            stripped
+                        }
+                    } else {
+                        line.trim_start()
+                    };
+                    current_doc.push_str(cleaned);
+                    current_doc.push('\n');
+                }
+            }
+
+            i += 1;
+        }
+
+        Ok(specs)
+    }
+
+    fn find_function_after(
+        &self,
+        lines: &[&str],
+        start: usize,
+    ) -> Option<(String, String, String)> {
+        for i in start..lines.len().min(start + 10) {
+            let line = lines[i];
+
+            // Skip blank lines and a plain comment before the function
+            let trimmed = line.trim_start();
+            if trimmed.is_empty() || trimmed.starts_with("/*") || trimmed.starts_with('*') {
+                continue;
+            }
+
+            // Check for SYSCALL_DEFINE
+            if let Some(caps) = self.syscall_regex.captures(line) {
+                let name = format!("sys_{}", caps.get(1).unwrap().as_str());
+                let signature = self.extract_syscall_signature(lines, i);
+                return Some((name, "syscall".to_string(), signature));
+            }
+
+            // Check for ioctl function
+            if let Some(caps) = self.ioctl_regex.captures(line) {
+                let name = caps.get(1).unwrap().as_str().to_string();
+                return Some((name, "ioctl".to_string(), line.to_string()));
+            }
+
+            // Check for regular function
+            if let Some(caps) = self.function_regex.captures(line) {
+                let name = caps.get(1).unwrap().as_str().to_string();
+                return Some((name, "function".to_string(), line.to_string()));
+            }
+
+            // Stop if we hit something that's clearly not part of the function declaration
+            if !line.starts_with(' ') && !line.starts_with('\t') && !line.trim().is_empty() {
+                break;
+            }
+        }
+
+        None
+    }
+
+    fn extract_syscall_signature(&self, lines: &[&str], start: usize) -> String {
+        // Extract the full SYSCALL_DEFINE signature
+        let mut sig = String::new();
+        let mut in_paren = false;
+        let mut paren_count = 0;
+
+        for line in lines.iter().skip(start).take(20) {
+            let line = *line;
+
+            // Start of SYSCALL_DEFINE
+            if line.contains("SYSCALL_DEFINE") {
+                if let Some(pos) = line.find('(') {
+                    sig.push_str(&line[pos..]);
+                    in_paren = true;
+                    paren_count = line[pos..].chars().filter(|&c| c == '(').count()
+                        - line[pos..].chars().filter(|&c| c == ')').count();
+                }
+            } else if in_paren {
+                sig.push(' ');
+                sig.push_str(line.trim());
+                paren_count += line.chars().filter(|&c| c == '(').count();
+                paren_count =
+                    paren_count.saturating_sub(line.chars().filter(|&c| c == ')').count());
+
+                if paren_count == 0 {
+                    break;
+                }
+            }
+        }
+
+        sig
+    }
+}
+
+impl ApiExtractor for SourceExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        let path = Path::new(&self.path);
+        let mut all_specs = Vec::new();
+
+        if path.is_file() {
+            // Single file
+            all_specs.extend(self.extract_from_file(path)?);
+        } else if path.is_dir() {
+            // Directory - walk all .c files
+            for entry in WalkDir::new(path)
+                .into_iter()
+                .filter_map(|e| e.ok())
+                .filter(|e| {
+                    e.path()
+                        .extension()
+                        .is_some_and(|ext| ext == "c" || ext == "h")
+                })
+            {
+                match self.extract_from_file(entry.path()) {
+                    Ok(specs) => all_specs.extend(specs),
+                    Err(e) => {
+                        eprintln!("Warning: failed to parse {}: {}", entry.path().display(), e);
+                    }
+                }
+            }
+        }
+
+        Ok(all_specs)
+    }
+
+    fn extract_by_name(&self, name: &str) -> Result<Option<ApiSpec>> {
+        let all_specs = self.extract_all()?;
+        Ok(all_specs.into_iter().find(|s| s.name == name))
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        output: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.extract_by_name(api_name)? {
+            display_api_spec(&spec, formatter, output)?;
+        } else {
+            writeln!(output, "API '{}' not found", api_name)?;
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn make_extractor() -> SourceExtractor {
+        SourceExtractor::new("/dev/null").unwrap()
+    }
+
+    #[test]
+    fn detect_contexts_spec() {
+        let content = r#"
+/**
+ * sys_foo - do foo
+ * @arg: the argument
+ *
+ * contexts: process, sleepable
+ *
+ * long-desc: Does foo.
+ */
+SYSCALL_DEFINE1(foo, int, arg)
+{
+    return 0;
+}
+
+/**
+ * bar - not a spec, even though the prose mentions context: here
+ * @x: value
+ */
+SYSCALL_DEFINE1(bar, int, x)
+{
+    return 0;
+}
+
+/**
+ * baz - not a spec either
+ * @x: value
+ *
+ * contexts: task, softirq, hardirq, nmi.
+ */
+SYSCALL_DEFINE1(baz, int, x)
+{
+    return 0;
+}
+"#;
+        let specs = make_extractor().extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_foo");
+    }
+
+    #[test]
+    fn prose_mentioning_a_section_key_is_not_a_spec() {
+        let content = r#"
+/**
+ * __probe - check a device
+ * @drv: driver
+ *
+ * returns 0 on success, else error.
+ * side-effect: dev->driver is set to drv when drv claims dev.
+ */
+static int __probe(struct driver *drv, struct device *dev)
+{
+    return 0;
+}
+"#;
+        let specs = make_extractor().extract_from_content(content).unwrap();
+        assert!(specs.is_empty());
+    }
+
+    #[test]
+    fn nonexistent_path_is_rejected() {
+        let err = SourceExtractor::new("/nonexistent/kapi-test")
+            .err()
+            .unwrap();
+
+        assert!(err.to_string().contains("does not exist"), "{err}");
+    }
+
+    #[test]
+    fn has_context_line_needs_two_sections() {
+        let rule = SourceExtractor::has_context_line;
+
+        assert!(rule("contexts: process\nlong-desc: foo\n"));
+        assert!(rule("context-flags: KAPI_CTX_PROCESS\n  lock: foo\n"));
+        assert!(rule("contexts: process\nparam: fd\n"));
+        assert!(rule("contexts: process\nerror: EINVAL, bad fd\n"));
+        assert!(!rule("contexts: process\n"));
+        assert!(!rule("context: process\nlong-desc: foo\n"));
+        assert!(!rule("long-desc: foo\nside-effect: bar\n"));
+    }
+
+    #[test]
+    fn detect_syscall_define3() {
+        let content = r#"
+/**
+ * sys_open - open a file
+ * context-flags: KAPI_CTX_PROCESS
+ * param-count: 3
+ * @filename: pathname to open
+ * param: filename
+ * error: ENOENT, test
+ */
+SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_open");
+        assert_eq!(specs[0].api_type, "syscall");
+    }
+
+    #[test]
+    fn detect_syscall_define1() {
+        let content = r#"
+/**
+ * sys_close - close a file descriptor
+ * context-flags: KAPI_CTX_PROCESS
+ * @fd: file descriptor to close
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE1(close, unsigned int, fd)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_close");
+    }
+
+    #[test]
+    fn detect_syscall_define6() {
+        let content = r#"
+/**
+ * sys_mmap - map memory
+ * context-flags: KAPI_CTX_PROCESS
+ * error: ENOMEM, test
+ */
+SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len, unsigned long, prot,
+    unsigned long, flags, unsigned long, fd, unsigned long, offset)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "sys_mmap");
+    }
+
+    #[test]
+    fn detect_ioctl_pattern() {
+        let content = r#"
+/**
+ * my_ioctl - handle ioctl
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EINVAL, test
+ */
+static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 1);
+        assert_eq!(specs[0].name, "my_ioctl");
+        assert_eq!(specs[0].api_type, "ioctl");
+    }
+
+    #[test]
+    fn find_function_after_skips_blanks() {
+        // Test that find_function_after looks past blank lines
+        let lines = vec!["", "", "SYSCALL_DEFINE2(foo, int, bar, int, baz)", "{"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_some());
+        let (name, api_type, _sig) = result.unwrap();
+        assert_eq!(name, "sys_foo");
+        assert_eq!(api_type, "syscall");
+    }
+
+    #[test]
+    fn find_function_after_skips_plain_comment() {
+        let lines = vec![
+            "/*",
+            " * Careful here!",
+            " */",
+            "SYSCALL_DEFINE1(close, unsigned int, fd)",
+            "{",
+        ];
+        let ext = make_extractor();
+        let (name, api_type, _sig) = ext.find_function_after(&lines, 0).unwrap();
+        assert_eq!(name, "sys_close");
+        assert_eq!(api_type, "syscall");
+    }
+
+    #[test]
+    fn find_function_after_returns_none_for_no_match() {
+        // No function declaration within lookahead range
+        let lines = vec!["#include <linux/fs.h>", "#define FOO 1", "/* comment */"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn find_function_after_detects_regular_function() {
+        let lines = vec!["", "int do_something(struct task_struct *task)", "{"];
+        let ext = make_extractor();
+        let result = ext.find_function_after(&lines, 0);
+        assert!(result.is_some());
+        let (name, api_type, _) = result.unwrap();
+        assert_eq!(name, "do_something");
+        assert_eq!(api_type, "function");
+    }
+
+    #[test]
+    fn no_kapi_annotations_produces_empty() {
+        // kerneldoc without any KAPI annotations should not produce a spec
+        let content = r#"
+/**
+ * my_func - does stuff
+ * @arg: an argument
+ */
+void my_func(int arg)
+{
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert!(specs.is_empty());
+    }
+
+    #[test]
+    fn multiple_syscalls_in_one_file() {
+        let content = r#"
+/**
+ * sys_read - read from fd
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
+{
+    return 0;
+}
+
+/**
+ * sys_write - write to fd
+ * context-flags: KAPI_CTX_PROCESS
+ * error: EBADF, test
+ */
+SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count)
+{
+    return 0;
+}
+"#;
+        let ext = make_extractor();
+        let specs = ext.extract_from_content(content).unwrap();
+        assert_eq!(specs.len(), 2);
+        assert_eq!(specs[0].name, "sys_read");
+        assert_eq!(specs[1].name, "sys_write");
+    }
+}
diff --git a/tools/kapi/src/extractor/vmlinux/binary_utils.rs b/tools/kapi/src/extractor/vmlinux/binary_utils.rs
new file mode 100644
index 0000000000000..046772ea690c1
--- /dev/null
+++ b/tools/kapi/src/extractor/vmlinux/binary_utils.rs
@@ -0,0 +1,461 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+// Array-bound constants matching `include/linux/kernel_api_spec.h`.
+// String fields are `const char *`; call `DataReader::ptr_size()` for
+// the per-target pointer width.
+pub mod sizes {
+    pub const MAX_PARAMS: usize = 16;
+    pub const MAX_ERRORS: usize = 32;
+    pub const MAX_CONSTRAINTS: usize = 32;
+    pub const MAX_LOCKS: usize = 16;
+    pub const MAX_CAPABILITIES: usize = 8;
+    pub const MAX_SIGNALS: usize = 32;
+    pub const MAX_STRUCT_SPECS: usize = 8;
+    pub const MAX_SIDE_EFFECTS: usize = 32;
+    pub const MAX_STATE_TRANS: usize = 8;
+
+    pub const NAME: usize = 0;
+    pub const DESC: usize = 0;
+}
+
+// Section markers of `struct kernel_api_spec`. The kernel only sets a
+// marker when the matching *_COUNT()/KAPI_EXAMPLES() macro is used, so
+// a zero marker is as valid as the expected one.
+pub mod magic {
+    pub const PARAMS: u32 = 0x4B415031; // 'KAP1'
+    pub const RETURN: u32 = 0x4B415232; // 'KAR2'
+    pub const ERRORS: u32 = 0x4B414533; // 'KAE3'
+    pub const LOCKS: u32 = 0x4B414C34; // 'KAL4'
+    pub const CONSTRAINTS: u32 = 0x4B414335; // 'KAC5'
+    pub const INFO: u32 = 0x4B414936; // 'KAI6'
+    pub const SIGNALS: u32 = 0x4B415337; // 'KAS7'
+    pub const SIGMASK: u32 = 0x4B414D38; // 'KAM8'
+    pub const STRUCTS: u32 = 0x4B415439; // 'KAT9'
+    pub const EFFECTS: u32 = 0x4B414641; // 'KAFA'
+    pub const TRANS: u32 = 0x4B415442; // 'KATB'
+    pub const CAPS: u32 = 0x4B414343; // 'KACC'
+}
+
+/// Resolve a virtual-address string pointer against the vmlinux ELF
+/// and return the NUL-terminated C string it points at.
+pub fn resolve_vaddr_string(elf: &goblin::elf::Elf, data: &[u8], vaddr: u64) -> Option<String> {
+    if vaddr == 0 {
+        return None;
+    }
+    for sh in &elf.section_headers {
+        let start = sh.sh_addr;
+        let end = start.checked_add(sh.sh_size)?;
+        if vaddr < start || vaddr >= end {
+            continue;
+        }
+        // File-backed sections only (skip SHT_NOBITS etc.)
+        if sh.sh_type == goblin::elf::section_header::SHT_NOBITS {
+            return None;
+        }
+        let rel = (vaddr - start) as usize;
+        let file_start = sh.sh_offset as usize + rel;
+        if file_start >= data.len() {
+            return None;
+        }
+        let tail = &data[file_start..];
+        let nul = tail.iter().position(|&b| b == 0)?;
+        return std::str::from_utf8(&tail[..nul]).ok().map(str::to_string);
+    }
+    None
+}
+
+/// Endianness of the target ELF binary
+#[derive(Clone, Copy, PartialEq)]
+pub enum Endian {
+    Little,
+    Big,
+}
+
+/// Resolves string pointers read from `.kapi_specs` back to their
+/// underlying C strings in the vmlinux rodata.
+pub struct StringResolver<'a> {
+    pub elf: &'a goblin::elf::Elf<'a>,
+    pub vmlinux: &'a [u8],
+}
+
+// Helper for reading data at specific offsets
+pub struct DataReader<'a> {
+    pub data: &'a [u8],
+    pub pos: usize,
+    pub endian: Endian,
+    /// true for 64-bit ELF, false for 32-bit
+    pub is_64bit: bool,
+    /// Used to follow `const char *` fields into rodata.
+    pub resolver: Option<StringResolver<'a>>,
+}
+
+impl<'a> DataReader<'a> {
+    pub fn new(data: &'a [u8], offset: usize, endian: Endian, is_64bit: bool) -> Self {
+        Self {
+            data,
+            pos: offset,
+            endian,
+            is_64bit,
+            resolver: None,
+        }
+    }
+
+    pub fn with_resolver(mut self, resolver: StringResolver<'a>) -> Self {
+        self.resolver = Some(resolver);
+        self
+    }
+
+    /// Pointer width of the target in bytes (4 or 8).
+    pub fn ptr_size(&self) -> usize {
+        if self.is_64bit {
+            8
+        } else {
+            4
+        }
+    }
+
+    /// Advance the read position to the next multiple of `align`.
+    /// Needed before every naturally-aligned field when the containing
+    /// struct is not `__packed`.
+    pub fn align_to(&mut self, align: usize) {
+        if align > 1 {
+            let rem = self.pos % align;
+            if rem != 0 {
+                self.pos = (self.pos + (align - rem)).min(self.data.len());
+            }
+        }
+    }
+
+    /// Read a target-sized pointer slot. Returns the virtual address
+    /// stored in the slot, or `None` if there isn't enough data. The
+    /// caller is expected to align the reader first if the containing
+    /// struct demands natural alignment.
+    pub fn read_ptr(&mut self) -> Option<u64> {
+        self.align_to(self.ptr_size());
+        if self.is_64bit {
+            self.read_u64()
+        } else {
+            self.read_u32().map(|v| v as u64)
+        }
+    }
+
+    pub fn read_bytes(&mut self, len: usize) -> Option<&'a [u8]> {
+        if self.pos + len <= self.data.len() {
+            let bytes = &self.data[self.pos..self.pos + len];
+            self.pos += len;
+            Some(bytes)
+        } else {
+            None
+        }
+    }
+
+    pub fn read_u32(&mut self) -> Option<u32> {
+        self.align_to(4);
+        let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => u32::from_le_bytes(b),
+            Endian::Big => u32::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_u8(&mut self) -> Option<u8> {
+        self.read_bytes(1).map(|b| b[0])
+    }
+
+    pub fn read_i32(&mut self) -> Option<i32> {
+        self.align_to(4);
+        let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => i32::from_le_bytes(b),
+            Endian::Big => i32::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_u64(&mut self) -> Option<u64> {
+        self.align_to(8);
+        let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => u64::from_le_bytes(b),
+            Endian::Big => u64::from_be_bytes(b),
+        })
+    }
+
+    pub fn read_i64(&mut self) -> Option<i64> {
+        self.align_to(8);
+        let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+        Some(match self.endian {
+            Endian::Little => i64::from_le_bytes(b),
+            Endian::Big => i64::from_be_bytes(b),
+        })
+    }
+
+    /// Read a target-sized unsigned value (4 bytes for 32-bit, 8 bytes for 64-bit).
+    pub fn read_usize(&mut self) -> Option<usize> {
+        self.align_to(self.ptr_size());
+        if self.is_64bit {
+            // No double-align: read_u64 would re-align, but we just
+            // did that with ptr_size() which is 8 on 64-bit.
+            let b: [u8; 8] = self.read_bytes(8)?.try_into().unwrap();
+            Some(match self.endian {
+                Endian::Little => u64::from_le_bytes(b) as usize,
+                Endian::Big => u64::from_be_bytes(b) as usize,
+            })
+        } else {
+            let b: [u8; 4] = self.read_bytes(4)?.try_into().unwrap();
+            Some(match self.endian {
+                Endian::Little => u32::from_le_bytes(b) as usize,
+                Endian::Big => u32::from_be_bytes(b) as usize,
+            })
+        }
+    }
+
+    // Helper methods for common patterns
+    pub fn read_bool(&mut self) -> Option<bool> {
+        self.read_u8().map(|v| v != 0)
+    }
+
+    /// Read a `const char *` slot using the target pointer width
+    /// (4 bytes on 32-bit, 8 bytes on 64-bit) and, if a resolver is
+    /// attached, follow the address into the vmlinux to recover the
+    /// C string. The `_max_len` argument is ignored.
+    pub fn read_optional_string(&mut self, _max_len: usize) -> Option<String> {
+        let vaddr = self.read_ptr()?;
+        let resolver = self.resolver.as_ref()?;
+        resolve_vaddr_string(resolver.elf, resolver.vmlinux, vaddr).filter(|s| !s.is_empty())
+    }
+
+    pub fn read_string_or_default(&mut self, max_len: usize) -> String {
+        self.read_optional_string(max_len).unwrap_or_default()
+    }
+
+    /// Follow a `const s64 *` slot (already read with `read_ptr`) to the
+    /// `count` values it points at. A NULL or unresolvable pointer yields
+    /// an empty list.
+    pub fn resolve_s64_array(&self, vaddr: u64, count: u32) -> Vec<i64> {
+        let Some(resolver) = self.resolver.as_ref() else {
+            return Vec::new();
+        };
+        if vaddr == 0 {
+            return Vec::new();
+        }
+        let Some(start) = super::vaddr_to_file_offset(resolver.elf, vaddr) else {
+            return Vec::new();
+        };
+        let raw = (count as usize)
+            .checked_mul(8)
+            .and_then(|len| start.checked_add(len))
+            .and_then(|end| resolver.vmlinux.get(start..end));
+        raw.map_or_else(Vec::new, |raw| {
+            raw.chunks_exact(8)
+                .map(|chunk| {
+                    let b: [u8; 8] = chunk.try_into().unwrap();
+                    match self.endian {
+                        Endian::Little => i64::from_le_bytes(b),
+                        Endian::Big => i64::from_be_bytes(b),
+                    }
+                })
+                .collect()
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // ---- DataReader little-endian tests ----
+
+    #[test]
+    fn read_u32_little_endian() {
+        let data = [0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u32_big_endian() {
+        let data = [0x12, 0x34, 0x56, 0x78];
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u64_little_endian() {
+        let data = 0xDEADBEEFCAFEBABEu64.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u64(), Some(0xDEADBEEFCAFEBABE));
+    }
+
+    #[test]
+    fn read_u64_big_endian() {
+        let data = 0xDEADBEEFCAFEBABEu64.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_u64(), Some(0xDEADBEEFCAFEBABE));
+    }
+
+    #[test]
+    fn read_i32_little_endian_negative() {
+        let val: i32 = -42;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_i32(), Some(-42));
+    }
+
+    #[test]
+    fn read_i32_big_endian_negative() {
+        let val: i32 = -1;
+        let data = val.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_i32(), Some(-1));
+    }
+
+    #[test]
+    fn read_i64_little_endian() {
+        let val: i64 = -9999999999;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_i64(), Some(-9999999999));
+    }
+
+    #[test]
+    fn read_i64_big_endian() {
+        let val: i64 = i64::MIN;
+        let data = val.to_be_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Big, true);
+        assert_eq!(reader.read_i64(), Some(i64::MIN));
+    }
+
+    // ---- read_usize tests ----
+
+    #[test]
+    fn read_usize_64bit() {
+        let val: u64 = 0x00000000FFFFFFFF;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_usize(), Some(0xFFFFFFFF));
+    }
+
+    #[test]
+    fn read_usize_32bit() {
+        let val: u32 = 0xABCD1234;
+        let data = val.to_le_bytes();
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_usize(), Some(0xABCD1234));
+    }
+
+    #[test]
+    fn read_usize_32bit_does_not_consume_8_bytes() {
+        // In 32-bit mode, read_usize should only consume 4 bytes
+        let mut data = [0u8; 8];
+        data[..4].copy_from_slice(&42u32.to_le_bytes());
+        data[4..8].copy_from_slice(&99u32.to_le_bytes());
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_usize(), Some(42));
+        // After reading 4 bytes, pos should be at 4
+        assert_eq!(reader.pos, 4);
+        assert_eq!(reader.read_usize(), Some(99));
+    }
+
+    // ---- Bounds checking ----
+
+    #[test]
+    fn read_u32_past_end_returns_none() {
+        let data = [0x01, 0x02, 0x03]; // only 3 bytes, need 4
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), None);
+    }
+
+    #[test]
+    fn read_u64_past_end_returns_none() {
+        let data = [0u8; 7]; // only 7 bytes, need 8
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u64(), None);
+    }
+
+    #[test]
+    fn read_bytes_past_end_returns_none() {
+        let data = [0u8; 4];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_bytes(5), None);
+    }
+
+    #[test]
+    fn read_at_offset() {
+        // read_u32 auto-aligns to a 4-byte boundary, so the starting
+        // offset must itself be 4-aligned for the value to be read
+        // from its declared position.
+        let data = [0xFF, 0xFF, 0xFF, 0xFF, 0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 4, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+    }
+
+    #[test]
+    fn read_u32_auto_aligns() {
+        // Starting mid-word, read_u32 snaps to the next 4-byte boundary.
+        let data = [0xDE, 0xAD, 0xBE, 0xEF, 0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 1, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(0x12345678));
+        assert_eq!(reader.pos, 8);
+    }
+
+    #[test]
+    fn read_ptr_32bit_uses_4_bytes() {
+        let data = [0x78, 0x56, 0x34, 0x12];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, false);
+        assert_eq!(reader.read_ptr(), Some(0x12345678));
+        assert_eq!(reader.pos, 4);
+    }
+
+    #[test]
+    fn read_ptr_64bit_uses_8_bytes() {
+        let data = [0x78, 0x56, 0x34, 0x12, 0x00, 0x00, 0x00, 0x00];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_ptr(), Some(0x12345678));
+        assert_eq!(reader.pos, 8);
+    }
+
+    #[test]
+    fn read_bool_values() {
+        let data = [0, 1, 255];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_bool(), Some(false));
+        assert_eq!(reader.read_bool(), Some(true));
+        assert_eq!(reader.read_bool(), Some(true)); // any non-zero is true
+    }
+
+    #[test]
+    fn sequential_reads_advance_position() {
+        let mut data = [0u8; 12];
+        data[..4].copy_from_slice(&1u32.to_le_bytes());
+        data[4..8].copy_from_slice(&2u32.to_le_bytes());
+        data[8..12].copy_from_slice(&3u32.to_le_bytes());
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u32(), Some(1));
+        assert_eq!(reader.read_u32(), Some(2));
+        assert_eq!(reader.read_u32(), Some(3));
+        assert_eq!(reader.pos, 12);
+    }
+
+    #[test]
+    fn read_optional_string_empty_returns_none() {
+        // A string buffer that is just NUL
+        let data = [0u8; 10];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_optional_string(10), None);
+    }
+
+    #[test]
+    fn read_string_or_default_with_empty() {
+        let data = [0u8; 10];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_string_or_default(10), "");
+    }
+
+    #[test]
+    fn read_u8_value() {
+        let data = [0x42];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+        assert_eq!(reader.read_u8(), Some(0x42));
+    }
+}
diff --git a/tools/kapi/src/extractor/vmlinux/mod.rs b/tools/kapi/src/extractor/vmlinux/mod.rs
new file mode 100644
index 0000000000000..5202f7d80c8d8
--- /dev/null
+++ b/tools/kapi/src/extractor/vmlinux/mod.rs
@@ -0,0 +1,1160 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::{
+    ApiExtractor, ApiSpec, CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec,
+    ReturnSpec, SideEffectSpec, SignalMaskSpec, SignalSpec, StateTransitionSpec, StructFieldSpec,
+    StructSpec,
+};
+use crate::formatter::OutputFormatter;
+use anyhow::{Context, Result};
+use goblin::elf::Elf;
+use std::fs;
+use std::io::Write;
+
+mod binary_utils;
+use binary_utils::{magic, sizes, DataReader, Endian};
+
+// Helper to convert empty strings to None
+fn opt_string(s: String) -> Option<String> {
+    if s.is_empty() {
+        None
+    } else {
+        Some(s)
+    }
+}
+
+pub struct VmlinuxExtractor {
+    vmlinux: Vec<u8>,
+    specs: Vec<KapiSpec>,
+    endian: Endian,
+    is_64bit: bool,
+}
+
+#[derive(Debug)]
+struct KapiSpec {
+    name: String,
+    api_type: String,
+    /// File offset in the vmlinux buffer where this spec's
+    /// `struct kernel_api_spec` begins.
+    file_offset: usize,
+}
+
+impl VmlinuxExtractor {
+    pub fn new(vmlinux_path: &str) -> Result<Self> {
+        let vmlinux = fs::read(vmlinux_path)
+            .with_context(|| format!("Failed to read vmlinux file: {vmlinux_path}"))?;
+
+        let elf = Elf::parse(&vmlinux).context("Failed to parse ELF file")?;
+        let endian = if elf.little_endian {
+            Endian::Little
+        } else {
+            Endian::Big
+        };
+        let is_64bit = elf.is_64;
+
+        // Locate the .kapi_specs section boundaries.
+        let mut start_addr = None;
+        let mut stop_addr = None;
+        for sym in &elf.syms {
+            if let Some(name) = elf.strtab.get_at(sym.st_name) {
+                match name {
+                    "__start_kapi_specs" => start_addr = Some(sym.st_value),
+                    "__stop_kapi_specs" => stop_addr = Some(sym.st_value),
+                    _ => {}
+                }
+            }
+        }
+        let start = start_addr.context("Could not find __start_kapi_specs symbol")?;
+        let stop = stop_addr.context("Could not find __stop_kapi_specs symbol")?;
+        if stop <= start {
+            anyhow::bail!("No kernel API specifications found in vmlinux");
+        }
+
+        // `.kapi_specs` is a tightly-packed array of `struct kernel_api_spec *`
+        // pointers; walk them to find each real spec's vaddr, then resolve to
+        // a file offset inside `vmlinux`. Pointer width tracks the target
+        // (4 bytes for 32-bit, 8 bytes for 64-bit).
+        let ptr_size = if is_64bit { 8usize } else { 4 };
+        let ptr_count = ((stop - start) as usize) / ptr_size;
+        let ptr_file_off =
+            vaddr_to_file_offset(&elf, start).context("Could not locate .kapi_specs in file")?;
+
+        let read_ptr = |raw: &[u8]| -> u64 {
+            match (endian, is_64bit) {
+                (Endian::Little, true) => u64::from_le_bytes(raw.try_into().unwrap()),
+                (Endian::Big, true) => u64::from_be_bytes(raw.try_into().unwrap()),
+                (Endian::Little, false) => u32::from_le_bytes(raw.try_into().unwrap()) as u64,
+                (Endian::Big, false) => u32::from_be_bytes(raw.try_into().unwrap()) as u64,
+            }
+        };
+
+        let mut specs = Vec::with_capacity(ptr_count);
+        for i in 0..ptr_count {
+            let p = ptr_file_off + i * ptr_size;
+            if p + ptr_size > vmlinux.len() {
+                break;
+            }
+            let spec_vaddr = read_ptr(&vmlinux[p..p + ptr_size]);
+            if spec_vaddr == 0 {
+                continue;
+            }
+            let Some(spec_file_off) = vaddr_to_file_offset(&elf, spec_vaddr) else {
+                continue;
+            };
+            // The first field of `struct kernel_api_spec` is `const char *name`.
+            if spec_file_off + ptr_size > vmlinux.len() {
+                continue;
+            }
+            let name_vaddr = read_ptr(&vmlinux[spec_file_off..spec_file_off + ptr_size]);
+            let name =
+                binary_utils::resolve_vaddr_string(&elf, &vmlinux, name_vaddr).unwrap_or_default();
+            if name.is_empty() {
+                continue;
+            }
+            let api_type = if name.starts_with("sys_") {
+                "syscall"
+            } else if name.ends_with("_ioctl") {
+                "ioctl"
+            } else {
+                "function"
+            }
+            .to_string();
+            specs.push(KapiSpec {
+                name,
+                api_type,
+                file_offset: spec_file_off,
+            });
+        }
+
+        Ok(VmlinuxExtractor {
+            vmlinux,
+            specs,
+            endian,
+            is_64bit,
+        })
+    }
+}
+
+/// Map a virtual address to a file offset inside the raw vmlinux bytes.
+fn vaddr_to_file_offset(elf: &Elf, vaddr: u64) -> Option<usize> {
+    for sh in &elf.section_headers {
+        let start = sh.sh_addr;
+        let end = start.checked_add(sh.sh_size)?;
+        if vaddr >= start && vaddr < end {
+            if sh.sh_type == goblin::elf::section_header::SHT_NOBITS {
+                return None;
+            }
+            return Some((sh.sh_offset + (vaddr - start)) as usize);
+        }
+    }
+    None
+}
+
+impl VmlinuxExtractor {
+    fn parse_at(&self, file_offset: usize) -> Result<ApiSpec> {
+        parse_binary_to_api_spec(&self.vmlinux, file_offset, self.endian, self.is_64bit)
+    }
+}
+
+impl ApiExtractor for VmlinuxExtractor {
+    fn extract_all(&self) -> Result<Vec<ApiSpec>> {
+        Ok(self
+            .specs
+            .iter()
+            .map(|spec| {
+                self.parse_at(spec.file_offset).unwrap_or_else(|e| {
+                    eprintln!("Warning: cannot parse the spec of {}: {e:#}", spec.name);
+                    ApiSpec {
+                        name: spec.name.clone(),
+                        api_type: spec.api_type.clone(),
+                        ..Default::default()
+                    }
+                })
+            })
+            .collect())
+    }
+
+    fn extract_by_name(&self, api_name: &str) -> Result<Option<ApiSpec>> {
+        if let Some(spec) = self.specs.iter().find(|s| s.name == api_name) {
+            Ok(Some(self.parse_at(spec.file_offset)?))
+        } else {
+            Ok(None)
+        }
+    }
+
+    fn display_api_details(
+        &self,
+        api_name: &str,
+        formatter: &mut dyn OutputFormatter,
+        writer: &mut dyn Write,
+    ) -> Result<()> {
+        if let Some(spec) = self.specs.iter().find(|s| s.name == api_name) {
+            let api_spec = self.parse_at(spec.file_offset)?;
+            super::display_api_spec(&api_spec, formatter, writer)?;
+        }
+        Ok(())
+    }
+}
+
+const TRUNCATED: &str = "kernel_api_spec runs past the end of the file";
+
+/// Consume a section marker. The kernel leaves it zero when the matching
+/// macro is not used, so only a different non-zero value means that the
+/// reader has lost sync with the struct layout.
+fn read_magic(reader: &mut DataReader, expected: u32) -> Result<()> {
+    let found = reader.read_u32().context(TRUNCATED)?;
+    if found != 0 && found != expected {
+        anyhow::bail!(
+            "unexpected section marker {found:#x} at offset {:#x}, expected {expected:#x}",
+            reader.pos - 4
+        );
+    }
+    Ok(())
+}
+
+/// Parse a `{ u32 magic; u32 count; T items[MAX]; }` section. Every one of
+/// the `max_items` slots is consumed, so the reader ends up behind the
+/// whole array; only the first `count` are returned.
+fn parse_array<T, F>(
+    reader: &mut DataReader,
+    expected_magic: u32,
+    max_items: usize,
+    parse_fn: F,
+) -> Result<Vec<T>>
+where
+    F: Fn(&mut DataReader, usize) -> Option<T>,
+{
+    read_magic(reader, expected_magic)?;
+    let count = reader.read_u32().context(TRUNCATED)? as usize;
+    let align = reader.ptr_size();
+    reader.align_to(align);
+
+    let mut items = Vec::new();
+    for i in 0..max_items {
+        let item = parse_fn(reader, i).context(TRUNCATED)?;
+        reader.align_to(align);
+        if i < count {
+            items.push(item);
+        }
+    }
+    Ok(items)
+}
+
+fn parse_binary_to_api_spec(
+    data: &[u8],
+    offset: usize,
+    endian: Endian,
+    is_64bit: bool,
+) -> Result<ApiSpec> {
+    let elf = Elf::parse(data).context("Failed to re-parse ELF for string resolution")?;
+    let resolver = binary_utils::StringResolver {
+        elf: &elf,
+        vmlinux: data,
+    };
+    let mut reader = DataReader::new(data, offset, endian, is_64bit).with_resolver(resolver);
+
+    // Read fields in exact order of struct kernel_api_spec.
+    // Every string field is a `const char *` pointer resolved via the
+    // StringResolver attached to the DataReader.
+    let name = reader
+        .read_optional_string(sizes::NAME)
+        .ok_or_else(|| anyhow::anyhow!("Failed to read API name"))?;
+
+    // Determine API type
+    let api_type = if name.starts_with("sys_") {
+        "syscall"
+    } else if name.ends_with("_ioctl") {
+        "ioctl"
+    } else if name.contains("sysfs") {
+        "sysfs"
+    } else {
+        "function"
+    }
+    .to_string();
+
+    let version = reader.read_u32().map(|v| v.to_string());
+
+    let description = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let long_description = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let context_flags = parse_context_flags(&mut reader);
+
+    let parameters = parse_array(&mut reader, magic::PARAMS, sizes::MAX_PARAMS, parse_param)?;
+
+    read_magic(&mut reader, magic::RETURN)?;
+    let return_spec = parse_return_spec(&mut reader);
+
+    let errors = parse_array(&mut reader, magic::ERRORS, sizes::MAX_ERRORS, |r, _| {
+        parse_error(r)
+    })?;
+
+    let locks = parse_array(&mut reader, magic::LOCKS, sizes::MAX_LOCKS, |r, _| {
+        parse_lock(r)
+    })?;
+
+    let constraints = parse_array(
+        &mut reader,
+        magic::CONSTRAINTS,
+        sizes::MAX_CONSTRAINTS,
+        |r, _| parse_constraint(r),
+    )?;
+
+    // Only KAPI_EXAMPLES() sets info_magic, so it says nothing about
+    // whether notes are present.
+    read_magic(&mut reader, magic::INFO)?;
+    let examples = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+    let notes = reader
+        .read_optional_string(sizes::DESC)
+        .filter(|s| !s.is_empty());
+
+    let signals = parse_array(&mut reader, magic::SIGNALS, sizes::MAX_SIGNALS, |r, _| {
+        parse_signal(r)
+    })?;
+
+    let signal_masks = parse_array(&mut reader, magic::SIGMASK, sizes::MAX_SIGNALS, |r, _| {
+        parse_signal_mask(r)
+    })?;
+
+    let struct_specs = parse_array(
+        &mut reader,
+        magic::STRUCTS,
+        sizes::MAX_STRUCT_SPECS,
+        |r, _| parse_struct_spec(r),
+    )?;
+
+    let side_effects = parse_array(
+        &mut reader,
+        magic::EFFECTS,
+        sizes::MAX_SIDE_EFFECTS,
+        |r, _| parse_side_effect(r),
+    )?;
+
+    let state_transitions =
+        parse_array(&mut reader, magic::TRANS, sizes::MAX_STATE_TRANS, |r, _| {
+            parse_state_transition(r)
+        })?;
+
+    let capabilities = parse_array(&mut reader, magic::CAPS, sizes::MAX_CAPABILITIES, |r, _| {
+        parse_capability(r)
+    })?;
+
+    Ok(ApiSpec {
+        name,
+        api_type,
+        description,
+        long_description,
+        version,
+        context_flags,
+        param_count: if parameters.is_empty() {
+            None
+        } else {
+            Some(parameters.len() as u32)
+        },
+        error_count: if errors.is_empty() {
+            None
+        } else {
+            Some(errors.len() as u32)
+        },
+        examples,
+        notes,
+        subsystem: None,
+        sysfs_path: None,
+        permissions: None,
+        capabilities,
+        parameters,
+        return_spec,
+        errors,
+        signals,
+        signal_masks,
+        side_effects,
+        state_transitions,
+        constraints,
+        locks,
+        struct_specs,
+    })
+}
+
+// Helper parsing functions
+
+fn parse_context_flags(reader: &mut DataReader) -> Vec<String> {
+    const KAPI_CTX_PROCESS: u32 = 1 << 0;
+    const KAPI_CTX_SOFTIRQ: u32 = 1 << 1;
+    const KAPI_CTX_HARDIRQ: u32 = 1 << 2;
+    const KAPI_CTX_NMI: u32 = 1 << 3;
+    const KAPI_CTX_ATOMIC: u32 = 1 << 4;
+    const KAPI_CTX_SLEEPABLE: u32 = 1 << 5;
+    const KAPI_CTX_PREEMPT_DISABLED: u32 = 1 << 6;
+    const KAPI_CTX_IRQ_DISABLED: u32 = 1 << 7;
+
+    if let Some(flags) = reader.read_u32() {
+        let mut parts = Vec::new();
+
+        if flags & KAPI_CTX_PROCESS != 0 {
+            parts.push("KAPI_CTX_PROCESS");
+        }
+        if flags & KAPI_CTX_SOFTIRQ != 0 {
+            parts.push("KAPI_CTX_SOFTIRQ");
+        }
+        if flags & KAPI_CTX_HARDIRQ != 0 {
+            parts.push("KAPI_CTX_HARDIRQ");
+        }
+        if flags & KAPI_CTX_NMI != 0 {
+            parts.push("KAPI_CTX_NMI");
+        }
+        if flags & KAPI_CTX_ATOMIC != 0 {
+            parts.push("KAPI_CTX_ATOMIC");
+        }
+        if flags & KAPI_CTX_SLEEPABLE != 0 {
+            parts.push("KAPI_CTX_SLEEPABLE");
+        }
+        if flags & KAPI_CTX_PREEMPT_DISABLED != 0 {
+            parts.push("KAPI_CTX_PREEMPT_DISABLED");
+        }
+        if flags & KAPI_CTX_IRQ_DISABLED != 0 {
+            parts.push("KAPI_CTX_IRQ_DISABLED");
+        }
+
+        parts.into_iter().map(|s| s.to_string()).collect()
+    } else {
+        vec![]
+    }
+}
+
+fn parse_param(reader: &mut DataReader, index: usize) -> Option<ParamSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let type_name = reader.read_string_or_default(sizes::NAME);
+    let param_type = reader.read_u32()?;
+    let flags = reader.read_u32()?;
+    let size = reader.read_usize()?;
+    let alignment = reader.read_usize()?;
+    let min_value = reader.read_i64()?;
+    let max_value = reader.read_i64()?;
+    let valid_mask = reader.read_u64()?;
+
+    let enum_values_ptr = reader.read_ptr()?;
+    let enum_count = reader.read_u32()?;
+    let constraint_type = reader.read_u32()?;
+    // Skip validate function pointer
+    reader.read_ptr()?;
+
+    let description = reader.read_string_or_default(sizes::DESC);
+    let constraint = reader.read_optional_string(sizes::DESC);
+    let size_param_idx_raw = reader.read_i32()?; // Must use ? to propagate errors
+    let _size_multiplier = reader.read_usize()?; // Must use ? to propagate errors
+
+    // In the C struct, size_param_idx is stored 1-based; 0 means
+    // "no size-carrying param". Surface the real (0-based) index as
+    // `Option<u32>`.
+    let size_param_idx = if size_param_idx_raw > 0 {
+        Some((size_param_idx_raw - 1) as u32)
+    } else {
+        None
+    };
+
+    let mut param = ParamSpec {
+        index: index as u32,
+        name,
+        type_name,
+        description,
+        flags,
+        param_type,
+        constraint_type,
+        constraint,
+        min_value: Some(min_value),
+        max_value: Some(max_value),
+        valid_mask: Some(valid_mask),
+        enum_values: reader
+            .resolve_s64_array(enum_values_ptr, enum_count)
+            .iter()
+            .map(i64::to_string)
+            .collect(),
+        size: Some(size as u32),
+        alignment: Some(alignment as u32),
+        size_param_idx,
+    };
+    param.keep_used_numbers();
+    Some(param)
+}
+
+fn parse_return_spec(reader: &mut DataReader) -> Option<ReturnSpec> {
+    // Read type_name, but treat empty as valid (will be empty string)
+    let type_name = reader.read_string_or_default(sizes::NAME);
+
+    // Read return_type and check_type
+    let return_type = reader.read_u32().unwrap_or(0);
+    let check_type = reader.read_u32().unwrap_or(0);
+    let success_value = reader.read_i64().unwrap_or(0);
+    let success_min = reader.read_i64().unwrap_or(0);
+    let success_max = reader.read_i64().unwrap_or(0);
+
+    let error_values_ptr = reader.read_ptr().unwrap_or(0);
+    let error_count = reader.read_u32().unwrap_or(0);
+    // Skip is_success function pointer
+    let _ = reader.read_ptr();
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    // Return a spec even if type_name is empty, as long as we have some data
+    // The type_name might be a string like "KAPI_TYPE_INT" that gets stored literally
+    if type_name.is_empty() && return_type == 0 && check_type == 0 && success_value == 0 {
+        // No return spec at all
+        return None;
+    }
+
+    let mut ret = ReturnSpec {
+        type_name,
+        description,
+        return_type,
+        check_type,
+        success_value: Some(success_value),
+        success_min: Some(success_min),
+        success_max: Some(success_max),
+        error_values: reader
+            .resolve_s64_array(error_values_ptr, error_count)
+            .into_iter()
+            .filter_map(|v| i32::try_from(v).ok())
+            .collect(),
+    };
+    ret.keep_used_success_fields();
+    Some(ret)
+}
+
+fn parse_error(reader: &mut DataReader) -> Option<ErrorSpec> {
+    let error_code = reader.read_i32()?;
+    let name = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(ErrorSpec {
+        error_code,
+        name,
+        condition,
+        description,
+    })
+}
+
+fn parse_lock(reader: &mut DataReader) -> Option<LockSpec> {
+    let lock_name = reader.read_string_or_default(sizes::NAME);
+    let lock_type = reader.read_u32()?;
+    let scope = reader.read_u32()?;
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(LockSpec {
+        lock_name,
+        lock_type,
+        scope,
+        description,
+    })
+}
+
+fn parse_constraint(reader: &mut DataReader) -> Option<ConstraintSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let description = reader.read_string_or_default(sizes::DESC);
+    let expression = reader.read_string_or_default(sizes::DESC);
+
+    Some(ConstraintSpec {
+        name,
+        description,
+        expression: opt_string(expression),
+    })
+}
+
+fn parse_signal(reader: &mut DataReader) -> Option<SignalSpec> {
+    // Matches `struct kapi_signal_spec`. All string fields are pointers.
+    let signal_num = reader.read_i32()?;
+    let signal_name = reader.read_optional_string(sizes::NAME).unwrap_or_default();
+    let direction = reader.read_u32()?;
+    let action = reader.read_u32()?;
+    let target = reader.read_optional_string(sizes::DESC);
+    let condition = reader.read_optional_string(sizes::DESC);
+    let description = reader.read_optional_string(sizes::DESC);
+    let restartable = reader.read_bool()?;
+    let sa_flags_required = reader.read_u32()?;
+    let sa_flags_forbidden = reader.read_u32()?;
+    let error_on_signal = reader.read_i32()?;
+    let transform_to = reader.read_i32()?;
+    // Read the symbolic timing token (const char *) and map it to the
+    // numeric timing code used by downstream consumers.
+    let timing_str = reader.read_optional_string(sizes::NAME).unwrap_or_default();
+    let timing = match timing_str.as_str() {
+        "KAPI_SIGNAL_TIME_BEFORE" | "before" => 0u32,
+        "KAPI_SIGNAL_TIME_DURING" | "during" => 1,
+        "KAPI_SIGNAL_TIME_AFTER" | "after" => 2,
+        _ => 0,
+    };
+    let priority = reader.read_u8()?;
+    let interruptible = reader.read_bool()?;
+    let queue_behavior = reader.read_optional_string(sizes::NAME);
+    let state_required = reader.read_u32()?;
+    let state_forbidden = reader.read_u32()?;
+
+    Some(SignalSpec {
+        signal_num,
+        signal_name,
+        direction,
+        action,
+        target,
+        condition,
+        description,
+        timing,
+        priority: priority as u32,
+        restartable,
+        interruptible,
+        queue: queue_behavior,
+        sa_flags: 0, // Not a field of struct kapi_signal_spec
+        sa_flags_required,
+        sa_flags_forbidden,
+        state_required,
+        state_forbidden,
+        // `error_on_signal` of 0 means "no errno returned"; surface
+        // that as None to match the source-parser convention.
+        error_on_signal: if error_on_signal != 0 {
+            Some(error_on_signal)
+        } else {
+            None
+        },
+        transform_to: if transform_to != 0 {
+            // The compiled struct holds the numeric value; the C
+            // preprocessor already resolved any signal symbol.
+            Some(transform_to)
+        } else {
+            None
+        },
+    })
+}
+
+fn parse_signal_mask(reader: &mut DataReader) -> Option<SignalMaskSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+
+    let mut signals = Vec::with_capacity(sizes::MAX_SIGNALS);
+    for _ in 0..sizes::MAX_SIGNALS {
+        signals.push(reader.read_i32()?);
+    }
+    let signal_count = reader.read_u32()?;
+    signals.truncate(signal_count as usize);
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(SignalMaskSpec {
+        name,
+        description,
+        signals,
+    })
+}
+
+fn parse_struct_field(reader: &mut DataReader) -> Option<StructFieldSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let field_type = reader.read_u32()?;
+    let type_name = reader.read_string_or_default(sizes::NAME);
+    let offset = reader.read_usize()?;
+    let size = reader.read_usize()?;
+    let flags = reader.read_u32()?;
+    let constraint_type = reader.read_u32()?;
+    let min_value = reader.read_i64()?;
+    let max_value = reader.read_i64()?;
+    let valid_mask = reader.read_u64()?;
+    // enum_values is a `const char *` that StructFieldSpec has no slot for
+    reader.read_ptr()?;
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StructFieldSpec {
+        name,
+        field_type,
+        type_name,
+        offset,
+        size,
+        flags,
+        constraint_type,
+        min_value,
+        max_value,
+        valid_mask,
+        description,
+    })
+}
+
+fn parse_struct_spec(reader: &mut DataReader) -> Option<StructSpec> {
+    let name = reader.read_string_or_default(sizes::NAME);
+    let size = reader.read_usize()?;
+    let alignment = reader.read_usize()?;
+    let field_count = reader.read_u32()?;
+
+    let mut fields = Vec::new();
+    for i in 0..sizes::MAX_PARAMS {
+        let field = parse_struct_field(reader)?;
+        reader.align_to(reader.ptr_size());
+        if i < field_count as usize {
+            fields.push(field);
+        }
+    }
+
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StructSpec {
+        name,
+        size,
+        alignment,
+        field_count: fields.len() as u32,
+        fields,
+        description,
+    })
+}
+
+fn parse_side_effect(reader: &mut DataReader) -> Option<SideEffectSpec> {
+    let effect_type = reader.read_u32()?;
+    let target = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let description = reader.read_string_or_default(sizes::DESC);
+    let reversible = reader.read_bool()?;
+
+    Some(SideEffectSpec {
+        effect_type,
+        target,
+        condition: opt_string(condition),
+        description,
+        reversible,
+    })
+}
+
+fn parse_state_transition(reader: &mut DataReader) -> Option<StateTransitionSpec> {
+    let from_state = reader.read_string_or_default(sizes::NAME);
+    let to_state = reader.read_string_or_default(sizes::NAME);
+    let condition = reader.read_string_or_default(sizes::DESC);
+    let object = reader.read_string_or_default(sizes::NAME);
+    let description = reader.read_string_or_default(sizes::DESC);
+
+    Some(StateTransitionSpec {
+        object,
+        from_state,
+        to_state,
+        condition: opt_string(condition),
+        description,
+    })
+}
+
+fn parse_capability(reader: &mut DataReader) -> Option<CapabilitySpec> {
+    // Struct layout matches `struct kapi_capability_spec`:
+    //   int capability; const char *cap_name; enum action;
+    //   const char *allows; const char *without_cap;
+    //   const char *check_condition; u8 priority;
+    //   int alternative[KAPI_MAX_CAPABILITIES]; u32 alternative_count;
+    let capability = reader.read_i32()?;
+    let cap_name = reader.read_string_or_default(sizes::NAME);
+    let action = reader.read_u32()?;
+    let allows = reader.read_string_or_default(sizes::DESC);
+    let without_cap = reader.read_string_or_default(sizes::DESC);
+    let check_condition = reader.read_optional_string(sizes::DESC);
+    let priority = reader.read_u8()?;
+
+    let mut alternatives = Vec::with_capacity(sizes::MAX_CAPABILITIES);
+    for _ in 0..sizes::MAX_CAPABILITIES {
+        alternatives.push(reader.read_i32()?);
+    }
+    let alternative_count = reader.read_u32()?;
+    alternatives.truncate(alternative_count as usize);
+
+    Some(CapabilitySpec {
+        capability,
+        name: cap_name,
+        action: capability_action_to_string(action),
+        allows,
+        without_cap,
+        check_condition,
+        priority: Some(priority),
+        alternatives,
+    })
+}
+
+/// Map the `enum kapi_capability_action` numeric value to its symbolic
+/// spelling, matching `include/linux/kernel_api_spec.h`.
+fn capability_action_to_string(n: u32) -> String {
+    match n {
+        0 => "KAPI_CAP_BYPASS_CHECK",
+        1 => "KAPI_CAP_INCREASE_LIMIT",
+        2 => "KAPI_CAP_OVERRIDE_RESTRICTION",
+        3 => "KAPI_CAP_GRANT_PERMISSION",
+        4 => "KAPI_CAP_MODIFY_BEHAVIOR",
+        5 => "KAPI_CAP_ACCESS_RESOURCE",
+        6 => "KAPI_CAP_PERFORM_OPERATION",
+        _ => return n.to_string(),
+    }
+    .to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn state_transition_with_empty_strings_consumes_the_whole_struct() {
+        // five `const char *` slots: from_state, to_state, condition,
+        // object, description
+        let data = [0u8; 5 * 8];
+        let mut reader = DataReader::new(&data, 0, Endian::Little, true);
+
+        let trans = parse_state_transition(&mut reader).unwrap();
+
+        assert_eq!(reader.pos, data.len());
+        assert_eq!(trans.from_state, "");
+        assert_eq!(trans.condition, None);
+        assert_eq!(trans.description, "");
+    }
+
+    // Offsets inside `struct kernel_api_spec` and its element structs as
+    // laid out by gcc on x86-64, taken from offsetof()/sizeof().
+    const SPEC_SIZE: usize = 26400;
+    const PARAM_MAGIC: usize = 36;
+    const PARAMS: usize = 48;
+    const PARAM_SIZE: usize = 120;
+    const RETURN_MAGIC: usize = 1968;
+    const RETURN_SPEC: usize = 1976;
+    const ERROR_MAGIC: usize = 2048;
+    const ERRORS: usize = 2056;
+    const ERROR_SIZE: usize = 32;
+    const LOCK_MAGIC: usize = 3080;
+    const LOCKS: usize = 3088;
+    const LOCK_SIZE: usize = 24;
+    const CONSTRAINT_MAGIC: usize = 3472;
+    const CONSTRAINTS: usize = 3480;
+    const CONSTRAINT_SIZE: usize = 24;
+    const INFO_MAGIC: usize = 4248;
+    const NOTES: usize = 4264;
+    const SIGNAL_MAGIC: usize = 4272;
+    const SIGNALS: usize = 4280;
+    const SIGNAL_SIZE: usize = 104;
+    const SIGMASK_MAGIC: usize = 7608;
+    const SIGNAL_MASKS: usize = 7616;
+    const SIGNAL_MASK_SIZE: usize = 152;
+    const STRUCT_MAGIC: usize = 12480;
+    const STRUCT_SPECS: usize = 12488;
+    const STRUCT_SPEC_SIZE: usize = 1448;
+    const STRUCT_FIELD_SIZE: usize = 88;
+    const EFFECT_MAGIC: usize = 24072;
+    const SIDE_EFFECTS: usize = 24080;
+    const SIDE_EFFECT_SIZE: usize = 40;
+    const TRANS_MAGIC: usize = 25360;
+    const STATE_TRANSITIONS: usize = 25368;
+    const STATE_TRANSITION_SIZE: usize = 40;
+    const CAP_MAGIC: usize = 25688;
+    const CAPABILITIES: usize = 25696;
+    const CAPABILITY_SIZE: usize = 88;
+
+    const BASE: u64 = 0xffff_ffff_8100_0000;
+    const CONTENT_OFFSET: usize = 0x1000;
+
+    /// A `struct kernel_api_spec` image preceded by the strings and
+    /// `s64` arrays it points at, wrapped in a minimal ELF file.
+    struct Image {
+        spec: Vec<u8>,
+        tail: Vec<u8>,
+    }
+
+    impl Image {
+        fn new() -> Self {
+            Image {
+                spec: vec![0; SPEC_SIZE],
+                tail: Vec::new(),
+            }
+        }
+
+        fn u32(&mut self, at: usize, v: u32) {
+            self.spec[at..at + 4].copy_from_slice(&v.to_le_bytes());
+        }
+
+        fn u64(&mut self, at: usize, v: u64) {
+            self.spec[at..at + 8].copy_from_slice(&v.to_le_bytes());
+        }
+
+        fn tail_vaddr(&self) -> u64 {
+            BASE + self.tail.len() as u64
+        }
+
+        fn string(&mut self, at: usize, s: &str) {
+            let vaddr = self.tail_vaddr();
+            self.tail.extend_from_slice(s.as_bytes());
+            self.tail.push(0);
+            self.u64(at, vaddr);
+        }
+
+        fn s64s(&mut self, at: usize, vals: &[i64]) {
+            while self.tail.len() % 8 != 0 {
+                self.tail.push(0);
+            }
+            let vaddr = self.tail_vaddr();
+            for v in vals {
+                self.tail.extend_from_slice(&v.to_le_bytes());
+            }
+            self.u64(at, vaddr);
+        }
+
+        fn elf(&self) -> Vec<u8> {
+            let mut content = self.tail.clone();
+            content.extend_from_slice(&self.spec);
+
+            let mut img = vec![0u8; CONTENT_OFFSET];
+            img[..4].copy_from_slice(b"\x7fELF");
+            img[4] = 2; // ELFCLASS64
+            img[5] = 1; // ELFDATA2LSB
+            img[6] = 1; // EV_CURRENT
+            img[16..18].copy_from_slice(&2u16.to_le_bytes()); // ET_EXEC
+            img[18..20].copy_from_slice(&62u16.to_le_bytes()); // EM_X86_64
+            img[20..24].copy_from_slice(&1u32.to_le_bytes());
+            img[40..48].copy_from_slice(&64u64.to_le_bytes()); // e_shoff
+            img[52..54].copy_from_slice(&64u16.to_le_bytes()); // e_ehsize
+            img[58..60].copy_from_slice(&64u16.to_le_bytes()); // e_shentsize
+            img[60..62].copy_from_slice(&2u16.to_le_bytes()); // e_shnum
+            let sh = 64 + 64; // section 1 follows the null section header at 64
+            img[sh + 4..sh + 8].copy_from_slice(&1u32.to_le_bytes()); // SHT_PROGBITS
+            img[sh + 8..sh + 16].copy_from_slice(&2u64.to_le_bytes()); // SHF_ALLOC
+            img[sh + 16..sh + 24].copy_from_slice(&BASE.to_le_bytes());
+            img[sh + 24..sh + 32].copy_from_slice(&(CONTENT_OFFSET as u64).to_le_bytes());
+            img[sh + 32..sh + 40].copy_from_slice(&(content.len() as u64).to_le_bytes());
+            img[sh + 48..sh + 56].copy_from_slice(&8u64.to_le_bytes());
+            img.extend_from_slice(&content);
+            img
+        }
+
+        fn parse(&mut self) -> Result<ApiSpec> {
+            self.string(0, "kapi_fixture");
+            while self.tail.len() % 8 != 0 {
+                self.tail.push(0);
+            }
+            let img = self.elf();
+            parse_binary_to_api_spec(&img, CONTENT_OFFSET + self.tail.len(), Endian::Little, true)
+        }
+    }
+
+    #[test]
+    fn every_array_slot_is_consumed() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 16);
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.u32(ERROR_MAGIC, magic::ERRORS);
+        img.u32(ERROR_MAGIC + 4, 32);
+        img.u32(LOCK_MAGIC, magic::LOCKS);
+        img.u32(LOCK_MAGIC + 4, 16);
+        img.u32(CONSTRAINT_MAGIC, magic::CONSTRAINTS);
+        img.u32(CONSTRAINT_MAGIC + 4, 32);
+        img.u32(SIGNAL_MAGIC, magic::SIGNALS);
+        img.u32(SIGNAL_MAGIC + 4, 32);
+        img.u32(SIGMASK_MAGIC, magic::SIGMASK);
+        img.u32(SIGMASK_MAGIC + 4, 32);
+        img.u32(STRUCT_MAGIC, magic::STRUCTS);
+        img.u32(STRUCT_MAGIC + 4, 8);
+        img.u32(EFFECT_MAGIC, magic::EFFECTS);
+        img.u32(EFFECT_MAGIC + 4, 32);
+        img.u32(TRANS_MAGIC, magic::TRANS);
+        img.u32(TRANS_MAGIC + 4, 8);
+        img.u32(CAP_MAGIC, magic::CAPS);
+        img.u32(CAP_MAGIC + 4, 8);
+
+        img.string(PARAMS + 15 * PARAM_SIZE, "last_param");
+        img.u32(ERRORS + 31 * ERROR_SIZE, -7i32 as u32);
+        img.string(ERRORS + 31 * ERROR_SIZE + 8, "ELAST");
+        img.string(LOCKS + 15 * LOCK_SIZE, "last_lock");
+        img.string(CONSTRAINTS + 31 * CONSTRAINT_SIZE, "last_constraint");
+        img.u32(SIGNALS + 31 * SIGNAL_SIZE, 31);
+        img.u32(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 8, 64);
+        img.u32(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 136, 1);
+        img.string(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE, "last mask");
+        img.string(SIGNAL_MASKS + 31 * SIGNAL_MASK_SIZE + 144, "mask desc");
+
+        let last_struct = STRUCT_SPECS + 7 * STRUCT_SPEC_SIZE;
+        img.string(last_struct, "last_struct");
+        img.u32(last_struct + 24, 16);
+        img.string(last_struct + 32 + 15 * STRUCT_FIELD_SIZE, "last_field");
+        img.string(last_struct + 32 + 15 * STRUCT_FIELD_SIZE + 80, "field desc");
+        img.string(last_struct + 1440, "struct desc");
+
+        img.string(SIDE_EFFECTS + 31 * SIDE_EFFECT_SIZE + 8, "last_effect");
+        img.string(
+            STATE_TRANSITIONS + 7 * STATE_TRANSITION_SIZE + 24,
+            "last_object",
+        );
+        let last_cap = CAPABILITIES + 7 * CAPABILITY_SIZE;
+        img.u32(last_cap, 40);
+        img.u32(last_cap + 52 + 7 * 4, 99);
+        img.u32(last_cap + 84, 8);
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.parameters.len(), 16);
+        assert_eq!(spec.parameters[15].name, "last_param");
+        assert_eq!(spec.errors.len(), 32);
+        assert_eq!(spec.errors[31].error_code, -7);
+        assert_eq!(spec.errors[31].name, "ELAST");
+        assert_eq!(spec.locks[15].lock_name, "last_lock");
+        assert_eq!(spec.constraints[31].name, "last_constraint");
+        assert_eq!(spec.signals[31].signal_num, 31);
+        assert_eq!(spec.signal_masks.len(), 32);
+        assert_eq!(spec.signal_masks[31].name, "last mask");
+        assert_eq!(spec.signal_masks[31].signals, [64]);
+        assert_eq!(spec.signal_masks[31].description, "mask desc");
+
+        let st = &spec.struct_specs[7];
+        assert_eq!(st.name, "last_struct");
+        assert_eq!(st.description, "struct desc");
+        assert_eq!(st.field_count, 16);
+        assert_eq!(st.fields[15].name, "last_field");
+        assert_eq!(st.fields[15].description, "field desc");
+
+        assert_eq!(spec.side_effects[31].target, "last_effect");
+        assert_eq!(spec.state_transitions[7].object, "last_object");
+        let cap = &spec.capabilities[7];
+        assert_eq!(cap.capability, 40);
+        assert_eq!(cap.alternatives, [0, 0, 0, 0, 0, 0, 0, 99]);
+    }
+
+    #[test]
+    fn notes_and_signal_masks_are_read_without_their_markers() {
+        let mut img = Image::new();
+        img.u32(SIGMASK_MAGIC + 4, 2);
+        img.string(NOTES, "only notes");
+        img.string(SIGNAL_MASKS, "first");
+        img.u32(SIGNAL_MASKS + 8, 2);
+        img.u32(SIGNAL_MASKS + 12, 15);
+        img.u32(SIGNAL_MASKS + 136, 2);
+        img.string(SIGNAL_MASKS + 144, "first desc");
+        img.string(SIGNAL_MASKS + SIGNAL_MASK_SIZE, "second");
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.examples, None);
+        assert_eq!(spec.notes.as_deref(), Some("only notes"));
+        assert_eq!(spec.signal_masks.len(), 2);
+        assert_eq!(spec.signal_masks[0].name, "first");
+        assert_eq!(spec.signal_masks[0].description, "first desc");
+        assert_eq!(spec.signal_masks[0].signals, [2, 15]);
+        assert_eq!(spec.signal_masks[1].name, "second");
+        assert!(spec.signal_masks[1].signals.is_empty());
+    }
+
+    #[test]
+    fn examples_are_read_next_to_notes() {
+        let mut img = Image::new();
+        img.u32(INFO_MAGIC, magic::INFO);
+        img.string(INFO_MAGIC + 8, "an example");
+        img.string(NOTES, "a note");
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(spec.examples.as_deref(), Some("an example"));
+        assert_eq!(spec.notes.as_deref(), Some("a note"));
+    }
+
+    #[test]
+    fn enum_and_error_values_are_followed_through_their_pointers() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 2);
+        img.string(PARAMS, "mode");
+        img.s64s(PARAMS + 64, &[0, 1, -7, 1 << 40]);
+        img.u32(PARAMS + 72, 4);
+        img.string(PARAMS + PARAM_SIZE, "plain");
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.string(RETURN_SPEC, "long");
+        img.u32(RETURN_SPEC + 12, 2);
+        img.s64s(RETURN_SPEC + 40, &[-22, -2, -(1 << 40)]);
+        img.u32(RETURN_SPEC + 48, 3);
+
+        let spec = img.parse().unwrap();
+
+        assert_eq!(
+            spec.parameters[0].enum_values,
+            ["0", "1", "-7", "1099511627776"]
+        );
+        assert!(spec.parameters[1].enum_values.is_empty());
+        let ret = spec.return_spec.unwrap();
+        assert_eq!(ret.check_type, 2);
+        assert_eq!(ret.error_values, [-22, -2]);
+    }
+
+    #[test]
+    fn numbers_the_constraint_does_not_use_are_unset() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 4);
+        img.string(PARAMS, "plain");
+        let ranged = PARAMS + PARAM_SIZE;
+        img.string(ranged, "ranged");
+        img.u64(ranged + 24, 16);
+        img.u64(ranged + 40, -5i64 as u64);
+        img.u64(ranged + 48, 9);
+        img.u64(ranged + 56, 0xff);
+        img.u32(ranged + 76, 1);
+        let string = PARAMS + 2 * PARAM_SIZE;
+        img.string(string, "string");
+        img.u64(string + 40, 1);
+        img.u64(string + 48, 255);
+        img.u32(string + 76, 8);
+        let unlimited = PARAMS + 3 * PARAM_SIZE;
+        img.string(unlimited, "unlimited");
+        img.u32(unlimited + 76, 8);
+        img.u32(RETURN_MAGIC, magic::RETURN);
+        img.string(RETURN_SPEC, "long");
+        img.u32(RETURN_SPEC + 12, 1);
+        img.u64(RETURN_SPEC + 16, 7);
+        img.u64(RETURN_SPEC + 32, 100);
+
+        let spec = img.parse().unwrap();
+
+        let plain = &spec.parameters[0];
+        assert_eq!((plain.min_value, plain.max_value), (None, None));
+        assert_eq!(
+            (plain.valid_mask, plain.size, plain.alignment),
+            (None, None, None)
+        );
+        let ranged = &spec.parameters[1];
+        assert_eq!((ranged.min_value, ranged.max_value), (Some(-5), Some(9)));
+        assert_eq!((ranged.valid_mask, ranged.size), (None, Some(16)));
+        let string = &spec.parameters[2];
+        assert_eq!((string.min_value, string.max_value), (Some(1), Some(255)));
+        let unlimited = &spec.parameters[3];
+        assert_eq!((unlimited.min_value, unlimited.max_value), (None, None));
+        let ret = spec.return_spec.unwrap();
+        assert_eq!(ret.success_value, None);
+        assert_eq!((ret.success_min, ret.success_max), (Some(0), Some(100)));
+    }
+
+    #[test]
+    fn enum_values_behind_a_dangling_pointer_are_dropped() {
+        let mut img = Image::new();
+        img.u32(PARAM_MAGIC, magic::PARAMS);
+        img.u32(PARAM_MAGIC + 4, 1);
+        img.string(PARAMS, "mode");
+        img.u64(PARAMS + 64, 0x1000);
+        img.u32(PARAMS + 72, 4);
+
+        let spec = img.parse().unwrap();
+
+        assert!(spec.parameters[0].enum_values.is_empty());
+    }
+
+    #[test]
+    fn unexpected_section_marker_is_rejected() {
+        let mut img = Image::new();
+        img.u32(ERROR_MAGIC, 0xdead_beef);
+
+        let err = img.parse().unwrap_err();
+
+        assert!(err.to_string().contains("0xdeadbeef"), "{err}");
+    }
+
+    #[test]
+    fn truncated_spec_is_rejected() {
+        let mut img = Image::new();
+        img.spec.truncate(SIGNAL_MASKS);
+
+        let err = img.parse().unwrap_err();
+
+        assert!(err.to_string().contains("past the end"), "{err}");
+    }
+}
diff --git a/tools/kapi/src/formatter/json.rs b/tools/kapi/src/formatter/json.rs
new file mode 100644
index 0000000000000..5f2367ad12d40
--- /dev/null
+++ b/tools/kapi/src/formatter/json.rs
@@ -0,0 +1,659 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec, StructSpec,
+};
+use serde::Serialize;
+use std::io::Write;
+
+pub struct JsonFormatter {
+    data: JsonData,
+}
+
+#[derive(Serialize)]
+struct JsonData {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    apis: Option<Vec<JsonApi>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    api_details: Option<JsonApiDetails>,
+}
+
+#[derive(Serialize)]
+struct JsonApi {
+    name: String,
+    api_type: String,
+}
+
+#[derive(Serialize)]
+struct JsonApiDetails {
+    name: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    description: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    long_description: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    context_flags: Vec<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    examples: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    notes: Option<String>,
+    // Sysfs-specific fields
+    #[serde(skip_serializing_if = "Option::is_none")]
+    subsystem: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    sysfs_path: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    permissions: Option<String>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    capabilities: Vec<CapabilitySpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    state_transitions: Vec<StateTransitionSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    side_effects: Vec<SideEffectSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    parameters: Vec<ParamSpec>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    return_spec: Option<ReturnSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    errors: Vec<ErrorSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    locks: Vec<LockSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    struct_specs: Vec<StructSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    signals: Vec<SignalSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    signal_masks: Vec<SignalMaskSpec>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    constraints: Vec<ConstraintSpec>,
+}
+
+impl JsonFormatter {
+    pub fn new() -> Self {
+        JsonFormatter {
+            data: JsonData {
+                apis: None,
+                api_details: None,
+            },
+        }
+    }
+}
+
+impl OutputFormatter for JsonFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let json = serde_json::to_string_pretty(&self.data)?;
+        writeln!(w, "{json}")?;
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, _w: &mut dyn Write, _title: &str) -> std::io::Result<()> {
+        if self.data.apis.is_none() {
+            self.data.apis = Some(Vec::new());
+        }
+        Ok(())
+    }
+
+    fn api_item(&mut self, _w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()> {
+        if let Some(apis) = &mut self.data.apis {
+            apis.push(JsonApi {
+                name: name.to_string(),
+                api_type: api_type.to_string(),
+            });
+        }
+        Ok(())
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, _w: &mut dyn Write, _count: usize) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_details(&mut self, _w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        self.data.api_details = Some(JsonApiDetails {
+            name: name.to_string(),
+            description: None,
+            long_description: None,
+            context_flags: Vec::new(),
+            examples: None,
+            notes: None,
+            subsystem: None,
+            sysfs_path: None,
+            permissions: None,
+            capabilities: Vec::new(),
+            state_transitions: Vec::new(),
+            side_effects: Vec::new(),
+            parameters: Vec::new(),
+            return_spec: None,
+            errors: Vec::new(),
+            locks: Vec::new(),
+            struct_specs: Vec::new(),
+            signals: Vec::new(),
+            signal_masks: Vec::new(),
+            constraints: Vec::new(),
+        });
+        Ok(())
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, _w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.description = Some(desc.to_string());
+        }
+        Ok(())
+    }
+
+    fn long_description(&mut self, _w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.long_description = Some(desc.to_string());
+        }
+        Ok(())
+    }
+
+    fn begin_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn context_flag(&mut self, _w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.context_flags.push(flag.to_string());
+        }
+        Ok(())
+    }
+
+    fn end_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_parameters(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, _w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.examples = Some(examples.to_string());
+        }
+        Ok(())
+    }
+
+    fn notes(&mut self, _w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.notes = Some(notes.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_subsystem(&mut self, _w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.subsystem = Some(subsystem.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_path(&mut self, _w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.sysfs_path = Some(path.to_string());
+        }
+        Ok(())
+    }
+
+    fn sysfs_permissions(&mut self, _w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.permissions = Some(perms.to_string());
+        }
+        Ok(())
+    }
+
+    fn begin_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn capability(&mut self, _w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.capabilities.push(cap.clone());
+        }
+        Ok(())
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn parameter(&mut self, _w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.parameters.push(param.clone());
+        }
+        Ok(())
+    }
+
+    fn return_spec(&mut self, _w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.return_spec = Some(ret.clone());
+        }
+        Ok(())
+    }
+
+    fn error(&mut self, _w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.errors.push(error.clone());
+        }
+        Ok(())
+    }
+
+    fn begin_signals(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn signal(&mut self, _w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.signals.push(signal.clone());
+        }
+        Ok(())
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn signal_mask(&mut self, _w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.signal_masks.push(mask.clone());
+        }
+        Ok(())
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_side_effects(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn side_effect(&mut self, _w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.side_effects.push(effect.clone());
+        }
+        Ok(())
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn state_transition(
+        &mut self,
+        _w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.state_transitions.push(trans.clone());
+        }
+        Ok(())
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_constraints(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn constraint(
+        &mut self,
+        _w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        if let Some(api_details) = &mut self.data.api_details {
+            api_details.constraints.push(constraint.clone());
+        }
+        Ok(())
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn lock(&mut self, _w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        if let Some(details) = &mut self.data.api_details {
+            details.locks.push(lock.clone());
+        }
+        Ok(())
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, _w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn struct_spec(&mut self, _w: &mut dyn Write, spec: &StructSpec) -> std::io::Result<()> {
+        if let Some(ref mut details) = self.data.api_details {
+            details.struct_specs.push(spec.clone());
+        }
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, ParamSpec, ReturnSpec};
+
+    fn render_json(f: &mut JsonFormatter) -> String {
+        let mut buf = Vec::new();
+        f.end_document(&mut buf).unwrap();
+        String::from_utf8(buf).unwrap()
+    }
+
+    #[test]
+    fn json_output_is_valid() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Verify it parses as valid JSON
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(parsed["api_details"]["name"].as_str(), Some("sys_test"));
+        assert_eq!(
+            parsed["api_details"]["description"].as_str(),
+            Some("A test syscall")
+        );
+    }
+
+    #[test]
+    fn json_api_list() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "Syscalls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let apis = parsed["apis"].as_array().unwrap();
+        assert_eq!(apis.len(), 2);
+        assert_eq!(apis[0]["name"].as_str(), Some("sys_open"));
+        assert_eq!(apis[0]["api_type"].as_str(), Some("syscall"));
+        assert_eq!(apis[1]["name"].as_str(), Some("sys_read"));
+    }
+
+    #[test]
+    fn json_special_characters_in_description() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "Contains \"quotes\" and \\backslashes\\")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Must be valid JSON despite special characters
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(
+            parsed["api_details"]["description"].as_str(),
+            Some("Contains \"quotes\" and \\backslashes\\")
+        );
+    }
+
+    #[test]
+    fn json_special_characters_in_name() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "APIs").unwrap();
+        // Names with underscores (common in kernel) and unusual strings
+        f.api_item(&mut sink, "sys_new\tline", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        // Must parse correctly; serde_json handles escaping for us
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(parsed["apis"][0]["name"].as_str(), Some("sys_new\tline"));
+    }
+
+    #[test]
+    fn json_parameters_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 2).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: Some(0),
+                max_value: Some(1024),
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let params = parsed["api_details"]["parameters"].as_array().unwrap();
+        assert_eq!(params.len(), 1);
+        assert_eq!(params[0]["name"].as_str(), Some("fd"));
+        assert_eq!(params[0]["param_type"].as_u64(), Some(2));
+    }
+
+    #[test]
+    fn json_errors_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_read").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -9,
+                name: "EBADF".to_string(),
+                condition: "fd is not valid".to_string(),
+                description: "Bad file descriptor".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let errors = parsed["api_details"]["errors"].as_array().unwrap();
+        assert_eq!(errors.len(), 1);
+        assert_eq!(errors[0]["name"].as_str(), Some("EBADF"));
+        assert_eq!(errors[0]["error_code"].as_i64(), Some(-9));
+    }
+
+    #[test]
+    fn json_empty_details_omits_empty_fields() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_empty").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        // description should not be present (skip_serializing_if = Option::is_none)
+        assert!(parsed["api_details"]["description"].is_null());
+        // parameters empty array should not be present (skip_serializing_if = Vec::is_empty)
+        assert!(parsed["api_details"]["parameters"].is_null());
+        // errors empty array should not be present
+        assert!(parsed["api_details"]["errors"].is_null());
+    }
+
+    #[test]
+    fn json_braces_balance() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_balanced").unwrap();
+        f.description(&mut sink, "Test braces balance").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+
+        let open_braces = json.chars().filter(|&c| c == '{').count();
+        let close_braces = json.chars().filter(|&c| c == '}').count();
+        assert_eq!(open_braces, close_braces, "Braces are unbalanced");
+
+        let open_brackets = json.chars().filter(|&c| c == '[').count();
+        let close_brackets = json.chars().filter(|&c| c == ']').count();
+        assert_eq!(open_brackets, close_brackets, "Brackets are unbalanced");
+    }
+
+    #[test]
+    fn json_return_spec_serialized() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_open").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "int".to_string(),
+                description: "file descriptor on success".to_string(),
+                return_type: 1,
+                check_type: 3,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![-1],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+
+        let ret = &parsed["api_details"]["return_spec"];
+        assert_eq!(ret["type_name"].as_str(), Some("int"));
+        assert_eq!(ret["check_type"].as_u64(), Some(3));
+    }
+
+    #[test]
+    fn json_unicode_in_description() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_uni").unwrap();
+        f.description(&mut sink, "Supports unicode: \u{00e9}\u{00e8}\u{00ea}")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert!(parsed["api_details"]["description"]
+            .as_str()
+            .unwrap()
+            .contains('\u{00e9}'));
+    }
+
+    #[test]
+    fn json_escapes_embedded_newlines() {
+        let mut f = JsonFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "One.\n\n- a\n- b").unwrap();
+        f.examples(&mut sink, "a();\nb();").unwrap();
+        f.notes(&mut sink, "Note one.\n\nNote two.").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let json = render_json(&mut f);
+        assert!(json.contains(r#""examples": "a();\nb();""#));
+        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
+        assert_eq!(
+            parsed["api_details"]["long_description"].as_str(),
+            Some("One.\n\n- a\n- b")
+        );
+        assert_eq!(
+            parsed["api_details"]["notes"].as_str(),
+            Some("Note one.\n\nNote two.")
+        );
+    }
+}
diff --git a/tools/kapi/src/formatter/mod.rs b/tools/kapi/src/formatter/mod.rs
new file mode 100644
index 0000000000000..8d375cb310b0b
--- /dev/null
+++ b/tools/kapi/src/formatter/mod.rs
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec, StructSpec,
+};
+use std::io::Write;
+
+mod json;
+mod plain;
+mod rst;
+
+pub use json::JsonFormatter;
+pub use plain::PlainFormatter;
+pub use rst::RstFormatter;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum OutputFormat {
+    Plain,
+    Json,
+    Rst,
+}
+
+impl std::str::FromStr for OutputFormat {
+    type Err = String;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "plain" => Ok(OutputFormat::Plain),
+            "json" => Ok(OutputFormat::Json),
+            "rst" => Ok(OutputFormat::Rst),
+            _ => Err(format!("Unknown output format: {}", s)),
+        }
+    }
+}
+
+/// Names of the KAPI_PARAM_* flags set in `flags`.
+fn param_flag_names(flags: u32) -> Vec<&'static str> {
+    const IN: u32 = 1 << 0;
+    const OUT: u32 = 1 << 1;
+    const OTHER: [(u32, &str); 6] = [
+        (1 << 3, "OPTIONAL"),
+        (1 << 4, "CONST"),
+        (1 << 5, "VOLATILE"),
+        (1 << 6, "USER"),
+        (1 << 7, "DMA"),
+        (1 << 8, "ALIGNED"),
+    ];
+
+    let mut names = Vec::new();
+    if flags & (IN | OUT) == IN | OUT {
+        names.push("INOUT");
+    } else if flags & IN != 0 {
+        names.push("IN");
+    } else if flags & OUT != 0 {
+        names.push("OUT");
+    }
+    names.extend(
+        OTHER
+            .iter()
+            .filter(|(bit, _)| flags & bit != 0)
+            .map(|(_, name)| *name),
+    );
+    names
+}
+
+/// Text for a parameter's bounds; either end may be unset.
+fn range_text(min: Option<i64>, max: Option<i64>) -> Option<String> {
+    match (min, max) {
+        (Some(min), Some(max)) => Some(format!("{min} to {max}")),
+        (Some(min), None) => Some(format!(">= {min}")),
+        (None, Some(max)) => Some(format!("<= {max}")),
+        (None, None) => None,
+    }
+}
+
+/// Write `text` line by line, indenting every non-blank line by `indent`.
+fn write_indented(w: &mut dyn Write, indent: &str, text: &str) -> std::io::Result<()> {
+    for line in text.lines() {
+        if line.is_empty() {
+            writeln!(w)?;
+        } else {
+            writeln!(w, "{indent}{line}")?;
+        }
+    }
+    Ok(())
+}
+
+/// Prepare text with embedded newlines for reStructuredText. Wrapped
+/// prose stays in its paragraph, but a bullet list needs a blank line
+/// before its first item and after its last one to be recognised.
+fn rst_text(text: &str) -> String {
+    let mut out: Vec<&str> = Vec::new();
+    let mut prev_bullet = false;
+    for line in text.lines() {
+        let bullet = line.starts_with("- ");
+        if !line.is_empty() && bullet != prev_bullet && out.last().is_some_and(|l| !l.is_empty()) {
+            out.push("");
+        }
+        out.push(line);
+        if !line.is_empty() {
+            prev_bullet = bullet;
+        }
+    }
+    out.join("\n")
+}
+
+pub trait OutputFormatter {
+    fn begin_document(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn end_document(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()>;
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()>;
+    fn end_api_list(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()>;
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()>;
+    fn end_api_details(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()>;
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()>;
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()>;
+    fn end_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()>;
+    fn end_parameters(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()>;
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()>;
+    fn end_errors(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()>;
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()>;
+
+    // Sysfs-specific methods
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()>;
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()>;
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()>;
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()>;
+    fn end_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Signal-related methods
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()>;
+    fn end_signals(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()>;
+    fn end_signal_masks(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Side effects and state transitions
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()>;
+    fn end_side_effects(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()>;
+    fn end_state_transitions(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    // Constraints and locks
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn constraint(&mut self, w: &mut dyn Write, constraint: &ConstraintSpec)
+        -> std::io::Result<()>;
+    fn end_constraints(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()>;
+    fn end_locks(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()>;
+    fn struct_spec(&mut self, w: &mut dyn Write, spec: &StructSpec) -> std::io::Result<()>;
+    fn end_struct_specs(&mut self, w: &mut dyn Write) -> std::io::Result<()>;
+}
+
+pub fn create_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
+    match format {
+        OutputFormat::Plain => Box::new(PlainFormatter::new()),
+        OutputFormat::Json => Box::new(JsonFormatter::new()),
+        OutputFormat::Rst => Box::new(RstFormatter::new()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn param_flags_use_the_header_bit_values() {
+        assert!(param_flag_names(0).is_empty());
+        assert_eq!(param_flag_names(0x1), ["IN"]);
+        assert_eq!(param_flag_names(0x2), ["OUT"]);
+        assert_eq!(param_flag_names(0x3), ["INOUT"]);
+        assert_eq!(param_flag_names(0x42), ["OUT", "USER"]);
+        assert_eq!(param_flag_names(0x9), ["IN", "OPTIONAL"]);
+        assert_eq!(
+            param_flag_names(0x1f8),
+            ["OPTIONAL", "CONST", "VOLATILE", "USER", "DMA", "ALIGNED"]
+        );
+    }
+
+    #[test]
+    fn range_text_shows_one_sided_bounds() {
+        assert_eq!(range_text(Some(0), Some(9)).as_deref(), Some("0 to 9"));
+        assert_eq!(range_text(Some(1), None).as_deref(), Some(">= 1"));
+        assert_eq!(range_text(None, Some(255)).as_deref(), Some("<= 255"));
+        assert_eq!(range_text(None, None), None);
+    }
+}
diff --git a/tools/kapi/src/formatter/plain.rs b/tools/kapi/src/formatter/plain.rs
new file mode 100644
index 0000000000000..0aa03ab8573b6
--- /dev/null
+++ b/tools/kapi/src/formatter/plain.rs
@@ -0,0 +1,679 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec,
+};
+use std::io::Write;
+
+pub struct PlainFormatter;
+
+impl PlainFormatter {
+    pub fn new() -> Self {
+        PlainFormatter
+    }
+}
+
+impl OutputFormatter for PlainFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{title}:")?;
+        writeln!(w, "{}", "-".repeat(title.len() + 1))
+    }
+
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, _api_type: &str) -> std::io::Result<()> {
+        writeln!(w, "  {name}")
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()> {
+        writeln!(w, "\nTotal specifications found: {count}")
+    }
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        writeln!(w, "\nDetailed information for {name}:")?;
+        writeln!(w, "{}=", "=".repeat(25 + name.len()))
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "Description: {desc}")
+    }
+
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "\nDetailed Description:")?;
+        super::write_indented(w, "  ", desc)
+    }
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w, "\nExecution Context:")
+    }
+
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        writeln!(w, "  - {flag}")
+    }
+
+    fn end_context_flags(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nParameters ({count}):")
+    }
+
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  [{}] {} ({})",
+            param.index, param.name, param.type_name
+        )?;
+        if !param.description.is_empty() {
+            writeln!(w, "      {}", param.description)?;
+        }
+
+        // Display flags
+        let flags = super::param_flag_names(param.flags);
+        if !flags.is_empty() {
+            writeln!(w, "      Flags: {}", flags.join(" | "))?;
+        }
+
+        // Display constraints
+        if let Some(constraint) = &param.constraint {
+            writeln!(w, "      Constraint: {constraint}")?;
+        }
+        if let Some(range) = super::range_text(param.min_value, param.max_value) {
+            writeln!(w, "      Range: {range}")?;
+        }
+        if let Some(mask) = param.valid_mask {
+            writeln!(w, "      Valid mask: 0x{mask:x}")?;
+        }
+        Ok(())
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        writeln!(w, "\nReturn Value:")?;
+        writeln!(w, "  Type: {}", ret.type_name)?;
+        writeln!(w, "  {}", ret.description)?;
+        if let Some(val) = ret.success_value {
+            writeln!(w, "  Success value: {val}")?;
+        }
+        if let (Some(min), Some(max)) = (ret.success_min, ret.success_max) {
+            writeln!(w, "  Success range: {min} to {max}")?;
+        }
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nPossible Errors ({count}):")
+    }
+
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        writeln!(w, "  {} ({})", error.name, error.error_code)?;
+        if !error.condition.is_empty() {
+            writeln!(w, "      Condition: {}", error.condition)?;
+        }
+        if !error.description.is_empty() {
+            writeln!(w, "      {}", error.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        writeln!(w, "\nExamples:")?;
+        super::write_indented(w, "  ", examples)
+    }
+
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        writeln!(w, "\nNotes:")?;
+        super::write_indented(w, "  ", notes)
+    }
+
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        writeln!(w, "Subsystem: {subsystem}")
+    }
+
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        writeln!(w, "Sysfs Path: {path}")
+    }
+
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        writeln!(w, "Permissions: {perms}")
+    }
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w, "\nRequired Capabilities:")
+    }
+
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        writeln!(w, "  {} ({}) - {}", cap.name, cap.capability, cap.action)?;
+        if !cap.allows.is_empty() {
+            writeln!(w, "    Allows: {}", cap.allows)?;
+        }
+        if !cap.without_cap.is_empty() {
+            writeln!(w, "    Without capability: {}", cap.without_cap)?;
+        }
+        if let Some(cond) = &cap.check_condition {
+            writeln!(w, "    Condition: {cond}")?;
+        }
+        Ok(())
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Signal-related methods
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSignal Specifications ({count}):")
+    }
+
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        write!(w, "  {} ({})", signal.signal_name, signal.signal_num)?;
+
+        // Display direction (bitmask matching C enum kapi_signal_direction)
+        let mut dirs = Vec::new();
+        if signal.direction & 1 != 0 {
+            dirs.push("RECEIVE");
+        }
+        if signal.direction & 2 != 0 {
+            dirs.push("SEND");
+        }
+        if signal.direction & 4 != 0 {
+            dirs.push("HANDLE");
+        }
+        if signal.direction & 8 != 0 {
+            dirs.push("BLOCK");
+        }
+        if signal.direction & 16 != 0 {
+            dirs.push("IGNORE");
+        }
+        let direction = if dirs.is_empty() {
+            "UNKNOWN".to_string()
+        } else {
+            dirs.join("|")
+        };
+        write!(w, " - {direction}")?;
+
+        // Display action (matching C enum kapi_signal_action)
+        let action = match signal.action {
+            0 => "DEFAULT",
+            1 => "TERMINATE",
+            2 => "COREDUMP",
+            3 => "STOP",
+            4 => "CONTINUE",
+            5 => "CUSTOM",
+            6 => "RETURN",
+            7 => "RESTART",
+            8 => "QUEUE",
+            9 => "DISCARD",
+            10 => "TRANSFORM",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, " - {action}")?;
+
+        if let Some(target) = &signal.target {
+            writeln!(w, "      Target: {target}")?;
+        }
+        if let Some(condition) = &signal.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if let Some(desc) = &signal.description {
+            writeln!(w, "      {desc}")?;
+        }
+
+        // Display timing
+        let timing = match signal.timing {
+            0 => "BEFORE",
+            1 => "DURING",
+            2 => "AFTER",
+            3 => "EXIT",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, "      Timing: {timing}")?;
+        writeln!(w, "      Priority: {}", signal.priority)?;
+
+        if signal.restartable {
+            writeln!(w, "      Restartable: yes")?;
+        }
+        if signal.interruptible {
+            writeln!(w, "      Interruptible: yes")?;
+        }
+        if let Some(queue) = &signal.queue {
+            writeln!(w, "      Queue: {queue}")?;
+        }
+        if signal.sa_flags_required != 0 {
+            writeln!(
+                w,
+                "      SA flags required: {:#x}",
+                signal.sa_flags_required
+            )?;
+        }
+        if signal.sa_flags_forbidden != 0 {
+            writeln!(
+                w,
+                "      SA flags forbidden: {:#x}",
+                signal.sa_flags_forbidden
+            )?;
+        }
+        if signal.state_required != 0 {
+            writeln!(w, "      State required: {:#x}", signal.state_required)?;
+        }
+        if signal.state_forbidden != 0 {
+            writeln!(w, "      State forbidden: {:#x}", signal.state_forbidden)?;
+        }
+        if let Some(error) = signal.error_on_signal {
+            writeln!(w, "      Error on signal: {error}")?;
+        }
+        if let Some(transform) = signal.transform_to {
+            writeln!(w, "      Transform to: {transform}")?;
+        }
+        Ok(())
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSignal Masks ({count}):")
+    }
+
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        writeln!(w, "  {}", mask.name)?;
+        if !mask.description.is_empty() {
+            writeln!(w, "      {}", mask.description)?;
+        }
+        if !mask.signals.is_empty() {
+            let signals: Vec<String> = mask.signals.iter().map(i32::to_string).collect();
+            writeln!(w, "      Signals: {}", signals.join(", "))?;
+        }
+        Ok(())
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Side effects and state transitions
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nSide Effects ({count}):")
+    }
+
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        writeln!(w, "  {} - {}", effect.target, effect.description)?;
+        if let Some(condition) = &effect.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if effect.reversible {
+            writeln!(w, "      Reversible: yes")?;
+        }
+        Ok(())
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nState Transitions ({count}):")
+    }
+
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  {} : {} -> {}",
+            trans.object, trans.from_state, trans.to_state
+        )?;
+        if let Some(condition) = &trans.condition {
+            writeln!(w, "      Condition: {condition}")?;
+        }
+        if !trans.description.is_empty() {
+            writeln!(w, "      {}", trans.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    // Constraints and locks
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nAdditional Constraints ({count}):")
+    }
+
+    fn constraint(
+        &mut self,
+        w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "  {}", constraint.name)?;
+        if !constraint.description.is_empty() {
+            writeln!(w, "      {}", constraint.description)?;
+        }
+        if let Some(expr) = &constraint.expression {
+            writeln!(w, "      Expression: {expr}")?;
+        }
+        Ok(())
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nLocking Requirements ({count}):")
+    }
+
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        write!(w, "  {}", lock.lock_name)?;
+
+        // Display lock type
+        let lock_type = match lock.lock_type {
+            0 => "NONE",
+            1 => "MUTEX",
+            2 => "SPINLOCK",
+            3 => "RWLOCK",
+            4 => "SEQLOCK",
+            5 => "RCU",
+            6 => "SEMAPHORE",
+            7 => "CUSTOM",
+            _ => "UNKNOWN",
+        };
+        writeln!(w, " ({lock_type})")?;
+
+        let scope_str = match lock.scope {
+            0 => "acquired and released",
+            1 => "acquired (not released)",
+            2 => "released (held on entry)",
+            3 => "held by caller",
+            _ => "unknown",
+        };
+        writeln!(w, "      Scope: {scope_str}")?;
+
+        if !lock.description.is_empty() {
+            writeln!(w, "      {}", lock.description)?;
+        }
+        Ok(())
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        writeln!(w, "\nStructure Specifications ({count}):")
+    }
+
+    fn struct_spec(
+        &mut self,
+        w: &mut dyn Write,
+        spec: &crate::extractor::StructSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "  {} (size={}, align={}):",
+            spec.name, spec.size, spec.alignment
+        )?;
+        if !spec.description.is_empty() {
+            writeln!(w, "      {}", spec.description)?;
+        }
+
+        if !spec.fields.is_empty() {
+            writeln!(w, "      Fields ({}):", spec.field_count)?;
+            for field in &spec.fields {
+                write!(w, "        - {} ({}):", field.name, field.type_name)?;
+                if !field.description.is_empty() {
+                    write!(w, " {}", field.description)?;
+                }
+                writeln!(w)?;
+
+                // Show constraints if present
+                if field.min_value != 0 || field.max_value != 0 {
+                    writeln!(
+                        w,
+                        "          Range: [{}, {}]",
+                        field.min_value, field.max_value
+                    )?;
+                }
+                if field.valid_mask != 0 {
+                    writeln!(w, "          Mask: {:#x}", field.valid_mask)?;
+                }
+            }
+        }
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, ParamSpec, ReturnSpec};
+
+    fn render_plain(f: &mut PlainFormatter, sink: &mut Vec<u8>) -> String {
+        f.end_document(sink).unwrap();
+        String::from_utf8(sink.clone()).unwrap()
+    }
+
+    #[test]
+    fn plain_api_list() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "System Calls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+        f.total_specs(&mut sink, 2).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("sys_open"));
+        assert!(out.contains("sys_read"));
+        assert!(out.contains("Total specifications found: 2"));
+    }
+
+    #[test]
+    fn plain_api_details() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.long_description(&mut sink, "Detailed description here")
+            .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("sys_test"));
+        assert!(out.contains("A test syscall"));
+        assert!(out.contains("Detailed description here"));
+    }
+
+    #[test]
+    fn plain_parameters() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 1).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: None,
+                max_value: None,
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("fd"));
+        assert!(out.contains("unsigned int"));
+        assert!(out.contains("file descriptor"));
+    }
+
+    #[test]
+    fn plain_errors() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -2,
+                name: "ENOENT".to_string(),
+                condition: "File not found".to_string(),
+                description: "The file does not exist".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("ENOENT"));
+        assert!(out.contains("-2"));
+        assert!(out.contains("File not found"));
+    }
+
+    #[test]
+    fn plain_return_spec() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "KAPI_TYPE_INT".to_string(),
+                description: "Returns 0 on success".to_string(),
+                return_type: 1,
+                check_type: 0,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("KAPI_TYPE_INT"));
+        assert!(out.contains("Returns 0 on success"));
+    }
+
+    #[test]
+    fn plain_context_flags() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_context_flags(&mut sink).unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_PROCESS").unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_SLEEPABLE").unwrap();
+        f.end_context_flags(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("KAPI_CTX_PROCESS"));
+        assert!(out.contains("KAPI_CTX_SLEEPABLE"));
+    }
+
+    #[test]
+    fn plain_signal_mask_lists_its_signals() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_signal_masks(&mut sink, 1).unwrap();
+        f.signal_mask(
+            &mut sink,
+            &SignalMaskSpec {
+                name: "blocked".to_string(),
+                description: "Blocked while running".to_string(),
+                signals: vec![2, 15],
+            },
+        )
+        .unwrap();
+        f.end_signal_masks(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out.contains("  blocked\n      Blocked while running\n      Signals: 2, 15\n"));
+    }
+
+    #[test]
+    fn plain_multiline_blocks_are_indented() {
+        let mut f = PlainFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "First paragraph.\n\n- item one\n- item two")
+            .unwrap();
+        f.examples(&mut sink, "a();\nif (x) {\n  b();\n}").unwrap();
+        f.notes(&mut sink, "Note one.\n\nNote two.").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_plain(&mut f, &mut sink);
+        assert!(out
+            .contains("Detailed Description:\n  First paragraph.\n\n  - item one\n  - item two\n"));
+        assert!(out.contains("Examples:\n  a();\n  if (x) {\n    b();\n  }\n"));
+        assert!(out.contains("Notes:\n  Note one.\n\n  Note two.\n"));
+    }
+}
diff --git a/tools/kapi/src/formatter/rst.rs b/tools/kapi/src/formatter/rst.rs
new file mode 100644
index 0000000000000..c208d876c9777
--- /dev/null
+++ b/tools/kapi/src/formatter/rst.rs
@@ -0,0 +1,802 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+use super::OutputFormatter;
+use crate::extractor::{
+    CapabilitySpec, ConstraintSpec, ErrorSpec, LockSpec, ParamSpec, ReturnSpec, SideEffectSpec,
+    SignalMaskSpec, SignalSpec, StateTransitionSpec,
+};
+use std::io::Write;
+
+pub struct RstFormatter;
+
+impl RstFormatter {
+    pub fn new() -> Self {
+        RstFormatter
+    }
+
+    fn section_char(level: usize) -> char {
+        match level {
+            0 => '=',
+            1 => '-',
+            2 => '~',
+            3 => '^',
+            _ => '"',
+        }
+    }
+}
+
+impl OutputFormatter for RstFormatter {
+    fn begin_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn end_document(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_api_list(&mut self, w: &mut dyn Write, title: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(0).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn api_item(&mut self, w: &mut dyn Write, name: &str, api_type: &str) -> std::io::Result<()> {
+        writeln!(w, "* **{name}** (*{api_type}*)")
+    }
+
+    fn end_api_list(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn total_specs(&mut self, w: &mut dyn Write, count: usize) -> std::io::Result<()> {
+        writeln!(w, "\n**Total specifications found:** {count}")
+    }
+
+    fn begin_api_details(&mut self, w: &mut dyn Write, name: &str) -> std::io::Result<()> {
+        writeln!(w, "\n{name}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(0).to_string().repeat(name.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_api_details(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "**{desc}**")?;
+        writeln!(w)
+    }
+
+    fn long_description(&mut self, w: &mut dyn Write, desc: &str) -> std::io::Result<()> {
+        writeln!(w, "{}", super::rst_text(desc))?;
+        writeln!(w)
+    }
+
+    fn begin_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let title = "Execution Context";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn context_flag(&mut self, w: &mut dyn Write, flag: &str) -> std::io::Result<()> {
+        writeln!(w, "* {flag}")
+    }
+
+    fn end_context_flags(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        writeln!(w)
+    }
+
+    fn begin_parameters(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Parameters ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_parameters(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_errors(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Possible Errors ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn end_errors(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn examples(&mut self, w: &mut dyn Write, examples: &str) -> std::io::Result<()> {
+        let title = "Examples";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)?;
+        writeln!(w, ".. code-block:: c")?;
+        writeln!(w)?;
+        super::write_indented(w, "   ", examples)?;
+        writeln!(w)
+    }
+
+    fn notes(&mut self, w: &mut dyn Write, notes: &str) -> std::io::Result<()> {
+        let title = "Notes";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)?;
+        writeln!(w, "{}", super::rst_text(notes))?;
+        writeln!(w)
+    }
+
+    fn sysfs_subsystem(&mut self, w: &mut dyn Write, subsystem: &str) -> std::io::Result<()> {
+        writeln!(w, ":Subsystem: {subsystem}")?;
+        writeln!(w)
+    }
+
+    fn sysfs_path(&mut self, w: &mut dyn Write, path: &str) -> std::io::Result<()> {
+        writeln!(w, ":Sysfs Path: {path}")?;
+        writeln!(w)
+    }
+
+    fn sysfs_permissions(&mut self, w: &mut dyn Write, perms: &str) -> std::io::Result<()> {
+        writeln!(w, ":Permissions: {perms}")?;
+        writeln!(w)
+    }
+
+    fn begin_capabilities(&mut self, w: &mut dyn Write) -> std::io::Result<()> {
+        let title = "Required Capabilities";
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn capability(&mut self, w: &mut dyn Write, cap: &CapabilitySpec) -> std::io::Result<()> {
+        writeln!(w, "**{} ({})** - {}", cap.name, cap.capability, cap.action)?;
+        writeln!(w)?;
+        if !cap.allows.is_empty() {
+            writeln!(w, "* **Allows:** {}", cap.allows)?;
+        }
+        if !cap.without_cap.is_empty() {
+            writeln!(w, "* **Without capability:** {}", cap.without_cap)?;
+        }
+        if let Some(cond) = &cap.check_condition {
+            writeln!(w, "* **Condition:** {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_capabilities(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn parameter(&mut self, w: &mut dyn Write, param: &ParamSpec) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "**[{}] {}** (*{}*)",
+            param.index, param.name, param.type_name
+        )?;
+        writeln!(w)?;
+        writeln!(w, "  {}", param.description)?;
+
+        // Display flags
+        let flags = super::param_flag_names(param.flags);
+        if !flags.is_empty() {
+            writeln!(w, "  :Flags: {}", flags.join(", "))?;
+        }
+
+        if let Some(constraint) = &param.constraint {
+            writeln!(w, "  :Constraint: {}", constraint)?;
+        }
+
+        if let Some(range) = super::range_text(param.min_value, param.max_value) {
+            writeln!(w, "  :Range: {}", range)?;
+        }
+
+        writeln!(w)
+    }
+
+    fn return_spec(&mut self, w: &mut dyn Write, ret: &ReturnSpec) -> std::io::Result<()> {
+        writeln!(w, "\nReturn Value")?;
+        writeln!(w, "{}\n", Self::section_char(1).to_string().repeat(12))?;
+        writeln!(w)?;
+        writeln!(w, ":Type: {}", ret.type_name)?;
+        writeln!(w, ":Description: {}", ret.description)?;
+        if let Some(success) = ret.success_value {
+            writeln!(w, ":Success value: {}", success)?;
+        }
+        writeln!(w)
+    }
+
+    fn error(&mut self, w: &mut dyn Write, error: &ErrorSpec) -> std::io::Result<()> {
+        writeln!(w, "**{}** ({})", error.name, error.error_code)?;
+        writeln!(w)?;
+        writeln!(w, "  :Condition: {}", error.condition)?;
+        if !error.description.is_empty() {
+            writeln!(w, "  :Description: {}", error.description)?;
+        }
+        writeln!(w)
+    }
+
+    fn begin_signals(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Signals ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn signal(&mut self, w: &mut dyn Write, signal: &SignalSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", signal.signal_name)?;
+        if signal.signal_num != 0 {
+            write!(w, " ({})", signal.signal_num)?;
+        }
+        writeln!(w)?;
+
+        // Direction (bitmask matching C enum kapi_signal_direction)
+        let mut dirs = Vec::new();
+        if signal.direction & 1 != 0 {
+            dirs.push("receive");
+        }
+        if signal.direction & 2 != 0 {
+            dirs.push("send");
+        }
+        if signal.direction & 4 != 0 {
+            dirs.push("handle");
+        }
+        if signal.direction & 8 != 0 {
+            dirs.push("block");
+        }
+        if signal.direction & 16 != 0 {
+            dirs.push("ignore");
+        }
+        let direction = if dirs.is_empty() {
+            "unknown".to_string()
+        } else {
+            dirs.join(", ")
+        };
+        writeln!(w, "  :Direction: {}", direction)?;
+
+        // Action (matching C enum kapi_signal_action)
+        let action = match signal.action {
+            0 => "default",
+            1 => "terminate",
+            2 => "coredump",
+            3 => "stop",
+            4 => "continue",
+            5 => "custom",
+            6 => "return",
+            7 => "restart",
+            8 => "queue",
+            9 => "discard",
+            10 => "transform",
+            _ => "unknown",
+        };
+        writeln!(w, "  :Action: {}", action)?;
+
+        if let Some(target) = &signal.target {
+            writeln!(w, "  :Target: {}", target)?;
+        }
+        if let Some(cond) = &signal.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        if let Some(desc) = &signal.description {
+            writeln!(w, "  :Description: {}", desc)?;
+        }
+        let timing = match signal.timing {
+            0 => "before",
+            1 => "during",
+            2 => "after",
+            3 => "exit",
+            _ => "",
+        };
+        if !timing.is_empty() {
+            writeln!(w, "  :Timing: {}", timing)?;
+        }
+        if signal.priority != 0 {
+            writeln!(w, "  :Priority: {}", signal.priority)?;
+        }
+        if signal.interruptible {
+            writeln!(w, "  :Interruptible: yes")?;
+        }
+        if signal.restartable {
+            writeln!(w, "  :Restartable: yes")?;
+        }
+        if let Some(queue) = &signal.queue {
+            writeln!(w, "  :Queue: {}", queue)?;
+        }
+        if signal.sa_flags_required != 0 {
+            writeln!(w, "  :SA flags required: {:#x}", signal.sa_flags_required)?;
+        }
+        if signal.sa_flags_forbidden != 0 {
+            writeln!(w, "  :SA flags forbidden: {:#x}", signal.sa_flags_forbidden)?;
+        }
+        if signal.state_required != 0 {
+            writeln!(w, "  :State required: {:#x}", signal.state_required)?;
+        }
+        if signal.state_forbidden != 0 {
+            writeln!(w, "  :State forbidden: {:#x}", signal.state_forbidden)?;
+        }
+        if let Some(error) = signal.error_on_signal {
+            writeln!(w, "  :Error on signal: {}", error)?;
+        }
+        if let Some(transform) = signal.transform_to {
+            writeln!(w, "  :Transform to: {}", transform)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_signals(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_signal_masks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Signal Masks ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn signal_mask(&mut self, w: &mut dyn Write, mask: &SignalMaskSpec) -> std::io::Result<()> {
+        writeln!(w, "* **{}**", mask.name)?;
+        if !mask.description.is_empty() {
+            writeln!(w, "  {}", mask.description)?;
+        }
+        if !mask.signals.is_empty() {
+            let signals: Vec<String> = mask.signals.iter().map(i32::to_string).collect();
+            writeln!(w, "  Signals: {}", signals.join(", "))?;
+        }
+        writeln!(w)
+    }
+
+    fn end_signal_masks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_side_effects(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Side Effects ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn side_effect(&mut self, w: &mut dyn Write, effect: &SideEffectSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", effect.target)?;
+        if effect.reversible {
+            write!(w, " *(reversible)*")?;
+        }
+        writeln!(w)?;
+        writeln!(w, "  {}", effect.description)?;
+        if let Some(cond) = &effect.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_side_effects(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_state_transitions(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("State Transitions ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn state_transition(
+        &mut self,
+        w: &mut dyn Write,
+        trans: &StateTransitionSpec,
+    ) -> std::io::Result<()> {
+        writeln!(
+            w,
+            "* **{}**: {} → {}",
+            trans.object, trans.from_state, trans.to_state
+        )?;
+        writeln!(w, "  {}", trans.description)?;
+        if let Some(cond) = &trans.condition {
+            writeln!(w, "  :Condition: {}", cond)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_state_transitions(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_constraints(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Constraints ({count})");
+        writeln!(w, "{title}")?;
+        writeln!(
+            w,
+            "{}",
+            Self::section_char(1).to_string().repeat(title.len())
+        )?;
+        writeln!(w)
+    }
+
+    fn constraint(
+        &mut self,
+        w: &mut dyn Write,
+        constraint: &ConstraintSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "* **{}**", constraint.name)?;
+        if !constraint.description.is_empty() {
+            writeln!(w, "  {}", constraint.description)?;
+        }
+        if let Some(expr) = &constraint.expression {
+            writeln!(w, "  :Expression: ``{}``", expr)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_constraints(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_locks(&mut self, w: &mut dyn Write, count: u32) -> std::io::Result<()> {
+        let title = format!("Locks ({count})");
+        writeln!(w, "{}\n", title)?;
+        writeln!(
+            w,
+            "{}\n",
+            Self::section_char(1).to_string().repeat(title.len())
+        )
+    }
+
+    fn lock(&mut self, w: &mut dyn Write, lock: &LockSpec) -> std::io::Result<()> {
+        write!(w, "* **{}**", lock.lock_name)?;
+        let lock_type_str = match lock.lock_type {
+            1 => " *(mutex)*",
+            2 => " *(spinlock)*",
+            3 => " *(rwlock)*",
+            4 => " *(seqlock)*",
+            5 => " *(RCU)*",
+            6 => " *(semaphore)*",
+            7 => " *(custom)*",
+            _ => "",
+        };
+        writeln!(w, "{}", lock_type_str)?;
+        if !lock.description.is_empty() {
+            writeln!(w, "  {}", lock.description)?;
+        }
+        writeln!(w)
+    }
+
+    fn end_locks(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+
+    fn begin_struct_specs(&mut self, w: &mut dyn Write, _count: u32) -> std::io::Result<()> {
+        writeln!(w)?;
+        writeln!(w, "Structure Specifications")?;
+        writeln!(w, "~~~~~~~~~~~~~~~~~~~~~~~")?;
+        writeln!(w)
+    }
+
+    fn struct_spec(
+        &mut self,
+        w: &mut dyn Write,
+        spec: &crate::extractor::StructSpec,
+    ) -> std::io::Result<()> {
+        writeln!(w, "**{}**", spec.name)?;
+        writeln!(w)?;
+
+        if !spec.description.is_empty() {
+            writeln!(w, "  {}", spec.description)?;
+            writeln!(w)?;
+        }
+
+        writeln!(w, "  :Size: {} bytes", spec.size)?;
+        writeln!(w, "  :Alignment: {} bytes", spec.alignment)?;
+        writeln!(w, "  :Fields: {}", spec.field_count)?;
+        writeln!(w)?;
+
+        if !spec.fields.is_empty() {
+            for field in &spec.fields {
+                writeln!(w, "  * **{}** ({})", field.name, field.type_name)?;
+                if !field.description.is_empty() {
+                    writeln!(w, "    {}", field.description)?;
+                }
+                if field.min_value != 0 || field.max_value != 0 {
+                    writeln!(w, "    Range: [{}, {}]", field.min_value, field.max_value)?;
+                }
+            }
+            writeln!(w)?;
+        }
+
+        Ok(())
+    }
+
+    fn end_struct_specs(&mut self, _w: &mut dyn Write) -> std::io::Result<()> {
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::extractor::{ErrorSpec, LockSpec, ParamSpec, ReturnSpec};
+
+    fn render_rst(f: &mut RstFormatter, sink: &mut Vec<u8>) -> String {
+        f.end_document(sink).unwrap();
+        String::from_utf8(sink.clone()).unwrap()
+    }
+
+    #[test]
+    fn rst_api_details_has_heading() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.description(&mut sink, "A test syscall").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("sys_test"));
+        assert!(out.contains("========"));
+        assert!(out.contains("**A test syscall**"));
+    }
+
+    #[test]
+    fn rst_api_list() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_list(&mut sink, "System Calls").unwrap();
+        f.api_item(&mut sink, "sys_open", "syscall").unwrap();
+        f.api_item(&mut sink, "sys_read", "syscall").unwrap();
+        f.end_api_list(&mut sink).unwrap();
+        f.total_specs(&mut sink, 2).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("sys_open"));
+        assert!(out.contains("sys_read"));
+    }
+
+    #[test]
+    fn rst_parameters() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_write").unwrap();
+        f.begin_parameters(&mut sink, 1).unwrap();
+        f.parameter(
+            &mut sink,
+            &ParamSpec {
+                index: 0,
+                name: "fd".to_string(),
+                type_name: "unsigned int".to_string(),
+                description: "file descriptor".to_string(),
+                flags: 1,
+                param_type: 2,
+                constraint_type: 0,
+                constraint: None,
+                min_value: None,
+                max_value: None,
+                valid_mask: None,
+                enum_values: vec![],
+                size: None,
+                alignment: None,
+                size_param_idx: None,
+            },
+        )
+        .unwrap();
+        f.end_parameters(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("**[0] fd**"));
+        assert!(out.contains("unsigned int"));
+        assert!(out.contains("file descriptor"));
+    }
+
+    #[test]
+    fn rst_errors() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_errors(&mut sink, 1).unwrap();
+        f.error(
+            &mut sink,
+            &ErrorSpec {
+                error_code: -2,
+                name: "ENOENT".to_string(),
+                condition: "File not found".to_string(),
+                description: "The file does not exist".to_string(),
+            },
+        )
+        .unwrap();
+        f.end_errors(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("**ENOENT**"));
+        assert!(out.contains("-2"));
+        assert!(out.contains("File not found"));
+    }
+
+    #[test]
+    fn rst_return_spec() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.return_spec(
+            &mut sink,
+            &ReturnSpec {
+                type_name: "KAPI_TYPE_INT".to_string(),
+                description: "Returns 0 on success".to_string(),
+                return_type: 1,
+                check_type: 0,
+                success_value: Some(0),
+                success_min: None,
+                success_max: None,
+                error_values: vec![],
+            },
+        )
+        .unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("KAPI_TYPE_INT"));
+        assert!(out.contains("Returns 0 on success"));
+        assert!(out.contains("Return Value"));
+    }
+
+    #[test]
+    fn rst_context_flags() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_context_flags(&mut sink).unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_PROCESS").unwrap();
+        f.context_flag(&mut sink, "KAPI_CTX_SLEEPABLE").unwrap();
+        f.end_context_flags(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("KAPI_CTX_PROCESS"));
+        assert!(out.contains("KAPI_CTX_SLEEPABLE"));
+        assert!(out.contains("Execution Context"));
+    }
+
+    #[test]
+    fn rst_signal_mask_lists_its_signals() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.begin_signal_masks(&mut sink, 1).unwrap();
+        f.signal_mask(
+            &mut sink,
+            &SignalMaskSpec {
+                name: "blocked".to_string(),
+                description: "Blocked while running".to_string(),
+                signals: vec![2, 15],
+            },
+        )
+        .unwrap();
+        f.end_signal_masks(&mut sink).unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("* **blocked**\n  Blocked while running\n  Signals: 2, 15\n"));
+    }
+
+    #[test]
+    fn rst_examples_are_a_literal_block_with_blank_lines_kept() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.examples(&mut sink, "a();\n  b();\n\nc();").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains(".. code-block:: c\n\n   a();\n     b();\n\n   c();\n"));
+    }
+
+    #[test]
+    fn rst_bullets_get_blank_lines_around_the_list() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        f.begin_document(&mut sink).unwrap();
+        f.begin_api_details(&mut sink, "sys_test").unwrap();
+        f.long_description(&mut sink, "Intro:\n- one\n- two\n\nTail.")
+            .unwrap();
+        f.notes(&mut sink, "Para one.\n\n- a\n- b\nafter").unwrap();
+        f.end_api_details(&mut sink).unwrap();
+
+        let out = render_rst(&mut f, &mut sink);
+        assert!(out.contains("Intro:\n\n- one\n- two\n\nTail.\n"));
+        assert!(out.contains("Para one.\n\n- a\n- b\n\nafter\n"));
+    }
+
+    #[test]
+    fn rst_lock_types_follow_the_kernel_enum() {
+        let mut f = RstFormatter::new();
+        let mut sink = Vec::new();
+
+        for (lock_type, label) in [
+            (1, "mutex"),
+            (2, "spinlock"),
+            (3, "rwlock"),
+            (4, "seqlock"),
+            (5, "RCU"),
+            (6, "semaphore"),
+            (7, "custom"),
+        ] {
+            f.lock(
+                &mut sink,
+                &LockSpec {
+                    lock_name: "l".to_string(),
+                    lock_type,
+                    scope: 0,
+                    description: String::new(),
+                },
+            )
+            .unwrap();
+            let out = String::from_utf8(std::mem::take(&mut sink)).unwrap();
+            assert_eq!(out, format!("* **l** *({label})*\n\n"));
+        }
+    }
+}
diff --git a/tools/kapi/src/main.rs b/tools/kapi/src/main.rs
new file mode 100644
index 0000000000000..93ed11b508e9f
--- /dev/null
+++ b/tools/kapi/src/main.rs
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: GPL-2.0
+// Copyright (C) 2026 Sasha Levin <sashal@kernel.org>
+
+//! kapi - Kernel API Specification Tool
+//!
+//! This tool extracts and displays kernel API specifications from multiple sources:
+//! - Kernel source code (kerneldoc blocks)
+//! - Compiled vmlinux binaries (`.kapi_specs` ELF section)
+//! - Running kernel via debugfs
+
+use anyhow::Result;
+use clap::Parser;
+use std::io::{self, Write};
+
+mod extractor;
+mod formatter;
+
+use extractor::{ApiExtractor, DebugfsExtractor, SourceExtractor, VmlinuxExtractor};
+use formatter::{create_formatter, OutputFormat};
+
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+struct Args {
+    /// Path to the vmlinux file
+    #[arg(long, value_name = "PATH", group = "input")]
+    vmlinux: Option<String>,
+
+    /// Path to kernel source directory or file
+    #[arg(long, value_name = "PATH", group = "input")]
+    source: Option<String>,
+
+    /// Path to debugfs (defaults to /sys/kernel/debug if not specified)
+    #[arg(long, value_name = "PATH", group = "input")]
+    debugfs: Option<String>,
+
+    /// Optional: Name of specific API to show details for
+    api_name: Option<String>,
+
+    /// Output format
+    #[arg(long, short = 'f', default_value = "plain")]
+    format: String,
+}
+
+fn main() -> Result<()> {
+    let args = Args::parse();
+
+    let output_format: OutputFormat = args
+        .format
+        .parse()
+        .map_err(|e: String| anyhow::anyhow!(e))?;
+
+    let extractor: Box<dyn ApiExtractor> = match (&args.vmlinux, &args.source, &args.debugfs) {
+        (Some(vmlinux_path), None, None) => Box::new(VmlinuxExtractor::new(vmlinux_path)?),
+        (None, Some(source_path), None) => Box::new(SourceExtractor::new(source_path)?),
+        (None, None, Some(_) | None) => {
+            // If debugfs is specified or no input is provided, use debugfs
+            Box::new(DebugfsExtractor::new(args.debugfs.clone())?)
+        }
+        _ => {
+            anyhow::bail!("Please specify only one of --vmlinux, --source, or --debugfs")
+        }
+    };
+
+    display_apis(extractor.as_ref(), args.api_name, output_format)
+}
+
+fn display_apis(
+    extractor: &dyn ApiExtractor,
+    api_name: Option<String>,
+    output_format: OutputFormat,
+) -> Result<()> {
+    let mut formatter = create_formatter(output_format);
+    let mut stdout = io::stdout();
+
+    formatter.begin_document(&mut stdout)?;
+
+    if let Some(api_name_req) = api_name {
+        // Use the extractor to display API details
+        if let Some(_spec) = extractor.extract_by_name(&api_name_req)? {
+            extractor.display_api_details(&api_name_req, &mut *formatter, &mut stdout)?;
+        } else {
+            eprintln!("API '{}' not found.", api_name_req);
+            if output_format == OutputFormat::Plain {
+                writeln!(stdout, "\nAvailable APIs:")?;
+                for spec in extractor.extract_all()? {
+                    writeln!(stdout, "  {} ({})", spec.name, spec.api_type)?;
+                }
+            }
+            std::process::exit(1);
+        }
+    } else {
+        // Display list of APIs using the extractor
+        let all_specs = extractor.extract_all()?;
+
+        // Helper to display API list for a specific type
+        let mut display_api_type = |api_type: &str, title: &str| -> Result<()> {
+            let filtered: Vec<_> = all_specs
+                .iter()
+                .filter(|s| s.api_type == api_type)
+                .collect();
+
+            if !filtered.is_empty() {
+                formatter.begin_api_list(&mut stdout, title)?;
+                for spec in filtered {
+                    formatter.api_item(&mut stdout, &spec.name, &spec.api_type)?;
+                }
+                formatter.end_api_list(&mut stdout)?;
+            }
+            Ok(())
+        };
+
+        display_api_type("syscall", "System Calls")?;
+        display_api_type("ioctl", "IOCTLs")?;
+        display_api_type("function", "Functions")?;
+        display_api_type("sysfs", "Sysfs Attributes")?;
+
+        formatter.total_specs(&mut stdout, all_specs.len())?;
+    }
+
+    formatter.end_document(&mut stdout)?;
+
+    Ok(())
+}
-- 
2.53.0


  parent reply	other threads:[~2026-10-08  8:50 UTC|newest]

Thread overview: 21+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-08  8:49 [PATCH v5 00/11] Kernel API Specification Framework Sasha Levin
2026-10-08  8:49 ` [PATCH v5 01/11] kernel/api: introduce kernel API specification framework Sasha Levin
2026-10-08  8:49 ` [PATCH v5 02/11] kernel/api: enable kerneldoc-based API specifications Sasha Levin
2026-10-08  8:49 ` [PATCH v5 03/11] kernel/api: add debugfs interface for kernel " Sasha Levin
2026-10-08  8:49 ` Sasha Levin [this message]
2026-10-08  8:49 ` [PATCH v5 05/11] kernel/api: add API specification for sys_open Sasha Levin
2026-10-08 12:49   ` Serge E. Hallyn
2026-10-08 13:13     ` Gregory Price
2026-10-08 14:20       ` Serge E. Hallyn
2026-10-08 14:37         ` Sasha Levin
2026-10-08 14:46           ` Serge E. Hallyn
2026-10-08 15:23             ` Sasha Levin
2026-10-08 16:12         ` David Laight
2026-10-08 16:16           ` Serge E. Hallyn
2026-10-08 22:47             ` Sasha Levin
2026-10-08  8:49 ` [PATCH v5 06/11] kernel/api: add API specification for sys_close Sasha Levin
2026-10-08  8:49 ` [PATCH v5 07/11] kernel/api: add API specification for sys_read Sasha Levin
2026-10-08  8:49 ` [PATCH v5 08/11] kernel/api: add API specification for sys_write Sasha Levin
2026-10-08  8:49 ` [PATCH v5 09/11] kernel/api: add runtime verification selftest Sasha Levin
2026-10-08  8:49 ` [PATCH v5 10/11] kernel/api: add API specification for sys_madvise Sasha Levin
2026-10-08  8:49 ` [PATCH v5 11/11] kernel/api: add syscall enter/exit tracepoints Sasha Levin

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261008084956.2911790-5-sashal@kernel.org \
    --to=sashal@kernel.org \
    --cc=akpm@linux-foundation.org \
    --cc=arnd@arndb.de \
    --cc=brauner@kernel.org \
    --cc=chrubis@suse.cz \
    --cc=corbet@lwn.net \
    --cc=david.laight.linux@gmail.com \
    --cc=dvyukov@google.com \
    --cc=gpaoloni@redhat.com \
    --cc=gregkh@linuxfoundation.org \
    --cc=jake@lwn.net \
    --cc=kees@kernel.org \
    --cc=linux-api@vger.kernel.org \
    --cc=linux-doc@vger.kernel.org \
    --cc=linux-fsdevel@vger.kernel.org \
    --cc=linux-kbuild@vger.kernel.org \
    --cc=linux-kernel@vger.kernel.org \
    --cc=linux-kselftest@vger.kernel.org \
    --cc=masahiroy@kernel.org \
    --cc=mathieu.desnoyers@efficios.com \
    --cc=mchehab@kernel.org \
    --cc=mhiramat@kernel.org \
    --cc=nathan@kernel.org \
    --cc=paulmck@kernel.org \
    --cc=rdunlap@infradead.org \
    --cc=rostedt@goodmis.org \
    --cc=skhan@linuxfoundation.org \
    --cc=tglx@kernel.org \
    --cc=tools@kernel.org \
    --cc=viro@zeniv.linux.org.uk \
    --cc=workflows@vger.kernel.org \
    --cc=x86@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.