Skip to main content

nu_command/formats/from/
md.rs

1use mq_markdown::{AttrValue, Markdown, Node};
2use nu_engine::command_prelude::*;
3
4#[derive(Clone)]
5pub struct FromMd;
6
7impl Command for FromMd {
8    fn name(&self) -> &str {
9        "from md"
10    }
11
12    fn description(&self) -> &str {
13        "Convert markdown text into structured data."
14    }
15
16    fn signature(&self) -> Signature {
17        Signature::build("from md")
18            .input_output_types(vec![(Type::String, Type::table())])
19            .category(Category::Formats)
20    }
21
22    fn examples(&self) -> Vec<Example<'_>> {
23        vec![
24            Example {
25                example: "'# Title' | from md | select type position attrs",
26                description: "Parse markdown and return key node fields.",
27                result: Some(Value::test_list(vec![heading_title_overview_node()])),
28            },
29            Example {
30                example: "'---
31title: Demo
32---
33# A' | from md | get 0.type",
34                description: "Parse markdown frontmatter as a dedicated node.",
35                result: Some(Value::test_string("yaml")),
36            },
37        ]
38    }
39
40    fn run(
41        &self,
42        _engine_state: &EngineState,
43        _stack: &mut Stack,
44        call: &Call,
45        input: PipelineData,
46    ) -> Result<PipelineData, ShellError> {
47        from_md(input, call.head)
48    }
49}
50
51fn title_position(line_start: i64, column_start: i64, line_end: i64, column_end: i64) -> Value {
52    Value::test_record(record! {
53        "start" => Value::test_record(record! {
54            "line" => Value::test_int(line_start),
55            "column" => Value::test_int(column_start),
56        }),
57        "end" => Value::test_record(record! {
58            "line" => Value::test_int(line_end),
59            "column" => Value::test_int(column_end),
60        }),
61    })
62}
63
64fn heading_title_overview_node() -> Value {
65    Value::test_record(record! {
66        "type" => Value::test_string("h1"),
67        "position" => title_position(1, 1, 1, 8),
68        "attrs" => Value::test_record(record! {
69            "depth" => Value::test_int(1),
70            "level" => Value::test_int(1),
71        }),
72    })
73}
74
75fn from_md(input: PipelineData, head: Span) -> Result<PipelineData, ShellError> {
76    let (string_input, span, metadata) = input.collect_string_strict(head)?;
77
78    let markdown =
79        Markdown::from_markdown_str(&string_input).map_err(|err| ShellError::CantConvert {
80            to_type: "structured markdown data".into(),
81            from_type: "string".into(),
82            span,
83            help: Some(err.to_string()),
84        })?;
85
86    let value = markdown_to_ast_value(&markdown, span);
87
88    Ok(value.into_pipeline_data_with_metadata(metadata.map(|md| md.with_content_type(None))))
89}
90
91fn markdown_to_ast_value(markdown: &Markdown, span: Span) -> Value {
92    let nodes = markdown
93        .nodes
94        .iter()
95        .map(|node| node_to_ast_value(node, span))
96        .collect();
97
98    Value::list(nodes, span)
99}
100
101fn node_to_ast_value(node: &Node, span: Span) -> Value {
102    let children = node.children();
103
104    let mut record = Record::new();
105    record.push("type", Value::string(node_type_name(node), span));
106
107    if let Some(position) = node.position() {
108        record.push("position", position_to_value(position, span));
109    } else {
110        record.push("position", Value::nothing(span));
111    }
112
113    let attrs = node_attrs_to_value(node, children.is_empty(), span);
114    record.push("attrs", attrs);
115
116    let children = children
117        .iter()
118        .map(|child| node_to_ast_value(child, span))
119        .collect();
120    record.push("children", Value::list(children, span));
121
122    Value::record(record, span)
123}
124
125fn node_type_name(node: &Node) -> String {
126    if node.is_empty() {
127        return "empty".to_string();
128    }
129
130    if node.is_fragment() {
131        return "fragment".to_string();
132    }
133
134    node.name().to_string()
135}
136
137fn position_to_value(position: mq_markdown::Position, span: Span) -> Value {
138    Value::record(
139        record! {
140            "start" => Value::record(
141                record! {
142                    "line" => Value::int(position.start.line as i64, span),
143                    "column" => Value::int(position.start.column as i64, span),
144                },
145                span,
146            ),
147            "end" => Value::record(
148                record! {
149                    "line" => Value::int(position.end.line as i64, span),
150                    "column" => Value::int(position.end.column as i64, span),
151                },
152                span,
153            ),
154        },
155        span,
156    )
157}
158
159fn node_attrs_to_value(node: &Node, is_leaf: bool, span: Span) -> Value {
160    const ATTRIBUTE_KEYS: &[&str] = &[
161        "depth", "level", "index", "ordered", "checked", "lang", "meta", "fence", "url", "title",
162        "alt", "ident", "label", "row", "column", "align", "name",
163    ];
164
165    let mut attrs = Record::new();
166
167    // Emit `value` only for leaves to avoid parent/child text duplication.
168    if is_leaf {
169        if let Some(value) = node.attr("value") {
170            attrs.push("value", attr_value_to_nu_value(value, span));
171        } else if node.is_text() {
172            // Some text-like nodes can carry content without exposing a `value` attribute.
173            attrs.push("value", Value::string(node.value(), span));
174        }
175    }
176
177    for key in ATTRIBUTE_KEYS {
178        if let Some(value) = node.attr(key) {
179            attrs.push(*key, attr_value_to_nu_value(value, span));
180        }
181    }
182
183    Value::record(attrs, span)
184}
185
186fn attr_value_to_nu_value(value: AttrValue, span: Span) -> Value {
187    match value {
188        AttrValue::String(value) => Value::string(value, span),
189        AttrValue::Integer(value) => Value::int(value, span),
190        AttrValue::Number(value) => Value::float(value, span),
191        AttrValue::Boolean(value) => Value::bool(value, span),
192        AttrValue::Null => Value::nothing(span),
193        AttrValue::Array(value) => Value::list(
194            value
195                .iter()
196                .map(|node| node_to_ast_value(node, span))
197                .collect(),
198            span,
199        ),
200    }
201}
202
203#[cfg(test)]
204mod test {
205    use super::FromMd;
206
207    #[test]
208    fn test_examples() -> nu_test_support::Result {
209        nu_test_support::test().examples(FromMd)
210    }
211}