use std::{str::FromStr, sync::LazyLock};
use comrak::nodes::AstNode;
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
static COMMAND_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\s*\$\s+(.+)$").unwrap_or_else(|e| {
log::error!(
"Failed to compile COMMAND_PROMPT_RE regex: {e}\n Falling back to never \
matching regex."
);
crate::utils::never_matching_regex().unwrap_or_else(|_| {
#[expect(
clippy::expect_used,
reason = "This pattern is guaranteed to be valid"
)]
Regex::new(r"[^\s\S]")
.expect("regex pattern [^\\s\\S] should always compile")
})
})
});
static REPL_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^nix-repl>\s*(.*)$").unwrap_or_else(|e| {
log::error!(
"Failed to compile REPL_PROMPT_RE regex: {e}\n Falling back to never \
matching regex."
);
crate::utils::never_matching_regex().unwrap_or_else(|_| {
#[expect(
clippy::expect_used,
reason = "This pattern is guaranteed to be valid"
)]
Regex::new(r"[^\s\S]")
.expect("regex pattern [^\\s\\S] should always compile")
})
})
});
#[derive(Debug, Clone)]
#[expect(
clippy::struct_excessive_bools,
reason = "Config struct with related boolean flags"
)]
pub struct MarkdownOptions {
pub gfm: bool,
pub nixpkgs: bool,
pub highlight_code: bool,
pub extensions: Vec<MarkdownExtension>,
pub highlight_theme: Option<String>,
pub manpage_urls: Option<FxHashMap<String, String>>,
pub syntax_queries_path: Option<String>,
pub auto_link_options: bool,
pub valid_options: Option<FxHashSet<String>>,
pub tab_style: TabStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabStyle {
None,
Warn,
Normalize,
}
impl MarkdownOptions {
#[must_use]
pub const fn with_all_features() -> Self {
Self {
gfm: cfg!(feature = "gfm"),
nixpkgs: cfg!(feature = "nixpkgs"),
highlight_code: cfg!(any(
feature = "syntastica",
feature = "syntect"
)),
extensions: Vec::new(),
highlight_theme: None,
manpage_urls: None,
syntax_queries_path: None,
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
}
}
#[must_use]
pub const fn with_features(
gfm: bool,
nixpkgs: bool,
highlight_code: bool,
) -> Self {
Self {
gfm,
nixpkgs,
highlight_code,
extensions: Vec::new(),
highlight_theme: None,
manpage_urls: None,
syntax_queries_path: None,
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
}
}
}
impl Default for MarkdownOptions {
fn default() -> Self {
Self {
gfm: cfg!(feature = "gfm"),
nixpkgs: cfg!(feature = "nixpkgs"),
highlight_code: cfg!(feature = "syntastica"),
extensions: Vec::new(),
manpage_urls: None,
syntax_queries_path: None,
highlight_theme: None,
auto_link_options: true,
valid_options: None,
tab_style: TabStyle::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MarkdownExtension {
Alerts,
Autolink,
BlockDirective,
CjkFriendlyEmphasis,
Greentext,
DescriptionLists,
Footnotes,
Highlight,
Insert,
InlineFootnotes,
MathCode,
MathDollars,
MathLatex,
MultilineBlockQuotes,
Spoiler,
Strikethrough,
Subscript,
Subtext,
Superscript,
Table,
Tasklist,
Underline,
WikilinksTitleAfterPipe,
WikilinksTitleBeforePipe,
}
impl FromStr for MarkdownExtension {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"alerts" => Ok(Self::Alerts),
"autolink" => Ok(Self::Autolink),
"block-directive" => Ok(Self::BlockDirective),
"cjk-friendly-emphasis" => Ok(Self::CjkFriendlyEmphasis),
"greentext" => Ok(Self::Greentext),
"description-lists" => Ok(Self::DescriptionLists),
"footnotes" => Ok(Self::Footnotes),
"highlight" => Ok(Self::Highlight),
"insert" => Ok(Self::Insert),
"inline-footnotes" => Ok(Self::InlineFootnotes),
"math-code" => Ok(Self::MathCode),
"math-dollars" => Ok(Self::MathDollars),
"math-latex" => Ok(Self::MathLatex),
"multiline-block-quotes" => Ok(Self::MultilineBlockQuotes),
"spoiler" => Ok(Self::Spoiler),
"strikethrough" => Ok(Self::Strikethrough),
"subscript" => Ok(Self::Subscript),
"subtext" => Ok(Self::Subtext),
"superscript" => Ok(Self::Superscript),
"table" => Ok(Self::Table),
"tasklist" => Ok(Self::Tasklist),
"underline" => Ok(Self::Underline),
"wikilinks-title-after-pipe" => Ok(Self::WikilinksTitleAfterPipe),
"wikilinks-title-before-pipe" => Ok(Self::WikilinksTitleBeforePipe),
_ => Err(format!("unknown Markdown extension: {value}")),
}
}
}
#[derive(Clone)]
pub struct MarkdownProcessor {
pub(crate) options: MarkdownOptions,
pub(crate) manpage_urls: Option<FxHashMap<String, String>>,
pub(crate) syntax_manager: Option<crate::syntax::SyntaxManager>,
pub(crate) base_dir: std::path::PathBuf,
}
pub trait AstTransformer {
fn transform<'a>(&self, node: &'a AstNode<'a>);
}
pub struct PromptTransformer;
impl AstTransformer for PromptTransformer {
fn transform<'a>(&self, node: &'a AstNode<'a>) {
use comrak::nodes::NodeValue;
for child in node.children() {
{
let mut data = child.data.borrow_mut();
if let NodeValue::Code(ref code) = data.value {
let literal = code.literal.trim();
if let Some(caps) = COMMAND_PROMPT_RE.captures(literal) {
if !literal.starts_with("\\$") && !literal.starts_with("$$") {
let command = caps[1].trim();
let html = format!(
"<code class=\"terminal\"><span class=\"prompt\">$</span> \
{command}</code>"
);
data.value = NodeValue::HtmlInline(html);
}
} else if let Some(caps) = REPL_PROMPT_RE.captures(literal) {
if !literal.starts_with("nix-repl>>") {
let expression = caps[1].trim();
let html = format!(
"<code class=\"nix-repl\"><span \
class=\"prompt\">nix-repl></span> {expression}</code>"
);
data.value = NodeValue::HtmlInline(html);
}
}
}
}
self.transform(child);
}
}
}
#[derive(Debug, Clone)]
pub struct MarkdownOptionsBuilder {
options: MarkdownOptions,
}
impl MarkdownOptionsBuilder {
#[must_use]
pub fn new() -> Self {
Self {
options: MarkdownOptions::default(),
}
}
#[must_use]
pub const fn gfm(mut self, enabled: bool) -> Self {
self.options.gfm = enabled;
self
}
#[must_use]
pub const fn nixpkgs(mut self, enabled: bool) -> Self {
self.options.nixpkgs = enabled;
self
}
#[must_use]
pub const fn highlight_code(mut self, enabled: bool) -> Self {
self.options.highlight_code = enabled;
self
}
#[must_use]
pub fn extensions(mut self, extensions: Vec<MarkdownExtension>) -> Self {
self.options.extensions = extensions;
self
}
#[must_use]
pub fn highlight_theme<S: Into<String>>(self, theme: Option<S>) -> Self {
fn inner(
mut this: MarkdownOptionsBuilder,
theme: Option<String>,
) -> MarkdownOptionsBuilder {
this.options.highlight_theme = theme;
this
}
inner(self, theme.map(Into::into))
}
#[must_use]
pub fn manpage_urls(
mut self,
mappings: Option<FxHashMap<String, String>>,
) -> Self {
self.options.manpage_urls = mappings;
self
}
#[must_use]
pub fn syntax_queries_path<S: Into<String>>(self, path: Option<S>) -> Self {
fn inner(
mut this: MarkdownOptionsBuilder,
path: Option<String>,
) -> MarkdownOptionsBuilder {
this.options.syntax_queries_path = path;
this
}
inner(self, path.map(Into::into))
}
#[must_use]
pub const fn auto_link_options(mut self, enabled: bool) -> Self {
self.options.auto_link_options = enabled;
self
}
#[must_use]
pub fn valid_options(mut self, options: Option<FxHashSet<String>>) -> Self {
self.options.valid_options = options;
self
}
#[must_use]
pub const fn tab_style(mut self, style: TabStyle) -> Self {
self.options.tab_style = style;
self
}
#[must_use]
pub fn build(self) -> MarkdownOptions {
self.options
}
}
impl Default for MarkdownOptionsBuilder {
fn default() -> Self {
Self::new()
}
}