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: */