proj/portage:master commit in: /, src/

"Matt Turner" <[email protected]>
Newsgroups gmane.linux.gentoo.cvs
Message-ID <1785963928.d1b8714417a538d1cfdc82b753650a47e54759bd.mattst88@gentoo>
commit:     d1b8714417a538d1cfdc82b753650a47e54759bd
Author:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
AuthorDate: Sun Aug  2 20:16:20 2026 +0000
Commit:     Matt Turner <mattst88 <AT> gentoo <DOT> org>
CommitDate: Wed Aug  5 21:05:28 2026 +0000
URL:        https://gitweb.gentoo.org/proj/portage.git/commit/?id=d1b87144

dep: add native C dep-string parser

Implement a recursive-descent dep-string parser in C, split across:

- src/dep_parser_core.{c,h}: pure-C scanner -- character class table,
  AtomInfo struct, scan_atom/version/slot/usedep, and dep-list traversal
  via a DepVisitor callback struct so the grammar logic is reusable
  without Python.  The body of an inactive USE-conditional group still
  has to be validated, so it is scanned with a visitor that builds
  nothing rather than with a second copy of the grammar.
- src/dep_atom.h: AtomObject Python type shared between modules.
- src/dep_parser.c: Python extension module.  Implements DepVisitor
  callbacks that build Python objects, exposes parse() and
  classify_use_deps().  The group stack starts inline in the parse
  context and spills to the heap, so nesting depth is not capped: the
  pure-Python path has no limit either, and rejecting a dep string it
  accepts would be a divergence.  classify_use_deps() scans flag name
  bodies with is_use_char so flag names containing -, +, or @ (e.g. c++,
  foo-bar, LINGUAS_en@euro) are handled correctly.
- src/meson.build: build dep_parser_core.c into both the extension
  module and a standalone test binary.
- src/test_parser.c: C unit tests for scan_version, scan_atom,
  scan_slot, and scan_use_flag, including flag names with -, +, @.
- src/fuzz_parser.c: a libFuzzer harness for the same scanner, behind
  -Dfuzzing=true and not built or run by default.

Category and package names have no length limit, so cp and cpv are
joined through a helper that uses a stack buffer where it fits and the
heap otherwise, rather than a fixed buffer whose overflow would make the
C path reject an atom the regex path accepts.

The module declares Py_MOD_GIL_NOT_USED on free-threaded builds: the
character-class table and the interned strings are written once at import
and only read afterwards, the scanner keeps its state on the stack or in
a per-call context, and Atom has no mutable fields.

Benchmarked over the 80065 DEPEND/RDEPEND/BDEPEND/PDEPEND/IDEPEND
strings in the ::gentoo md5-cache, best of three, -O2:

  portage (pure-Python use_reduce)   2.90 s    27610 str/s
  _parser.parse()                    0.171 s  468585 str/s

Signed-off-by: Matt Turner <mattst88 <AT> gentoo.org>

 meson_options.txt     |   4 +
 src/dep_atom.h        | 154 +++++++++++
 src/dep_parser.c      | 715 ++++++++++++++++++++++++++++++++++++++++++++++++++
 src/dep_parser_core.c | 635 ++++++++++++++++++++++++++++++++++++++++++++
 src/dep_parser_core.h |  85 ++++++
 src/fuzz_parser.c     |  89 +++++++
 src/meson.build       |  41 +++
 src/test_parser.c     | 339 ++++++++++++++++++++++++
 8 files changed, 2062 insertions(+)

