use ferritin_common::DocRef;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use rustdoc_types::Item;
use std::borrow::Cow;
#[derive(Debug, Clone)]
pub enum TuiAction<'a> {
Navigate {
doc_ref: DocRef<'a, Item>,
url: Option<Cow<'a, str>>,
},
NavigateToPath {
path: Cow<'a, str>,
url: Option<Cow<'a, str>>,
},
ExpandBlock(NodePath),
OpenUrl(Cow<'a, str>),
SelectTheme(Cow<'a, str>),
}
impl<'a> TuiAction<'a> {
pub fn url(&self) -> Option<Cow<'a, str>> {
match self {
TuiAction::Navigate { doc_ref, url } => {
url.clone().or_else(|| {
Some(Cow::Owned(crate::generate_docsrs_url::generate_docsrs_url(
*doc_ref,
)))
})
}
TuiAction::NavigateToPath { path, url } => {
url.clone().or_else(|| {
Some(Cow::Owned(generate_url_from_path(path)))
})
}
TuiAction::ExpandBlock(_) => None,
TuiAction::OpenUrl(cow) => Some(cow.clone()),
TuiAction::SelectTheme(_) => None,
}
}
}
fn generate_url_from_path(path: &str) -> String {
let parts: Vec<&str> = path.split("::").collect();
if parts.is_empty() {
return String::new();
}
let crate_name = parts[0];
let is_std = matches!(crate_name, "std" | "core" | "alloc" | "proc_macro");
let base = if is_std {
"https://doc.rust-lang.org/nightly".to_string()
} else {
format!("https://docs.rs/{}/latest", crate_name)
};
if parts.len() == 1 {
return format!("{}/{}/index.html", base, crate_name);
}
let module_path = if parts.len() > 2 {
parts[1..parts.len() - 1].join("/")
} else {
String::new()
};
let index_path = if module_path.is_empty() {
format!("{}/{}/index.html", base, crate_name)
} else {
format!("{}/{}/{}/index.html", base, crate_name, module_path)
};
format!(
"{}?search={}",
index_path,
utf8_percent_encode(path, NON_ALPHANUMERIC)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NodePath {
indices: [u16; 8], len: u8,
}
impl NodePath {
pub fn new() -> Self {
Self {
indices: [0; 8],
len: 0,
}
}
pub fn push(&mut self, index: usize) {
if (self.len as usize) < self.indices.len() {
self.indices[self.len as usize] = index as u16;
self.len += 1;
}
}
pub fn indices(&self) -> &[u16] {
&self.indices[..self.len as usize]
}
}
#[derive(Debug, Clone)]
pub enum LinkTarget<'a> {
Resolved(DocRef<'a, Item>),
Path(Cow<'a, str>),
}
#[derive(Debug, Clone)]
pub struct Document<'a> {
pub nodes: Vec<DocumentNode<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShowWhen {
Always,
Interactive,
NonInteractive,
}
#[derive(Debug, Clone)]
pub enum DocumentNode<'a> {
Paragraph { spans: Vec<Span<'a>> },
Heading {
level: HeadingLevel,
spans: Vec<Span<'a>>,
},
Section {
title: Option<Vec<Span<'a>>>,
nodes: Vec<DocumentNode<'a>>,
},
List { items: Vec<ListItem<'a>> },
CodeBlock {
lang: Option<Cow<'a, str>>,
code: Cow<'a, str>,
},
GeneratedCode { spans: Vec<Span<'a>> },
HorizontalRule,
BlockQuote { nodes: Vec<DocumentNode<'a>> },
Table {
header: Option<Vec<TableCell<'a>>>,
rows: Vec<Vec<TableCell<'a>>>,
},
TruncatedBlock {
nodes: Vec<DocumentNode<'a>>,
level: TruncationLevel,
},
Conditional {
show_when: ShowWhen,
nodes: Vec<DocumentNode<'a>>,
},
}
#[derive(Debug, Clone)]
pub struct TableCell<'a> {
pub spans: Vec<Span<'a>>,
}
#[derive(Debug, Clone)]
pub struct ListItem<'a> {
pub content: Vec<DocumentNode<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeadingLevel {
Title, Section, }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TruncationLevel {
SingleLine,
Brief,
Full,
}
#[derive(Debug, Clone)]
pub struct Span<'a> {
pub text: Cow<'a, str>,
pub style: SpanStyle,
pub action: Option<TuiAction<'a>>,
}
impl<'a> Span<'a> {
pub fn url(&self) -> Option<Cow<'a, str>> {
self.action.as_ref()?.url()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpanStyle {
Keyword, TypeName, FunctionName, FieldName, Lifetime, Generic,
Plain, Punctuation, Operator, Comment,
InlineRustCode, InlineCode,
Strong, Emphasis, Strikethrough, }
impl<'a> Span<'a> {
pub fn keyword(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Keyword,
action: None,
}
}
pub fn type_name(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::TypeName,
action: None,
}
}
pub fn function_name(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::FunctionName,
action: None,
}
}
pub fn field_name(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::FieldName,
action: None,
}
}
pub fn lifetime(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Lifetime,
action: None,
}
}
pub fn generic(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Generic,
action: None,
}
}
pub fn plain(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Plain,
action: None,
}
}
pub fn punctuation(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Punctuation,
action: None,
}
}
pub fn operator(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Operator,
action: None,
}
}
pub fn comment(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Comment,
action: None,
}
}
pub fn inline_rust_code(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::InlineRustCode,
action: None,
}
}
pub fn inline_code(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::InlineCode,
action: None,
}
}
pub fn strong(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Strong,
action: None,
}
}
pub fn emphasis(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Emphasis,
action: None,
}
}
pub fn strikethrough(text: impl Into<Cow<'a, str>>) -> Self {
Self {
text: text.into(),
style: SpanStyle::Strikethrough,
action: None,
}
}
pub fn with_action(mut self, action: TuiAction<'a>) -> Self {
self.action = Some(action);
self
}
pub fn with_target(mut self, target: Option<DocRef<'a, Item>>) -> Self {
if let Some(target) = target {
self.action = Some(TuiAction::Navigate {
doc_ref: target,
url: None,
});
}
self
}
pub fn with_path(mut self, path: impl Into<Cow<'a, str>>) -> Self {
self.action = Some(TuiAction::NavigateToPath {
path: path.into(),
url: None,
});
self
}
}
impl<'a> Document<'a> {
pub fn new() -> Self {
Self { nodes: Vec::new() }
}
pub fn with_nodes(nodes: Vec<DocumentNode<'a>>) -> Self {
Self { nodes }
}
}
impl<'a> Default for Document<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> From<Vec<DocumentNode<'a>>> for Document<'a> {
fn from(nodes: Vec<DocumentNode<'a>>) -> Self {
Self { nodes }
}
}
impl<'a, 'b> From<&'b Document<'a>> for Document<'a> {
fn from(doc: &'b Document<'a>) -> Self {
doc.clone()
}
}
impl<'a> ListItem<'a> {
pub fn new(content: Vec<DocumentNode<'a>>) -> Self {
Self { content }
}
}
impl<'a> DocumentNode<'a> {
pub fn paragraph(spans: Vec<Span<'a>>) -> Self {
DocumentNode::Paragraph { spans }
}
pub fn heading(level: HeadingLevel, spans: Vec<Span<'a>>) -> Self {
DocumentNode::Heading { level, spans }
}
pub fn section(title: Vec<Span<'a>>, nodes: Vec<DocumentNode<'a>>) -> Self {
DocumentNode::Section {
title: Some(title),
nodes,
}
}
pub fn section_untitled(nodes: Vec<DocumentNode<'a>>) -> Self {
DocumentNode::Section { title: None, nodes }
}
pub fn list(items: Vec<ListItem<'a>>) -> Self {
DocumentNode::List { items }
}
pub fn code_block(
lang: Option<impl Into<Cow<'a, str>>>,
code: impl Into<Cow<'a, str>>,
) -> Self {
DocumentNode::CodeBlock {
lang: lang.map(Into::into),
code: code.into(),
}
}
pub fn generated_code(spans: Vec<Span<'a>>) -> Self {
DocumentNode::GeneratedCode { spans }
}
pub fn horizontal_rule() -> Self {
DocumentNode::HorizontalRule
}
pub fn block_quote(nodes: Vec<DocumentNode<'a>>) -> Self {
DocumentNode::BlockQuote { nodes }
}
pub fn table(header: Option<Vec<TableCell<'a>>>, rows: Vec<Vec<TableCell<'a>>>) -> Self {
DocumentNode::Table { header, rows }
}
pub fn truncated_block(nodes: Vec<DocumentNode<'a>>, level: TruncationLevel) -> Self {
DocumentNode::TruncatedBlock { nodes, level }
}
}
impl<'a> TableCell<'a> {
pub fn new(spans: Vec<Span<'a>>) -> Self {
Self { spans }
}
pub fn from_span(span: Span<'a>) -> Self {
Self { spans: vec![span] }
}
}
#[allow(dead_code)]
const _: () = {
const fn assert_send<T: Send>() {}
const fn assert_sync<T: Sync>() {}
const fn check_document_send() {
assert_send::<Document<'_>>();
}
const fn check_document_sync() {
assert_sync::<Document<'_>>();
}
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_span_creation() {
let span = Span::keyword("struct");
assert_eq!(span.text, "struct");
assert!(matches!(span.style, SpanStyle::Keyword));
}
#[test]
fn test_section() {
let section = DocumentNode::section(
vec![Span::plain("Fields:")],
vec![DocumentNode::list(vec![
ListItem::new(vec![DocumentNode::paragraph(vec![Span::field_name("x")])]),
ListItem::new(vec![DocumentNode::paragraph(vec![Span::field_name("y")])]),
])],
);
if let DocumentNode::Section { title, nodes } = section {
assert!(title.is_some());
assert_eq!(nodes.len(), 1);
} else {
panic!("Expected section node");
}
}
#[test]
fn test_list_items() {
let list = DocumentNode::list(vec![
ListItem::new(vec![DocumentNode::paragraph(vec![
Span::field_name("foo"),
Span::punctuation(":"),
Span::type_name("u32"),
])]),
ListItem::new(vec![DocumentNode::paragraph(vec![Span::field_name("bar")])]),
]);
if let DocumentNode::List { items } = list {
assert_eq!(items.len(), 2);
assert_eq!(items[0].content.len(), 1);
assert_eq!(items[1].content.len(), 1);
} else {
panic!("Expected list node");
}
}
#[test]
fn test_heading_levels() {
let title = DocumentNode::heading(HeadingLevel::Title, vec![Span::plain("Item: Vec")]);
let section = DocumentNode::heading(HeadingLevel::Section, vec![Span::plain("Methods:")]);
assert!(matches!(title, DocumentNode::Heading { .. }));
assert!(matches!(section, DocumentNode::Heading { .. }));
}
#[test]
fn test_code_block() {
let code = DocumentNode::code_block(Some("rust".to_string()), "fn main() {}".to_string());
if let DocumentNode::CodeBlock { lang, code } = code {
assert_eq!(lang, Some("rust".into()));
assert_eq!(code, "fn main() {}");
} else {
panic!("Expected code block");
}
}
}