#namespaces #nested #logging #tree #no-alloc

no-std nstree

construct branched 'namespace strings' for nested subcomponents, often for logging

1 stable release

new 1.0.0 Jan 21, 2025

#46 in Value formatting

GPL-3.0-or-later

110KB
1.5K SLoC

nstree

Utilities to construct branched "namespace strings" for recursive subcomponents, efficiently and flexibly. This is often useful for multiplexing output from processes and other components of a program - e.g. nested dependent services.

This can be used in custom systems, and it may be useful when creating custom log::log! targets or tracing-like nested span structures for when tracing is not quite suitable (or even in cooperation with tracing via custom targets).

Namespaces

This crate primarily operates upon the notion of a namespace path, which consists of "::"-prefixed namespace components. For example:

  • The namespace path "africa::tunisia" is a namespace path consisting of the components "africa" and "tunisia" in that order.
  • The namespace path "::africa::tunisia" is semantically identical to the above namespace path, consisting of the components "africa" and "tunisia", in that order.
  • Importantly, however, the "::"-prefix does not create an empty namespace component before itself. It can, however, have one after itself. For example, the namespace path "::::africa::tunisia" consists of the 3 components "" (an empty component), "africa", and "tunisia".
  • "::" pairs are grouped from left to right, so ":::africa:::tunisia::tunis" would be a namespace path with components ":africa", ":tunisia", "tunis". It's not recommended to do things like this, but there is an unambiguous decoding of such strings.

These are constructed in combinator style, similar to Rust iterators, by composing sequences of such components. It's possible (and indeed may be desirable) to construct custom types implementing specific sequences.

Basic Examples

Simple rendering of two "forked" paths:

use nstree::{NamespacePath as _, RenderStyle};

let base_path = "europe::uk".by_ref_cache();
let london = base_path.join("london");
let birmingham = base_path.join("birmingham");

assert_eq!(london.render(RenderStyle::WithFirstSeparator).to_string(), "::europe::uk::london");
assert_eq!(birmingham.render(RenderStyle::NoFirstSeparator).to_string(), "europe::uk::birmingham");

Chaining of paths of different structures (also see notes on arrays/vecs/slices/join-macro):

use nstree::NamespacePath;

let root = ["my-program", "subcode"];
let subprocess = nstree::join!["my", "subprocess::logger"];
let full_namespace_path = root.join(subprocess);
assert_eq!(
    full_namespace_path.render(Default::default()).to_string(), 
    "::my-program::subcode::my::subprocess::logger"
);

Custom sub-sequence/sub-namespace-path:

use nstree::{NamespacePath, NamespaceComponent}; 

// The easiest way to do something like this is to just defer to some static stuff.
// If you need to allocate or do custom formatting, consider implementing 
// `IntoNamespacePath` instead, though the idea is pretty similar ^.^
#[derive(Clone, Copy, Debug)]
enum AsyncRuntimeMode {
    SingleThreaded,
    MultiThreaded {
        workstealing: bool
    }
}

impl AsyncRuntimeMode {
    #[inline]
    const fn to_namespace_path_string(&self) -> &'static str {
        match self {
            Self::SingleThreaded => "singlethreaded",
            Self::MultiThreaded { workstealing } => if *workstealing {
                "multithreaded::workstealing"    
            } else {
                "multithreaded::thread-per-core"
            }
        }
    }
}

impl NamespacePath for AsyncRuntimeMode {
    #[inline]
    fn components(&self) -> impl IntoIterator<Item = NamespaceComponent<'_>> {
        self.to_namespace_path_string().components()
    }

    #[inline]
    fn components_hint(&self) -> nstree::NamespaceComponentsHint {
        self.to_namespace_path_string().components_hint()
    }
}

Custom sub-sections of a namespace path (one or more components):

use nstree::{NamespacePath, RawNamespacePath, IntoNamespacePath, path::combinators};
use empty_fallback_chain::IteratorExt as _;
use core::{iter, fmt};

#[derive(Debug, Clone)]
pub struct ProcessInstance {
    pub pid: u32,
    pub name: Option<String>
}

// RawNamespacePath lets you directly perform `fmt::Display` operations without 
// allocations for the whole strings required when generating sequenceso of 
// `nstree::NamespaceComponent` dynamically.
//
// However, this makes it harder to do intermediate caching, and results in more 
// complex iterator internals, potentially making code less performant. It also makes it 
// far more difficult to analyse sequences of components for custom behaviour, and should 
// you intend to allocate at some point, it provides less information for making it 
// efficient. Not only this, but it cannot guaruntee that implementations do proper 
// `"::"`-separation of the iterated components.
// 
// All implementations of `NamespacePath` automatically provide a method to convert into a
// RawNamespacePath using the implementation, though you can always implement both - 
// or implement `IntoNamespacePath`.
// 
// This could be implemented (arguably more cleanly) using `IntoRawNamespacePath`. The only 
// problem is that specifying combinators can become unweildy due to lack of ability to 
// refer to the output of a function returning `impl Trait` 
// (see: <https://rust-lang.github.io/rfcs/3654-return-type-notation.html> 
// and <https://rust-lang.github.io/rfcs/2071-impl-trait-type-alias.html>) 
// and/or some existential types.
impl RawNamespacePath for ProcessInstance {
    fn raw_components(&self) -> impl IntoIterator<Item = impl fmt::Display> {
        // We could also make a custom type for logging PIDs and have it implement 
        // RawNamespacePath as well, or have it implement Display without any `::` being 
        // inserted.
        let init_iter = nstree::get_components("pid").map(either::Right);
        let pid_iter = iter::once(self.pid).map(either::Left);
        let name_iter = self.name
            .as_deref()
            .map(nstree::get_components)
            .into_iter()
            .flatten();
        let name_iter_with_fallback = name_iter
            .empty_fallback_chain(nstree::get_components("::{unknown name}"))
            .map(either::Right);
        init_iter.chain(pid_iter).chain(name_iter_with_fallback)
    }
}

impl<'s> IntoNamespacePath for &'s ProcessInstance {
    // You could also do something as simple as a bare string here, or go even further and 
    // make a custom `NamespacePath` rendered type, or anything in-between really.
    // 
    // The major benefit to doing it with namespace combinators is you avoid pitfalls with 
    // separators e.g. if you did two strings just by formatting them together, you could
    // experience issues depending on the separators within the strings and the separators 
    // between them. It also avoids unnecessary extra allocations. 
    //
    // An intermediary way of dealing with this while having still some simple types would 
    // be to use `NamespacePath::build_cache_string` to make some inner types. This is 
    // unfortunately less efficient, however, due to some amount of unnecessary allocation.
    type NSPath = combinators::Join<String, combinators::Fallback<Option<&'s str>, &'s str>>; 

    fn into_namespace_path(self) -> Self::NSPath {
        format!("pid::{}", self.pid).join(self.name.as_deref().fallback("{unknown name}"))
    }
}

// Rendering, both by RawNamespacePath and NamespacePath
let root = "my-program";
let init_process = ProcessInstance { pid: 1, name: Some("init".to_string())};
let special_process = ProcessInstance { pid: 4849, name: Some("my::special::process".to_string()) };
let unnamed_ephemeral = ProcessInstance { pid: 3780, name: None };

// RawNamespacePath
let init_process_log = root.raw_join(&init_process); 
// NamespacePath
let special_process_log = root.join(&special_process);
let unnamed_ephemeral_log = root.join(&unnamed_ephemeral);

// RawNamespacePath::raw_render
assert_eq!(
    init_process_log.raw_render(Default::default()).to_string(), 
    "::my-program::pid::1::init"
);
// NamespacePath::render
assert_eq!(
    special_process_log.render(Default::default()).to_string(), 
    "::my-program::pid::4849::my::special::process"
);
assert_eq!(
    unnamed_ephemeral_log.render(Default::default()).to_string(),
    "::my-program::pid::3780::{unknown name}"
);

Note on Arrays/Vecs/Slices

Arrays, vectors, and slices all function to combine a sequence of NamespacePaths and RawNamespacePaths into a single combined form of the respective trait. All things written here about NamespacePath also apply to the RawNamespacePath trait. This also applies to the nstree::join! macro.

This means, for instance, that ["a::b", "c::d"] is a NamespacePath consisting of the components of "c::d" concatenated onto the components of "a::b". In most cases this is intuitive.

However, if a member of the array/slice/vec (from now on we will just call these arrays) is a NamespacePath with no components - for example, "" (the empty string) - then there will be no separator between it and any components afterwards. That is, the array ["a::b", "", "c::d"] is an entirely equivalent NamespacePath to the array ["a::b", "c::d"] - there is no extra "empty" component between "b" and "c", like might be expected if you had written ["a", "b", "", "c", "d"] or if you'd received some parameter that sometimes could have an entirely empty/"zero-component" NamespacePath as a part of your own constructions.

If you want to guarantee that there is always at least one component in some sub-sequence of an outputted NamespacePath, there are several options:

  • Apply a non-zero-component prefix (or suffix or both) unconditionally to each sub-collection that you wish to guarantee has some notation - such that it always appears even if the original sequence definition may sometimes have no components - using NamespacePath::join
  • Apply a fallback default either to an entire subsection or some part of it, such that when that part of the namespace path has no components, it is replaced with some default sequence such as "::" (you could also do multi-level fallbacks with customisation).
    This would be done with NamespacePath::fallback
  • Perform a combination of these.

Available Crate Features

This crate provides 2 features:

  • alloc (default enabled) - when enabled, this provides functions that produce dynamically allocated stdlib types (such as String and Vec), as well as implementations of NamespacePath and RawNamespacePath on them and their combinations. It makes this library dependent on the alloc standard library crate.
  • std (default enabled) - when enabled, this makes the library depend on the full Rust Standard Library (std) - it provides some comparison implementations, and it also implies that alloc is enabled.

By default, alloc and std are both enabled - you can disable them by using default-features=false inside your Cargo.toml

Dependencies

~84KB