diff --git a/meson_options.txt b/meson_options.txt
index a433a52e9..a56c66d9a 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -57,3 +57,7 @@ option('rsync-verify', type : 'boolean', value : true,
 option('xattr', type : 'boolean', value : false,
     description : 'Preserve extended attributes when installing files'
 )
+
+option('fuzzing', type : 'boolean', value : false,
+    description : 'Build the libFuzzer harness for the native dependency parser'
+)

diff --git a/src/dep_atom.h b/src/dep_atom.h
new file mode 100644
index 000000000..7451171c3
--- /dev/null
+++ b/src/dep_atom.h
@@ -0,0 +1,154 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+#pragma once
+
+#define PY_SSIZE_T_CLEAN
+#include <Python.h>
+
+#include "dep_parser_core.h"
+
+typedef struct {
+    PyObject_HEAD
+    PyObject *str;          /* original dep token string, used for __hash__/__eq__ */
+    PyObject *cp;
+    PyObject *cpv;
+    PyObject *version;
+    PyObject *operator;
+    PyObject *blocker;
+    PyObject *slot;
+    PyObject *sub_slot;
+    PyObject *slot_operator;
+    PyObject *use;
+} AtomObject;
+
+/* Forward declaration so Atom_richcompare can reference AtomType. */
+static PyTypeObject AtomType;
+
+static void Atom_dealloc(AtomObject *self)
+{
+    Py_XDECREF(self->str);
+    Py_XDECREF(self->cp);
+    Py_XDECREF(self->cpv);
+    Py_XDECREF(self->version);
+    Py_XDECREF(self->operator);
+    Py_XDECREF(self->blocker);
+    Py_XDECREF(self->slot);
+    Py_XDECREF(self->sub_slot);
+    Py_XDECREF(self->slot_operator);
+    Py_XDECREF(self->use);
+    Py_TYPE(self)->tp_free((PyObject *)self);
+}
+
+static PyObject *Atom_repr(AtomObject *self)
+{
+    return PyUnicode_FromFormat("Atom(%R)", self->str);
+}
+
+static PyObject *Atom_str(AtomObject *self)
+{
+    return Py_NewRef(self->str);
+}
+
+static Py_hash_t Atom_hash(AtomObject *self)
+{
+    return PyObject_Hash(self->str);
+}
+
+static PyObject *Atom_richcompare(AtomObject *self, PyObject *other, int op)
+{
+    if (op != Py_EQ && op != Py_NE)
+        Py_RETURN_NOTIMPLEMENTED;
+
+    if (Py_TYPE(other) != &AtomType) {
+        if (op == Py_EQ) {
+            Py_RETURN_FALSE;
+        } else {
+            Py_RETURN_TRUE;
+        }
+    }
+
+    return PyObject_RichCompare(self->str, ((AtomObject *)other)->str, op);
+}
+
+#define ATOM_GETTER(field) \
+    static PyObject *Atom_get_##field(AtomObject *self, UNUSED void *closure) \
+    { return Py_NewRef(self->field); }
+
+ATOM_GETTER(cp)
+ATOM_GETTER(cpv)
+ATOM_GETTER(version)
+ATOM_GETTER(operator)
+ATOM_GETTER(blocker)
+ATOM_GETTER(slot)
+ATOM_GETTER(sub_slot)
+ATOM_GETTER(slot_operator)
+ATOM_GETTER(use)
+
+static PyGetSetDef Atom_getset[] = {
+    { "cp",            (getter)Atom_get_cp,            NULL, NULL, NULL },
+    { "cpv",           (getter)Atom_get_cpv,           NULL, NULL, NULL },
+    { "version",       (getter)Atom_get_version,       NULL, NULL, NULL },
+    { "operator",      (getter)Atom_get_operator,      NULL, NULL, NULL },
+    { "blocker",       (getter)Atom_get_blocker,       NULL, NULL, NULL },
+    { "slot",          (getter)Atom_get_slot,          NULL, NULL, NULL },
+    { "sub_slot",      (getter)Atom_get_sub_slot,      NULL, NULL, NULL },
+    { "slot_operator", (getter)Atom_get_slot_operator, NULL, NULL, NULL },
+    { "use",           (getter)Atom_get_use,           NULL, NULL, NULL },
+    { NULL }
+};
+
+static PyTypeObject AtomType = {
+    .ob_base        = PyVarObject_HEAD_INIT(NULL, 0)
+    .tp_name        = "portage.dep._parser.Atom",
+    .tp_basicsize   = sizeof(AtomObject),
+    .tp_dealloc     = (destructor)Atom_dealloc,
+    .tp_repr        = (reprfunc)Atom_repr,
+    .tp_hash        = (hashfunc)Atom_hash,
+    .tp_str         = (reprfunc)Atom_str,
+    .tp_richcompare = (richcmpfunc)Atom_richcompare,
+    .tp_flags       = Py_TPFLAGS_DEFAULT,
+    .tp_getset      = Atom_getset,
+};
+
+/* Allocate an AtomObject and populate it.  Steals all refs.  Returns the
+ * object, or NULL if the allocation failed (refs NOT consumed on NULL). */
+static inline PyObject *atom_new(
+    PyObject *str,
+    PyObject *cp, PyObject *cpv, PyObject *version,
+    PyObject *operator, PyObject *blocker,
+    PyObject *slot, PyObject *sub_slot, PyObject *slot_operator,
+    PyObject *use)
+{
+    AtomObject *obj = PyObject_New(AtomObject, &AtomType);
+
+    if (!obj)
+        return NULL;
+
+    obj->str           = str;
+    obj->cp            = cp;
+    obj->cpv           = cpv;
+    obj->version       = version;
+    obj->operator      = operator;
+    obj->blocker       = blocker;
+    obj->slot          = slot;
+    obj->sub_slot      = sub_slot;
+    obj->slot_operator = slot_operator;
+    obj->use           = use;
+
+    return (PyObject *)obj;
+}
+
+/* Register AtomType with a module.  Call after PyType_Ready. */
+static inline int atom_add_to_module(PyObject *m)
+{
+    Py_INCREF(&AtomType);
+    if (PyModule_AddObject(m, "Atom", (PyObject *)&AtomType) < 0) {
+        Py_DECREF(&AtomType);
+        return -1;
+    }
+    return 0;
+}
+
+/* vim: set ts=4 sw=4 et: */

diff --git a/src/dep_parser.c b/src/dep_parser.c
new file mode 100644
index 000000000..94dd3fdf2
--- /dev/null
+++ b/src/dep_parser.c
@@ -0,0 +1,715 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+#include "dep_atom.h"
+#include "dep_parser_core.h"
+#include <assert.h>
+#include <string.h>
+
+#define MODULE_NAME "portage.dep._parser"
+
+typedef struct {
+    PyObject *useset;   /* frozenset of active USE flags, or NULL */
+    int       matchall;
+} UseContext;
+
+static inline void py_decref_p(PyObject **p) {
+    Py_XDECREF(*p);
+}
+#define AUTO_PY __attribute__((cleanup(py_decref_p))) PyObject *
+
+static struct {
+    /* operator strings */
+    PyObject *op_lt, *op_gt, *op_le, *op_ge, *op_eq, *op_tilde;
+    /* blocker strings */
+    PyObject *blocker_weak, *blocker_strong;   /* "!" weak, "!!" strong */
+    /* slot operator strings */
+    PyObject *slot_op_eq, *slot_op_star;
+} interned;
+
+static int init_globals(void)
+{
+    init_cc_table();
+
+    if (PyType_Ready(&AtomType) < 0)
+        return 0;
+
+#define INTERN(var, s) \
+    do { interned.var = PyUnicode_InternFromString(s); if (!interned.var) return 0; } while (0)
+    INTERN(op_lt,          "<");
+    INTERN(op_gt,          ">");
+    INTERN(op_le,          "<=");
+    INTERN(op_ge,          ">=");
+    INTERN(op_eq,          "=");
+    INTERN(op_tilde,       "~");
+    INTERN(blocker_weak,   "!");
+    INTERN(blocker_strong, "!!");
+    INTERN(slot_op_eq,     "=");
+    INTERN(slot_op_star,   "*");
+#undef INTERN
+
+    return 1;
+}
+
+/* Return an interned operator string, avoiding allocation for the common cases. */
+static PyObject *op_str(const char *op, int len)
+{
+    if (len == 1) {
+        if (op[0] == '<') return Py_NewRef(interned.op_lt);
+        if (op[0] == '>') return Py_NewRef(interned.op_gt);
+        if (op[0] == '=') return Py_NewRef(interned.op_eq);
+        if (op[0] == '~') return Py_NewRef(interned.op_tilde);
+    } else if (len == 2) {
+        if (op[0] == '<') return Py_NewRef(interned.op_le);
+        if (op[0] == '>') return Py_NewRef(interned.op_ge);
+    }
+    return PyUnicode_FromStringAndSize(op, len);
+}
+
+static PyObject *blocker_str(int len)
+{
+    assert(len == 1 || len == 2);
+    return Py_NewRef(len == 1 ? interned.blocker_weak : interned.blocker_strong);
+}
+
+/* Split the raw text between ':' and the end of the slot into the three
+ * fields portage.dep.Atom keeps, following PMS 8.3.3:
+ *
+ *   "0"     -> slot "0",  sub_slot None, op None
+ *   "0/53"  -> slot "0",  sub_slot "53", op None
+ *   "0="    -> slot "0",  sub_slot None, op "="
+ *   "0/53=" -> slot "0",  sub_slot "53", op "="
+ *   "="     -> slot None, sub_slot None, op "="
+ *   "*"     -> slot None, sub_slot None, op "*"
+ *
+ * The scanner has already validated the text, so this only has to divide it.
+ * Each out parameter is set to a new reference. */
+static void parse_slot_raw(const char *raw, int rlen,
+                            PyObject **out_slot, PyObject **out_sub,
+                            PyObject **out_op)
+{
+    if (rlen == 1 && (raw[0] == '*' || raw[0] == '=')) {
+        *out_slot = Py_NewRef(Py_None);
+        *out_sub  = Py_NewRef(Py_None);
+        *out_op   = raw[0] == '=' ? Py_NewRef(interned.slot_op_eq) : Py_NewRef(interned.slot_op_star);
+        return;
+    }
+
+    const char *slash = memchr(raw, '/', rlen);
+    char slot_op = 0;
+
+    if (!slash) {
+        int slen = rlen;
+        if (slen > 0 && raw[slen - 1] == '=') {
+            slot_op = '=';
+            slen--;
+        }
+        *out_slot = PyUnicode_FromStringAndSize(raw, slen);
+        *out_sub  = Py_NewRef(Py_None);
+    } else {
+        int main_len = (int)(slash - raw);
+        if (main_len > 0 && raw[main_len - 1] == '=') {
+            slot_op = '=';
+            main_len--;
+        }
+        *out_slot = PyUnicode_FromStringAndSize(raw, main_len);
+
+        const char *sub = slash + 1;
+        int sub_len = rlen - (int)(sub - raw);
+        if (sub_len == 1 && (sub[0] == '*' || sub[0] == '=')) {
+            *out_sub = Py_NewRef(Py_None);
+            slot_op = sub[0];
+        } else {
+            if (sub_len > 0 && sub[sub_len - 1] == '=') {
+                slot_op = '=';
+                sub_len--;
+            }
+            *out_sub = PyUnicode_FromStringAndSize(sub, sub_len);
+        }
+    }
+
+    if (slot_op == '=') {
+        *out_op = Py_NewRef(interned.slot_op_eq);
+    } else if (slot_op == '*') {
+        *out_op = Py_NewRef(interned.slot_op_star);
+    } else {
+        *out_op = Py_NewRef(Py_None);
+    }
+}
+
+/* Split the raw text between '[' and ']' into one string per flag:
+ *
+ *   "foo,-bar,baz?" -> ("foo", "-bar", "baz?")
+ *
+ * The prefixes and suffixes are left on; _use_dep (or classify_use_deps)
+ * interprets them.  The scanner has already validated the text, so a ',' here
+ * can only be a separator. */
+static PyObject *parse_use_raw(const char *raw, int rlen)
+{
+    int count = 1;
+    for (int i = 0; i < rlen; i++) {
+        if (raw[i] == ',') {
+            count++;
+        }
+    }
+
+    PyObject *tup = PyTuple_New(count);
+    if (!tup)
+        return NULL;
+
+    int idx = 0, start = 0;
+    for (int j = 0; j <= rlen; j++) {
+        if (j == rlen || raw[j] == ',') {
+            PyObject *flag = PyUnicode_FromStringAndSize(raw + start, j - start);
+            if (!flag) {
+                Py_DECREF(tup);
+                return NULL;
+            }
+
+            PyTuple_SET_ITEM(tup, idx++, flag);
+            start = j + 1;
+        }
+    }
+    return tup;
+}
+
+/* Join the pieces of an atom into one string:
+ *
+ *   ("dev-libs", "foo", NULL)  -> "dev-libs/foo"
+ *   ("dev-libs", "foo", "1.2") -> "dev-libs/foo-1.2"
+ *
+ * The scanner reports spans into the caller's dep string rather than
+ * NUL-terminated pieces, so they have to be copied to be joined.  Category and
+ * package names are unbounded, so a stack buffer big enough for every real
+ * atom is used where it fits and the heap otherwise; rejecting a long atom
+ * here would make the C path refuse a dep string the regex path accepts. */
+static PyObject *join_atom_string(const char *cat, int cat_len,
+                                  const char *pkg, int pkg_len,
+                                  const char *ver, int ver_len)
+{
+    char  stack_buf[256];
+    char *buf = stack_buf;
+    int   len = cat_len + 1 + pkg_len + (ver ? 1 + ver_len : 0);
+
+    if (len > (int)sizeof(stack_buf)) {
+        buf = PyMem_Malloc(len);
+        if (!buf)
+            return PyErr_NoMemory();
+    }
+
+    char *w = buf;
+    memcpy(w, cat, cat_len);
+    w += cat_len;
+    *w++ = '/';
+    memcpy(w, pkg, pkg_len);
+    w += pkg_len;
+    if (ver) {
+        *w++ = '-';
+        memcpy(w, ver, ver_len);
+    }
+
+    PyObject *result = PyUnicode_FromStringAndSize(buf, len);
+    if (buf != stack_buf)
+        PyMem_Free(buf);
+    return result;
+}
+
+static PyObject *build_atom_obj(const AtomInfo *a, const char *tok, int tok_len)
+{
+    PyObject *py_str = PyUnicode_FromStringAndSize(tok, tok_len);
+    if (!py_str)
+        return NULL;
+
+    PyObject *py_cp = join_atom_string(a->cat, a->cat_len,
+                                       a->pkg, a->pkg_len, NULL, 0);
+    if (!py_cp) {
+        Py_DECREF(py_str);
+        return NULL;
+    }
+
+    PyObject *py_ver, *py_cpv;
+    if (a->ver) {
+        py_ver = PyUnicode_FromStringAndSize(a->ver, a->ver_len);
+        py_cpv = py_ver ? join_atom_string(a->cat, a->cat_len, a->pkg,
+                                           a->pkg_len, a->ver, a->ver_len)
+                        : NULL;
+        if (!py_cpv) {
+            Py_DECREF(py_str);
+            Py_DECREF(py_cp);
+            Py_XDECREF(py_ver);
+            return NULL;
+        }
+    } else {
+        py_ver = Py_NewRef(Py_None);
+        py_cpv = Py_NewRef(py_cp);
+    }
+
+    PyObject *py_operator = a->op ?
+        op_str(a->op, a->op_len)  : Py_NewRef(Py_None);
+    PyObject *py_blocker  = a->block ?
+        blocker_str(a->block_len) : Py_NewRef(Py_None);
+
+    PyObject *py_slot, *py_sub, *py_slot_op;
+    if (a->slot_raw) {
+        parse_slot_raw(a->slot_raw, a->slot_raw_len,
+                       &py_slot, &py_sub, &py_slot_op);
+    } else {
+        py_slot    = Py_NewRef(Py_None);
+        py_sub     = Py_NewRef(Py_None);
+        py_slot_op = Py_NewRef(Py_None);
+    }
+
+    PyObject *py_use = a->use_raw
+        ? parse_use_raw(a->use_raw, a->use_raw_len)
+        : Py_NewRef(Py_None);
+
+    if (!py_operator || !py_blocker || !py_slot || !py_sub || !py_slot_op || !py_use)
+        goto cleanup;
+
+    PyObject *obj = atom_new(py_str, py_cp, py_cpv, py_ver, py_operator, py_blocker,
+                             py_slot, py_sub, py_slot_op, py_use);
+    if (obj)
+        return obj;
+
+cleanup:
+    Py_DECREF(py_str);
+    Py_DECREF(py_cp);
+    Py_DECREF(py_cpv);
+    Py_DECREF(py_ver);
+    Py_XDECREF(py_operator);
+    Py_XDECREF(py_blocker);
+    Py_XDECREF(py_slot);
+    Py_XDECREF(py_sub);
+    Py_XDECREF(py_slot_op);
+    Py_XDECREF(py_use);
+    return NULL;
+}
+
+typedef struct {
+    PyObject *list;
+    PyObject *op;
+} PyGroupFrame;
+
+/* Groups nest only a few levels deep in practice, so the first levels live in
+ * the context itself and deeper nesting spills to the heap.  There is no fixed
+ * ceiling: the pure-Python path has none either, and rejecting a dep string it
+ * accepts would be a divergence between the two. */
+#define PY_PARSE_INLINE_DEPTH 32
+
+typedef struct {
+    PyGroupFrame  inline_frames[PY_PARSE_INLINE_DEPTH];
+    PyGroupFrame *frames;
+    int         depth;
+    int         capacity;
+    PyObject   *useset;
+    int         matchall;
+} PyParseContext;
+
+static int py_ctx_grow(PyParseContext *ctx)
+{
+    int new_cap = ctx->capacity * 2;
+    PyGroupFrame *frames;
+
+    if (ctx->frames == ctx->inline_frames) {
+        frames = PyMem_New(PyGroupFrame, new_cap);
+        if (frames)
+            memcpy(frames, ctx->inline_frames, sizeof(ctx->inline_frames));
+    } else {
+        frames = PyMem_Realloc(ctx->frames, new_cap * sizeof(*frames));
+    }
+
+    if (!frames) {
+        PyErr_NoMemory();
+        return 0;
+    }
+
+    ctx->frames   = frames;
+    ctx->capacity = new_cap;
+    return 1;
+}
+
+static void py_ctx_free(PyParseContext *ctx)
+{
+    if (ctx->frames != ctx->inline_frames)
+        PyMem_Free(ctx->frames);
+}
+
+static int py_on_atom(void *vctx, const char *start, int len, const AtomInfo *info)
+{
+    PyParseContext *ctx = vctx;
+    PyObject *obj = build_atom_obj(info, start, len);
+    if (!obj)
+        return 0;
+
+    int rc = PyList_Append(ctx->frames[ctx->depth - 1].list, obj);
+    Py_DECREF(obj);
+    return rc >= 0;
+}
+
+static int py_on_group_start(void *vctx, const char *op, int op_len)
+{
+    PyParseContext *ctx = vctx;
+    if (ctx->depth >= ctx->capacity && !py_ctx_grow(ctx))
+        return 0;
+
+    PyObject *group_op = PyUnicode_FromStringAndSize(op, op_len);
+    if (!group_op)
+        return 0;
+
+    PyObject *sublist = PyList_New(0);
+    if (!sublist) {
+        Py_DECREF(group_op);
+        return 0;
+    }
+
+    ctx->frames[ctx->depth].op   = group_op;
+    ctx->frames[ctx->depth].list = sublist;
+    ctx->depth++;
+    return 1;
+}
+
+static int py_on_group_end(void *vctx)
+{
+    PyParseContext *ctx      = vctx;
+    ctx->depth--;
+    PyObject *group_op = ctx->frames[ctx->depth].op;
+    PyObject *sublist  = ctx->frames[ctx->depth].list;
+    PyObject *parent   = ctx->frames[ctx->depth - 1].list;
+
+    int rc = PyList_Append(parent, group_op);
+    Py_DECREF(group_op);
+    if (rc < 0) {
+        Py_DECREF(sublist);
+        return 0;
+    }
+
+    rc = PyList_Append(parent, sublist);
+    Py_DECREF(sublist);
+    return rc >= 0;
+}
+
+static int py_use_active(void *vctx, const char *flag, int len, int is_neg)
+{
+    PyParseContext *ctx = vctx;
+    if (ctx->matchall)
+        return 1;
+    int in_set = 0;
+    if (ctx->useset) {
+        PyObject *key = PyUnicode_FromStringAndSize(flag, len);
+        if (!key)
+            return -1;
+        in_set = PySet_Contains(ctx->useset, key);
+        Py_DECREF(key);
+        if (in_set < 0)
+            return -1;
+    }
+    return is_neg ? !in_set : in_set;
+}
+
+static int dep_parse(const char *s, Py_ssize_t n, const char **err,
+                     PyObject *result, UseContext *use)
+{
+    DepScanner p = { s, s + n, NULL };
+
+    PyParseContext ctx = {
+        .depth    = 1,
+        .capacity = PY_PARSE_INLINE_DEPTH,
+        .useset   = use->useset,
+        .matchall = use->matchall,
+    };
+
+    ctx.frames = ctx.inline_frames;
+    ctx.frames[0].list = result;
+    ctx.frames[0].op   = NULL;
+    DepVisitor v = {
+        .ctx            = &ctx,
+        .on_atom        = py_on_atom,
+        .on_group_start = py_on_group_start,
+        .on_group_end   = py_on_group_end,
+        .use_active     = py_use_active,
+    };
+
+    skip_whitespace(&p);
+
+    if (p.cur < p.end && !scan_dep_list(&p, &v)) {
+        for (int i = 1; i < ctx.depth; i++) {  /* frame 0's list is caller-owned */
+            Py_XDECREF(ctx.frames[i].op);
+            Py_XDECREF(ctx.frames[i].list);
+        }
+        py_ctx_free(&ctx);
+
+        if (PyErr_Occurred())
+            return 0;
+
+        if (err)
+            *err = p.err ? p.err : "parse error";
+        return 0;
+    }
+
+    py_ctx_free(&ctx);
+
+    skip_whitespace(&p);
+
+    if (p.cur < p.end) {
+        if (err)
+            *err = "unexpected token";
+        return 0;
+    }
+    return 1;
+}
+
+/*
+ * classify_use_deps(tokens) -> tuple or None
+ *
+ * Classify a sequence of use-dep token strings (already split at ',') into
+ * the sets that _use_dep.__init__ would produce, bypassing its per-token
+ * regex.  Returns a 6-tuple:
+ *   (enabled_fs, disabled_fs, missing_enabled_fs, missing_disabled_fs,
+ *    conditional_dict_or_None, required_fs)
+ * where the frozensets and dict match exactly what _use_dep expects in its
+ * shortcut constructor path (enabled_flags is not None).
+ * Raises ValueError if any token cannot be classified.
+ */
+static PyObject *
+py_classify_use_deps(UNUSED PyObject *self, PyObject *arg)
+{
+    AUTO_PY seq  = PySequence_Fast(arg, "expected sequence");
+    if (!seq)
+        return NULL;
+
+    Py_ssize_t ntok = PySequence_Fast_GET_SIZE(seq);
+
+    AUTO_PY en   = PySet_New(NULL);
+    AUTO_PY dis  = PySet_New(NULL);
+    AUTO_PY me   = PySet_New(NULL);
+    AUTO_PY md   = PySet_New(NULL);
+    AUTO_PY req  = PySet_New(NULL);
+    AUTO_PY cen  = NULL;
+    AUTO_PY cdis = NULL;
+    AUTO_PY ceq  = NULL;
+    AUTO_PY cneq = NULL;
+    if (!en || !dis || !me || !md || !req)
+        return NULL;
+
+#define SET_ADD(set, f)  do { if (PySet_Add((set), (f)) < 0) return NULL; } while (0)
+#define SET_LAZY(ptr)    do { if (!(ptr) && !((ptr) = PySet_New(NULL))) return NULL; } while (0)
+
+    for (Py_ssize_t i = 0; i < ntok; i++) {
+        PyObject *tok = PySequence_Fast_GET_ITEM(seq, i);  /* borrowed */
+        Py_ssize_t slen;
+
+        const char *s = PyUnicode_AsUTF8AndSize(tok, &slen);
+        if (!s)
+            return NULL;
+
+        const char *p = s, *end = s + slen;
+
+        int is_neg = 0, is_dis_pfx = 0;
+        if (p < end && *p == '!') {
+            is_neg = 1;
+            p++;
+        } else if (p < end && *p == '-') {
+            is_dis_pfx = 1;
+            p++;
+        }
+
+        if (p >= end || !is_nw_char(*p)) {
+            PyErr_Format(PyExc_ValueError, "invalid use dep token: %R", tok);
+            return NULL;
+        }
+
+        const char *flag_start = p++;
+        while (p < end && is_use_char(*p)) {
+            p++;
+        }
+        const char *flag_end = p;
+
+        /* optional default: (+) or (-) */
+        int def = 0;  /* 0=none, +1=(+), -1=(-) */
+        if (p + 3 <= end && p[0] == '(' && p[2] == ')') {
+            if (p[1] == '+') {
+                def = 1;
+                p += 3;
+            } else if (p[1] == '-') {
+                def = -1;
+                p += 3;
+            }
+        }
+
+        /* optional suffix */
+        char suf = 0;
+        if (p < end && (*p == '?' || *p == '=')) {
+            suf = *p++;
+        }
+
+        if (p != end || (is_neg && !suf) || (is_dis_pfx && suf)) {
+            PyErr_Format(PyExc_ValueError, "invalid use dep token: %R", tok);
+            return NULL;
+        }
+
+        AUTO_PY flag = PyUnicode_FromStringAndSize(flag_start, flag_end - flag_start);
+        if (!flag)
+            return NULL;
+
+        /* classify into enabled/disabled/conditional */
+        if (!is_neg && !is_dis_pfx && !suf) {
+            SET_ADD(en, flag);
+        } else if (is_dis_pfx) {
+            SET_ADD(dis, flag);
+        } else if (!is_neg && suf == '?') {
+            SET_LAZY(cen);  SET_ADD(cen, flag);
+        } else if (!is_neg && suf == '=') {
+            SET_LAZY(ceq);  SET_ADD(ceq, flag);
+        } else if (is_neg && suf == '?') {
+            SET_LAZY(cdis); SET_ADD(cdis, flag);
+        } else {  /* is_neg && suf == '=' */
+            SET_LAZY(cneq); SET_ADD(cneq, flag);
+        }
+
+        /* required = flags without a default */
+        if (!def)         SET_ADD(req, flag);
+        if (def > 0)      SET_ADD(me,  flag);
+        else if (def < 0) SET_ADD(md,  flag);
+    }
+
+#undef SET_ADD
+#undef SET_LAZY
+
+    AUTO_PY cond = NULL;
+    if (cen || cdis || ceq || cneq) {
+        cond = PyDict_New();
+        if (!cond)
+            return NULL;
+
+#define COND_SET(key, obj) \
+        if (obj) { \
+            AUTO_PY fs = PyFrozenSet_New(obj); \
+            if (!fs || PyDict_SetItemString(cond, key, fs) < 0) return NULL; \
+        }
+        COND_SET("enabled",   cen)
+        COND_SET("disabled",  cdis)
+        COND_SET("equal",     ceq)
+        COND_SET("not_equal", cneq)
+#undef COND_SET
+    } else {
+        cond = Py_NewRef(Py_None);
+    }
+
+    AUTO_PY fen  = PyFrozenSet_New(en);
+    AUTO_PY fdis = PyFrozenSet_New(dis);
+    AUTO_PY fme  = PyFrozenSet_New(me);
+    AUTO_PY fmd  = PyFrozenSet_New(md);
+    AUTO_PY freq = PyFrozenSet_New(req);
+    if (!fen || !fdis || !fme || !fmd || !freq)
+        return NULL;
+
+    return PyTuple_Pack(6, fen, fdis, fme, fmd, cond, freq);
+}
+
+static PyObject *
+py_parse(UNUSED PyObject *self, PyObject *args, PyObject *kwargs)
+{
+    static const char * const kwlist[] = {"s", "uselist", "matchall", NULL};
+    PyObject *py_str;
+    PyObject *py_uselist = Py_None;
+    int matchall = 0;
+
+    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O|Op", (char **)kwlist,
+                                      &py_str, &py_uselist, &matchall))
+        return NULL;
+
+    const char *s;
+    Py_ssize_t n;
+    s = PyUnicode_AsUTF8AndSize(py_str, &n);
+    if (!s)
+        return NULL;
+
+    AUTO_PY useset = NULL;
+    if (py_uselist != Py_None) {
+        useset = PyFrozenSet_New(py_uselist);
+        if (!useset) {
+            return NULL;
+        }
+    }
+
+    UseContext use = { useset, matchall };
+    AUTO_PY result = PyList_New(0);
+    if (!result) {
+        return NULL;
+    }
+
+    const char *err = NULL;
+    if (!dep_parse(s, n, &err, result, &use)) {
+        if (!PyErr_Occurred()) {
+            PyErr_SetString(PyExc_ValueError, err ? err : "parse error");
+        }
+        return NULL;
+    }
+    return Py_NewRef(result);
+}
+
+static PyMethodDef methods[] = {
+    {
+        .ml_name  = "parse",
+        .ml_meth  = (PyCFunction)py_parse,
+        .ml_flags = METH_VARARGS | METH_KEYWORDS,
+        .ml_doc   =
+            "parse(s, uselist=None, matchall=False) -> list\n"
+            "Parse a Gentoo dep spec. Returns a list of Atom objects, where\n"
+            "|| / ^^ / ?? groups appear as the operator string followed by a\n"
+            "sublist and a plain all-of group appears as a bare sublist.\n"
+            "Use conditionals are evaluated: an active one contributes a\n"
+            "sublist, an inactive one contributes nothing.",
+    },
+    {
+        .ml_name  = "classify_use_deps",
+        .ml_meth  = py_classify_use_deps,
+        .ml_flags = METH_O,
+        .ml_doc   =
+            "classify_use_deps(tokens) -> tuple\n"
+            "Classify pre-split use-dep tokens into (enabled_fs, disabled_fs,\n"
+            "missing_enabled_fs, missing_disabled_fs, conditional_dict_or_None,\n"
+            "required_fs). Raises ValueError if a token cannot be classified.",
+    },
+    { NULL, NULL, 0, NULL },
+};
+
+static struct PyModuleDef module = {
+    .m_base    = PyModuleDef_HEAD_INIT,
+    .m_name    = MODULE_NAME,
+    .m_doc     = NULL,
+    .m_size    = -1,
+    .m_methods = methods,
+};
+
+PyMODINIT_FUNC
+PyInit__parser(void)
+{
+    if (!init_globals())
+        return NULL;
+
+    PyObject *m = PyModule_Create(&module);
+    if (!m)
+        return NULL;
+
+    if (atom_add_to_module(m) < 0) {
+        Py_DECREF(m);
+        return NULL;
+    }
+
+#ifdef Py_GIL_DISABLED
+    /* Safe to run without the GIL: the character-class table and the interned
+     * strings are written once here and only read afterwards, the scanner
+     * keeps all of its state on the stack or in a per-call context, and Atom
+     * is an ordinary refcounted object with no mutable fields. */
+    if (PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) < 0) {
+        Py_DECREF(m);
+        return NULL;
+    }
+#endif
+
+    return m;
+}
+
+/* vim: set ts=4 sw=4 et: */

