Repository: rune-rs/rune (language: rust)

Issue:
Feature request: More accurate types in generated rune API documentation
Currently when a generic type (e.g. `Option<MyCustomType>`) is part of a function signature in an external function. the rune docs only show the outermost type (e.g. `Option`). It would be very useful to be able to see the full type.

I'm not sure how hard this would be to add, I would be interested in looking into this if this is a "relatively easy" thing.


Discussion / hints:
Might be relatively straight forward albeit a bit tedious. You'd have to add the generic wherever they are referenced like here for the return type:

https://github.com/rune-rs/rune/blob/7fd96d290e1c1a307558b947c2828fcec328a6fd/crates/rune/src/doc/context.rs#L41

And populate them from the context.

Finally you'd have to pass them into the [link function here](https://github.com/rune-rs/rune/blob/7fd96d290e1c1a307558b947c2828fcec328a6fd/crates/rune/src/doc/build.rs#L545) and modify it to take and recursively build links for generic parameters.

I'm thinking it might be appropriate to define a higher level type which captures both the base type and its generic parameters instead of just exposing a bare hash like we do now. Something like this:

```
#[cfg(feature = "doc")]
struct FullType {
    base: Option<Hash>,
    generics: Box<[Option<Hash>]>,
}
```

And then pass *that* into the `link` function, replacing any unknown `Hash`:es with `?`.
After landing #759, what we need to do next is to provide the necessary metadata through the `MaybeTypeOf` trait and pass it along so it can be used by the new context structures.

This also affects `Any` macro expansion.

Fix patch:
diff --git a/crates/rune/src/doc/build.rs b/crates/rune/src/doc/build.rs
index 7edf19c31..6ef93d2c3 100644
--- a/crates/rune/src/doc/build.rs
+++ b/crates/rune/src/doc/build.rs
@@ -25,6 +25,7 @@ use crate::doc::artifacts::Test;
 use crate::doc::context::{Function, Kind, Meta, Signature};
 use crate::doc::templating;
 use crate::doc::{Artifacts, Context, Visitor};
+use crate::runtime::static_type;
 use crate::std::borrow::ToOwned;
 use crate::Hash;
 
@@ -411,6 +412,22 @@ impl<'m> Ctxt<'_, 'm> {
         ))
     }
 
+    /// Render an optional return type parameter.
+    ///
+    /// Returning `None` indicates that the return type is the default return
+    /// type, which is `()`.
+    fn return_type(&self, ty: &meta::DocType) -> Result<Option<String>> {
+        match *ty {
+            meta::DocType {
+                base, ref generics, ..
+            } if base == static_type::TUPLE_TYPE.hash && generics.is_empty() => Ok(None),
+            meta::DocType {
+                base, ref generics, ..
+            } if !base.is_empty() => Ok(Some(self.link(base, None, generics)?)),
+            _ => Ok(None),
+        }
+    }
+
     /// Render line docs.
     fn render_line_docs<S>(&mut self, meta: Meta<'_>, docs: &[S]) -> Result<Option<String>>
     where
@@ -564,57 +581,68 @@ impl<'m> Ctxt<'_, 'm> {
             }
         }
 
-        let mut it = self
-            .context
-            .meta_by_hash(hash)?
-            .into_iter()
-            .flat_map(|m| Some((m, into_item_kind(m)?)));
+        if hash == static_type::TUPLE_TYPE.hash {
+            write!(o, "(")?;
+            self.write_generics(o, generics)?;
+            write!(o, ")")?;
+        } else {
+            let mut it = self
+                .context
+                .meta_by_hash(hash)?
+                .into_iter()
+                .flat_map(|m| Some((m, into_item_kind(m)?)));
 
-        let Some((meta, kind)) = it.next() else {
-            tracing::warn!(?hash, "No link for hash");
+            let Some((meta, kind)) = it.next() else {
+                tracing::warn!(?hash, "No link for hash");
 
-            for meta in self.context.meta_by_hash(hash)? {
-                tracing::warn!("Candidate: {:?}", meta.kind);
-            }
+                for meta in self.context.meta_by_hash(hash)? {
+                    tracing::warn!("Candidate: {:?}", meta.kind);
+                }
 
-            write!(o, "{hash}")?;
-            return Ok(());
-        };
+                write!(o, "{hash}")?;
+                return Ok(());
+            };
 
-        let item = meta.item.context("Missing item link meta")?;
+            let item = meta.item.context("Missing item link meta")?;
 
-        let name = match text {
-            Some(text) => text,
-            None => item
-                .last()
-                .and_then(|c| c.as_str())
-                .context("missing name")?,
-        };
+            let name = match text {
+                Some(text) => text,
+                None => item
+                    .last()
+                    .and_then(|c| c.as_str())
+                    .context("missing name")?,
+            };
+
+            let path = self.item_path(item, kind)?;
 
-        let path = self.item_path(item, kind)?;
+            write!(o, "<a class=\"{kind}\" href=\"{path}\">{name}</a>")?;
 
-        write!(o, "<a class=\"{kind}\" href=\"{path}\">{name}</a>")?;
+            if !generics.is_empty() {
+                write!(o, "<")?;
+                self.write_generics(o, generics)?;
+                write!(o, ">")?;
+            }
+        }
 
-        if !generics.is_empty() {
-            let mut it = generics.iter().peekable();
-            write!(o, "<")?;
+        Ok(())
+    }
 
