C++ Attribute System

Home > Attributes

tsujikiri can read C++17 [[namespace::name]] attributes directly from C++ source files and use them to control binding generation — skipping declarations, keeping them, or renaming them.


How Attributes Are Detected

libclang does not expose custom-namespace attributes as cursor children (only standard C++ attributes like [[nodiscard]] are cursor-accessible). tsujikiri works around this by scanning the source text of each declaration.

For each cursor, tsujikiri looks in three locations:

  1. Trailing same-line: the rest of the line after the cursor’s spelling location

  2. Leading same-line: the portion of the line before the cursor that ends with [[...]]

  3. Preceding line: the full text of the line immediately above the cursor (skipping lines that end with ;, {, or })

Source files are read once per parse and cached. The scanner extracts all [[...]] blocks it finds and parses their contents.

Multi-attribute blocks: [[a::x, b::y]] yields two entries: a::x and b::y.

Attribute arguments: [[tsujikiri::rename("newName")]] — the argument "newName" is extracted from the first quoted string.

What the Scanner Looks For

Attributes must use the [[double-bracket]] C++17 syntax. GNU-style __attribute__((x)) annotations are not detected.


Built-in Attributes

The following attributes are always active — no configuration required.

[[tsujikiri::skip]]

Sets emit=False on the annotated node. Applies to:

  • Classes

  • Methods

  • Fields

  • Constructors

  • Enums and enum values

  • Free functions

The attribute is processed after the FilterEngine runs, so it can suppress a node that the filter config would include.

namespace mylib {

[[tsujikiri::skip]]
class InternalHelper { ... };   // suppressed, never appears in bindings

class Shape {
public:
    // This method is always hidden, regardless of filter config
    [[tsujikiri::skip]]
    void internalOptimize();

    // Normal methods appear in the binding
    double area() const;
};

}

[[tsujikiri::keep]]

Sets emit=True on the annotated node. This is most useful for re-enabling a node that the filter config suppressed.

namespace mylib {

class Shape {
public:
    // Globally suppressed by filter: methods.global_blacklist with pattern "operator.*"
    // But we explicitly want this one exposed:
    [[tsujikiri::keep]]
    bool operator==(const Shape& other) const;

    // This operator stays suppressed (no [[keep]])
    bool operator<(const Shape& other) const;
};

}

When [[tsujikiri::skip]] and [[tsujikiri::keep]] both appear on the same line (e.g. [[tsujikiri::skip, tsujikiri::keep]]), the last one processed wins — they are applied in attribute list order.

[[tsujikiri::rename("newName")]]

Sets the rename field on the annotated node to the first quoted string argument. Works on:

  • Classes → changes the binding name

  • Methods → changes the binding name

  • Fields → changes the binding name

namespace mylib {

[[tsujikiri::rename("Vector3")]]
class Vec3 {
public:
    [[tsujikiri::rename("length_sq")]]
    float getLengthSquared() const;

    [[tsujikiri::rename("x")]]
    float x_ = 0.0f;
};

}

With the config:

filters:
  namespaces: ["mylib"]
  constructors:
    include: false

The generated binding will use Vector3, length_sq, and x as the names — even though no transforms are configured. The attribute-based rename is applied in the same pass as [[skip]] and [[keep]].

[[tsujikiri::readonly]]

Forces a field to be exposed as read-only, even if the C++ field is not const.

Applies to: Fields.

class Circle {
public:
    // Exposed as read-only even though the field is mutable in C++:
    [[tsujikiri::readonly]]
    double radius_ = 1.0;
};

In luabridge3, read-only fields are emitted with a nullptr setter:

.addProperty("radius_", &mylib::Circle::radius_, nullptr)

In pybind11, they use .def_readonly(...) instead of .def_readwrite(...).

[[tsujikiri::thread_safe]]

Marks a method or free function as thread-safe, setting allow_thread=True on the IR node. This is a template hint — the built-in formats do not use it directly, but custom formats can use it to emit GIL-release annotations or other thread safety wrappers.

Applies to: Methods, Free functions.

class Processor {
public:
    [[tsujikiri::thread_safe]]
    void process(const float* data, int count);
};

[[tsujikiri::doc("text")]]

Attaches a documentation string to a node. The text is available as node.doc in Jinja2 templates. Both pybind11 and pyi built-in formats use this to emit Python docstrings.

Applies to: Classes, Methods, Constructors, Fields, Enums, Enum values, Free functions.

/// [[tsujikiri::doc("A 2D geometric shape.")]]
class Shape {
public:
    /// [[tsujikiri::doc("Compute the area of the shape.")]]
    virtual double area() const = 0;

    /// [[tsujikiri::doc("The shape's scale factor.")]]
    [[tsujikiri::readonly]]
    double scale_ = 1.0;
};

Generated pybind11 output:

py::class_<mylib::Shape>(m, "Shape", "A 2D geometric shape.")
    .def("area", &mylib::Shape::area, "Compute the area of the shape.")
    .def_readonly("scale_", &mylib::Shape::scale_, "The shape's scale factor.");

[[tsujikiri::rename_argument("old", "new")]]

Renames a parameter by name on the annotated method or function. Equivalent to a modify_argument transform but expressed in the C++ source.

Applies to: Methods, Free functions.

class Circle {
public:
    // Expose the parameter as "radius" instead of the C++ name "r"
    [[tsujikiri::rename_argument("r", "radius")]]
    void setRadius(double r);
};

[[tsujikiri::type_map("CppType", "TargetType")]]

Overrides the type of a matching parameter or return value on the annotated method or function. Also applies to fields when the field’s type matches. Equivalent to a targeted add_type_mapping but scoped to a single declaration.

Applies to: Methods, Free functions, Fields.

class Processor {
public:
    // Map juce::String to std::string only for this method's signature:
    [[tsujikiri::type_map("juce::String", "std::string")]]
    void setName(juce::String name);
};

Custom Attribute Handlers

Register custom attribute names by adding attributes.handlers to your .input.yml. This maps an attribute name (including namespace) to one of three actions.

attributes:
  handlers:
    "mygame::no_export": skip     # [[mygame::no_export]] → suppress
    "mygame::export": keep        # [[mygame::export]] → include (overrides filters)
    "mygame::bind_as": rename     # [[mygame::bind_as("newName")]] → rename
    "api::internal": skip         # supports any namespace

Available Actions

Action

Effect

skip

Sets emit=False on the annotated node

keep

Sets emit=True on the annotated node

rename

Sets rename to the first quoted string argument

Custom handlers work identically to built-in ones. They extend the built-in set — registering "tsujikiri::skip" would be redundant, but registering "mygame::skip" adds a project-specific alias.

Real-World Example

Given this C++ header:

// game_api.hpp
#pragma once
#include <string>

namespace game {

// Attribute namespaces must be declared with [[using]] in the compiler
// but tsujikiri's scanner doesn't require this
class [[mygame::export]] AudioEngine {
public:
    [[mygame::export]]
    AudioEngine() = default;

    [[mygame::export]]
    void play(const std::string& soundName);

    [[mygame::no_export]]
    void internalUpdate(float dt);   // only called from the engine loop

    [[mygame::bind_as("volume")]]
    float masterVolume = 1.0f;

    float internalBuffer[256];       // no attribute = excluded by field filter
};

[[mygame::no_export]]
class AudioDriverImpl { ... };       // implementation detail

} // namespace game

And this config:

# game.input.yml
source:
  path: game_api.hpp
  parse_args: ["-std=c++17"]

filters:
  namespaces: ["game"]
  constructors:
    include: true
  fields:
    global_blacklist:
      - "internalBuffer"

attributes:
  handlers:
    "mygame::no_export": skip
    "mygame::export": keep
    "mygame::bind_as": rename

Result:

  • AudioEngine is included ([[mygame::export]]emit=True)

  • AudioDriverImpl is excluded ([[mygame::no_export]]emit=False)

  • play is included (attribute keep)

  • internalUpdate is excluded (attribute skip)

  • masterVolume field is renamed to volume (attribute rename)

  • internalBuffer is excluded by the field filter


Attributes vs YAML Filters and Transforms

Both the attribute system and YAML config can control which declarations are included and how they’re named. Choosing between them is a matter of where the decision belongs:

Use attributes when…

Use YAML filters/transforms when…

The decision is made by the C++ author

The decision is made by the binding author

You control the header and can annotate it

You do not modify the header (third-party library)

The policy applies to every project that uses the header

The policy is specific to this binding configuration

You want the binding intent visible in the C++ source

You want binding config separate from C++ code

Practical guideline: For your own library headers, annotate with [[tsujikiri::skip]] and [[tsujikiri::rename(...)]] to express the binding intent alongside the C++ API. For third-party headers you cannot modify, use YAML filters and transforms exclusively.


Attribute Placement Rules and Gotchas

Scanning Scope

The scanner checks three locations (in order) and stops when it finds attributes:

  1. Trailing same-line text — any [[...]] after the identifier on the same line

  2. Leading same-line text — any [[...]] at the start of the same line, before the identifier

  3. Preceding line text — the full previous line if it doesn’t end with ;, {, or }

What Is Skipped

The preceding-line scan skips lines that end with:

  • ; — statement separator (previous statement, not an attribute)

  • { — opening brace (start of a block)

  • } — closing brace (end of a block)

This prevents accidentally picking up attributes from sibling declarations.

Line Comments

Content inside // line comments is not filtered out by the scanner (the raw line text is searched). Avoid placing [[...]] attribute-like strings inside line comments if you don’t want them detected.

Only Double-Bracket Style

Only [[namespace::name]] and [[namespace::name("arg")]] syntax is detected. GNU __attribute__((x)), MSVC __declspec(x), and pragma annotations are not supported.

Inner Classes

Attributes on inner class declarations are detected. The scanner looks at the cursor’s location in the source, so inner classes at any nesting depth are handled correctly.


See Also

  • Filtering — attributes are processed after FilterEngine; [[keep]] can override filter suppressions

  • Transforms — transforms run after attributes; can further modify emit and rename fields

  • Input File Referenceattributes.handlers configuration key