diff --git a/src/dep_parser_core.c b/src/dep_parser_core.c
new file mode 100644
index 000000000..6e8b237ae
--- /dev/null
+++ b/src/dep_parser_core.c
@@ -0,0 +1,635 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+#include <string.h>
+#include "dep_parser_core.h"
+
+uint8_t CC[256];
+
+void init_cc_table(void)
+{
+    for (int c = 0; c < ARRAY_SIZE(CC); c++) {
+        unsigned char uc = (unsigned char)c;
+        uint8_t v = 0;
+
+        /* digits */
+        if (uc >= '0' && uc <= '9') {
+            v |= CC_DIGIT | CC_NW | CC_CAT | CC_USE;
+        }
+
+        /* letters */
+        if ((uc >= 'a' && uc <= 'z') || (uc >= 'A' && uc <= 'Z')) {
+            v |= CC_ALPHA | CC_NW | CC_CAT | CC_USE;
+
+            if (uc >= 'a' && uc <= 'z') {
+                v |= CC_LOWER;
+            }
+        }
+        /* extra name-word chars */
+        if (uc == '+' || uc == '_') v |= CC_NW | CC_CAT | CC_USE;
+        if (uc == '.') v |= CC_CAT;
+        if (uc == '-') v |= CC_CAT | CC_USE;
+        if (uc == '@') v |= CC_USE;
+        CC[c] = v;
+    }
+}
+
+void skip_whitespace(DepScanner *p)
+{
+    while (p->cur < p->end && is_whitespace(*p->cur)) {
+        p->cur++;
+    }
+}
+
+/* PMS 3.2: a version is digits, optionally dot-separated, an optional single
+ * letter, zero or more _alpha/_beta/_pre/_rc/_p suffixes with optional
+ * numbers, and an optional -rN revision.  A trailing '*' is accepted here and
+ * rejected later unless the operator is '='.
+ *
+ *   "1.2.3"        -> consumed
+ *   "1.0_alpha1"   -> consumed
+ *   "1.0-r1"       -> consumed
+ *   "1.2*"         -> consumed
+ *   "1.0 rest"     -> consumes "1.0", stops at the space
+ *   "alpha"        -> rejected, must start with a digit
+ *
+ * Advances cur past the version and returns 1, or leaves cur alone and
+ * returns 0.  A version must end at whitespace, ':', '[' or ')'. */
+int scan_version(DepScanner *p)
+{
+    static const struct {
+        const char *str;
+        int len;
+    } sfx[] = {
+#define SFX(s) { s, (int)(sizeof(s) - 1) }
+        SFX("_alpha"), SFX("_beta"), SFX("_pre"), SFX("_rc"), SFX("_p"),
+#undef SFX
+    };
+
+    const char *s = p->cur;
+
+    if (s >= p->end || !is_digit_c(*s))
+        return 0;
+
+    while (s < p->end && is_digit_c(*s)) {
+        s++;
+    }
+
+    while (s < p->end && *s == '.') {
+        s++;
+        if (s >= p->end || !is_digit_c(*s))
+            return 0;
+
+        while (s < p->end && is_digit_c(*s)) {
+            s++;
+        }
+    }
+
+    if (s < p->end && is_lower_c(*s))
+        s++;
+
+    for (;;) {
+        int hit = 0;
+        for (int i = 0; i < ARRAY_SIZE(sfx); i++) {
+            int l = sfx[i].len;
+            if (s + l <= p->end && memcmp(s, sfx[i].str, l) == 0 &&
+                (s + l >= p->end || !is_alpha_c(s[l]))) {
+                s += l;
+                while (s < p->end && is_digit_c(*s)) {
+                    s++;
+                }
+                hit = 1;
+                break;
+            }
+        }
+        if (!hit) {
+            break;
+        }
+    }
+
+    if (s + 2 < p->end && s[0] == '-' && s[1] == 'r' &&
+        is_digit_c(s[2])) {
+        s += 2;
+        while (s < p->end && is_digit_c(*s)) {
+            s++;
+        }
+    }
+
+    if (s < p->end && *s == '*') {
+        s++;  /* glob: =cat/pkg-1.2* */
+    }
+
+    if (s >= p->end || is_whitespace(*s) || *s == ':' || *s == '[' || *s == ')') {
+        p->cur = s;
+        return 1;
+    }
+    return 0;
+}
+
+/* The examples below spell out a "0/" slot followed by '*', which the
+ * compiler sees as a comment opener. */
+#pragma GCC diagnostic push
+#pragma GCC diagnostic ignored "-Wcomment"
+/* PMS 8.3.3: the text after ':' -- a slot, an optional /sub-slot, and an
+ * optional '=' operator, or a bare ':=' / ':*'.
+ *
+ *   "0"     "myslot"   "0/53"   "0="   "0/53="   "="   "*"   -> consumed
+ *   "/slot"  "-slot"                                        -> rejected
+ *
+ * Slot names share the category character set, except that the first
+ * character may not be '+'. */
+#pragma GCC diagnostic pop
+int scan_slot(DepScanner *p)
+{
+    const char *s = p->cur;
+    if (s >= p->end)
+        return 0;
+
+    if (*s == '*' || *s == '=') {
+        p->cur = s + 1;
+        return 1;
+    }
+
+    if (!is_nw_char(*s))
+        return 0;
+
+    s++;
+    while (s < p->end && is_slot_char(*s)) {
+        s++;
+    }
+    if (s < p->end && *s == '=') {
+        s++;
+    }
+
+    if (s < p->end && *s == '/') {
+        s++;
+        if (s < p->end && (*s == '*' || *s == '=')) {
+            s++;
+        } else if (s < p->end && is_nw_char(*s)) {
+            s++;
+            while (s < p->end && is_slot_char(*s)) {
+                s++;
+            }
+            if (s < p->end && *s == '=') {
+                s++;
+            }
+        } else {
+            return 0;
+        }
+    }
+
+    p->cur = s;
+    return 1;
+}
+
+/* PMS 8.3.4: one use dep, with its optional prefix, (+)/(-) default and
+ * suffix.  Flag names may contain '-', '+' and '@' (c++, LINGUAS_en@euro), so
+ * the name body is scanned with is_use_char rather than the name-word set.
+ *
+ *   "foo"  "-foo"  "foo?"  "foo="  "foo(+)"  "!foo?"  "!foo(-)="  -> consumed
+ *   "!foo"   -> rejected, '!' requires a '?' or '=' suffix
+ *   "-foo="  -> rejected, '-' and a suffix are mutually exclusive
+ *
+ * Advances cur past the flag and returns 1, or returns 0. */
+int scan_use_flag(DepScanner *p)
+{
+    const char *s = p->cur;
+    if (s >= p->end)
+        return 0;
+
+    int is_neg = 0, is_dis = 0;
+    if (*s == '!') {
+        is_neg = 1;
+        s++;
+    } else if (*s == '-') {
+        is_dis = 1;
+        s++;
+    }
+
+    if (s >= p->end || !is_nw_char(*s))
+        return 0;
+
+    s++;
+    while (s < p->end && is_use_char(*s)) {
+        s++;
+    }
+
+    if (s + 2 < p->end && *s == '(' && (s[1] == '+' || s[1] == '-') && s[2] == ')')
+        s += 3;
+
+    if (is_neg) {
+        if (s < p->end && (*s == '?' || *s == '=')) {
+            s++;
+        } else {
+            return 0;
+        }
+    } else if (!is_dis) {
+        if (s < p->end && (*s == '?' || *s == '=' || *s == '-')) {
+            s++;
+        }
+    }
+
+    p->cur = s;
+    return 1;
+}
+
+int scan_usedep(DepScanner *p)
+{
+    for (;;) {
+        if (!scan_use_flag(p))
+            return 0;
+
+        if (p->cur < p->end && *p->cur == ',') {
+            p->cur++;
+        } else {
+            break;
+        }
+    }
+    return 1;
+}
+
+/* One whole atom: [blocker][operator]category/package[-version][:slot][use].
+ *
+ *   "dev-libs/foo"                  -> cat "dev-libs", pkg "foo"
+ *   ">=dev-libs/foo-1.2:0=[a,-b]"   -> op ">=", ver "1.2", slot ":0=", use "a,-b"
+ *   "!!dev-libs/foo"                -> block "!!"
+ *
+ * Package names may contain hyphens, so the boundary between name and version
+ * is ambiguous ("log4j-12-api-2.0") and is resolved by trying to scan a
+ * version after each '-'.  On success the spans in *info point into the
+ * caller's string; on failure cur is restored and 0 is returned, which lets
+ * the caller try a different production. */
+int scan_atom(DepScanner *p, AtomInfo *info)
+{
+    const char *start = p->cur;
+    const char *s = start;
+
+    /* block */
+    SPAN(block, NULL);
+    if (s < p->end && *s == '!') {
+        block = s; s++;
+        if (s < p->end && *s == '!') {
+            s++;
+        }
+        block_len = (int)(s - block);
+    }
+
+    /* operator */
+    SPAN(op, NULL);
+    if (s < p->end) {
+        if ((s[0] == '<' || s[0] == '>') && s + 1 < p->end && s[1] == '=') {
+            op = s;
+            op_len = 2;
+            s += 2;
+        } else if (s[0] == '<' || s[0] == '>' || s[0] == '=' || s[0] == '~') {
+            op = s;
+            op_len = 1;
+            s++;
+        }
+    }
+
+    /* category */
+    const char *cat = s;
+    if (s >= p->end || !is_nw_char(*s))
+        goto fail;
+
+    s++;
+    while (s < p->end && is_cat_char(*s)) {
+        s++;
+    }
+    int cat_len = (int)(s - cat);
+
+    if (s >= p->end || *s != '/')
+        goto fail;
+    s++;
+
+    /* first name-word */
+    const char *pkg = s;
+    if (s >= p->end)
+        goto fail;
+
+    if (is_alpha_c(*s) || *s == '_') {
+        s++;
+        while (s < p->end && is_nw_char(*s)) {
+            s++;
+        }
+    } else if (is_digit_c(*s)) {
+        while (s < p->end && is_digit_c(*s)) {
+            s++;
+        }
+
+        if (s >= p->end || (!is_alpha_c(*s) && *s != '_'))
+            goto fail;
+
+        s++;
+        while (s < p->end && is_nw_char(*s)) {
+            s++;
+        }
+    } else {
+        goto fail;
+    }
+
+    {
+        int pkg_len = 0;
+        SPAN(ver, NULL);
+
+        const char *pkg_end = s;
+
+        /* additional '-' segments: name-word or version */
+        for (;;) {
+            if (s >= p->end || *s != '-' || s + 1 >= p->end)
+                break;
+
+            char nxt = s[1];
+            if (is_digit_c(nxt)) {
+                DepScanner tmp = { s + 1, p->end, NULL };
+                if (scan_version(&tmp)) {
+                    ver = s + 1;
+                    ver_len = (int)(tmp.cur - ver);
+                    pkg_end = s;
+                    s = tmp.cur;
+                    goto after_pkgver;
+                }
+
+                const char *t = s + 1;
+                while (t < p->end && is_digit_c(*t)) {
+                    t++;
+                }
+
+                if (t < p->end && (is_alpha_c(*t) || *t == '_')) {
+                    t++;
+                    while (t < p->end && is_nw_char(*t)) {
+                        t++;
+                    }
+                    s = t;
+                } else if (t < p->end && *t == '-') {
+                    s = t;
+                } else {
+                    break;
+                }
+            } else if (is_alpha_c(nxt) || nxt == '_') {
+                s += 2;
+                while (s < p->end && is_nw_char(*s)) {
+                    s++;
+                }
+            } else {
+                break;
+            }
+        }
+        pkg_end = s;
+
+after_pkgver:
+        pkg_len = (int)(pkg_end - pkg);
+        p->cur = s;
+
+        /* optional ':' slot */
+        SPAN(slot_raw, NULL);
+        if (p->cur < p->end && *p->cur == ':') {
+            p->cur++;
+            slot_raw = p->cur;
+            if (!scan_slot(p))
+                goto fail;
+            slot_raw_len = (int)(p->cur - slot_raw);
+        }
+
+        /* optional '[' usedep ']' */
+        SPAN(use_raw, NULL);
+        if (p->cur < p->end && *p->cur == '[') {
+            p->cur++;
+            use_raw = p->cur;
+
+            if (!scan_usedep(p))
+                goto fail;
+
+            use_raw_len = (int)(p->cur - use_raw);
+
+            if (p->cur >= p->end || *p->cur != ']')
+                goto fail;
+
+            p->cur++;
+        }
+
+        if (op && !ver)
+            goto fail;
+
+        if (info) {
+            SPAN_SET(info, block);
+            SPAN_SET(info, op);
+            SPAN_SET(info, cat);
+            SPAN_SET(info, pkg);
+            SPAN_SET(info, ver);
+            SPAN_SET(info, slot_raw);
+            SPAN_SET(info, use_raw);
+        }
+        return 1;
+    }
+
+fail:
+    p->cur = start;
+    return 0;
+}
+
+static int scan_item(DepScanner *p, DepVisitor *v);
+
+/* Read items until ')' (stopping before it). */
+static int scan_group_contents(DepScanner *p, DepVisitor *v);
+
+/* Skipping the body of an inactive USE-conditional group still has to
+ * validate its contents -- "x? ( bogus )" is a malformed dep string whether or
+ * not x is set -- so the body is scanned with the real grammar and this
+ * visitor, which builds nothing and reports every nested conditional inactive
+ * so its body is skipped in turn.  The alternative, a second copy of the
+ * grammar that only skips, is one more thing to keep in sync. */
+static int skip_on_atom(UNUSED void *ctx, UNUSED const char *start,
+                        UNUSED int len, UNUSED const AtomInfo *info)
+{
+    return 1;
+}
+
+static int skip_on_group_start(UNUSED void *ctx, UNUSED const char *op,
+                               UNUSED int op_len)
+{
+    return 1;
+}
+
+static int skip_on_group_end(UNUSED void *ctx)
+{
+    return 1;
+}
+
+static int skip_use_active(UNUSED void *ctx, UNUSED const char *flag,
+                           UNUSED int len, UNUSED int is_neg)
+{
+    return 0;
+}
+
+static DepVisitor skip_visitor = {
+    .ctx            = NULL,
+    .on_atom        = skip_on_atom,
+    .on_group_start = skip_on_group_start,
+    .on_group_end   = skip_on_group_end,
+    .use_active     = skip_use_active,
+};
+
+static int scan_group_contents(DepScanner *p, DepVisitor *v)
+{
+    for (;;) {
+        if (!scan_item(p, v))
+            return 0;
+
+        if (p->cur >= p->end || !is_whitespace(*p->cur)) {
+            p->err = "expected whitespace after item in group";
+            return 0;
+        }
+        skip_whitespace(p);
+
+        if (p->cur < p->end && *p->cur == ')')
+            return 1;
+
+        if (p->cur >= p->end) {
+            p->err = "unexpected end inside group";
+            return 0;
+        }
+    }
+}
+
+/* One element of a dep list: an atom, a plain "( ... )" group, an operator
+ * group "|| ( ... )", or a use conditional "flag? ( ... )".
+ *
+ *   "dev-libs/a"          -> on_atom
+ *   "( a b )"             -> on_group_start(""), items, on_group_end
+ *   "|| ( a b )"          -> on_group_start("||"), items, on_group_end
+ *   "foo? ( a )", active  -> on_group_start(""), items, on_group_end
+ *   "foo? ( a )", not     -> nothing reported; body still validated
+ *
+ * A conditional group is reported as a plain group rather than inlined so
+ * that a conjunction inside an any-of keeps its nesting. */
+static int scan_item(DepScanner *p, DepVisitor *v)
+{
+    const char *save      = p->cur;
+    const char *tok_start = p->cur;
+    AtomInfo    info;
+
+    if (scan_atom(p, &info))
+        return v->on_atom(v->ctx, tok_start, (int)(p->cur - tok_start), &info);
+    p->cur = save;
+
+    const char *s = p->cur;
+
+    /* naked group: ( items ) - all-of, inline */
+    if (s < p->end && *s == '(') {
+        p->cur = s + 1;
+        if (p->cur >= p->end || !is_whitespace(*p->cur)) {
+            p->err = "expected whitespace after '('";
+            return 0;
+        }
+        skip_whitespace(p);
+
+        if (!scan_group_contents(p, v))
+            return 0;
+
+        p->cur++;  /* consume ')' */
+        return 1;
+    }
+
+    int         is_group_op = 0;
+    int         is_neg      = 0;
+    SPAN(flag, NULL);
+    const char *op          = NULL;
+    int         op_len      = 0;
+
+    if (s + 2 <= p->end &&
+        (s[0] == s[1] && (s[0] == '|' || s[0] == '^' || s[0] == '?'))) {
+        op          = s;
+        op_len      = 2;
+        is_group_op = 1;
+        p->cur        = s + 2;
+    } else {
+        if (s < p->end && *s == '!') {
+            is_neg = 1;
+            s++;
+        }
+        if (s < p->end && is_nw_char(*s)) {
+            flag = s++;
+            while (s < p->end && is_use_char(*s)) {
+                s++;
+            }
+            flag_len = (int)(s - flag);
+
+            if (s < p->end && *s == '?') {
+                p->cur = s + 1;
+            } else {
+                p->err = "expected '?'";
+                return 0;
+            }
+        } else {
+            p->err = "expected atom or group";
+            return 0;
+        }
+    }
+
+    if (p->cur >= p->end || !is_whitespace(*p->cur)) {
+        p->err = "expected whitespace after prefix";
+        return 0;
+    }
+    skip_whitespace(p);
+
+    if (p->cur >= p->end || *p->cur != '(') {
+        p->err = "expected '('";
+        return 0;
+    }
+    p->cur++;
+
+    if (p->cur >= p->end || !is_whitespace(*p->cur)) {
+        p->err = "expected whitespace after '('";
+        return 0;
+    }
+    skip_whitespace(p);
+
+    if (is_group_op) {
+        if (!v->on_group_start(v->ctx, op, op_len))
+            return 0;
+
+        if (!scan_group_contents(p, v))
+            return 0;
+
+        p->cur++;  /* consume ')' */
+        return v->on_group_end(v->ctx);
+    } else {
+        int active = v->use_active(v->ctx, flag, flag_len, is_neg);
+        if (active < 0)
+            return 0;
+
+        if (active) {
+            if (!scan_group_contents(p, v)) {
+                return 0;
+            }
+        } else {
+            if (!scan_group_contents(p, &skip_visitor)) {
+                return 0;
+            }
+        }
+        p->cur++;  /* consume ')' */
+        return 1;
+    }
+}
+
+int scan_dep_list(DepScanner *p, DepVisitor *v)
+{
+    if (!scan_item(p, v))
+        return 0;
+
+    while (p->cur < p->end && is_whitespace(*p->cur)) {
+        skip_whitespace(p);
+
+        if (p->cur >= p->end)
+            return 1;
+
+        if (!scan_item(p, v))
+            return 0;
+    }
+    return 1;
+}
+
+/* vim: set ts=4 sw=4 et: */