-            while let Some(ty) = it.next() {
-                match ty.base.as_non_empty() {
-                    Some(hash) => {
-                        self.write_link(o, hash, None, &ty.generics)?;
-                    }
-                    None => {
-                        write!(o, "?")?;
-                    }
-                }
+    fn write_generics(&self, o: &mut dyn TryWrite, generics: &[meta::DocType]) -> Result<()> {
+        let mut it = generics.iter().peekable();
 
-                if it.peek().is_some() {
-                    write!(o, ", ")?;
+        while let Some(ty) = it.next() {
+            match ty.base.as_non_empty() {
+                Some(hash) => {
+                    self.write_link(o, hash, None, &ty.generics)?;
+                }
+                None => {
+                    write!(o, "any")?;
                 }
             }
 
-            write!(o, ">")?;
+            if it.peek().is_some() {
+                write!(o, ", ")?;
+            }
         }
 
         Ok(())
@@ -1109,12 +1137,7 @@ fn build_function<'m>(cx: &mut Ctxt<'_, 'm>, meta: Meta<'m>) -> Result<Builder<'
 
     let doc = cx.render_docs(meta, meta.docs, true)?;
 
-    let return_type = match f.return_type {
-        meta::DocType { base, generics, .. } if !base.is_empty() => {
-            Some(cx.link(*base, None, generics)?)
-        }
-        _ => None,
-    };
+    let return_type = cx.return_type(f.return_type)?;
 
     let item = meta.item.context("Missing item")?;
     let deprecated = meta.deprecated;
diff --git a/crates/rune/src/doc/build/type_.rs b/crates/rune/src/doc/build/type_.rs
index a1e5c34f8..706a2b94a 100644
--- a/crates/rune/src/doc/build/type_.rs
+++ b/crates/rune/src/doc/build/type_.rs
@@ -5,7 +5,7 @@ use crate::alloc::borrow::Cow;
 use crate::alloc::fmt::TryWrite;
 use crate::alloc::prelude::*;
 use crate::alloc::{String, Vec};
-use crate::compile::{meta, ComponentRef, Item};
+use crate::compile::{ComponentRef, Item};
 use crate::doc::build::{Builder, Ctxt, IndexEntry, IndexKind};
 use crate::doc::context::{Assoc, AssocFnKind, Meta};
 
@@ -110,12 +110,7 @@ pub(super) fn build_assoc_fns<'m>(
                             name,
                             args: cx.args_to_string(sig, assoc.arguments)?,
                             parameters,
-                            return_type: match assoc.return_type {
-                                meta::DocType { base, generics, .. } if !base.is_empty() => {
-                                    Some(cx.link(*base, None, generics)?)
-                                }
-                                _ => None,
-                            },
+                            return_type: cx.return_type(assoc.return_type)?,
                             line_doc,
                             doc,
                         })?;
@@ -140,12 +135,7 @@ pub(super) fn build_assoc_fns<'m>(
                     name: protocol.name,
                     field,
                     repr,
-                    return_type: match assoc.return_type {
-                        meta::DocType { base, generics, .. } if !base.is_empty() => {
-                            Some(cx.link(*base, None, generics)?)
-                        }
-                        _ => None,
-                    },
+                    return_type: cx.return_type(assoc.return_type)?,
                     doc,
                     deprecated: assoc.deprecated,
                 })?;
diff --git a/crates/rune/src/modules/string.rs b/crates/rune/src/modules/string.rs
index ffdef5bcc..ad4d86170 100644
--- a/crates/rune/src/modules/string.rs
+++ b/crates/rune/src/modules/string.rs
@@ -55,6 +55,7 @@ pub fn module() -> Result<Module, ContextError> {
     module.function_meta(shrink_to_fit)?;
     module.function_meta(char_at)?;
     module.function_meta(split)?;
+    module.function_meta(split_once)?;
     module
         .associated_function("split_str", __rune_fn__split)?
         .deprecated("Use String::split instead")?;
@@ -816,6 +817,57 @@ fn split(this: &str, value: Value) -> VmResult<Iterator> {
     VmResult::Ok(Iterator::from_double_ended(NAME, lines.into_iter()))
 }
 
+/// Splits the string on the first occurrence of the specified delimiter and
+/// returns prefix before delimiter and suffix after delimiter.
+///
+/// # Examples
+///
+/// ```rune
+/// assert_eq!("cfg".split_once('='), None);
+/// assert_eq!("cfg=".split_once('='), Some(("cfg", "")));
+/// assert_eq!("cfg=foo".split_once('='), Some(("cfg", "foo")));
+/// assert_eq!("cfg=foo=bar".split_once('='), Some(("cfg", "foo=bar")));
+/// ```
+#[rune::function(instance)]
+fn split_once(this: &str, value: Value) -> VmResult<Option<(String, String)>> {
+    let outcome = match *vm_try!(value.borrow_kind_ref()) {
+        ValueKind::String(ref s) => this.split_once(s.as_str()),
+        ValueKind::Char(pat) => this.split_once(pat),
+        ValueKind::Function(ref f) => {
+            let mut err = None;
+
+            let outcome = this.split_once(|c: char| match f.call::<bool>((c,)) {
+                VmResult::Ok(b) => b,
+                VmResult::Err(e) => {
+                    if err.is_none() {
+                        err = Some(e);
+                    }
+
+                    false
+                }
+            });
+
+            if let Some(e) = err.take() {
+                return VmResult::Err(e);
+            }
+
+            outcome
+        }
+        ref actual => {
+            return VmResult::err([
+                VmErrorKind::expected::<String>(actual.type_info()),
+                VmErrorKind::bad_argument(0),
+            ])
+        }
+    };
+
+    let Some((a, b)) = outcome else {
+        return VmResult::Ok(None);
+    };
+
+    VmResult::Ok(Some((vm_try!(a.try_to_owned()), vm_try!(b.try_to_owned()))))
+}
+
 /// Returns a string slice with leading and trailing whitespace removed.
 ///
 /// 'Whitespace' is defined according to the terms of the Unicode Derived Core


Test patch: