use std::collections::BTreeMap;
use std::fmt::Display;
use std::ops::Deref;
use std::ops::DerefMut;
use std::path::Path;
use maud::Markup;
use maud::html;
use wdl_ast::AstNode;
use wdl_ast::AstToken;
use wdl_ast::Comment;
use wdl_ast::DOC_COMMENT_PREFIX;
use wdl_ast::SyntaxKind;
use wdl_ast::v1::MetadataObjectItem;
use wdl_ast::v1::MetadataValue;
use crate::Markdown;
use crate::Render;
pub(crate) const DESCRIPTION_KEY: &str = "description";
const HELP_KEY: &str = "help";
const EXTERNAL_HELP_KEY: &str = "external_help";
const WARNING_KEY: &str = "warning";
const DESCRIPTION_MAX_LENGTH: usize = 140;
const DESCRIPTION_CLIP_LENGTH: usize = 80;
pub(crate) const DEFAULT_DESCRIPTION: &str = "No description provided";
pub(crate) fn parse_metadata_items(meta: impl Iterator<Item = MetadataObjectItem>) -> MetaMap {
meta.map(|m| {
let name = m.name().text().to_owned();
let item = m.value();
(name, MetaMapValueSource::MetaValue(item))
})
.collect()
}
#[derive(Debug, Clone)]
pub(crate) enum MetaMapValueSource {
MetaValue(MetadataValue),
Comment(String),
}
impl MetaMapValueSource {
pub fn text(&self) -> Option<String> {
match self {
MetaMapValueSource::Comment(text) => Some(text.clone()),
MetaMapValueSource::MetaValue(MetadataValue::String(s)) => Some(
s.text()
.expect("meta string should not be interpolated")
.text()
.to_string(),
),
_ => None,
}
}
#[cfg(test)]
pub fn into_meta(self) -> Option<MetadataValue> {
match self {
MetaMapValueSource::MetaValue(meta) => Some(meta),
_ => None,
}
}
}
pub(crate) type MetaMap = BTreeMap<String, MetaMapValueSource>;
pub(crate) trait MetaMapExt {
fn full_description(&self) -> Option<String>;
fn description(&self) -> Option<String>;
fn render_description(&self, summarize: bool) -> Markup;
#[expect(dead_code, reason = "Pending design work")]
fn render_full_description(&self, summarize: bool) -> Markup;
fn render_remaining(&self, filter_keys: &[&str], assets: &Path) -> Option<Markup>;
}
fn maybe_summarize_text(text: String, summarize: bool) -> Markup {
if !summarize {
return Markdown(text).render();
}
match summarize_if_needed(text, DESCRIPTION_MAX_LENGTH, DESCRIPTION_CLIP_LENGTH) {
MaybeSummarized::No(desc) => Markdown(desc).render(),
MaybeSummarized::Yes(summary) => {
html! {
div class="main__summary-container" {
(Markdown(summary))
"..."
button type="button" class="main__button" x-on:click="description_expanded = !description_expanded" {
b x-text="description_expanded ? 'Hide full description' : 'Show full description'" {}
}
}
}
}
}
}
impl MetaMapExt for MetaMap {
fn full_description(&self) -> Option<String> {
let help = self.get(HELP_KEY).and_then(MetaMapValueSource::text);
if let Some(mut description) = self.description() {
if let Some(help) = help {
description.push_str("\n\n");
description.push_str(&help);
}
return Some(description);
}
help
}
fn description(&self) -> Option<String> {
self.get(DESCRIPTION_KEY).and_then(MetaMapValueSource::text)
}
fn render_description(&self, summarize: bool) -> Markup {
let desc = self
.description()
.unwrap_or_else(|| DEFAULT_DESCRIPTION.to_string());
maybe_summarize_text(desc, summarize)
}
fn render_full_description(&self, summarize: bool) -> Markup {
let desc = self
.full_description()
.unwrap_or_else(|| DEFAULT_DESCRIPTION.to_string());
maybe_summarize_text(desc, summarize)
}
fn render_remaining(&self, filter_keys: &[&str], assets: &Path) -> Option<Markup> {
let custom_keys = &[HELP_KEY, EXTERNAL_HELP_KEY, WARNING_KEY];
let filtered_items = self
.iter()
.filter(|(k, _v)| {
!filter_keys.contains(&k.as_str()) && !custom_keys.contains(&k.as_str())
})
.collect::<Vec<_>>();
let help_item = self.get(HELP_KEY);
let external_help_item = self.get(EXTERNAL_HELP_KEY);
let warning_item = self.get(WARNING_KEY);
let any_additional_items = !filtered_items.is_empty();
let custom_key_present =
help_item.is_some() || external_help_item.is_some() || warning_item.is_some();
if !(any_additional_items || custom_key_present) {
return None;
}
let external_link_on_click =
if let Some(MetaMapValueSource::MetaValue(MetadataValue::String(s))) =
external_help_item
{
Some(format!(
"window.open('{}', '_blank')",
s.text()
.expect("meta string should not be interpolated")
.text()
))
} else {
None
};
Some(html! {
@if let Some(help) = help_item {
div class="markdown-body" {
(render_value(help))
}
}
@if let Some(on_click) = external_link_on_click {
button type="button" class="main__button" x-on:click=(on_click) {
b { "Go to External Documentation" }
img src=(assets.join("link.svg").to_string_lossy()) alt="External Documentation Icon" class="size-5 block light:hidden";
img src=(assets.join("link.light.svg").to_string_lossy()) alt="External Documentation Icon" class="size-5 hidden light:block";
}
}
@if let Some(warning) = warning_item {
div class="metadata__warning" {
img src=(assets.join("information-circle.svg").to_string_lossy()) alt="Warning Icon" class="size-5 block light:hidden";
img src=(assets.join("information-circle.light.svg").to_string_lossy()) alt="Warning Icon" class="size-5 hidden light:block";
p { (render_value(warning)) }
}
}
@if any_additional_items {
div class="main__grid-nested-container" {
@for (key, value) in filtered_items {
@if let MetaMapValueSource::MetaValue(value) = value {
(render_key_value(key, value))
}
}
}
}
})
}
}
fn render_value(value: &MetaMapValueSource) -> Markup {
match value {
MetaMapValueSource::Comment(comment) => render_string(comment),
MetaMapValueSource::MetaValue(meta) => render_metadata_value(meta),
}
}
fn render_metadata_value(value: &MetadataValue) -> Markup {
match value {
MetadataValue::String(s) => s
.text()
.map(|t| render_string(t.text()))
.expect("meta string should not be interpolated"),
MetadataValue::Boolean(b) => html! { code { (b.text()) } },
MetadataValue::Integer(i) => html! { code { (i.text()) } },
MetadataValue::Float(f) => html! { code { (f.text()) } },
MetadataValue::Null(n) => html! { code { (n.text()) } },
MetadataValue::Array(a) => {
html! {
div class="main__grid-meta-array-container" {
@for item in a.elements() {
@match item {
MetadataValue::Array(_) | MetadataValue::Object(_) => {
(render_metadata_value(&item))
}
_ => {
div class="main__grid-meta-array-item" {
code { (item.text()) }
}
}
}
}
}
}
}
MetadataValue::Object(o) => {
html! {
div class="main__grid-nested-container" {
@for item in o.items() {
(render_key_value(item.name().text(), &item.value()))
}
}
}
}
}
}
fn render_string(s: &str) -> Markup {
Markdown(s).render()
}
fn render_key_value(key: &str, value: &MetadataValue) -> Markup {
let (ty, rhs_markup) = match value {
MetadataValue::String(s) => (
s.inner().kind(),
html! { code { (s.text().expect("meta string should not be interpolated").text()) } },
),
MetadataValue::Boolean(b) => (b.inner().kind(), html! { code { (b.text()) } }),
MetadataValue::Integer(i) => (i.inner().kind(), html! { code { (i.text()) } }),
MetadataValue::Float(f) => (f.inner().kind(), html! { code { (f.text()) } }),
MetadataValue::Null(n) => (n.inner().kind(), html! { code { (n.text()) } }),
MetadataValue::Array(a) => {
let markup = html! {
div class="main__grid-meta-array-container" {
@for item in a.elements() {
@match item {
MetadataValue::Array(_) | MetadataValue::Object(_) => {
(render_metadata_value(&item))
}
_ => {
div class="main__grid-meta-array-item" {
code { (item.text()) }
}
}
}
}
}
};
(a.inner().kind(), markup)
}
MetadataValue::Object(o) => {
let markup = html! {
div class="main__grid-nested-container" {
@for item in o.items() {
(render_key_value(item.name().text(), &item.value()))
}
}
};
(o.inner().kind(), markup)
}
};
let lhs_markup = match ty {
SyntaxKind::MetadataArrayNode | SyntaxKind::MetadataObjectNode => {
html! { code { (key) } }
}
_ => {
html! { code { (key) } }
}
};
html! {
div class="main__grid-nested-row" {
div class="main__grid-nested-cell" {
(lhs_markup)
}
div class="main__grid-nested-cell" {
(rhs_markup)
}
}
}
}
#[derive(Debug)]
pub(crate) enum MaybeSummarized {
Yes(String),
No(String),
}
pub(crate) fn summarize_if_needed(
in_string: String,
max_length: usize,
clip_length: usize,
) -> MaybeSummarized {
if in_string.len() > max_length {
MaybeSummarized::Yes(in_string[..clip_length].trim_end().to_string())
} else {
MaybeSummarized::No(in_string)
}
}
#[derive(Debug, Clone, Default)]
pub struct Paragraph(Vec<String>);
impl Display for Paragraph {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.join("\n"))
}
}
impl Deref for Paragraph {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Paragraph {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub(crate) fn doc_comments(comments: impl IntoIterator<Item = Comment>) -> MetaMap {
let mut map = MetaMap::new();
let mut current_paragraph = Paragraph::default();
let mut paragraphs = Vec::new();
for doc_comment in comments {
let Some(comment) = doc_comment.text().strip_prefix(DOC_COMMENT_PREFIX) else {
continue;
};
if comment.trim().is_empty() {
paragraphs.push(current_paragraph);
current_paragraph = Paragraph::default();
continue;
}
current_paragraph.push(comment.to_owned());
}
if !current_paragraph.is_empty() {
paragraphs.push(current_paragraph);
}
if paragraphs.is_empty() {
return map;
}
let min_indent = paragraphs
.iter()
.map(|paragraph| {
paragraph
.iter()
.filter(|line| line.chars().any(|c| !c.is_whitespace()))
.map(|line| line.chars().take_while(|c| *c == ' ' || *c == '\t').count())
.min()
.unwrap_or(usize::MAX)
})
.min()
.unwrap_or(0);
for paragraph in &mut paragraphs {
for line in paragraph
.iter_mut()
.filter(|line| !line.chars().all(char::is_whitespace))
{
assert!(line.len() > min_indent);
*line = line.split_off(min_indent);
}
}
let mut paragraphs = paragraphs.into_iter();
map.insert(
DESCRIPTION_KEY.to_string(),
MetaMapValueSource::Comment(paragraphs.next().unwrap().to_string()),
);
let help = paragraphs.fold(String::new(), |mut acc, p| {
if !acc.is_empty() {
acc.push_str("\n\n");
}
acc.push_str(&p.to_string());
acc
});
if !help.is_empty() {
map.insert(HELP_KEY.to_string(), MetaMapValueSource::Comment(help));
}
map
}
pub(crate) trait DefinitionMeta {
fn meta(&self) -> &MetaMap;
fn render_description(&self, summarize: bool) -> Markup {
self.meta().render_description(summarize)
}
}
pub fn main_container(item_type: &str, external: bool, children: Markup) -> Markup {
if external {
return html! {
div
class="main__container"
{
(children)
}
};
}
html! {
div
class="main__container"
data-pagefind-body
meta-img-dark=(format!("{item_type}-selected.svg"))
meta-img-light=(format!("{item_type}-selected.light.svg"))
data-pagefind-meta="image_dark[meta-img-dark], image_light[meta-img-light]"
{
(children)
}
}
}