diff --git a/src/dep_parser_core.h b/src/dep_parser_core.h
new file mode 100644
index 000000000..25af21518
--- /dev/null
+++ b/src/dep_parser_core.h
@@ -0,0 +1,85 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+#pragma once
+#include <stdint.h>
+
+/* Cursor over the dep string being scanned.  Every scan_* function advances
+ * cur on success and leaves it untouched on failure, so a caller can try one
+ * production and fall back to another. */
+typedef struct {
+    const char *cur;   /* next unconsumed character */
+    const char *end;   /* one past the last character */
+    const char *err;   /* static message describing the first failure */
+} DepScanner;
+
+typedef enum {
+    CC_CAT   = 1 << 0,  /* category / slot:  alnum + _ + . - */
+    CC_NW    = 1 << 1,  /* name-word:        alnum + _        */
+    CC_USE   = 1 << 2,  /* use dep:          alnum + _ @ - +  */
+    CC_DIGIT = 1 << 3,
+    CC_LOWER = 1 << 4,
+    CC_ALPHA = 1 << 5,  /* any letter */
+} CC_flag;
+
+extern uint8_t CC[256];  /* filled by init_cc_table() */
+
+#define is_cat_char(c)  (CC[(unsigned char)(c)] & CC_CAT)
+#define is_nw_char(c)   (CC[(unsigned char)(c)] & CC_NW)
+/* PMS gives slot names and category names the same character set, so this is
+ * deliberately an alias; it is spelled out for readability at the use sites. */
+#define is_slot_char(c) is_cat_char(c)
+#define is_use_char(c)  (CC[(unsigned char)(c)] & CC_USE)
+#define is_digit_c(c)   (CC[(unsigned char)(c)] & CC_DIGIT)
+#define is_lower_c(c)   (CC[(unsigned char)(c)] & CC_LOWER)
+#define is_alpha_c(c)   (CC[(unsigned char)(c)] & CC_ALPHA)
+
+#define ARRAY_SIZE(a) ((int)(sizeof(a) / sizeof((a)[0])))
+
+/* For parameters a function must declare to match a signature but never
+ * reads, so that -Wunused-parameter stays usable. */
+#define UNUSED __attribute__((unused))
+
+/* Declare a pointer+length pair.  With an initializer: SPAN(name, NULL). */
+#define SPAN(name, ...)  const char *name __VA_OPT__(= __VA_ARGS__); int name##_len __VA_OPT__(= 0)
+/* Copy locals `name` / `name_len` into the matching SPAN fields of *s. */
+#define SPAN_SET(s, name)  do { (s)->name = name; (s)->name##_len = name##_len; } while (0)
+
+static inline int is_whitespace(char c)
+{
+    return c == ' ' || c == '\t' || c == '\r' || c == '\n';
+}
+
+typedef struct {
+    SPAN(block);
+    SPAN(op);
+    SPAN(cat);
+    SPAN(pkg);
+    SPAN(ver);       /* NULL if absent */
+    SPAN(slot_raw);  /* NULL if absent */
+    SPAN(use_raw);   /* NULL if absent */
+} AtomInfo;
+
+void init_cc_table(void);
+void skip_whitespace(DepScanner *p);
+int  scan_version(DepScanner *p);
+int  scan_slot(DepScanner *p);
+int  scan_use_flag(DepScanner *p);
+int  scan_usedep(DepScanner *p);
+int  scan_atom(DepScanner *p, AtomInfo *info);
+
+typedef struct {
+    void *ctx;
+    /* Called for each atom token. Returns 1 on success, 0 on error. */
+    int (*on_atom)(void *ctx, const char *start, int len, const AtomInfo *info);
+    /* Called before/after || ^^ ?? group contents. */
+    int (*on_group_start)(void *ctx, const char *op, int op_len);
+    int (*on_group_end)(void *ctx);
+    /* Returns 1 if the use flag is active, 0 if not, -1 on error. */
+    int (*use_active)(void *ctx, const char *flag, int len, int is_neg);
+} DepVisitor;
+
+int scan_dep_list(DepScanner *p, DepVisitor *v);
+
+/* vim: set ts=4 sw=4 et: */

diff --git a/src/fuzz_parser.c b/src/fuzz_parser.c
new file mode 100644
index 000000000..28e3b1e1f
--- /dev/null
+++ b/src/fuzz_parser.c
@@ -0,0 +1,89 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+/* libFuzzer entry point for the dep-string scanner.
+ *
+ * This drives the pure-C side only -- no Python objects are built -- so it can
+ * run without an interpreter.  Build it with:
+ *
+ *     meson setup build -Dfuzzing=true -Db_sanitize=address,undefined
+ *     ninja -C build src/fuzz_parser
+ *     ./build/src/fuzz_parser corpus/
+ *
+ * It is not part of `meson test`; the fixed cases live in test_parser.c.
+ */
+
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "dep_parser_core.h"
+
+/* Counting visitor: exercises the callback paths without allocating. */
+static int fuzz_on_atom(void *ctx, UNUSED const char *start, UNUSED int len,
+                        UNUSED const AtomInfo *info)
+{
+    ++*(unsigned long *)ctx;
+    return 1;
+}
+
+static int fuzz_on_group_start(UNUSED void *ctx, UNUSED const char *op,
+                               UNUSED int op_len)
+{
+    return 1;
+}
+
+static int fuzz_on_group_end(UNUSED void *ctx)
+{
+    return 1;
+}
+
+/* Derive activity from the flag text so both branches get explored without
+ * needing a USE list in the input. */
+static int fuzz_use_active(UNUSED void *ctx, const char *flag, int len,
+                           int is_neg)
+{
+    int active = len > 0 && (flag[0] & 1);
+    return is_neg ? !active : active;
+}
+
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
+{
+    static int initialized;
+    if (!initialized) {
+        init_cc_table();
+        initialized = 1;
+    }
+
+    /* Copy so the scanner runs against an exactly-sized buffer and any read
+     * past the end is caught by the sanitizer rather than landing in slack. */
+    char *buf = malloc(size ? size : 1);
+    if (!buf)
+        return 0;
+    memcpy(buf, data, size);
+
+    unsigned long atoms = 0;
+    DepVisitor visitor = {
+        .ctx            = &atoms,
+        .on_atom        = fuzz_on_atom,
+        .on_group_start = fuzz_on_group_start,
+        .on_group_end   = fuzz_on_group_end,
+        .use_active     = fuzz_use_active,
+    };
+
+    DepScanner scanner = { buf, buf + size, NULL };
+    skip_whitespace(&scanner);
+    if (scanner.cur < scanner.end)
+        scan_dep_list(&scanner, &visitor);
+
+    /* Also drive the single-atom entry, which has its own validation. */
+    DepScanner atom_scanner = { buf, buf + size, NULL };
+    AtomInfo info;
+    scan_atom(&atom_scanner, &info);
+
+    free(buf);
+    return 0;
+}
+
+/* vim: set ts=4 sw=4 et: */

diff --git a/src/meson.build b/src/meson.build
index 0220e8d56..a47594b5d 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -19,3 +19,44 @@ run_command(
     capture : false,
     check : true
 )
+
+dep_parser_ext = py.extension_module(
+    '_parser',
+    'dep_parser_core.c',
+    'dep_parser.c',
+    include_directories : include_directories('.'),
+    dependencies : py.dependency(),
+    subdir : 'portage' / 'dep',
+    install : true,
+)
+
+run_command(
+    [
+        'ln', '-srnf',
+        dep_parser_ext.full_path(),
+        meson.project_source_root() / 'lib' / 'portage' / 'dep/'
+    ],
+    capture : false,
+    check : true
+)
+
+test_parser = executable(
+    'test_parser',
+    'dep_parser_core.c',
+    'test_parser.c',
+    include_directories : include_directories('.'),
+)
+
+test('parser', test_parser)
+
+if get_option('fuzzing')
+    fuzz_parser = executable(
+        'fuzz_parser',
+        'dep_parser_core.c',
+        'fuzz_parser.c',
+        include_directories : include_directories('.'),
+        c_args : ['-fsanitize=fuzzer'],
+        link_args : ['-fsanitize=fuzzer'],
+        build_by_default : false,
+    )
+endif

diff --git a/src/test_parser.c b/src/test_parser.c
new file mode 100644
index 000000000..b6305aa47
--- /dev/null
+++ b/src/test_parser.c
@@ -0,0 +1,339 @@
+/* Copyright 2026 Gentoo Authors
+ * SPDX-License-Identifier: GPL-2.0-or-later OR MIT
+ */
+
+#include "dep_parser_core.h"
+#include <stdio.h>
+#include <string.h>
+
+static int failures = 0;
+static int passes = 0;
+
+#define FAIL(fmt, ...) \
+    do { fprintf(stderr, "FAIL %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); failures++; } while (0)
+#define PASS() \
+    do { passes++; } while (0)
+
+#define CHECK(expr) \
+    do { if (expr) { PASS(); } else { FAIL("%s", #expr); } } while (0)
+
+static int span_eq(const char *ptr, size_t len, const char *expected)
+{
+    if (!expected)
+        return ptr == NULL;
+
+    if (!ptr)
+        return 0;
+
+    size_t elen = strlen(expected);
+    return len == elen && memcmp(ptr, expected, elen) == 0;
+}
+
+/* Parse a complete atom string; return 1 on success and fill *info. */
+static int parse_atom_str(const char *s, AtomInfo *info)
+{
+    DepScanner p = { s, s + strlen(s), NULL };
+    if (!scan_atom(&p, info))
+        return 0;
+
+    return p.cur == p.end;  /* must consume all input */
+}
+
+static void test_scan_version(void)
+{
+    static const struct {
+        const char *input;
+        int ok;
+        const char *ver;
+    } cases[] = {
+        { "1",                 1, "1"                },
+        { "1.0",               1, "1.0"              },
+        { "1.2.3",             1, "1.2.3"            },
+        { "1.0a",              1, "1.0a"             },
+        { "1.0_alpha",         1, "1.0_alpha"        },
+        { "1.0_alpha1",        1, "1.0_alpha1"       },
+        { "1.0_beta2_rc3",     1, "1.0_beta2_rc3"    },
+        { "1.0_pre",           1, "1.0_pre"          },
+        { "1.0_pre1",          1, "1.0_pre1"         },
+        { "1.0_rc1",           1, "1.0_rc1"          },
+        { "1.0_p1",            1, "1.0_p1"           },
+        { "1.0_p",             1, "1.0_p"            },
+        { "1.0-r1",            1, "1.0-r1"           },
+        { "1.0-r12",           1, "1.0-r12"          },
+        { "99999999",          1, "99999999"         },
+        { "1.0*",              1, "1.0*"             },
+        /* must stop at terminating chars */
+        { "1.0 rest",          1, "1.0"              },
+        { "1.0:slot",          1, "1.0"              },
+        { "1.0[use]",          1, "1.0"              },
+        { "1.0)",              1, "1.0"              },
+        /* invalid */
+        { "alpha",             0, NULL               },
+        { ".1",                0, NULL               },
+        { "1.",                0, NULL               },
+    };
+
+    for (int i = 0; i < ARRAY_SIZE(cases); i++) {
+        const char *s = cases[i].input;
+        DepScanner p = { s, s + strlen(s), NULL };
+        int ok = scan_version(&p);
+        if (ok != cases[i].ok) {
+            FAIL("scan_version(%s): got %d, want %d", s, ok, cases[i].ok);
+            continue;
+        }
+        if (ok && cases[i].ver) {
+            size_t vlen = (size_t)(p.cur - s);
+            if (!span_eq(s, vlen, cases[i].ver)) {
+                FAIL("scan_version(%s): ver=%.*s, want %s", s, (int)vlen, s, cases[i].ver);
+                continue;
+            }
+        }
+        PASS();
+    }
+}
+
+static void test_scan_atom_basic(void)
+{
+    AtomInfo a;
+
+    /* simple unversioned */
+    CHECK(parse_atom_str("cat/pkg", &a));
+    CHECK(span_eq(a.cat, a.cat_len, "cat"));
+    CHECK(span_eq(a.pkg, a.pkg_len, "pkg"));
+    CHECK(a.ver == NULL);
+    CHECK(a.op  == NULL);
+    CHECK(a.block == NULL);
+
+    /* operator + version */
+    CHECK(parse_atom_str(">=cat/pkg-1.2.3", &a));
+    CHECK(span_eq(a.op,  a.op_len,  ">="));
+    CHECK(span_eq(a.cat, a.cat_len, "cat"));
+    CHECK(span_eq(a.pkg, a.pkg_len, "pkg"));
+    CHECK(span_eq(a.ver, a.ver_len, "1.2.3"));
+
+    /* single blocker */
+    CHECK(parse_atom_str("!cat/pkg", &a));
+    CHECK(span_eq(a.block, a.block_len, "!"));
+
+    /* double blocker */
+    CHECK(parse_atom_str("!!cat/pkg", &a));
+    CHECK(span_eq(a.block, a.block_len, "!!"));
+
+    /* tilde operator */
+    CHECK(parse_atom_str("~cat/pkg-1.0", &a));
+    CHECK(span_eq(a.op, a.op_len, "~"));
+    CHECK(span_eq(a.ver, a.ver_len, "1.0"));
+
+    /* glob */
+    CHECK(parse_atom_str("=cat/pkg-1.0*", &a));
+    CHECK(span_eq(a.ver, a.ver_len, "1.0*"));
+}
+
+static void test_scan_atom_hyphenated_name(void)
+{
+    AtomInfo a;
+
+    CHECK(parse_atom_str("sys-apps/portage", &a));
+    CHECK(span_eq(a.cat, a.cat_len, "sys-apps"));
+    CHECK(span_eq(a.pkg, a.pkg_len, "portage"));
+
+    /* digit segment in package name */
+    CHECK(parse_atom_str("=dev-java/log4j-12-api-2.0", &a));
+    CHECK(span_eq(a.cat, a.cat_len, "dev-java"));
+    CHECK(span_eq(a.pkg, a.pkg_len, "log4j-12-api"));
+    CHECK(span_eq(a.ver, a.ver_len, "2.0"));
+
+    /* multiple hyphen segments */
+    CHECK(parse_atom_str("=dev-libs/libfoo-bar-baz-1.0", &a));
+    CHECK(span_eq(a.pkg, a.pkg_len, "libfoo-bar-baz"));
+    CHECK(span_eq(a.ver, a.ver_len, "1.0"));
+}
+
+static void test_scan_atom_slot(void)
+{
+    AtomInfo a;
+
+    CHECK(parse_atom_str("cat/pkg:0", &a));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "0"));
+
+    CHECK(parse_atom_str("cat/pkg:0/53", &a));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "0/53"));
+
+    CHECK(parse_atom_str("cat/pkg:0=", &a));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "0="));
+
+    CHECK(parse_atom_str("cat/pkg:*", &a));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "*"));
+
+    CHECK(parse_atom_str("cat/pkg:=", &a));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "="));
+
+    /* no slot */
+    CHECK(parse_atom_str("cat/pkg", &a));
+    CHECK(a.slot_raw == NULL);
+}
+
+static void test_scan_atom_use(void)
+{
+    AtomInfo a;
+
+    CHECK(parse_atom_str("cat/pkg[foo]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "foo"));
+
+    CHECK(parse_atom_str("cat/pkg[-foo]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "-foo"));
+
+    CHECK(parse_atom_str("cat/pkg[foo,bar]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "foo,bar"));
+
+    CHECK(parse_atom_str("cat/pkg[!foo=]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "!foo="));
+
+    CHECK(parse_atom_str("cat/pkg[foo(+)]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "foo(+)"));
+
+    /* @ in use flag name (old LINGUAS_en@euro style) */
+    CHECK(parse_atom_str("cat/pkg[LINGUAS_en@euro]", &a));
+    CHECK(span_eq(a.use_raw, a.use_raw_len, "LINGUAS_en@euro"));
+}
+
+static void test_scan_atom_combined(void)
+{
+    AtomInfo a;
+
+    CHECK(parse_atom_str("=cat/pkg-1.0:2[foo,-bar]", &a));
+    CHECK(span_eq(a.op,       a.op_len,       "="));
+    CHECK(span_eq(a.cat,      a.cat_len,      "cat"));
+    CHECK(span_eq(a.pkg,      a.pkg_len,      "pkg"));
+    CHECK(span_eq(a.ver,      a.ver_len,      "1.0"));
+    CHECK(span_eq(a.slot_raw, a.slot_raw_len, "2"));
+    CHECK(span_eq(a.use_raw,  a.use_raw_len,  "foo,-bar"));
+}
+
+static void test_scan_atom_invalid(void)
+{
+    AtomInfo a;
+
+    CHECK(!parse_atom_str("pkg", &a));            /* missing category */
+    CHECK(!parse_atom_str("/pkg", &a));           /* empty category */
+    CHECK(!parse_atom_str("cat/", &a));           /* empty package */
+    CHECK(!parse_atom_str(".cat/pkg", &a));       /* leading dot in category */
+    CHECK(!parse_atom_str("cat/pkg:/slot", &a));  /* invalid slot */
+    CHECK(!parse_atom_str(">=cat/pkg", &a));       /* operator without version */
+    CHECK(!parse_atom_str("=cat/pkg", &a));        /* operator without version */
+    /* use before slot */
+    CHECK(!parse_atom_str("cat/pkg[doc]:0", &a));
+}
+
+static void test_scan_slot(void)
+{
+    static const struct {
+        const char *input;
+        int ok;
+        const char *slot;
+    } cases[] = {
+        { "0",       1, "0"      },
+        { "myslot",  1, "myslot" },
+        { "0/53",    1, "0/53"   },
+        { "0=",      1, "0="     },
+        { "0/53=",   1, "0/53="  },
+        { "*",       1, "*"      },
+        { "=",       1, "="      },
+        { "",        0, NULL     },
+        { "/slot",   0, NULL     },
+        { "-slot",   0, NULL     },
+    };
+
+    for (int i = 0; i < ARRAY_SIZE(cases); i++) {
+        const char *s = cases[i].input;
+        DepScanner p = { s, s + strlen(s), NULL };
+        int ok = scan_slot(&p);
+        if (ok != cases[i].ok) {
+            FAIL("scan_slot(%s): got %d, want %d", s, ok, cases[i].ok);
+            continue;
+        }
+        if (ok && cases[i].slot) {
+            int slen = (int)(p.cur - s);
+            if (!span_eq(s, slen, cases[i].slot)) {
+                FAIL("scan_slot(%s): slot=%.*s, want %s", s, slen, s, cases[i].slot);
+                continue;
+            }
+        }
+        PASS();
+    }
+}
+
+static void test_scan_use_flag(void)
+{
+    static const struct {
+        const char *input;
+        int ok;
+    } cases[] = {
+        { "foo",              1 },
+        { "-foo",             1 },
+        { "!foo=",            1 },
+        { "!foo?",            1 },
+        { "foo=",             1 },
+        { "foo?",             1 },
+        { "foo(+)",           1 },
+        { "foo(-)",           1 },
+        { "foo(+)=",          1 },
+        { "!foo(-)=",         1 },
+        /* flag names with - */
+        { "foo-bar",          1 },
+        { "-foo-bar",         1 },
+        { "foo-bar?",         1 },
+        { "!foo-bar?",        1 },
+        { "foo-bar=",         1 },
+        /* flag names with + (e.g. c++) */
+        { "c++",              1 },
+        { "-c++",             1 },
+        { "c++?",             1 },
+        { "!c++?",            1 },
+        /* flag names with @ (old LINGUAS syntax) */
+        { "LINGUAS_en@euro",  1 },
+        { "-LINGUAS_en@euro", 1 },
+        { "LINGUAS_en@euro?", 1 },
+        /* invalid */
+        { "!foo",             0 },  /* bare ! without ?/= */
+        { "-foo=",            0 },  /* -foo with suffix */
+        { "",                 0 },
+    };
+
+    for (int i = 0; i < ARRAY_SIZE(cases); i++) {
+        const char *s = cases[i].input;
+        DepScanner p = { s, s + strlen(s), NULL };
+        int ok = scan_use_flag(&p);
+        /* for valid flags, must also consume all input */
+        if (ok && p.cur != p.end) ok = 0;
+        if (ok != cases[i].ok) {
+            FAIL("scan_use_flag(%s): got %d, want %d", s, ok, cases[i].ok);
+        } else {
+            PASS();
+        }
+    }
+}
+
+int main(void)
+{
+    init_cc_table();
+
+    test_scan_version();
+    test_scan_atom_basic();
+    test_scan_atom_hyphenated_name();
+    test_scan_atom_slot();
+    test_scan_atom_use();
+    test_scan_atom_combined();
+    test_scan_atom_invalid();
+    test_scan_slot();
+    test_scan_use_flag();
+
+    if (failures) {
+        fprintf(stderr, "%d/%d tests failed\n", failures, failures + passes);
+        return 1;
+    }
+    printf("%d tests passed\n", passes);
+    return 0;
+}
+
+/* vim: set ts=4 sw=4 et: */
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.