diff --git a/.github/workflows/gfm-spec.yml b/.github/workflows/gfm-spec.yml index 60d34e313..1e4d74d5f 100644 --- a/.github/workflows/gfm-spec.yml +++ b/.github/workflows/gfm-spec.yml @@ -1,4 +1,4 @@ -name: GFM spec round-trip fidelity +name: Markdown spec conformance on: schedule: @@ -21,5 +21,7 @@ jobs: with: just-version: 1.58.0 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Render the spec examples + run: just test-spec - name: Run GFM spec round-trip check run: just test-gfm-spec diff --git a/.typos.toml b/.typos.toml index 31075d51e..71cf2657a 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,3 +1,7 @@ +[files] +# HTML entity names from the HTML spec, which include short non-words +extend-exclude = ["crates/mq-markdown/src/parser/inline/entities.rs"] + [default.extend-words] # HashiCorp is a proper noun Hashi = "Hashi" @@ -6,6 +10,8 @@ ist = "ist" # Intentional misspelling in the fuzzy-match-headings cookbook recipe instalation = "instalation" juxt = "juxt" +# `import` with its first letter as a character reference (`import`), as MDX text is written +mport = "mport" reakpoint = "reakpoint" sction = "sction" diff --git a/Cargo.lock b/Cargo.lock index f50853854..533836d39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2505,15 +2505,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "markdown" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" -dependencies = [ - "unicode-id", -] - [[package]] name = "markup5ever" version = "0.39.0" @@ -2771,6 +2762,7 @@ dependencies = [ "itertools 0.15.0", "libfuzzer-sys", "mq-lang", + "mq-markdown", "sha2", ] @@ -2911,7 +2903,6 @@ dependencies = [ "codspeed-divan-compat", "ego-tree", "itertools 0.15.0", - "markdown", "miette", "proptest", "rstest", @@ -2920,6 +2911,7 @@ dependencies = [ "serde", "serde_json", "smol_str", + "unicode-id", "unicode-width 0.2.2", "ureq", "url", diff --git a/Cargo.toml b/Cargo.toml index 95c5a1658..4492e6384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,7 +80,6 @@ itertools = "0.15.0" js-sys = "0.3.77" libc = "0.2" libfuzzer-sys = "0.4" -markdown = "1.0.0" memchr = "2" nix = {version = "0.31", default-features = false, features = ["fs"]} md5 = "0.8.0" @@ -128,6 +127,7 @@ tower-lsp-server = "0.23.0" tracing = "0.1.44" tracing-opentelemetry = "0.34" tracing-subscriber = "0.3.23" +unicode-id = "0.3.6" unicode-normalization = "0.1.25" unicode-segmentation = "1.13.3" unicode-width = "0.2.2" diff --git a/crates/mq-check/src/constraint.rs b/crates/mq-check/src/constraint.rs index 007634e60..9e93e2c61 100644 --- a/crates/mq-check/src/constraint.rs +++ b/crates/mq-check/src/constraint.rs @@ -149,7 +149,7 @@ pub fn generate_constraints(hir: &Hir, ctx: &mut InferenceContext) -> ChildrenIn // Pass 2.5: Process Assign symbols before other operators/calls. // This ensures that variable types are updated by assignments before // Refs and Calls in Pass 3 resolve against the (potentially stale) type. - // e.g., `var x = 10 | x = "hello" | upcase(x)` — the Assign updates + // e.g., `var x = 10 | x = "hello" | upcase(x)`: the Assign updates // x's type to String before upcase(x) resolves its argument type. for (symbol_id, kind) in &cats.assign_symbols { generate_symbol_constraints(hir, *symbol_id, kind.clone(), ctx, &children_index); @@ -675,7 +675,7 @@ pub(super) fn generate_symbol_constraints( ctx.set_symbol_type(symbol_id, last_ty); } } else { - // No HIR resolution — try builtin registry by name as fallback + // No HIR resolution: try builtin registry by name as fallback if let Some(symbol) = hir.symbol(symbol_id) && let Some(name) = &symbol.value && ctx.get_builtin_overloads(name.as_str()).is_some() @@ -765,7 +765,7 @@ pub(super) fn generate_symbol_constraints( }, )); - // No need to re-bind subsequent Refs — Assigns are processed + // No need to re-bind subsequent Refs: Assigns are processed // in Pass 2.5 (before Refs/Calls in Pass 3), so Refs will // pick up the updated variable type when they are processed. } else { @@ -912,7 +912,7 @@ pub(super) fn generate_symbol_constraints( return; } - // Homogeneous or unresolved — unify all element types + // Homogeneous or unresolved: unify all element types let elem_ty = elem_tys[0].clone(); let range = get_symbol_range(hir, symbol_id); for ty in &elem_tys[1..] { @@ -1037,7 +1037,7 @@ pub(super) fn generate_symbol_constraints( if !children.is_empty() { let range = get_symbol_range(hir, symbol_id); - // First child is the condition — mq is dynamically typed, so any + // First child is the condition: mq is dynamically typed, so any // value can be used as a condition (truthy/falsy), not just Bool. let _cond_ty = ctx.get_or_create_symbol_type(children[0]); @@ -1080,7 +1080,7 @@ pub(super) fn generate_symbol_constraints( true }; - // Check if any branch is None or a Union containing None — in mq, + // Check if any branch is None or a Union containing None: in mq, // `if (...): value else: None` (or a while-loop branch that returns // `Union(T, None)`) should not unify `value` with None; instead treat // as different types so the overall if-expression preserves the None @@ -1129,7 +1129,7 @@ pub(super) fn generate_symbol_constraints( || (has_none_branch && resolved.len() >= 2) || has_parameter_branch { - // Different concrete types across branches — use Union type. + // Different concrete types across branches: use Union type. // Include ALL resolved branch types (vars and concrete) so that // branches whose types are not yet fully resolved can still be // tracked and resolved later (e.g., `if (...): items else: None` @@ -1137,7 +1137,7 @@ pub(super) fn generate_symbol_constraints( let union_ty = Type::union(resolved.clone()); ctx.set_symbol_type(symbol_id, union_ty); } else { - // Homogeneous or unresolved — unify all branch types + // Homogeneous or unresolved: unify all branch types ctx.set_symbol_type(symbol_id, then_ty.clone()); for ty in &branch_tys[1..] { ctx.add_constraint(Constraint::Equal( @@ -1446,12 +1446,12 @@ pub(super) fn generate_symbol_constraints( }; if !all_same && concrete.len() >= 2 { - // Different concrete types in arms — use Union type + // Different concrete types in arms: use Union type let unique_types: Vec = concrete.into_iter().cloned().collect(); let union_ty = Type::union(unique_types); ctx.set_symbol_type(symbol_id, union_ty); } else if concrete.len() == 1 && !var_tys.is_empty() { - // One concrete arm (e.g. `None`) plus still-unresolved arms — union + // One concrete arm (e.g. `None`) plus still-unresolved arms: union // them instead of unifying, or the unresolved type collapses to the // concrete one. Mirrors `merge_loop_types`. let mut unified_var = var_tys[0].clone(); @@ -1535,7 +1535,7 @@ pub(super) fn generate_symbol_constraints( )); ctx.set_symbol_type(symbol_id, try_ty); } else { - // A branch type is still pending deferred resolution — decide later. + // A branch type is still pending deferred resolution: decide later. let ty_var = ctx.fresh_var(); ctx.set_symbol_type(symbol_id, Type::Var(ty_var)); ctx.add_deferred_try_catch(infer::DeferredTryCatch { @@ -1638,7 +1638,7 @@ pub(super) fn generate_symbol_constraints( Type::Var(ty_var) } } else { - // Piped type not yet resolved — defer to post-unification. + // Piped type not yet resolved: defer to post-unification. // Keep the selector so that if the piped type resolves to a Markdown node, // the right type can be returned (e.g., `md.depth` → number). let ty_var = ctx.fresh_var(); @@ -1741,7 +1741,7 @@ pub(super) fn generate_symbol_constraints( } } - // Block: pipe chain — compute type from children in pass 3. + // Block: pipe chain: compute type from children in pass 3. // Block has higher insertion order than its parent (If/Else/etc.), so in // the reverse-order pass 3 loop it is processed before its parent. This // means the If handler can see the Block's real type (e.g. Union(T, None) @@ -1926,7 +1926,7 @@ fn generate_call_constraints( let real_children = &children[..children.len() - trailing_bracket_count]; let arg_symbol_ids = if prepend_piped { - // piped input was prepended — include a placeholder + // piped input was prepended: include a placeholder let mut ids = vec![symbol_id]; // placeholder for piped arg ids.extend_from_slice(real_children); ids @@ -2037,7 +2037,7 @@ fn generate_call_constraints( // Only constrain the index to Number when it is a literal // numeric index (e.g. v[0]). For variable indices // (e.g. a String key used for Dict access), skip this - // constraint — the correct element type is resolved via + // constraint: the correct element type is resolved via // resolve_deferred_tuple_accesses once the container's // type is known. if literal_index.is_some() diff --git a/crates/mq-help/src/docs/table.rs b/crates/mq-help/src/docs/table.rs index 662f96716..91cd21c13 100644 --- a/crates/mq-help/src/docs/table.rs +++ b/crates/mq-help/src/docs/table.rs @@ -3640,7 +3640,7 @@ x = 1 BuiltinDoc { name: "to_h", aliases: &[], - description: "Creates a markdown heading node with the given value and depth.", + description: "Creates a markdown heading node with the given value and depth. A depth outside of 1 to 6 becomes the nearest of them.", params: &["value", "depth"], param_types: &["dynamic", "number"], returns: "markdown", diff --git a/crates/mq-lang/modules/section_test.mq b/crates/mq-lang/modules/section_test.mq index d14a99a18..68889a5df 100644 --- a/crates/mq-lang/modules/section_test.mq +++ b/crates/mq-lang/modules/section_test.mq @@ -387,8 +387,3 @@ end def test_section_toc_markdown_empty(): assert_eq(section::toc_markdown([]), []) end - -def test_section_sections_invalid_heading_depth(): - let secs = section::sections([to_h("A", 7)]) - | assert(is_none(secs)) -end diff --git a/crates/mq-lang/src/lib.rs b/crates/mq-lang/src/lib.rs index f2c89d382..0b3462bd9 100644 --- a/crates/mq-lang/src/lib.rs +++ b/crates/mq-lang/src/lib.rs @@ -212,7 +212,12 @@ pub(crate) fn parse_in_module( /// Parses an MDX string and returns an iterator over `Value` nodes. pub fn parse_mdx_input(input: &str) -> miette::Result> { - let mdx = mq_markdown::Markdown::from_mdx_str(input)?; + parse_mdx_input_with(input, mq_markdown::ParseOptions::default()) +} + +/// Parses an MDX string with the given options and returns an iterator over `Value` nodes. +pub fn parse_mdx_input_with(input: &str, options: mq_markdown::ParseOptions) -> miette::Result> { + let mdx = mq_markdown::Markdown::from_mdx_str_with(input, options)?; Ok(mdx.nodes.into_iter().map(RuntimeValue::from).collect()) } @@ -233,7 +238,12 @@ pub fn parse_html_input_with_options( /// Parses a Markdown string and returns an iterator over `Value` nodes. pub fn parse_markdown_input(input: &str) -> miette::Result> { - let md = mq_markdown::Markdown::from_markdown_str(input)?; + parse_markdown_input_with(input, mq_markdown::ParseOptions::default()) +} + +/// Parses a Markdown string with the given options and returns an iterator over `Value` nodes. +pub fn parse_markdown_input_with(input: &str, options: mq_markdown::ParseOptions) -> miette::Result> { + let md = mq_markdown::Markdown::from_markdown_str_with(input, options)?; Ok(md.nodes.into_iter().map(RuntimeValue::from).collect()) } diff --git a/crates/mq-lang/src/runtime/builtin.rs b/crates/mq-lang/src/runtime/builtin.rs index 2170e6975..e4c9c98fc 100644 --- a/crates/mq-lang/src/runtime/builtin.rs +++ b/crates/mq-lang/src/runtime/builtin.rs @@ -3182,14 +3182,14 @@ fn to_h_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Result { Ok(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: (*depth).value() as u8, + depth: mq_markdown::HeadingDepth::saturating((*depth).value() as i64), values: node.node_values(), position: None, }) .into()) } [a, RuntimeValue::Number(depth)] if !a.is_none() => Ok(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: (*depth).value() as u8, + depth: mq_markdown::HeadingDepth::saturating((*depth).value() as i64), values: vec![a.to_string().into()], position: None, }) @@ -3200,7 +3200,11 @@ fn to_h_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Result Result { - Ok(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }).into()) + Ok(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule { + marker: None, + position: None, + }) + .into()) } #[mq_macros::mq_fn(name = "to_link", params = Fixed(3))] @@ -3280,7 +3284,7 @@ fn to_md_name_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Re fn md_heading_level_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Result { let level = match args.as_slice() { [RuntimeValue::Markdown(node, _)] => match &**node { - mq_markdown::Node::Heading(mq_markdown::Heading { depth, .. }) if (1..=6).contains(depth) => *depth, + mq_markdown::Node::Heading(mq_markdown::Heading { depth, .. }) => depth.get(), _ => 0, }, _ => 0, @@ -3386,6 +3390,7 @@ fn to_callout_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Re RuntimeValue::String(kind), RuntimeValue::String(title), ] => Ok(mq_markdown::Node::Callout(mq_markdown::Callout { + fold: None, kind: kind.to_uppercase(), title: if title.is_empty() { None @@ -3398,6 +3403,7 @@ fn to_callout_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Re .into()), [a, RuntimeValue::String(kind), RuntimeValue::String(title)] if !a.is_none() => { Ok(mq_markdown::Node::Callout(mq_markdown::Callout { + fold: None, kind: kind.to_uppercase(), title: if title.is_empty() { None @@ -3504,6 +3510,7 @@ fn to_md_list_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Re match args.as_slice() { [RuntimeValue::Markdown(node, _), RuntimeValue::Number(level)] => { Ok(mq_markdown::Node::List(mq_markdown::List { + marker: None, values: node.node_values(), index: 0, ordered: false, @@ -3516,6 +3523,7 @@ fn to_md_list_impl(_: &Ident, _: &RuntimeValue, args: Args, _: &SharedEnv) -> Re .into()) } [a, RuntimeValue::Number(level)] if !a.is_none() => Ok(mq_markdown::Node::List(mq_markdown::List { + marker: None, values: vec![a.to_string().into()], index: 0, ordered: false, @@ -4532,7 +4540,7 @@ fn shift_left_impl(ident: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedEn if let mq_markdown::Node::Heading(heading) = runtime_value::markdown_mut(node) { let shift_amount = n.to_int().max(0).min(u8::MAX as i64) as u8; - heading.depth = heading.depth.saturating_sub(shift_amount).max(1); + heading.depth = heading.depth.saturating_sub(shift_amount); Ok(mq_markdown::Node::Heading(std::mem::take(heading)).into()) } else { Ok(RuntimeValue::Markdown(std::mem::take(node), selector.take())) @@ -4574,8 +4582,8 @@ fn shift_right_impl(ident: &Ident, _: &RuntimeValue, mut args: Args, _: &SharedE if let mq_markdown::Node::Heading(heading) = runtime_value::markdown_mut(node) { let shift_amount = n.to_int().max(0).min(u8::MAX as i64) as u8; - if heading.depth + shift_amount <= 6 { - heading.depth += shift_amount; + if let Some(depth) = heading.depth.checked_add(shift_amount) { + heading.depth = depth; } Ok(mq_markdown::Node::Heading(std::mem::take(heading)).into()) } else { @@ -6231,7 +6239,7 @@ pub fn eval_selector_with_args(node: &mq_markdown::Node, selector: &Selector, ar } if let mq_markdown::Node::Heading(mq_markdown::Heading { depth, .. }) = node { - depths.contains(depth) + depths.contains(&depth.get()) } else { false } @@ -7664,12 +7672,12 @@ mod tests { true )] #[case::heading_matching_depth( - Node::Heading(mq_markdown::Heading { depth: 2, values: vec!["test".to_string().into()], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H2, values: vec!["test".to_string().into()], position: None }), Selector::Heading(Some(2)), true )] #[case::heading_wrong_depth( - Node::Heading(mq_markdown::Heading { depth: 2, values: vec!["test".to_string().into()], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H2, values: vec!["test".to_string().into()], position: None }), Selector::Heading(Some(3)), false )] @@ -7709,62 +7717,62 @@ mod tests { false )] #[case::list_with_matching_index_checked( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::List(Some(1), Some(true)), true )] #[case::list_with_wrong_index( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::List(Some(2), Some(true)), false )] #[case::list_without_index( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::List(None, None), true )] #[case::task_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::Task, true )] #[case::task_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), Selector::Task, true )] #[case::task_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), Selector::Task, false )] #[case::todo_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), Selector::Todo, true )] #[case::todo_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::Todo, false )] #[case::todo_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), Selector::Todo, false )] #[case::done_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(true), position: None }), Selector::Done, true )] #[case::done_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: Some(false), position: None }), Selector::Done, false )] #[case::done_list( - Node::List(mq_markdown::List { start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), + Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["test".to_string().into()], ordered: false, index: 1, level: 1, checked: None, position: None }), Selector::Done, false )] @@ -7819,7 +7827,7 @@ mod tests { true )] #[case::horizontal_rule( - Node::HorizontalRule(mq_markdown::HorizontalRule{ position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule{ marker: None, position: None }), Selector::HorizontalRule, true )] @@ -7873,7 +7881,7 @@ mod tests { }), ], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }); let result = eval_selector(&node, &Selector::Recursive); assert_eq!( @@ -7918,7 +7926,7 @@ mod tests { let heading = Node::Heading(mq_markdown::Heading { values: vec![inner_text.clone()], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }); let node = Node::Blockquote(mq_markdown::Blockquote { values: vec![heading.clone()], @@ -9758,25 +9766,25 @@ mod tests { #[rstest] #[case::heading_depth_match( - Node::Heading(mq_markdown::Heading { depth: 1, values: vec![], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H1, values: vec![], position: None }), Selector::Heading(None), vec![RuntimeValue::Number(1.into())], true )] #[case::heading_depth_no_match( - Node::Heading(mq_markdown::Heading { depth: 2, values: vec![], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H2, values: vec![], position: None }), Selector::Heading(None), vec![RuntimeValue::Number(1.into())], false )] #[case::heading_multi_depth_match( - Node::Heading(mq_markdown::Heading { depth: 2, values: vec![], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H2, values: vec![], position: None }), Selector::Heading(None), vec![RuntimeValue::Number(1.into()), RuntimeValue::Number(2.into())], true )] #[case::heading_no_args_fallback( - Node::Heading(mq_markdown::Heading { depth: 1, values: vec![], position: None }), + Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H1, values: vec![], position: None }), Selector::Heading(None), vec![], true @@ -9800,31 +9808,31 @@ mod tests { true )] #[case::callout_kind_match( - Node::Callout(mq_markdown::Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), + Node::Callout(mq_markdown::Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), Selector::Callout, vec![RuntimeValue::String(Shared::new("NOTE".into()))], true )] #[case::callout_kind_no_match( - Node::Callout(mq_markdown::Callout { kind: "WARNING".to_string(), title: None, values: vec![], position: None }), + Node::Callout(mq_markdown::Callout { fold: None, kind: "WARNING".to_string(), title: None, values: vec![], position: None }), Selector::Callout, vec![RuntimeValue::String(Shared::new("NOTE".into()))], false )] #[case::callout_multi_kind_match( - Node::Callout(mq_markdown::Callout { kind: "TIP".to_string(), title: None, values: vec![], position: None }), + Node::Callout(mq_markdown::Callout { fold: None, kind: "TIP".to_string(), title: None, values: vec![], position: None }), Selector::Callout, vec![RuntimeValue::String(Shared::new("NOTE".into())), RuntimeValue::String(Shared::new("TIP".into()))], true )] #[case::callout_no_args_fallback( - Node::Callout(mq_markdown::Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), + Node::Callout(mq_markdown::Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), Selector::Callout, vec![], true )] #[case::callout_non_callout_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Callout, vec![RuntimeValue::String(Shared::new("NOTE".into()))], false @@ -9854,7 +9862,7 @@ mod tests { true )] #[case::wikilink_non_wikilink_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::WikiLink, vec![RuntimeValue::String(Shared::new("Some Page".into()))], false @@ -9884,7 +9892,7 @@ mod tests { true )] #[case::embed_non_embed_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Embed, vec![RuntimeValue::String(Shared::new("image.png".into()))], false @@ -9914,7 +9922,7 @@ mod tests { true )] #[case::link_ref_non_link_ref_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::LinkRef, vec![RuntimeValue::String(Shared::new("ref".into()))], false @@ -9944,7 +9952,7 @@ mod tests { true )] #[case::image_ref_non_image_ref_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::ImageRef, vec![RuntimeValue::String(Shared::new("ref".into()))], false @@ -9974,7 +9982,7 @@ mod tests { true )] #[case::footnote_ref_non_footnote_ref_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::FootnoteRef, vec![RuntimeValue::String(Shared::new("1".into()))], false @@ -10004,7 +10012,7 @@ mod tests { true )] #[case::footnote_non_footnote_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Footnote, vec![RuntimeValue::String(Shared::new("1".into()))], false @@ -10034,7 +10042,7 @@ mod tests { true )] #[case::definition_non_definition_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Definition, vec![RuntimeValue::String(Shared::new("ref".into()))], false @@ -10064,7 +10072,7 @@ mod tests { true )] #[case::mdx_jsx_flow_element_non_matching_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::MdxJsxFlowElement, vec![RuntimeValue::String(Shared::new("Alert".into()))], false @@ -10094,43 +10102,43 @@ mod tests { true )] #[case::mdx_jsx_text_element_non_matching_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::MdxJsxTextElement, vec![RuntimeValue::String(Shared::new("Alert".into()))], false )] #[case::non_heading_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Heading(None), vec![RuntimeValue::Number(1.into())], false )] #[case::list_index_match( - Node::List(mq_markdown::List { index: 2, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), + Node::List(mq_markdown::List { marker: None, index: 2, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), Selector::List(None, None), vec![RuntimeValue::Number(2.into())], true )] #[case::list_index_no_match( - Node::List(mq_markdown::List { index: 0, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), + Node::List(mq_markdown::List { marker: None, index: 0, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), Selector::List(None, None), vec![RuntimeValue::Number(1.into())], false )] #[case::list_multi_index_match( - Node::List(mq_markdown::List { index: 3, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), + Node::List(mq_markdown::List { marker: None, index: 3, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), Selector::List(None, None), vec![RuntimeValue::Number(1.into()), RuntimeValue::Number(3.into())], true )] #[case::list_no_args_fallback( - Node::List(mq_markdown::List { index: 0, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), + Node::List(mq_markdown::List { marker: None, index: 0, level: 0, checked: None, ordered: false, start: None, spread: false, values: vec![], position: None }), Selector::List(None, None), vec![], true )] #[case::list_non_list_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::List(None, None), vec![RuntimeValue::Number(0.into())], false @@ -10166,7 +10174,7 @@ mod tests { true )] #[case::table_non_table_node( - Node::HorizontalRule(mq_markdown::HorizontalRule { position: None }), + Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None }), Selector::Table(None, None), vec![RuntimeValue::Number(0.into())], false diff --git a/crates/mq-lang/src/runtime/builtin/compress.rs b/crates/mq-lang/src/runtime/builtin/compress.rs index 6f325e51b..6e1258faf 100644 --- a/crates/mq-lang/src/runtime/builtin/compress.rs +++ b/crates/mq-lang/src/runtime/builtin/compress.rs @@ -373,7 +373,7 @@ mod tests { fn heading(depth: u8, value: &str) -> Node { Node::Heading(Heading { - depth, + depth: mq_markdown::HeadingDepth::new(depth).expect("a heading depth"), values: vec![text(value)], position: None, }) @@ -381,6 +381,7 @@ mod tests { fn list_item(value: &str, index: usize) -> Node { Node::List(List { + marker: None, values: vec![text(value)], index, level: 0, diff --git a/crates/mq-lang/src/runtime/builtin/convert.rs b/crates/mq-lang/src/runtime/builtin/convert.rs index 5ed937eb0..eb7a78166 100644 --- a/crates/mq-lang/src/runtime/builtin/convert.rs +++ b/crates/mq-lang/src/runtime/builtin/convert.rs @@ -12,7 +12,7 @@ use url::Url; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum ConvertKind { Blockquote, - Heading(u8), + Heading(mq_markdown::HeadingDepth), HorizontalRule, Link(String), ListItem, @@ -57,12 +57,12 @@ impl TryFrom<&RuntimeValue> for Convert { fn try_from(value: &RuntimeValue) -> Result { match value { RuntimeValue::Symbol(symbol) => match symbol.to_string().as_str() { - "h1" => Ok(Convert::Markdown(ConvertKind::Heading(1))), - "h2" => Ok(Convert::Markdown(ConvertKind::Heading(2))), - "h3" => Ok(Convert::Markdown(ConvertKind::Heading(3))), - "h4" => Ok(Convert::Markdown(ConvertKind::Heading(4))), - "h5" => Ok(Convert::Markdown(ConvertKind::Heading(5))), - "h6" => Ok(Convert::Markdown(ConvertKind::Heading(6))), + "h1" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H1))), + "h2" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H2))), + "h3" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H3))), + "h4" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H4))), + "h5" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H5))), + "h6" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H6))), "html" => Ok(Convert::Html), "md5" => Ok(Convert::Md5), @@ -75,12 +75,12 @@ impl TryFrom<&RuntimeValue> for Convert { _ => Err(Error::InvalidConvert(symbol.to_string())), }, RuntimeValue::String(s) => match s.as_str() { - "#" => Ok(Convert::Markdown(ConvertKind::Heading(1))), - "##" => Ok(Convert::Markdown(ConvertKind::Heading(2))), - "###" => Ok(Convert::Markdown(ConvertKind::Heading(3))), - "####" => Ok(Convert::Markdown(ConvertKind::Heading(4))), - "#####" => Ok(Convert::Markdown(ConvertKind::Heading(5))), - "######" => Ok(Convert::Markdown(ConvertKind::Heading(6))), + "#" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H1))), + "##" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H2))), + "###" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H3))), + "####" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H4))), + "#####" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H5))), + "######" => Ok(Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H6))), ">" => Ok(Convert::Markdown(ConvertKind::Blockquote)), "-" => Ok(Convert::Markdown(ConvertKind::ListItem)), "~~" => Ok(Convert::Markdown(ConvertKind::Strikethrough)), @@ -194,6 +194,7 @@ impl Convert { ), ConvertKind::ListItem => RuntimeValue::Markdown( Shared::new(mq_markdown::Node::List(mq_markdown::List { + marker: None, values: vec![text.into()], index: 0, ordered: false, @@ -230,6 +231,7 @@ impl Convert { ), ConvertKind::HorizontalRule => RuntimeValue::Markdown( Shared::new(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule { + marker: None, position: None, })), None, @@ -960,23 +962,41 @@ mod tests { // Test convert::try_from #[rstest] - #[case::h1_symbol(RuntimeValue::Symbol(Ident::new("h1")), Convert::Markdown(ConvertKind::Heading(1)))] - #[case::h2_symbol(RuntimeValue::Symbol(Ident::new("h2")), Convert::Markdown(ConvertKind::Heading(2)))] - #[case::h3_symbol(RuntimeValue::Symbol(Ident::new("h3")), Convert::Markdown(ConvertKind::Heading(3)))] - #[case::h4_symbol(RuntimeValue::Symbol(Ident::new("h4")), Convert::Markdown(ConvertKind::Heading(4)))] - #[case::h5_symbol(RuntimeValue::Symbol(Ident::new("h5")), Convert::Markdown(ConvertKind::Heading(5)))] - #[case::h6_symbol(RuntimeValue::Symbol(Ident::new("h6")), Convert::Markdown(ConvertKind::Heading(6)))] + #[case::h1_symbol( + RuntimeValue::Symbol(Ident::new("h1")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H1)) + )] + #[case::h2_symbol( + RuntimeValue::Symbol(Ident::new("h2")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H2)) + )] + #[case::h3_symbol( + RuntimeValue::Symbol(Ident::new("h3")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H3)) + )] + #[case::h4_symbol( + RuntimeValue::Symbol(Ident::new("h4")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H4)) + )] + #[case::h5_symbol( + RuntimeValue::Symbol(Ident::new("h5")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H5)) + )] + #[case::h6_symbol( + RuntimeValue::Symbol(Ident::new("h6")), + Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H6)) + )] #[case::html_symbol(RuntimeValue::Symbol(Ident::new("html")), Convert::Html)] #[case::md5_symbol(RuntimeValue::Symbol(Ident::new("md5")), Convert::Md5)] #[case::sha256_symbol(RuntimeValue::Symbol(Ident::new("sha256")), Convert::Sha256)] #[case::text_symbol(RuntimeValue::Symbol(Ident::new("text")), Convert::Text)] #[case::sh_symbol(RuntimeValue::Symbol(Ident::new("sh")), Convert::Shell)] - #[case::h1_string(RuntimeValue::String(Shared::new("#".to_string())), Convert::Markdown(ConvertKind::Heading(1)))] - #[case::h2_string(RuntimeValue::String(Shared::new("##".to_string())), Convert::Markdown(ConvertKind::Heading(2)))] - #[case::h3_string(RuntimeValue::String(Shared::new("###".to_string())), Convert::Markdown(ConvertKind::Heading(3)))] - #[case::h4_string(RuntimeValue::String(Shared::new("####".to_string())), Convert::Markdown(ConvertKind::Heading(4)))] - #[case::h5_string(RuntimeValue::String(Shared::new("#####".to_string())), Convert::Markdown(ConvertKind::Heading(5)))] - #[case::h6_string(RuntimeValue::String(Shared::new("######".to_string())), Convert::Markdown(ConvertKind::Heading(6)))] + #[case::h1_string(RuntimeValue::String(Shared::new("#".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H1)))] + #[case::h2_string(RuntimeValue::String(Shared::new("##".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H2)))] + #[case::h3_string(RuntimeValue::String(Shared::new("###".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H3)))] + #[case::h4_string(RuntimeValue::String(Shared::new("####".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H4)))] + #[case::h5_string(RuntimeValue::String(Shared::new("#####".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H5)))] + #[case::h6_string(RuntimeValue::String(Shared::new("######".to_string())), Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H6)))] #[case::blockquote_string(RuntimeValue::String(Shared::new(">".to_string())), Convert::Markdown(ConvertKind::Blockquote))] #[case::list_item_string(RuntimeValue::String(Shared::new("-".to_string())), Convert::Markdown(ConvertKind::ListItem))] #[case::strikethrough_string(RuntimeValue::String(Shared::new("~~".to_string())), Convert::Markdown(ConvertKind::Strikethrough))] @@ -1639,6 +1659,7 @@ mod tests { #[case(5)] #[case(6)] fn test_convert_all_heading_levels(#[case] depth: u8) { + let depth = mq_markdown::HeadingDepth::new(depth).expect("a heading depth"); let convert = Convert::Markdown(ConvertKind::Heading(depth)); let input = RuntimeValue::String(Shared::new("Test".to_string())); let result = convert.convert(&input); @@ -1746,7 +1767,7 @@ mod tests { position: None, }); let input = RuntimeValue::new_markdown(markdown_node); - let convert = Convert::Markdown(ConvertKind::Heading(2)); + let convert = Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H2)); let result = convert.convert(&input); @@ -1756,13 +1777,13 @@ mod tests { let mq_markdown::Node::Heading(heading) = Shared::unwrap_or_clone(node) else { panic!("Expected Heading node") }; - assert_eq!(heading.depth, 2); + assert_eq!(heading.depth, mq_markdown::HeadingDepth::H2); } // Test convert with number input #[test] fn test_convert_number_to_heading() { - let convert = Convert::Markdown(ConvertKind::Heading(1)); + let convert = Convert::Markdown(ConvertKind::Heading(mq_markdown::HeadingDepth::H1)); let input = RuntimeValue::Number(42.into()); let result = convert.convert(&input); @@ -1772,7 +1793,7 @@ mod tests { let mq_markdown::Node::Heading(heading) = Shared::unwrap_or_clone(node) else { panic!("Expected Heading node") }; - assert_eq!(heading.depth, 1); + assert_eq!(heading.depth, mq_markdown::HeadingDepth::H1); assert_eq!(heading.values.len(), 1); } diff --git a/crates/mq-lang/src/tarn/tests.rs b/crates/mq-lang/src/tarn/tests.rs index c618f6b90..6581993c2 100644 --- a/crates/mq-lang/src/tarn/tests.rs +++ b/crates/mq-lang/src/tarn/tests.rs @@ -2011,7 +2011,7 @@ fn heading(depth: u8) -> RuntimeValue { RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth, + depth: mq_markdown::HeadingDepth::new(depth).expect("a heading depth"), })) } @@ -2350,12 +2350,12 @@ fn markdown_selector_recurses_into_a_non_matching_container_to_find_matches_belo let matching_child = mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }); let outer = mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![matching_child.clone(), text_node("no match anywhere")], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }); let code = ".h1"; let token_arena = Shared::new(SharedCell::new(Arena::new(100))); diff --git a/crates/mq-lang/tests/integration_tests.rs b/crates/mq-lang/tests/integration_tests.rs index dc615eb14..25bc620b6 100644 --- a/crates/mq-lang/tests/integration_tests.rs +++ b/crates/mq-lang/tests/integration_tests.rs @@ -223,7 +223,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "h1".to_string(), @@ -240,7 +240,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 3, + depth: mq_markdown::HeadingDepth::H3, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "deep".to_string(), @@ -289,7 +289,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine | _: \"other\" end ", - vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { start: None, spread: false, + vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec![], index: 0, level: 1, @@ -308,7 +308,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine | _: \"other\" end ", - vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { start: None, spread: false, + vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec![], index: 0, level: 1, @@ -337,7 +337,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "top-level".to_string(), @@ -365,7 +365,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine | _: \"other\" end ", - vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { start: None, spread: false, + vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec![], index: 0, level: 1, @@ -400,7 +400,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "other".to_string(), @@ -614,15 +614,15 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{ values: vec![ mq_markdown::Node::Link(mq_markdown::Link { url: mq_markdown::Url::new("url".to_string()), title: None, values: Vec::new(), position: None }), mq_markdown::Node::Image(mq_markdown::Image{ alt: "".to_string(), url: "url".to_string(), title: None, position: None }) - ], position: None, depth: 1 }))], + ], position: None, depth: mq_markdown::HeadingDepth::H1 }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Link(mq_markdown::Link { url: mq_markdown::Url::new("test".to_string()), title: None, values: Vec::new(), position: None }))].into()))] #[case::selector("nodes | .h", vec![ - RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{ values: vec![mq_markdown::Node::Text(mq_markdown::Text { value: "text".to_string(), position: None }),], position: None, depth: 1 })), + RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{ values: vec![mq_markdown::Node::Text(mq_markdown::Text { value: "text".to_string(), position: None }),], position: None, depth: mq_markdown::HeadingDepth::H1 })), RuntimeValue::String(Shared::new("test".to_string())), ], Ok(vec![ - RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{ values: vec![mq_markdown::Node::Text(mq_markdown::Text { value: "text".to_string(), position: None }),], position: None, depth: 1 })), + RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{ values: vec![mq_markdown::Node::Text(mq_markdown::Text { value: "text".to_string(), position: None }),], position: None, depth: mq_markdown::HeadingDepth::H1 })), RuntimeValue::NONE ].into()))] #[case::selector("nodes | .h", @@ -1173,7 +1173,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1192,7 +1192,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1202,7 +1202,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1221,7 +1221,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1231,7 +1231,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 3, + depth: mq_markdown::HeadingDepth::H3, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1250,7 +1250,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 3, + depth: mq_markdown::HeadingDepth::H3, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1260,7 +1260,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 4, + depth: mq_markdown::HeadingDepth::H4, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1279,7 +1279,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 4, + depth: mq_markdown::HeadingDepth::H4, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1289,7 +1289,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 5, + depth: mq_markdown::HeadingDepth::H5, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1308,7 +1308,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 5, + depth: mq_markdown::HeadingDepth::H5, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1318,7 +1318,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 4, + depth: mq_markdown::HeadingDepth::H4, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1337,7 +1337,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 6, + depth: mq_markdown::HeadingDepth::H6, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "true".to_string(), @@ -1347,7 +1347,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 5, + depth: mq_markdown::HeadingDepth::H5, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), @@ -1468,14 +1468,14 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { values: vec![], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "false".to_string(), position: None, }))].into()))] #[case::is_list_true("is_list()", - vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { start: None, spread: false, + vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec![], position: None, ordered: false, @@ -1788,7 +1788,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine mq_markdown::Node::Link(mq_markdown::Link { url: mq_markdown::Url::new("url".to_string()), title: None, values: Vec::new(), position: None }), ], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, })), ], Ok(vec![ @@ -1809,7 +1809,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine mq_markdown::Node::Text(mq_markdown::Text { value: "nested".to_string(), position: None }), ], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, }), ], position: None, @@ -1822,7 +1822,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine mq_markdown::Node::Text(mq_markdown::Text { value: "nested".to_string(), position: None }), ], position: None, - depth: 2, + depth: mq_markdown::HeadingDepth::H2, })), ].into()))] #[case::recursive_selector_pipe_filter("nodes | .. | filter(fn(x): select(x, .text);)", @@ -1833,7 +1833,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine mq_markdown::Node::Link(mq_markdown::Link { url: mq_markdown::Url::new("url".to_string()), title: None, values: Vec::new(), position: None }), ], position: None, - depth: 1, + depth: mq_markdown::HeadingDepth::H1, })), ], Ok(vec![ @@ -2269,42 +2269,42 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine #[case::shift_right_header_level_h1("to_markdown(\"# Heading 1\") | first() | shift_right(1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 2, + depth: mq_markdown::HeadingDepth::H2, values: vec!["Heading 1".to_string().into()], position: None }))].into()))] #[case::shift_right_header_level_h1_operator("let md = do to_markdown(\"# Heading 1\") | first(); | md >> 1", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 2, + depth: mq_markdown::HeadingDepth::H2, values: vec!["Heading 1".to_string().into()], position: None }))].into()))] #[case::shift_right_header_level_h6("to_markdown(\"###### Heading 6\") | first() | shift_right(1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 6, + depth: mq_markdown::HeadingDepth::H6, values: vec!["Heading 6".to_string().into()], position: None }))].into()))] #[case::shift_left_header_level_h2("to_markdown(\"## Heading 2\") | first() | shift_left(1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Heading 2".to_string().into()], position: None }))].into()))] #[case::shift_left_header_level_h2_via_binding("let md = do to_markdown(\"## Heading 2\") | first(); | md << 1", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Heading 2".to_string().into()], position: None }))].into()))] #[case::shift_left_header_level_h1("to_markdown(\"# Heading 1\") | first() | shift_left(1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Heading 1".to_string().into()], position: None }))].into()))] @@ -2323,35 +2323,35 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine #[case::convert_string_to_h1_function("convert(\"Hello\", :h1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Hello".to_string().into()], position: None, }))].into()))] #[case::convert_string_to_h1_operator("\"Hello\" @ :h1", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Hello".to_string().into()], position: None, }))].into()))] #[case::convert_string_to_h2_operator("\"Hello\" @ :h2", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 2, + depth: mq_markdown::HeadingDepth::H2, values: vec!["Hello".to_string().into()], position: None, }))].into()))] #[case::convert_string_to_h1_via_string_operator("\"Hello\" @ \"#\"", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Hello".to_string().into()], position: None, }))].into()))] #[case::convert_string_to_h2_via_string_operator("\"Hello\" @ \"##\"", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 2, + depth: mq_markdown::HeadingDepth::H2, values: vec!["Hello".to_string().into()], position: None, }))].into()))] @@ -2375,7 +2375,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine }))].into()))] #[case::convert_string_to_list_item_operator("\"Hello\" @ \"-\"", vec![RuntimeValue::None], - Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { start: None, spread: false, + Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List { marker: None, start: None, spread: false, values: vec!["Hello".to_string().into()], index: 0, ordered: false, @@ -2397,7 +2397,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine }))].into()))] #[case::convert_string_to_horizontal_rule_operator("\"Hello\" @ \"--\"", vec![RuntimeValue::None], - Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule { + Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule { marker: None, position: None, }))].into()))] #[case::skip_while_basic("skip_while([1,2,3,4,5], fn(x): x < 3;)", @@ -2539,7 +2539,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine // type pattern `:markdown` matches any markdown node value #[case::match_type_markdown( r#"match (.) do | :markdown: "is markdown" | _: "other" end"#, - vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { depth: 1, values: vec!["Title".to_string().into()], position: None }))], + vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { depth: mq_markdown::HeadingDepth::H1, values: vec!["Title".to_string().into()], position: None }))], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text { value: "is markdown".to_string(), position: None }))].into()))] // type pattern `:markdown` does not match a plain string #[case::match_type_markdown_no_match_on_string( @@ -2574,7 +2574,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine r#""

Hello

" | from_html() | first()"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec!["Hello".to_string().into()], position: None, }))].into()))] @@ -2648,7 +2648,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine #[case::token_count_no_model_simple(r#"token_count("Hello, world!")"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(4.into())].into()))] #[case::token_count_no_model_markdown(r#"to_md_text("Hello, world!") | token_count()"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(4.into())].into()))] #[case::token_count_no_model_none(r#"token_count(None)"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Number(0.into())].into()))] -#[case::token_compress_under_budget(r##"to_markdown("# Title") | token_compress(1000)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Array(Shared::new(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: 1, values: vec!["Title".to_string().into()], position: None}))]))].into()))] +#[case::token_compress_under_budget(r##"to_markdown("# Title") | token_compress(1000)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Array(Shared::new(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H1, values: vec!["Title".to_string().into()], position: None}))]))].into()))] #[case::token_compress_none(r#"token_compress(None, 100)"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Array(Shared::new(vec![]))].into()))] #[case::explode_simple(r##"explode("abc")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::Array(Shared::new(vec![RuntimeValue::Number(97.into()), RuntimeValue::Number(98.into()), RuntimeValue::Number(99.into())]))].into()))] #[case::implode_simple(r##"implode([97, 98, 99])"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::String(Shared::new("abc".to_string()))].into()))] @@ -2656,12 +2656,17 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine #[case::nan_builtin("nan() | is_nan()", vec![RuntimeValue::None], Ok(vec![RuntimeValue::Boolean(true)].into()))] #[case::infinite_builtin("infinite() > 0", vec![RuntimeValue::None], Ok(vec![RuntimeValue::Boolean(true)].into()))] #[case::to_md_text_simple(r##"to_md_text("hello")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Text(mq_markdown::Text{value: "hello".to_string(), position: None}))].into()))] -#[case::to_h_simple(r##"to_h("title", 1)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: 1, values: vec!["title".to_string().into()], position: None}))].into()))] +#[case::to_h_simple(r##"to_h("title", 1)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H1, values: vec!["title".to_string().into()], position: None}))].into()))] #[case::to_h_none("to_h(None, 1)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] +#[case::to_h_depth_above_six(r##"to_h("title", 9)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H6, values: vec!["title".to_string().into()], position: None}))].into()))] +#[case::to_h_depth_below_one(r##"to_h("title", 0)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H1, values: vec!["title".to_string().into()], position: None}))].into()))] +#[case::shift_right_heading_past_six(r##"to_h("title", 6) | shift_right(255)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H6, values: vec!["title".to_string().into()], position: None}))].into()))] +#[case::shift_right_heading(r##"to_h("title", 2) | shift_right(3)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H5, values: vec!["title".to_string().into()], position: None}))].into()))] +#[case::shift_left_heading_past_one(r##"to_h("title", 2) | shift_left(5)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Heading(mq_markdown::Heading{depth: mq_markdown::HeadingDepth::H1, values: vec!["title".to_string().into()], position: None}))].into()))] #[case::to_code_none(r#"to_code(None, "rust")"#, vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] #[case::to_math_none("to_math(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] #[case::to_math_inline_none("to_math_inline(None)", vec![RuntimeValue::None], Ok(vec![RuntimeValue::None].into()))] -#[case::to_hr_simple("to_hr()", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule{position: None}))].into()))] +#[case::to_hr_simple("to_hr()", vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::HorizontalRule(mq_markdown::HorizontalRule{ marker: None,position: None}))].into()))] #[case::to_strong_simple(r##"to_strong("bold")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Strong(mq_markdown::Strong{values: vec!["bold".to_string().into()], position: None}))].into()))] #[case::to_em_simple(r##"to_em("italic")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Emphasis(mq_markdown::Emphasis{values: vec!["italic".to_string().into()], position: None}))].into()))] #[case::to_code_inline_simple(r##"to_code_inline("code")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::CodeInline(mq_markdown::CodeInline{value: "code".to_string().into(), position: None}))].into()))] @@ -2688,7 +2693,7 @@ fn to_code_round_trip_preserves_compound_assign_result(mut engine: DefaultEngine #[case::to_link_simple(r##"to_link("url", "text", "title")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Link(mq_markdown::Link{url: mq_markdown::Url::new("url".to_string()), title: Some(mq_markdown::Title::new("title".to_string())), values: vec!["text".to_string().into()], position: None}))].into()))] #[case::to_math_simple(r##"to_math("E=mc^2")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::Math(mq_markdown::Math{value: "E=mc^2".to_string(), position: None}))].into()))] #[case::to_math_inline_simple(r##"to_math_inline("E=mc^2")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::MathInline(mq_markdown::MathInline{value: "E=mc^2".to_string().into(), position: None}))].into()))] -#[case::to_md_list_simple(r##"to_md_list("item", 1)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List{ start: None, spread: false,values: vec!["item".to_string().into()], index: 0, ordered: false, level: 1, checked: None, position: None}))].into()))] +#[case::to_md_list_simple(r##"to_md_list("item", 1)"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::List(mq_markdown::List{ marker: None, start: None, spread: false,values: vec!["item".to_string().into()], index: 0, ordered: false, level: 1, checked: None, position: None}))].into()))] #[case::to_md_table_row_simple(r##"to_md_table_row("a", "b")"##, vec![RuntimeValue::None], Ok(vec![RuntimeValue::new_markdown(mq_markdown::Node::TableRow(mq_markdown::TableRow{values: vec![ mq_markdown::Node::TableCell(mq_markdown::TableCell{row: 0, column: 0, values: vec!["a".to_string().into()], position: None}), mq_markdown::Node::TableCell(mq_markdown::TableCell{row: 0, column: 1, values: vec!["b".to_string().into()], position: None}), diff --git a/crates/mq-markdown/Cargo.toml b/crates/mq-markdown/Cargo.toml index f85a42850..b00ee8c68 100644 --- a/crates/mq-markdown/Cargo.toml +++ b/crates/mq-markdown/Cargo.toml @@ -14,13 +14,13 @@ version.workspace = true [dependencies] ego-tree = {workspace = true, optional = true} itertools = {workspace = true} -markdown = {workspace = true} miette = {workspace = true} rustc-hash = {workspace = true} scraper = {workspace = true, optional = true} serde = {workspace = true, features = ["derive"], optional = true} serde_json = {workspace = true, optional = true} smol_str = {workspace = true} +unicode-id = {workspace = true} unicode-width = {workspace = true} url = {workspace = true, optional = true} yaml-rust2 = {workspace = true, optional = true} @@ -35,6 +35,10 @@ ureq = {workspace = true} harness = false name = "benchmark" +[[example]] +name = "html_convert" +required-features = ["html-to-markdown"] + [features] callout = [] color = [] diff --git a/crates/mq-markdown/benches/benchmark.rs b/crates/mq-markdown/benches/benchmark.rs index 4aab2570c..a1429328a 100644 --- a/crates/mq-markdown/benches/benchmark.rs +++ b/crates/mq-markdown/benches/benchmark.rs @@ -3,6 +3,7 @@ fn main() { } // Build 100-paragraph document with wikilinks in each paragraph. +#[cfg(feature = "wikilink")] fn wikilink_doc() -> String { (0..100) .map(|i| format!("# Heading {i}\n\nText with [[target{i}]] and [[another{i}|Display {i}]].\n\n")) @@ -10,6 +11,7 @@ fn wikilink_doc() -> String { } // Same shape but no [[...]] patterns. +#[cfg(feature = "wikilink")] fn plain_doc() -> String { (0..100) .map(|i| format!("# Heading {i}\n\nText without any wikilink patterns here.\n\n")) @@ -17,6 +19,7 @@ fn plain_doc() -> String { } // 100 callouts of various kinds. +#[cfg(feature = "callout")] fn callout_doc() -> String { (0..100) .map(|i| { @@ -27,6 +30,7 @@ fn callout_doc() -> String { } // 100 plain blockquotes (no callout header). +#[cfg(feature = "callout")] fn plain_blockquote_doc() -> String { (0..100) .map(|i| format!("> Plain blockquote {i} without any callout header.\n\n")) @@ -34,12 +38,51 @@ fn plain_blockquote_doc() -> String { } // 100 embeds spread through paragraphs. +#[cfg(feature = "embed")] fn embed_doc() -> String { (0..100) .map(|i| format!("# Heading {i}\n\nSee ![[note{i}.md]] for details.\n\n")) .collect() } +fn ascii_prose_doc() -> String { + (0..500) + .map(|i| format!("# Heading {i}\n\nSome *emphasis*, **strong** text with `code` and a [link](https://example.com/{i}) in paragraph {i}.\n\n- item *a*\n- item **b**\n\n")) + .collect() +} + +fn japanese_prose_doc() -> String { + (0..500) + .map(|i| format!("# 見出し {i}\n\n日本語の *強調* や **太字** を含む文章と `コード`、[リンク](https://example.com/{i}) を段落 {i} に入れます。\n\n- 項目 *あ*\n- 項目 **い**\n\n")) + .collect() +} + +/// One very long line, so that a position near its end is far from the start of the line. +fn japanese_long_line_doc() -> String { + (0..3000) + .map(|i| format!("語{i}は *強調* と `コード` です。")) + .collect::() + + "\n" +} + +#[divan::bench(name = "parse/ascii_prose")] +fn parse_ascii_prose(bencher: divan::Bencher) { + let doc = ascii_prose_doc(); + bencher.bench(|| mq_markdown::Markdown::from_markdown_str(&doc).unwrap()); +} + +#[divan::bench(name = "parse/japanese_prose")] +fn parse_japanese_prose(bencher: divan::Bencher) { + let doc = japanese_prose_doc(); + bencher.bench(|| mq_markdown::Markdown::from_markdown_str(&doc).unwrap()); +} + +#[divan::bench(name = "parse/japanese_long_line")] +fn parse_japanese_long_line(bencher: divan::Bencher) { + let doc = japanese_long_line_doc(); + bencher.bench(|| mq_markdown::Markdown::from_markdown_str(&doc).unwrap()); +} + fn table_doc() -> String { let rows = (0..100) .map(|i| format!("| Item {i} | {} | テーブル {i} |\n", i * 10)) @@ -55,53 +98,20 @@ fn render_table(bencher: divan::Bencher) { bencher.bench(|| markdown.to_string()); } -// Full end-to-end: mdast parse + expand_wikilinks (wikilinks present, early-exit skipped). +// Full end-to-end parse of a document with wikilinks. #[cfg(feature = "wikilink")] #[divan::bench(name = "from_markdown_str/with_wikilinks")] fn from_markdown_str_with_wikilinks() -> mq_markdown::Markdown { mq_markdown::Markdown::from_markdown_str(&wikilink_doc()).unwrap() } -// Full end-to-end: mdast parse only (early-exit fires, expand_wikilinks skipped). +// Full end-to-end parse of a document without wikilinks. #[cfg(feature = "wikilink")] #[divan::bench(name = "from_markdown_str/without_wikilinks")] fn from_markdown_str_without_wikilinks() -> mq_markdown::Markdown { mq_markdown::Markdown::from_markdown_str(&plain_doc()).unwrap() } -// Isolate expand_wikilinks alone (wikilinks present). -#[cfg(feature = "wikilink")] -#[divan::bench(name = "expand_wikilinks/with_wikilinks")] -fn expand_wikilinks_with(bencher: divan::Bencher) { - let doc = wikilink_doc(); - let nodes = mq_markdown::Markdown::from_markdown_str_no_expand(&doc).unwrap(); - bencher.bench(|| mq_markdown::Node::expand_wikilinks(nodes.clone())); -} - -// Isolate expand_wikilinks alone (no wikilinks — measures pure traversal cost before early-exit). -#[cfg(feature = "wikilink")] -#[divan::bench(name = "expand_wikilinks/without_wikilinks")] -fn expand_wikilinks_without(bencher: divan::Bencher) { - let doc = plain_doc(); - let nodes = mq_markdown::Markdown::from_markdown_str_no_expand(&doc).unwrap(); - bencher.bench(|| mq_markdown::Node::expand_wikilinks(nodes.clone())); -} - -// Isolate the early-exit check (contains("[[")) vs full expand_wikilinks for plain doc. -#[cfg(feature = "wikilink")] -#[divan::bench(name = "contains_check/with_wikilinks")] -fn contains_check_with() { - let doc = wikilink_doc(); - divan::black_box(doc.contains("[[")); -} - -#[cfg(feature = "wikilink")] -#[divan::bench(name = "contains_check/without_wikilinks")] -fn contains_check_without() { - let doc = plain_doc(); - divan::black_box(doc.contains("[[")); -} - // Callout: end-to-end parse with callout headers (mdast + try_parse_callout per blockquote). #[cfg(feature = "callout")] #[divan::bench(name = "from_markdown_str/with_callouts")] @@ -123,17 +133,44 @@ fn from_markdown_str_with_embeds() -> mq_markdown::Markdown { mq_markdown::Markdown::from_markdown_str(&embed_doc()).unwrap() } -// Early-exit check for embeds. -#[cfg(feature = "embed")] -#[divan::bench(name = "contains_check/with_embeds")] -fn contains_check_embed_with() { - let doc = embed_doc(); - divan::black_box(doc.contains("![[")); -} - -#[cfg(feature = "embed")] -#[divan::bench(name = "contains_check/without_embeds")] -fn contains_check_embed_without() { - let doc = plain_doc(); - divan::black_box(doc.contains("![[")); +// Real-world documents: the README and the book sources, concatenated. +fn real_doc() -> String { + fn walk(dir: &std::path::Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|ext| ext == "md") { + out.push(path); + } + } + } + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut paths = vec![root.join("README.md")]; + walk(&root.join("docs/books/src"), &mut paths); + paths.sort(); + paths + .iter() + .filter_map(|path| std::fs::read_to_string(path).ok()) + .collect::>() + .join("\n\n") +} + +#[divan::bench(name = "from_markdown_str/real_docs")] +fn from_markdown_str_real_docs(bencher: divan::Bencher) { + let doc = real_doc(); + bencher + .counter(divan::counter::BytesCount::of_str(&doc)) + .bench(|| mq_markdown::Markdown::from_markdown_str(&doc).unwrap()); +} + +// Many short lines, to stress line splitting. +#[divan::bench(name = "from_markdown_str/many_lines")] +fn from_markdown_str_many_lines(bencher: divan::Bencher) { + let doc = "line of plain text\n".repeat(20_000); + bencher + .counter(divan::counter::BytesCount::of_str(&doc)) + .bench(|| mq_markdown::Markdown::from_markdown_str(&doc).unwrap()); } diff --git a/crates/mq-markdown/src/lib.rs b/crates/mq-markdown/src/lib.rs index cf2963cfe..9e42c3664 100644 --- a/crates/mq-markdown/src/lib.rs +++ b/crates/mq-markdown/src/lib.rs @@ -11,6 +11,11 @@ //! - **MDX Support**: Parse and manipulate MDX (Markdown + JSX) content //! - **JSON Export**: Serialize markdown AST to JSON (with `json` feature) //! - **Configurable Rendering**: Customize output formatting and styles +//! - **Built-in parser**: CommonMark, GFM (tables, strikethrough, autolink literals, task lists, +//! footnotes), frontmatter, math and MDX are parsed, and rendered to HTML, by a parser of this crate. +//! - **Obsidian syntax**: with the `wikilink`, `embed` and `callout` features, `[[links]]`, `![[embeds]]` and +//! `> [!callouts]` are parsed, and rendered to HTML with the classes Obsidian uses (`internal-link`, `callout`). +//! Link targets are used as URLs as written. //! //! ## Quick Start //! @@ -44,7 +49,7 @@ //! //! let mut doc = "- Item 1\n- Item 2".parse::().unwrap(); //! doc.set_options(RenderOptions { -//! list_style: ListStyle::Plus, +//! list_style: Some(ListStyle::Plus), //! ..Default::default() //! }); //! @@ -82,15 +87,18 @@ mod html_to_markdown; mod markdown; mod node; mod node_kind; -pub use markdown::{Markdown, to_html}; +mod parser; +pub use markdown::{Markdown, ParseOptions, to_html}; pub use node::{ Blockquote, Break, Code, CodeInline, ColorTheme, Definition, Delete, Emphasis, Footnote, FootnoteRef, Fragment, - Heading, HorizontalRule, Html, Image, ImageRef, Link, LinkRef, List, ListStyle, Math, MathInline, - MdxFlowExpression, MdxJsEsm, MdxJsxFlowElement, MdxJsxTextElement, MdxTextExpression, Node, Point, Position, - RenderOptions, Strong, TableAlign, TableAlignKind, TableCell, TableRow, Text, Title, TitleSurroundStyle, Toml, Url, + Heading, HeadingDepth, HorizontalRule, HorizontalRuleMarker, Html, Image, ImageRef, Link, LinkRef, List, + ListMarker, ListStyle, Math, MathInline, MdxAttributeContent, MdxAttributeValue, MdxFlowExpression, MdxJsEsm, + MdxJsxAttribute, MdxJsxFlowElement, MdxJsxTextElement, MdxTextExpression, Node, Point, Position, RenderOptions, + Strong, TableAlign, TableAlignKind, TableCell, TableRow, Text, Title, TitleSurroundStyle, Toml, Url, UrlSurroundStyle, Yaml, attr_value::AttrValue, }; pub use node_kind::{AttrSpec, AttrType, NodeKind}; +pub use parser::{MdxDiagnostic, MdxError, MdxErrorKind, MdxFound, MdxPlace}; #[cfg(feature = "wikilink")] pub use node::WikiLink; diff --git a/crates/mq-markdown/src/markdown.rs b/crates/mq-markdown/src/markdown.rs index 8ef692b52..92b2aa8b4 100644 --- a/crates/mq-markdown/src/markdown.rs +++ b/crates/mq-markdown/src/markdown.rs @@ -2,17 +2,44 @@ use crate::html_to_markdown; #[cfg(feature = "html-to-markdown")] use crate::html_to_markdown::ConversionOptions; +#[cfg(feature = "html-to-markdown")] +use crate::node::ListStyle; use crate::node::{ - Code, ColorTheme, Node, Position, RenderOptions, TableAlign, TableCell, list_own_prefix_width, reindent_all_lines, - render_values, + Code, ColorTheme, Node, Position, RenderOptions, TableAlign, TableCell, indent_lines, list_own_prefix_width, + reindent_all_lines, render_before, render_cell_values, render_values_block, separated_from, }; -use markdown::{CompileOptions, Constructs, Options, ParseOptions}; +#[cfg(any(feature = "json", feature = "html-to-markdown"))] use miette::miette; use std::{fmt, str::FromStr}; use table_layout::{TableLayout, write_padded_cell}; +pub(crate) mod list_order; mod table_layout; +/// What reading Markdown or MDX turns on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ParseOptions { + /// Read YAML between `---` lines, or TOML between `+++` lines, at the start of the input as frontmatter. + /// Without it these lines are a rule and text, or a heading. + pub frontmatter: bool, +} + +impl ParseOptions { + /// Sets whether frontmatter at the start of the input is read as such. + #[must_use] + pub fn with_frontmatter(mut self, frontmatter: bool) -> Self { + self.frontmatter = frontmatter; + self + } +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { frontmatter: true } + } +} + #[derive(Debug, Clone)] pub struct Markdown { pub nodes: Vec, @@ -33,6 +60,16 @@ impl fmt::Display for Markdown { } } +/// The line where the block before a node ends. An item that ends in a fence without a closing one +/// ends at the start of the line after it, which is not yet a line of its own. +fn last_line(position: &Position, node: Option<&Node>) -> usize { + if matches!(node, Some(Node::List(_))) && position.end.column == 1 && position.end.line > position.start.line { + position.end.line - 1 + } else { + position.end.line + } +} + impl Markdown { pub fn new(nodes: Vec) -> Self { Self { @@ -57,7 +94,7 @@ impl Markdown { self.render_with_theme(theme) } - fn render_with_theme(&self, theme: &ColorTheme<'_>) -> String { + pub(crate) fn render_with_theme(&self, theme: &ColorTheme<'_>) -> String { let mut pre_position: Option = None; let mut is_first = true; let mut current_table_row: Option = None; @@ -66,23 +103,34 @@ impl Markdown { // Sibling list items only carry a `level`, not a parent link; reconstructs // nested-list indentation from the nearest open ancestor at each level. let mut list_indent_stack: Vec = Vec::new(); + // What `render_before` returned for the node before, to pick a delimiter that does not fuse with it. + let mut previous_rendered: Option = None; + + let reordered = list_order::reorder(&self.nodes); + let nodes: &[Node] = reordered.as_ref().map_or(&self.nodes, |reordered| &reordered.nodes); + let is_continuation = |index: usize| { + reordered + .as_ref() + .is_some_and(|reordered| reordered.continuation[index]) + }; + let mut buffer = String::with_capacity(nodes.len() * 50); - let mut buffer = String::with_capacity(self.nodes.len() * 50); + for (i, node) in nodes.iter().enumerate() { + let prev_node = i.checked_sub(1).and_then(|j| nodes.get(j)); - for (i, node) in self.nodes.iter().enumerate() { if let Node::TableCell(TableCell { row, column, values, .. }) = node { let is_new_table = current_table.as_ref().is_none_or(|t| i >= t.end); if is_new_table { - current_table = Some(TableLayout::compute(&self.nodes, &self.options, i)); + current_table = Some(TableLayout::compute(nodes, &self.options, i)); } let table = current_table.as_ref().unwrap(); let align = table.align_for(*column); let width = table.width_for(*column); - let value = render_values(values, &self.options, theme); + let value = render_cell_values(values, &self.options, theme); let plain_width = table.plain_width_at(*row, *column); let is_new_row = current_table_row != Some(*row); @@ -97,11 +145,19 @@ impl Markdown { buffer.push('\n'); } else if let Some(pos) = node.position() { // Insert newlines before the first row of a table - let new_line_count = pre_position + let mut new_line_count = pre_position .as_ref() - .map(|p| pos.start.line.saturating_sub(p.end.line)) + .map(|p| pos.start.line.saturating_sub(last_line(p, prev_node))) .unwrap_or_else(|| if is_first { 0 } else { 1 }) .min(2); + // A footnote ends where the next line starts, and a single newline after a + // block quote reads back as lazy continuation: a table needs a blank line. + if new_line_count < 2 + && i > 0 + && matches!(prev_node, Some(Node::Footnote(_) | Node::Blockquote(_))) + { + new_line_count = 2; + } for _ in 0..new_line_count { buffer.push('\n'); } @@ -115,7 +171,7 @@ impl Markdown { write_padded_cell(&mut buffer, &value, plain_width, width, &align); - let next_node = self.nodes.get(i + 1); + let next_node = nodes.get(i + 1); let next_is_different_row = next_node.is_none_or( |next| !matches!(next, Node::TableCell(TableCell { row: next_row, .. }) if *next_row == *row), ); @@ -133,7 +189,7 @@ impl Markdown { if let Node::TableAlign(TableAlign { .. }) = node { if current_table.as_ref().is_none_or(|t| i >= t.end) { - current_table = Some(TableLayout::compute(&self.nodes, &self.options, i)); + current_table = Some(TableLayout::compute(nodes, &self.options, i)); } let table = current_table.as_ref().unwrap(); buffer.push_str(&table.render_separator()); @@ -148,15 +204,22 @@ impl Markdown { current_table = None; in_table = false; - let prev_node = i.checked_sub(1).and_then(|j| self.nodes.get(j)); - - let value = if let Node::List(list) = node { + let previous = previous_rendered.take(); + let mut plain = false; + let value = if let Node::List(list) = node + && is_continuation(i) + { + // The rest of an item after its nested items, aligned with the content of the item. + list_indent_stack.truncate(list.level as usize + 1); + let indent = list_indent_stack.last().copied().unwrap_or(0); + indent_lines(&render_values_block(&list.values, &self.options, theme), indent) + } else if let Node::List(list) = node { while list_indent_stack.len() > list.level as usize { list_indent_stack.pop(); } let own_indent = list_indent_stack.last().copied().unwrap_or(0); let delta = own_indent as isize - list.level as isize * 2; - let own_width = own_indent + list_own_prefix_width(list.ordered, list.index, list.start, list.checked); + let own_width = own_indent + list_own_prefix_width(list.ordered, list.index, list.start); list_indent_stack.push(own_width); reindent_all_lines(&node.render_with_theme(&self.options, theme), delta) } else if let Node::Code(code) = node @@ -172,8 +235,17 @@ impl Markdown { .render_with_theme(&self.options, theme) } else { list_indent_stack.clear(); - node.render_with_theme(&self.options, theme) + plain = true; + render_before( + node, + &nodes[..i], + previous.as_deref(), + nodes.get(i + 1), + &self.options, + theme, + ) }; + previous_rendered = plain.then(|| value.clone()); if value.is_empty() || value == "\n" { pre_position = None; @@ -183,14 +255,20 @@ impl Markdown { if let Some(pos) = node.position() { let mut new_line_count = pre_position .as_ref() - .map(|p| pos.start.line.saturating_sub(p.end.line)) + .map(|p| pos.start.line.saturating_sub(last_line(p, prev_node))) .unwrap_or_else(|| if is_first { 0 } else { 1 }) .min(2); - // Single newline after a block quote reads back as lazy continuation. - if new_line_count < 2 && prev_node.is_some_and(Node::is_blockquote_like) { - new_line_count = 2; - } + new_line_count = separated_from(new_line_count, prev_node, node, &value); + + // An item that starts on the line of the marker that holds it follows it directly. + let value = + if new_line_count == 0 && matches!(node, Node::List(_)) && matches!(prev_node, Some(Node::List(_))) + { + value.trim_start().to_string() + } else { + value + }; // Same-list adjacent items separate by `spread`, not source line gap. if let Node::List(cur_list) = node @@ -202,6 +280,11 @@ impl Markdown { new_line_count = if cur_list.spread { 2 } else { 1 }; } + // The last row of a table ends with its own line ending, so one more makes the blank line. + if matches!(prev_node, Some(Node::TableCell(_) | Node::TableAlign(_))) { + new_line_count = 1; + } + pre_position = Some(pos.clone()); for _ in 0..new_line_count { @@ -224,27 +307,36 @@ impl Markdown { } } - if buffer.is_empty() || buffer.ends_with('\n') { - buffer - } else { + if !buffer.is_empty() && !buffer.ends_with('\n') { buffer.push('\n'); - buffer } + // A rule of dashes first and a line of three dashes later would read as frontmatter. + if matches!(nodes.first(), Some(Node::HorizontalRule(_))) + && buffer.starts_with("---\n") + && buffer[4..].lines().any(|line| line == "---") + { + buffer.replace_range(..3, "***"); + } + buffer } pub fn from_mdx_str(content: &str) -> miette::Result { - let root = markdown::to_mdast(content, &markdown::ParseOptions::mdx()).map_err(|e| miette!(e.reason))?; - let nodes = Node::from_mdast_node(root); + Self::from_mdx_str_with(content, ParseOptions::default()) + } + /// Reads MDX with the given options. + pub fn from_mdx_str_with(content: &str, options: ParseOptions) -> miette::Result { Ok(Self { - nodes, - options: RenderOptions::default(), + nodes: parse_mdx_nodes(content, options)?, + options: RenderOptions { + mdx: true, + ..RenderOptions::default() + }, }) } pub fn to_html(&self) -> String { - let md_str = self.to_string(); - markdown::to_html_with_options(&md_str, &html_options()).unwrap_or_else(|_| markdown::to_html(&md_str)) + to_html(&crate::node::for_html(|| self.to_string())) } pub fn to_text(&self) -> String { @@ -276,198 +368,44 @@ impl Markdown { html_to_markdown::convert_html_to_markdown(content, options) .map_err(|e| miette!(e)) .and_then(|md_string| Self::from_markdown_str(&md_string)) + .map(|mut markdown| { + // The converter's `*` bullets are an artifact, not a source style to preserve. + markdown.options.list_style = Some(ListStyle::Dash); + markdown + }) } pub fn from_markdown_str(content: &str) -> miette::Result { - let root = markdown::to_mdast( - content, - &markdown::ParseOptions { - gfm_strikethrough_single_tilde: true, - math_text_single_dollar: true, - mdx_expression_parse: None, - mdx_esm_parse: None, - constructs: Constructs { - attention: true, - autolink: true, - block_quote: true, - character_escape: true, - character_reference: true, - code_indented: true, - code_fenced: true, - code_text: true, - definition: true, - frontmatter: true, - gfm_autolink_literal: true, - gfm_label_start_footnote: true, - gfm_footnote_definition: true, - gfm_strikethrough: true, - gfm_table: true, - gfm_task_list_item: true, - hard_break_escape: true, - hard_break_trailing: true, - heading_atx: true, - heading_setext: true, - html_flow: true, - html_text: true, - label_start_image: true, - label_start_link: true, - label_end: true, - list_item: true, - math_flow: true, - math_text: true, - mdx_esm: false, - mdx_expression_flow: false, - mdx_expression_text: false, - mdx_jsx_flow: false, - mdx_jsx_text: false, - thematic_break: true, - }, - }, - ) - .map_err(|e| miette!(e.reason))?; - let nodes = Node::from_mdast_node(root); - - #[cfg(all(feature = "embed", feature = "wikilink"))] - let nodes = if content.contains("[[") { - Node::expand_inline_links(nodes) - } else { - nodes - }; + Self::from_markdown_str_with(content, ParseOptions::default()) + } - #[cfg(all(feature = "embed", not(feature = "wikilink")))] - let nodes = if content.contains("![[") { - Node::expand_embeds(nodes) - } else { - nodes - }; - - #[cfg(all(feature = "wikilink", not(feature = "embed")))] - let nodes = if content.contains("[[") { - Node::expand_wikilinks(nodes) - } else { - nodes - }; + /// Reads Markdown with the given options. + pub fn from_markdown_str_with(content: &str, options: ParseOptions) -> miette::Result { + let nodes = parse_nodes(content, options)?; Ok(Self { nodes, options: RenderOptions::default(), }) } +} - /// Parses markdown without running any expand pass. Used in benchmarks to - /// isolate expand cost from the mdast parse cost. - #[cfg(any(feature = "wikilink", feature = "embed"))] - pub fn from_markdown_str_no_expand(content: &str) -> miette::Result> { - let root = markdown::to_mdast( - content, - &markdown::ParseOptions { - gfm_strikethrough_single_tilde: true, - math_text_single_dollar: true, - mdx_expression_parse: None, - mdx_esm_parse: None, - constructs: markdown::Constructs { - attention: true, - autolink: true, - block_quote: true, - character_escape: true, - character_reference: true, - code_indented: true, - code_fenced: true, - code_text: true, - definition: true, - frontmatter: true, - gfm_autolink_literal: true, - gfm_label_start_footnote: true, - gfm_footnote_definition: true, - gfm_strikethrough: true, - gfm_table: true, - gfm_task_list_item: true, - hard_break_escape: true, - hard_break_trailing: true, - heading_atx: true, - heading_setext: true, - html_flow: true, - html_text: true, - label_start_image: true, - label_start_link: true, - label_end: true, - list_item: true, - math_flow: true, - math_text: true, - mdx_esm: false, - mdx_expression_flow: false, - mdx_expression_text: false, - mdx_jsx_flow: false, - mdx_jsx_text: false, - thematic_break: true, - }, - }, - ) - .map_err(|e| miette::miette!(e.reason))?; - Ok(Node::from_mdast_node(root)) - } +/// Parses `content` into nodes. +fn parse_nodes(content: &str, options: ParseOptions) -> miette::Result> { + crate::parser::parse_with(content, options.frontmatter) } -/// Returns the shared `Options` used for both `Markdown::to_html` and the -/// standalone `to_html` helper. The options mirror the constructs that are -/// enabled during parsing (see `from_markdown_str`) so that every feature -/// that can be *parsed* is also correctly *rendered* to HTML, including: -/// -/// - GFM tables → `` -/// - GFM task-list items → `` -/// - GFM strikethrough → `` -/// - GFM footnotes -/// - GFM autolink literals -/// - Math (flow and inline) via `` -/// - YAML / TOML frontmatter (stripped from HTML output) -fn html_options() -> Options { - Options { - parse: ParseOptions { - gfm_strikethrough_single_tilde: true, - math_text_single_dollar: true, - constructs: Constructs { - attention: true, - autolink: true, - block_quote: true, - character_escape: true, - character_reference: true, - code_indented: true, - code_fenced: true, - code_text: true, - definition: true, - frontmatter: true, - gfm_autolink_literal: true, - gfm_label_start_footnote: true, - gfm_footnote_definition: true, - gfm_strikethrough: true, - gfm_table: true, - gfm_task_list_item: true, - hard_break_escape: true, - hard_break_trailing: true, - heading_atx: true, - heading_setext: true, - html_flow: true, - html_text: true, - label_start_image: true, - label_start_link: true, - label_end: true, - list_item: true, - math_flow: true, - math_text: true, - thematic_break: true, - ..Constructs::default() - }, - ..ParseOptions::default() - }, - compile: CompileOptions { - allow_dangerous_html: true, - ..CompileOptions::default() - }, - } +/// Parses `content` as MDX into nodes. +fn parse_mdx_nodes(content: &str, options: ParseOptions) -> miette::Result> { + crate::parser::parse_mdx_with(content, options.frontmatter) } +/// Converts Markdown to HTML. +/// +/// Everything that is parsed is rendered: GFM tables, task list items, strikethrough, footnotes and +/// autolink literals, math, and raw HTML. Frontmatter is left out. pub fn to_html(s: &str) -> String { - markdown::to_html_with_options(s, &html_options()).unwrap_or_else(|_| markdown::to_html(s)) + crate::parser::to_html(s) } #[cfg(test)] @@ -509,7 +447,7 @@ mod tests { #[case::footnote("[^a]: b", 1, "[^a]: b\n")] #[case::definition("[a]: b", 1, "[a]: b\n")] #[case::footnote("[^a]: b", 1, "[^a]: b\n")] - #[case::footnote_ref("[^a]: b\n\n[^a]", 2, "[^a]: b\n[^a]\n")] + #[case::footnote_ref("[^a]: b\n\n[^a]", 2, "[^a]: b\n\n[^a]\n")] #[case::image("![a](b)", 1, "![a](b)\n")] #[case::image_with_title("![a](b \"c\")", 1, "![a](b \"c\")\n")] #[case::image_ref("[a]: b\n\n ![c][a]", 2, "[a]: b\n\n![c][a]\n")] @@ -523,7 +461,12 @@ mod tests { #[case::break_("a\\b", 1, "a\\\\b\n")] #[case::delete("~~a~~", 1, "~~a~~\n")] #[case::emphasis("*a*", 1, "*a*\n")] - #[case::horizontal_rule("---", 1, "***\n")] + #[case::horizontal_rule("---", 1, "---\n")] + #[case::horizontal_rule_star("***", 1, "***\n")] + #[case::horizontal_rule_underscore("___", 1, "___\n")] + #[case::list_star("* a\n* b", 2, "* a\n* b\n")] + #[case::list_plus("+ a\n+ b", 2, "+ a\n+ b\n")] + #[case::list_ordered_paren("3) a\n4) b", 2, "3) a\n4) b\n")] #[case::table( "| Column1 | Column2 | Column3 |\n|:--------|:--------:|---------:|\n| Left | Center | Right |\n", 7, @@ -569,6 +512,9 @@ mod tests { 10, "| A | B |\n| - | - |\n| 1 | 2 |\n\n| C | D |\n| - | - |\n| 3 | 4 |\n" )] + // the line ending of the last row is part of the blank line before what follows + #[case::table_then_paragraph("| A |\n|---|\n| 1 |\n\npara\n", 4, "| A |\n| - |\n| 1 |\n\npara\n")] + #[case::table_then_rule("| A |\n|---|\n| 1 |\n---\n", 4, "| A |\n| - |\n| 1 |\n\n---\n")] // adjacent tables of different widths must not share column layout. #[case::tables_adjacent_different_widths( "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C |\n|---|\n| 3 |\n", @@ -642,8 +588,44 @@ mod tests { #[case::fence_switches_to_tilde_on_backtick_in_info("~~~ aa ``` ~~~\nfoo\n~~~", 1, "~~~aa ``` ~~~\nfoo\n~~~\n")] // a sibling right after a block quote needs a blank line, or it's read as lazy continuation #[case::blockquote_sibling_gets_blank_line_separator("> bar\n>\nbaz", 2, "> bar\n\nbaz\n")] + #[case::dash_rule_under_text_stays_a_rule("Foo\n--- -\n", 2, "Foo\n\n---\n")] + #[case::dash_rule_under_emphasis_stays_a_rule("*Foo*\n- - -\n", 2, "*Foo*\n\n---\n")] + #[case::star_rule_under_text_needs_no_blank_line("Foo\n***\n", 2, "Foo\n***\n")] + #[cfg_attr( + feature = "wikilink", + case::dash_rule_under_wikilink_stays_a_rule("[[note]]\n- - -\n", 2, "[[note]]\n\n---\n") + )] + #[cfg_attr( + feature = "embed", + case::dash_rule_under_embed_stays_a_rule("![[note]]\n- - -\n", 2, "![[note]]\n\n---\n") + )] + #[case::dash_rule_under_inline_html_stays_a_rule("x\n- - -\n", 4, "x\n\n---\n")] // items whose marker sits alone on its own line must stay tight, not go loose #[case::list_tight_despite_marker_on_own_line("-\n foo\n-\n bar", 2, "- foo\n- bar\n")] + // an empty task item must not take the paragraph after it + #[case::empty_task_item_then_paragraph("- [ ] \n\npara", 2, "- [ ] \n\npara\n")] + #[case::empty_checked_ordered_task_item_then_paragraph("1. [x] \n\npara", 2, "1. [x] \n\npara\n")] + // a fence that is not closed ends with its item, and what follows needs a line of its own + #[case::quote_after_an_unclosed_fence_in_an_item("- ```\n a\n> q\n", 2, "- ```\n a\n ```\n> q\n")] + // emphasis next to emphasis, or nested at its edge, keeps its own run of delimiters + #[case::adjacent_emphasis("_*_*] x*_", 3, "*\\**_\\] x_\\_\n")] + #[case::emphasis_in_emphasis_at_an_edge("***a*b*", 2, "\\*_*a*b_\n")] + #[case::emphasis_after_emphasis_after_a_nested_one("\\**_*a***b**)", 4, "\\*_\\_*a*_*b*\\*)\n")] + #[case::strong_after_emphasis("*a*__b__", 2, "*a*__b__\n")] + #[case::emphasis_in_the_next_paragraph("*a*\n\n*b*", 2, "*a*\n\n*b*\n")] + #[case::strong_in_the_next_paragraph("*a*\n\n**b**", 2, "*a*\n\n**b**\n")] + #[case::emphasis_nested_three_deep("***a***", 1, "***a***\n")] + #[case::reference_then_colon_is_not_a_definition("[x] a\n\n[x]: /u", 3, "[x] a\n\n[x]: /u\n")] + #[case::bang_before_a_link_in_the_next_paragraph("a!\n\n[b](u)", 2, "a!\n\n[b](u)\n")] + #[case::colon_after_a_reference_in_the_next_paragraph("[x]\n\n:a\n\n[x]: /u", 3, "[x]\n\n:a\n\n[x]: /u\n")] + // lists in a block quote: items on one line, the rest of an item after nested ones, nested under an ordered one + #[case::quote_list_in_list_on_one_line("> - - - x", 1, "> - - - x\n")] + #[case::quote_paragraph_after_nested_item("> - a\n> - b\n>\n> para", 1, "> - a\n> - b\n> \n> para\n")] + #[case::quote_bullet_nested_under_ordered("> 1. a\n> - b", 1, "> 1. a\n> - b\n")] + // inside a container a block after a quote or footnote needs a blank line, and empty math stays empty + #[case::quote_after_nested_quote("> > a\n> >\n> b", 1, "> > a\n> \n> b\n")] + #[case::paragraph_after_footnote_in_item("- [^1]: b\n\n c", 1, "- [^1]: b\n\n c\n")] + #[case::empty_math("$$\n$$", 1, "$$\n$$\n")] // an indented code block right after a list must not read back as list continuation #[case::code_after_list_uses_fence(" - one\n\n two", 2, "- one\n\n```\n two\n```\n")] // a block quote nested in a list item needs its continuation line's own indent @@ -682,6 +664,8 @@ mod tests { 1, "\n" )] + #[case::dash_rule_under_text_expression("a {b}\n- - -\n", 3, "a {b}\n\n---\n")] + #[case::dash_rule_under_jsx_text("a \n- - -\n", 3, "a \n\n---\n")] fn test_markdown_from_mdx_str(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) { let md = Markdown::from_mdx_str(input).unwrap(); assert_eq!(md.nodes.len(), expected_nodes); @@ -725,6 +709,18 @@ mod tests { assert_eq!(first, "[https://example.com](https://example.com)\n"); } + #[rstest] + #[case::paragraphs_in_an_item("- a\n\n b\n\n c\n", "- a\n\n b\n\n c\n")] + // the order of a paragraph after a nested list is lost, but its content is not + #[case::paragraph_after_a_nested_list("- a\n\n - n\n\n para\n", "- a\n\n para\n - n\n")] + #[case::paragraphs_in_an_ordered_item("1. a\n\n b\n", "1. a\n\n b\n")] + #[case::paragraphs_in_a_quote_in_an_item("- > a\n >\n > b\n", "- > a\n > \n > b\n")] + fn test_paragraphs_stay_apart_without_positions(#[case] input: &str, #[case] expected: &str) { + let mut md = Markdown::from_markdown_str(input).unwrap(); + md.nodes.iter_mut().for_each(Node::strip_positions); + assert_eq!(md.to_string(), expected); + } + #[test] fn test_markdown_to_html() { let md = "# Hello".parse::().unwrap(); @@ -834,10 +830,10 @@ mod tests { assert_eq!(md.options, RenderOptions::default()); md.set_options(RenderOptions { - list_style: ListStyle::Plus, + list_style: Some(ListStyle::Plus), ..RenderOptions::default() }); - assert_eq!(md.options.list_style, ListStyle::Plus); + assert_eq!(md.options.list_style, Some(ListStyle::Plus)); let pretty = md.to_string(); assert!(pretty.contains("+ Item 1")); @@ -872,9 +868,10 @@ mod tests { let mut md = "- Item 1\n- Item 2".parse::().unwrap(); md.set_options(RenderOptions { - list_style: ListStyle::Star, + list_style: Some(ListStyle::Star), link_title_style: TitleSurroundStyle::default(), link_url_style: UrlSurroundStyle::default(), + ..RenderOptions::default() }); let formatted = md.to_string(); @@ -909,7 +906,7 @@ mod color_tests { #[case::link("[text](url)", "\x1b[4m\x1b[34m[text](url)\x1b[0m\n")] #[case::image("![alt](url)", "\x1b[35m![alt](url)\x1b[0m\n")] #[case::delete("~~deleted~~", "\x1b[31m\x1b[2m~~deleted~~\x1b[0m\n")] - #[case::horizontal_rule("---", "\x1b[2m***\x1b[0m\n")] + #[case::horizontal_rule("---", "\x1b[2m---\x1b[0m\n")] #[case::blockquote("> quote", "\x1b[2m> \x1b[0mquote\n")] #[case::math_inline("$x^2$", "\x1b[32m$x^2$\x1b[0m\n")] #[case::list("- item", "\x1b[33m-\x1b[0m item\n")] @@ -1033,6 +1030,29 @@ mod json_tests { assert!(json.contains("\"values\":")); } + #[test] + fn test_to_json_mdx_attributes() { + let md = Markdown::from_mdx_str("").unwrap(); + let json: serde_json::Value = serde_json::from_str(&md.to_json().unwrap()).unwrap(); + let attributes = &json[0]["attributes"]; + assert_eq!( + attributes[0], + serde_json::json!({"type": "property", "value": {"type": "MdxJsxAttribute", "name": "b", "value": {"type": "literal", "value": "c"}}}) + ); + assert_eq!( + attributes[1], + serde_json::json!({"type": "property", "value": {"type": "MdxJsxAttribute", "name": "d", "value": {"type": "expression", "value": "e"}}}) + ); + assert_eq!( + attributes[2], + serde_json::json!({"type": "property", "value": {"type": "MdxJsxAttribute", "name": "f", "value": null}}) + ); + assert_eq!( + attributes[3], + serde_json::json!({"type": "expression", "value": "...g"}) + ); + } + #[test] fn test_to_json_complex() { let md = "# Header\n\n- Item 1\n- Item 2\n\n*Emphasis* and **Strong**" diff --git a/crates/mq-markdown/src/markdown/list_order.rs b/crates/mq-markdown/src/markdown/list_order.rs new file mode 100644 index 000000000..5a74a4751 --- /dev/null +++ b/crates/mq-markdown/src/markdown/list_order.rs @@ -0,0 +1,142 @@ +//! Puts the children of a list item back in order with the items nested in it. +//! +//! A flat list holds the items nested in an item right after it, even when some of the children of +//! that item come after them in the source, like a paragraph that follows a nested list. Rendering +//! the nodes as they are would write those children before the nested items. + +use crate::node::{List, Node, Position}; + +/// Nodes in source order. +pub(crate) struct Reordered { + pub(crate) nodes: Vec, + /// For each node, whether it holds the rest of an item that follows the items nested in it. It is + /// rendered as its content, indented to the content of the item, without a marker. + pub(crate) continuation: Vec, +} + +impl Reordered { + fn push(&mut self, node: Node) { + self.nodes.push(node); + self.continuation.push(false); + } + + fn push_continuation(&mut self, node: Node) { + self.nodes.push(node); + self.continuation.push(true); + } +} + +/// Returns the nodes in source order, or `None` when they already are. +pub(crate) fn reorder(nodes: &[Node]) -> Option { + // Some child of an item starts after the item nested right after it. + let needed = nodes.windows(2).any(|pair| match (&pair[0], &pair[1]) { + (Node::List(item), Node::List(next)) if next.level > item.level => { + let nested = start_line(&pair[1]); + item.values + .iter() + .filter_map(start_line) + .any(|line| Some(line) > nested) + } + _ => false, + }); + if !needed { + return None; + } + let mut out = Reordered { + nodes: Vec::with_capacity(nodes.len() + 4), + continuation: Vec::with_capacity(nodes.len() + 4), + }; + reorder_into(nodes, &mut out); + Some(out) +} + +fn start_line(node: &Node) -> Option { + node.position().map(|position| position.start.line) +} + +fn reorder_into(nodes: &[Node], out: &mut Reordered) { + let mut index = 0; + while index < nodes.len() { + let node = &nodes[index]; + let Node::List(item) = node else { + out.push(node.clone()); + index += 1; + continue; + }; + let nested = nodes[index + 1..] + .iter() + .take_while(|next| matches!(next, Node::List(next) if next.level > item.level)) + .count(); + let lines: Option> = item.values.iter().map(start_line).collect(); + let (true, Some(lines)) = (!item.values.is_empty() && nested > 0, lines) else { + out.push(node.clone()); + index += 1; + continue; + }; + + let run = &nodes[index + 1..index + 1 + nested]; + let Node::List(first) = &run[0] else { unreachable!() }; + let segment_level = first.level; + // Each item nested directly in this one, with the items nested in those. + let mut segments: Vec<&[Node]> = Vec::new(); + let mut from = 0; + for at in 1..=run.len() { + if at == run.len() || matches!(&run[at], Node::List(next) if next.level <= segment_level) { + segments.push(&run[from..at]); + from = at; + } + } + let segment_lines: Option> = segments.iter().map(|segment| start_line(&segment[0])).collect(); + let Some(segment_lines) = segment_lines else { + out.push(node.clone()); + index += 1; + continue; + }; + + // The children before the first nested item stay with the item. + let head = lines.iter().take_while(|line| **line < segment_lines[0]).count(); + out.push(Node::List(List { + values: item.values[..head].to_vec(), + position: span(&item.values[..head]).or_else(|| { + // Without children it ends where it starts, so the nested item follows the marker. + item.position.clone().map(|position| Position { + end: position.start.clone(), + start: position.start, + }) + }), + ..item.clone() + })); + + let mut child = head; + for (segment, line) in segments.iter().zip(&segment_lines) { + let from = child; + while child < item.values.len() && lines[child] < *line { + child += 1; + } + if child > from { + out.push_continuation(continuation(item, &item.values[from..child])); + } + reorder_into(segment, out); + } + if child < item.values.len() { + out.push_continuation(continuation(item, &item.values[child..])); + } + index += 1 + nested; + } +} + +fn continuation(item: &List, values: &[Node]) -> Node { + Node::List(List { + values: values.to_vec(), + position: span(values), + ..item.clone() + }) +} + +/// The position from the start of the first node to the end of the last. +fn span(values: &[Node]) -> Option { + Some(Position { + start: values.first()?.position()?.start, + end: values.last()?.position()?.end, + }) +} diff --git a/crates/mq-markdown/src/markdown/table_layout.rs b/crates/mq-markdown/src/markdown/table_layout.rs index c06c6fbfd..83ed0c934 100644 --- a/crates/mq-markdown/src/markdown/table_layout.rs +++ b/crates/mq-markdown/src/markdown/table_layout.rs @@ -1,4 +1,4 @@ -use crate::node::{ColorTheme, Node, RenderOptions, TableAlign, TableAlignKind, TableCell, render_values}; +use crate::node::{ColorTheme, Node, RenderOptions, TableAlign, TableAlignKind, TableCell, render_cell_values}; use unicode_width::UnicodeWidthStr; /// Per-column widths and alignment of a single contiguous table, computed by @@ -8,6 +8,8 @@ use unicode_width::UnicodeWidthStr; pub(super) struct TableLayout { widths: Vec, align: Vec, + /// Number of cells in the header row, which the delimiter row has to match. + header_columns: usize, /// Plain (color-free) display width of every cell, keyed by `(row, column)`, /// computed once during the scan so the main render pass never has to /// re-render a cell just to measure it. @@ -21,6 +23,7 @@ impl TableLayout { let mut widths: Vec = Vec::new(); let mut align: Vec = Vec::new(); let mut cell_widths = rustc_hash::FxHashMap::default(); + let mut header_columns = 0; let mut i = start; while let Some(node) = nodes.get(i) { @@ -32,6 +35,9 @@ impl TableLayout { if *row == 0 && *column == 0 && !align.is_empty() { break; } + if *row == 0 { + header_columns = header_columns.max(*column + 1); + } let width = cell_display_width(values, options); cell_widths.insert((*row, *column), width); if *column >= widths.len() { @@ -60,6 +66,7 @@ impl TableLayout { Self { widths, align, + header_columns, cell_widths, end: i, } @@ -82,9 +89,16 @@ impl TableLayout { } pub(super) fn render_separator(&self) -> String { + // The delimiter row has as many columns as the header, whatever a body row has more. + let columns = if self.header_columns == 0 { + self.widths.len() + } else { + self.header_columns + }; let segments = self .widths .iter() + .take(columns) .enumerate() .map(|(idx, &width)| { let align = self.align_for(idx); @@ -116,7 +130,7 @@ fn column_min_width(align: &TableAlignKind) -> usize { /// content, ignoring any ANSI color codes so padding stays correct with /// `--color` output. fn cell_display_width(values: &[Node], options: &RenderOptions) -> usize { - UnicodeWidthStr::width(render_values(values, options, &ColorTheme::PLAIN).as_str()) + UnicodeWidthStr::width(render_cell_values(values, options, &ColorTheme::PLAIN).as_str()) } /// Writes an already-rendered (possibly colored) cell value padded to `width` columns. diff --git a/crates/mq-markdown/src/node.rs b/crates/mq-markdown/src/node.rs index 48871b594..ced775fa6 100644 --- a/crates/mq-markdown/src/node.rs +++ b/crates/mq-markdown/src/node.rs @@ -3,7 +3,6 @@ use crate::node::attr_value::{ attr_keys::{self, CHILDREN}, }; use itertools::Itertools; -use markdown::mdast::{self}; use smol_str::SmolStr; use std::{ borrow::Cow, @@ -12,7 +11,17 @@ use std::{ type ColorPair<'a> = (Cow<'a, str>, Cow<'a, str>); +mod attr; pub mod attr_value; +mod expand; +mod render; +pub(crate) use render::{ + for_html, indent_lines, list_own_prefix_width, reindent_all_lines, render_before, render_cell_values, + render_values_block, separated_from, values_to_string, +}; +use render::{render_link_destination, render_link_title}; +#[cfg(test)] +mod tests; /// Color theme for rendering markdown nodes with optional ANSI escape codes. /// @@ -145,18 +154,16 @@ impl ColorTheme<'_> { } } -type Level = u8; - -pub const EMPTY_NODE: Node = Node::Text(Text { - value: String::new(), - position: None, -}); +pub(crate) type Level = u8; #[derive(Debug, Clone, Default, PartialEq)] pub struct RenderOptions { - pub list_style: ListStyle, + /// Bullet marker for lists. `None` keeps each list's original marker (`-` if unknown). + pub list_style: Option, pub link_url_style: UrlSurroundStyle, pub link_title_style: TitleSurroundStyle, + /// Write for MDX: text that MDX would read as an expression or as ESM is escaped. + pub mdx: bool, } #[derive(Debug, Clone, Default, PartialEq)] @@ -177,13 +184,115 @@ impl Display for ListStyle { } } +/// The marker of a list item as it is written in the source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +pub enum ListMarker { + /// `-`, a bullet. + #[cfg_attr(feature = "json", serde(rename = "-"))] + Dash, + /// `+`, a bullet. + #[cfg_attr(feature = "json", serde(rename = "+"))] + Plus, + /// `*`, a bullet. + #[cfg_attr(feature = "json", serde(rename = "*"))] + Star, + /// `.` after the number of an ordered item. + #[cfg_attr(feature = "json", serde(rename = "."))] + Period, + /// `)` after the number of an ordered item. + #[cfg_attr(feature = "json", serde(rename = ")"))] + Paren, +} + +impl ListMarker { + /// The character the marker is written with. + pub fn as_char(self) -> char { + match self { + Self::Dash => '-', + Self::Plus => '+', + Self::Star => '*', + Self::Period => '.', + Self::Paren => ')', + } + } + + /// The marker written with `char`: `-`, `+`, `*`, `.` or `)`. + pub fn from_char(char: char) -> Option { + match char { + '-' => Some(Self::Dash), + '+' => Some(Self::Plus), + '*' => Some(Self::Star), + '.' => Some(Self::Period), + ')' => Some(Self::Paren), + _ => None, + } + } + + /// The bullet style of the marker, if it is a bullet. + pub fn style(self) -> Option { + match self { + Self::Dash => Some(ListStyle::Dash), + Self::Plus => Some(ListStyle::Plus), + Self::Star => Some(ListStyle::Star), + Self::Period | Self::Paren => None, + } + } +} + +impl From for ListMarker { + fn from(style: ListStyle) -> Self { + match style { + ListStyle::Dash => Self::Dash, + ListStyle::Plus => Self::Plus, + ListStyle::Star => Self::Star, + } + } +} + +/// The character a horizontal rule is written with in the source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +pub enum HorizontalRuleMarker { + /// `*` + #[cfg_attr(feature = "json", serde(rename = "*"))] + Star, + /// `-` + #[cfg_attr(feature = "json", serde(rename = "-"))] + Dash, + /// `_` + #[cfg_attr(feature = "json", serde(rename = "_"))] + Underscore, +} + +impl HorizontalRuleMarker { + /// The character the rule is written with. + pub fn as_char(self) -> char { + match self { + Self::Star => '*', + Self::Dash => '-', + Self::Underscore => '_', + } + } + + /// The marker written with `char`: `*`, `-` or `_`. + pub fn from_char(char: char) -> Option { + match char { + '*' => Some(Self::Star), + '-' => Some(Self::Dash), + '_' => Some(Self::Underscore), + _ => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Default)] #[cfg_attr( feature = "json", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "camelCase") )] -pub struct Url(String); +pub struct Url(pub(crate) String); #[derive(Debug, Clone, PartialEq, Default)] pub enum UrlSurroundStyle { @@ -220,7 +329,7 @@ pub enum TitleSurroundStyle { derive(serde::Serialize, serde::Deserialize), serde(rename_all = "camelCase") )] -pub struct Title(String); +pub struct Title(pub(crate) String); impl Display for Title { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -255,17 +364,6 @@ pub enum TableAlignKind { None, } -impl From for TableAlignKind { - fn from(value: mdast::AlignKind) -> Self { - match value { - mdast::AlignKind::Left => Self::Left, - mdast::AlignKind::Right => Self::Right, - mdast::AlignKind::Center => Self::Center, - mdast::AlignKind::None => Self::None, - } - } -} - impl From<&str> for TableAlignKind { fn from(value: &str) -> Self { match value { @@ -306,6 +404,9 @@ pub struct List { /// Starting number for an ordered list (e.g. `5` in `5. foo`); `None` means 1. #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))] pub start: Option, + /// Source marker. `None` keeps the default of the list kind: `-` for bullets, `.` for ordered lists. + #[cfg_attr(feature = "json", serde(default, skip_serializing_if = "Option::is_none"))] + pub marker: Option, #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))] pub position: Option, } @@ -423,8 +524,11 @@ pub struct Link { serde(rename_all = "camelCase", tag = "type") )] pub struct Callout { - /// The callout type in uppercase (e.g. `"NOTE"`, `"WARNING"`, `"TIP"`). + /// The callout type as written, e.g. `"NOTE"` or `"warning"`. pub kind: String, + /// The fold marker right after `[!TYPE]`: `+` for open and `-` for folded. + #[cfg_attr(feature = "json", serde(default, skip_serializing_if = "Option::is_none"))] + pub fold: Option, /// Optional custom title after the `[!TYPE]` marker. pub title: Option, /// Body content nodes (the lines after the header). @@ -507,6 +611,132 @@ pub struct LinkRef { pub position: Option, } +/// The depth of a heading, from 1 to 6. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "json", + derive(serde::Serialize, serde::Deserialize), + serde(try_from = "u8", into = "u8") +)] +pub struct HeadingDepth(u8); + +impl fmt::Debug for HeadingDepth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(&self.0, f) + } +} + +impl HeadingDepth { + pub const H1: Self = Self(1); + pub const H2: Self = Self(2); + pub const H3: Self = Self(3); + pub const H4: Self = Self(4); + pub const H5: Self = Self(5); + pub const H6: Self = Self(6); + + /// The depth `depth`, if it is from 1 to 6. + pub const fn new(depth: u8) -> Option { + if depth >= 1 && depth <= 6 { + Some(Self(depth)) + } else { + None + } + } + + /// The depth nearest to `depth` from 1 to 6. + pub fn saturating(depth: i64) -> Self { + Self(depth.clamp(1, 6) as u8) + } + + pub const fn get(self) -> u8 { + self.0 + } + + /// The depth `levels` deeper, if that is still at most 6. + pub fn checked_add(self, levels: u8) -> Option { + self.0.checked_add(levels).and_then(Self::new) + } + + /// The depth `levels` shallower, or 1 when that is less. + pub fn saturating_sub(self, levels: u8) -> Self { + Self(self.0.saturating_sub(levels).max(1)) + } +} + +impl Default for HeadingDepth { + fn default() -> Self { + Self::H1 + } +} + +impl TryFrom for HeadingDepth { + type Error = InvalidHeadingDepth; + + fn try_from(depth: u8) -> Result { + Self::new(depth).ok_or(InvalidHeadingDepth(depth)) + } +} + +impl From for u8 { + fn from(depth: HeadingDepth) -> Self { + depth.0 + } +} + +impl PartialEq for HeadingDepth { + fn eq(&self, other: &u8) -> bool { + self.0 == *other + } +} + +impl PartialEq for u8 { + fn eq(&self, other: &HeadingDepth) -> bool { + *self == other.0 + } +} + +impl PartialOrd for HeadingDepth { + fn partial_cmp(&self, other: &u8) -> Option { + self.0.partial_cmp(other) + } +} + +impl PartialOrd for u8 { + fn partial_cmp(&self, other: &HeadingDepth) -> Option { + self.partial_cmp(&other.0) + } +} + +impl From for usize { + fn from(depth: HeadingDepth) -> Self { + usize::from(depth.0) + } +} + +impl From for i64 { + fn from(depth: HeadingDepth) -> Self { + i64::from(depth.0) + } +} + +impl Display for HeadingDepth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +/// A heading depth outside of 1 to 6. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidHeadingDepth(pub u8); + +impl Display for InvalidHeadingDepth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "heading depth {} is not from 1 to 6", self.0) + } +} + +impl std::error::Error for InvalidHeadingDepth {} + #[derive(Debug, Clone, PartialEq, Default)] #[cfg_attr( feature = "json", @@ -514,7 +744,7 @@ pub struct LinkRef { serde(rename_all = "camelCase", tag = "type") )] pub struct Heading { - pub depth: u8, + pub depth: HeadingDepth, pub values: Vec, #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))] pub position: Option, @@ -648,8 +878,9 @@ pub struct MdxJsxFlowElement { #[cfg_attr( feature = "json", derive(serde::Serialize, serde::Deserialize), - serde(rename_all = "camelCase", tag = "type") + serde(rename_all = "camelCase", tag = "type", content = "value") )] +/// An attribute of a JSX element: a property such as `a="b"`, or an expression such as `{...props}`. pub enum MdxAttributeContent { Expression(SmolStr), Property(MdxJsxAttribute), @@ -661,6 +892,7 @@ pub enum MdxAttributeContent { derive(serde::Serialize, serde::Deserialize), serde(rename_all = "camelCase", tag = "type") )] +/// A property of a JSX element, with the value it has after its quotes or braces. pub struct MdxJsxAttribute { pub name: SmolStr, pub value: Option, @@ -670,8 +902,9 @@ pub struct MdxJsxAttribute { #[cfg_attr( feature = "json", derive(serde::Serialize, serde::Deserialize), - serde(rename_all = "camelCase", tag = "type") + serde(rename_all = "camelCase", tag = "type", content = "value") )] +/// The value of a JSX property: a string, or an expression in braces. pub enum MdxAttributeValue { Expression(SmolStr), Literal(SmolStr), @@ -781,6 +1014,9 @@ pub struct Break { serde(rename_all = "camelCase", tag = "type") )] pub struct HorizontalRule { + /// Source marker. `None` renders as `***`. + #[cfg_attr(feature = "json", serde(default, skip_serializing_if = "Option::is_none"))] + pub marker: Option, #[cfg_attr(feature = "json", serde(skip_serializing_if = "Option::is_none"))] pub position: Option, } @@ -869,32 +1105,19 @@ pub struct Position { pub end: Point, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr( feature = "json", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "camelCase") )] pub struct Point { + /// The one-based line. pub line: usize, + /// The one-based column, counted in UTF-16 code units. A tab is one column. pub column: usize, } -impl From for Position { - fn from(value: markdown::unist::Position) -> Self { - Self { - start: Point { - line: value.start.line, - column: value.start.column, - }, - end: Point { - line: value.end.line, - column: value.end.column, - }, - } - } -} - impl From for Node { fn from(value: String) -> Self { Self::Text(Text { value, position: None }) @@ -1007,21 +1230,13 @@ impl Node { /// /// Leaf nodes become [`Node::Empty`]. This is the consuming counterpart to /// [`Self::to_fragment`]. - pub fn into_fragment(self) -> Node { + pub fn into_fragment(mut self) -> Node { + if let Some(values) = self.fragment_values_mut() { + return Self::Fragment(Fragment { + values: std::mem::take(values), + }); + } match self { - Node::List(List { values, .. }) - | Node::TableCell(TableCell { values, .. }) - | Node::TableRow(TableRow { values, .. }) - | Node::Link(Link { values, .. }) - | Node::Footnote(Footnote { values, .. }) - | Node::LinkRef(LinkRef { values, .. }) - | Node::Heading(Heading { values, .. }) - | Node::Blockquote(Blockquote { values, .. }) - | Node::Delete(Delete { values, .. }) - | Node::Emphasis(Emphasis { values, .. }) - | Node::Strong(Strong { values, .. }) => Self::Fragment(Fragment { values }), - #[cfg(feature = "callout")] - Node::Callout(Callout { values, .. }) => Self::Fragment(Fragment { values }), node @ Node::Fragment(_) => node, _ => Self::Empty, } @@ -1032,7 +1247,53 @@ impl Node { } fn _apply_fragment(node: &mut Node, fragment: Node) { - match node { + let (Some(values), Node::Fragment(Fragment { values: new_values })) = (node.fragment_values_mut(), fragment) + else { + return; + }; + let new_values = values + .iter() + .zip(new_values) + .map(|(current_value, new_value)| { + if new_value.is_empty() { + current_value.clone() + } else if new_value.is_fragment() { + let mut current_value = current_value.clone(); + Self::_apply_fragment(&mut current_value, new_value); + current_value + } else { + new_value + } + }) + .collect::>(); + *values = new_values; + } + + /// The children of a node that has them, which [`Self::set_children`] replaces. + fn children_mut(&mut self) -> Option<&mut Vec> { + match self { + Node::Footnote(Footnote { values, .. }) + | Node::Link(Link { values, .. }) + | Node::Heading(Heading { values, .. }) + | Node::List(List { values, .. }) + | Node::TableCell(TableCell { values, .. }) + | Node::TableRow(TableRow { values, .. }) + | Node::Strong(Strong { values, .. }) + | Node::Blockquote(Blockquote { values, .. }) + | Node::Delete(Delete { values, .. }) + | Node::Emphasis(Emphasis { values, .. }) + | Node::Fragment(Fragment { values }) => Some(values), + #[cfg(feature = "callout")] + Node::Callout(Callout { values, .. }) => Some(values), + Node::MdxJsxFlowElement(MdxJsxFlowElement { children, .. }) + | Node::MdxJsxTextElement(MdxJsxTextElement { children, .. }) => Some(children), + _ => None, + } + } + + /// The children that a fragment made from this node holds, and that applying a fragment maps onto. + fn fragment_values_mut(&mut self) -> Option<&mut Vec> { + match self { Node::List(List { values, .. }) | Node::TableCell(TableCell { values, .. }) | Node::TableRow(TableRow { values, .. }) @@ -1043,437 +1304,30 @@ impl Node { | Node::Blockquote(Blockquote { values, .. }) | Node::Delete(Delete { values, .. }) | Node::Emphasis(Emphasis { values, .. }) - | Node::Strong(Strong { values, .. }) => { - if let Node::Fragment(Fragment { values: new_values }) = fragment { - let new_values = values - .iter() - .zip(new_values) - .map(|(current_value, new_value)| { - if new_value.is_empty() { - current_value.clone() - } else if new_value.is_fragment() { - let mut current_value = current_value.clone(); - Self::_apply_fragment(&mut current_value, new_value); - current_value - } else { - new_value - } - }) - .collect::>(); - *values = new_values; - } - } + | Node::Strong(Strong { values, .. }) => Some(values), #[cfg(feature = "callout")] - Node::Callout(Callout { values, .. }) => { - if let Node::Fragment(Fragment { values: new_values }) = fragment { - let new_values = values - .iter() - .zip(new_values) - .map(|(current_value, new_value)| { - if new_value.is_empty() { - current_value.clone() - } else if new_value.is_fragment() { - let mut current_value = current_value.clone(); - Self::_apply_fragment(&mut current_value, new_value); - current_value - } else { - new_value - } - }) - .collect::>(); - *values = new_values; - } - } - _ => {} + Node::Callout(Callout { values, .. }) => Some(values), + _ => None, } } - pub fn to_string_with(&self, options: &RenderOptions) -> String { - self.render_with_theme(options, &ColorTheme::PLAIN) - } - - /// Returns a colored string representation of this node using ANSI escape codes. - #[cfg(feature = "color")] - pub fn to_colored_string_with(&self, options: &RenderOptions) -> String { - self.render_with_theme(options, &ColorTheme::COLORED) - } - - pub(crate) fn render_with_theme(&self, options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + /// The children whose value [`Self::into_with_children_value`] replaces and whose text position + /// [`Self::clear_text_position_at`] clears. + fn editable_values_mut(&mut self) -> Option<&mut Vec> { match self { - Self::List(List { - level, - checked, - values, - ordered, - index, - start, - .. - }) => { - let marker = if *ordered { - format!("{}.", start.unwrap_or(1) as usize + *index) - } else { - options.list_style.to_string() - }; - let checkbox = (*checked).map(|it| if it { "[x] " } else { "[ ] " }).unwrap_or(""); - let prefix_width = *level as usize * 2 + list_own_prefix_width(*ordered, *index, *start, *checked); - // A block quote/callout child needs a flat prefix_width add, not the delta below. - let delta = if values.first().is_some_and(Self::is_blockquote_like) { - prefix_width as isize - } else { - // Marker width is normalized, shifting continuation lines' source columns. - let original_first_column = values.first().and_then(|v| v.position()).map(|p| p.start.column); - original_first_column - .map(|c| prefix_width as isize - (c as isize - 1)) - .unwrap_or(0) - }; - let content = reindent_continuation(&render_values(values, options, theme), delta); - let content = reindent_first_leaf_block(content, values.first(), options, theme, prefix_width); - let (ms, me) = &theme.list_marker; - format!( - "{}{}{}{} {}{}", - " ".repeat(*level as usize), - ms, - marker, - me, - checkbox, - content - ) - } - Self::TableRow(TableRow { values, .. }) => { - let (ts, te) = &theme.table_separator; - let cells = values - .iter() - .map(|cell| cell.render_with_theme(options, theme)) - .collect::>() - .join("|"); - format!("{}|{}{}|", ts, te, cells) - } - Self::TableCell(TableCell { values, .. }) => render_values(values, options, theme), - Self::TableAlign(TableAlign { align, .. }) => { - let (ts, te) = &theme.table_separator; - format!("{}|{}|{}", ts, align.iter().map(|a| a.to_string()).join("|"), te) - } - Self::Blockquote(Blockquote { values, .. }) => { - let (bs, be) = &theme.blockquote_marker; - render_values_block(values, options, theme) - .split('\n') - .map(|line| format!("{}> {}{}", bs, be, line)) - .join("\n") - } + Node::Blockquote(Blockquote { values, .. }) + | Node::Delete(Delete { values, .. }) + | Node::Emphasis(Emphasis { values, .. }) + | Node::List(List { values, .. }) + | Node::TableCell(TableCell { values, .. }) + | Node::Strong(Strong { values, .. }) + | Node::LinkRef(LinkRef { values, .. }) + | Node::Heading(Heading { values, .. }) => Some(values), #[cfg(feature = "callout")] - Self::Callout(Callout { - kind, title, values, .. - }) => { - let (bs, be) = &theme.blockquote_marker; - let header = match title.as_deref() { - Some(t) if !t.is_empty() => format!("[!{}] {}", kind, t), - _ => format!("[!{}]", kind), - }; - let header_line = format!("{}> {}{}", bs, be, header); - if values.is_empty() { - return header_line; - } - let body = render_values_block(values, options, theme); - if body.trim().is_empty() { - header_line - } else { - let body_lines = body - .split('\n') - .map(|line| format!("{}> {}{}", bs, be, line)) - .join("\n"); - format!("{}\n{}", header_line, body_lines) - } - } - #[cfg(feature = "embed")] - Self::Embed(Embed { target, display, .. }) => match display.as_deref() { - Some(d) if !d.is_empty() => format!("![[{}|{}]]", target, d), - _ => format!("![[{}]]", target), - }, - Self::Code(Code { - value, - lang, - fence, - meta, - .. - }) => { - let (cs, ce) = &theme.code; - if lang.is_some() || *fence { - let meta = meta.as_deref().map(|meta| format!(" {}", meta)).unwrap_or_default(); - let info = format!("{}{}", lang.as_deref().unwrap_or(""), meta); - // Empty body skips the content line so it doesn't gain a blank one. - let fence_str = code_fence(value, &info); - if value.is_empty() { - format!("{}{}{}\n{}{}", cs, fence_str, info, fence_str, ce) - } else { - format!("{}{}{}\n{}\n{}{}", cs, fence_str, info, value, fence_str, ce) - } - } else { - value.lines().map(|line| format!("{} {}{}", cs, line, ce)).join("\n") - } - } - Self::Definition(Definition { - ident, - label, - url, - title, - .. - }) => { - let (us, ue) = &theme.link_url; - format!( - "[{}]: {}{}{}{}", - escape_label(label.as_deref().unwrap_or(ident)), - us, - url.to_string_with(options), - ue, - title - .as_ref() - .map(|title| format!(" {}", title.to_string_with(options))) - .unwrap_or_default() - ) - } - Self::Delete(Delete { values, .. }) => { - let (ds, de) = &theme.delete; - format!("{}~~{}~~{}", ds, render_values(values, options, theme), de) - } - Self::Emphasis(Emphasis { values, .. }) => { - let (es, ee) = &theme.emphasis; - // A lone nested Emphasis child needs the other delimiter, or adjacent - // `*` `*` pairs fuse into `**` and reparse as Strong instead. - let delim = if matches!(values.as_slice(), [Self::Emphasis(_)]) { - "_" - } else { - "*" - }; - format!( - "{}{}{}{}{}", - es, - delim, - render_values(values, options, theme), - delim, - ee - ) - } - Self::Footnote(Footnote { values, ident, .. }) => { - format!("[^{}]: {}", escape_label(ident), render_values(values, options, theme)) - } - Self::FootnoteRef(FootnoteRef { label, .. }) => { - format!("[^{}]", escape_label(label.as_deref().unwrap_or_default())) - } - Self::Heading(Heading { depth, values, .. }) => { - let (hs, he) = &theme.heading; - let text = render_values(values, options, theme); - // Multi-line content must stay setext for depths 1-2; ATX has no setext form. - if text.contains('\n') && matches!(depth, 1 | 2) { - let underline = if *depth == 1 { "===" } else { "---" }; - format!("{}{}\n{}{}", hs, text, underline, he) - } else { - // A trailing `#` run reads back as an ATX closing sequence and gets - // stripped as syntax rather than kept as heading text. - let text = text.replace('\n', " "); - let text = match text.rfind(|c: char| c != '#') { - Some(i) => { - let hash_start = i + text[i..].chars().next().map_or(1, char::len_utf8); - if hash_start < text.len() { - format!("{}\\{}", &text[..hash_start], &text[hash_start..]) - } else { - text - } - } - None if !text.is_empty() => format!("\\{text}"), - _ => text, - }; - format!("{}{} {}{}", hs, "#".repeat(*depth as usize), text, he) - } - } - Self::Html(Html { value, .. }) => { - let (hs, he) = &theme.html; - format!("{}{}{}", hs, value, he) - } - Self::Image(Image { alt, url, title, .. }) => { - let (is, ie) = &theme.image; - format!( - "{}![{}]({}{}){}", - is, - escape_label(alt), - render_link_destination(url, &options.link_url_style), - title - .as_deref() - .map(|it| format!(" {}", render_link_title(it, &options.link_title_style))) - .unwrap_or_default(), - ie - ) - } - // Shortcut is safe only if `alt` normalizes back to `ident`; `ident` is raw - // (already correctly escaped), so escape_label would double it up. - Self::ImageRef(ImageRef { alt, ident, .. }) => { - let (is, ie) = &theme.image; - let mismatched = normalize_reference_identifier(alt) != ident.as_str(); - if mismatched || needs_broad_escaping(alt) { - format!("{}![{}][{}]{}", is, escape_label(alt), ident, ie) - } else { - format!("{}![{}]{}", is, escape_label(alt), ie) - } - } - Self::CodeInline(CodeInline { value, .. }) => { - let (cs, ce) = &theme.code_inline; - let fence = code_span_fence(value); - // Padding avoids fusing with an edge backtick and protects a genuine - // leading+trailing space from the parser's own space-stripping rule. - let all_spaces = value.chars().all(|c| c == ' '); - if value.starts_with('`') - || value.ends_with('`') - || (!all_spaces && value.starts_with(' ') && value.ends_with(' ')) - { - format!("{}{} {} {}{}", cs, fence, value, fence, ce) - } else { - format!("{}{}{}{}{}", cs, fence, value, fence, ce) - } - } - Self::MathInline(MathInline { value, .. }) => { - let (ms, me) = &theme.math; - format!("{}${}${}", ms, value, me) - } - Self::Link(Link { url, title, values, .. }) => { - let (ls, le) = &theme.link; - format!( - "{}[{}]({}{}){}", - ls, - render_values(values, options, theme), - url.to_string_with(options), - title - .as_ref() - .map(|title| format!(" {}", title.to_string_with(options))) - .unwrap_or_default(), - le - ) - } - #[cfg(feature = "wikilink")] - Self::WikiLink(WikiLink { target, text, .. }) => { - let (ls, le) = &theme.link; - match text.as_deref() { - Some(t) if t != target.as_str() => format!("{}[[{}|{}]]{}", ls, target, t, le), - _ => format!("{}[[{}]]{}", ls, target, le), - } - } - // Same reasoning as ImageRef, plus the same broad-escaping fallback. - Self::LinkRef(LinkRef { values, ident, .. }) => { - let (ls, le) = &theme.link; - let rendered = render_values(values, options, theme); - let plain = values_to_value(values); - let mismatched = normalize_reference_identifier(&plain) != ident.as_str(); - - if mismatched || needs_broad_escaping(&plain) { - format!("{}[{}][{}]{}", ls, rendered, ident, le) - } else { - format!("{}[{}]{}", ls, rendered, le) - } - } - Self::Math(Math { value, .. }) => { - let (ms, me) = &theme.math; - format!("{}$$\n{}\n$${}", ms, value, me) - } - // Only escape text parsed from real markdown (has a position); programmatic - // values like JSON output or attr lookups must stay untouched. - Self::Text(Text { value, position }) => { - if position.is_some() { - escape_text(value.clone()) - } else { - value.clone() - } - } - Self::MdxFlowExpression(mdx_flow_expression) => { - format!("{{{}}}", mdx_flow_expression.value) - } - Self::MdxJsxFlowElement(mdx_jsx_flow_element) => { - let name = mdx_jsx_flow_element.name.as_deref().unwrap_or_default(); - let attributes = if mdx_jsx_flow_element.attributes.is_empty() { - "".to_string() - } else { - format!( - " {}", - mdx_jsx_flow_element - .attributes - .iter() - .map(Self::mdx_attribute_content_to_string) - .join(" ") - ) - }; - - if mdx_jsx_flow_element.children.is_empty() { - format!("<{}{} />", name, attributes,) - } else { - format!( - "<{}{}>{}", - name, - attributes, - render_values(&mdx_jsx_flow_element.children, options, theme), - name - ) - } - } - Self::MdxJsxTextElement(mdx_jsx_text_element) => { - let name = mdx_jsx_text_element.name.as_deref().unwrap_or_default(); - let attributes = if mdx_jsx_text_element.attributes.is_empty() { - "".to_string() - } else { - format!( - " {}", - mdx_jsx_text_element - .attributes - .iter() - .map(Self::mdx_attribute_content_to_string) - .join(" ") - ) - }; - - if mdx_jsx_text_element.children.is_empty() { - format!("<{}{} />", name, attributes,) - } else { - format!( - "<{}{}>{}", - name, - attributes, - render_values(&mdx_jsx_text_element.children, options, theme), - name - ) - } - } - Self::MdxTextExpression(mdx_text_expression) => { - format!("{{{}}}", mdx_text_expression.value) - } - Self::MdxJsEsm(mdxjs_esm) => mdxjs_esm.value.to_string(), - Self::Strong(Strong { values, .. }) => { - let (ss, se) = &theme.strong; - format!( - "{}**{}**{}", - ss, - values - .iter() - .map(|value| value.render_with_theme(options, theme)) - .collect::(), - se - ) - } - Self::Yaml(Yaml { value, .. }) => { - let (fs, fe) = &theme.frontmatter; - format!("{}---\n{}\n---{}", fs, value, fe) - } - Self::Toml(Toml { value, .. }) => { - let (fs, fe) = &theme.frontmatter; - format!("{}+++\n{}\n+++{}", fs, value, fe) - } - Self::Break(_) => "\\\n".to_string(), - Self::HorizontalRule(_) => { - // `***` avoids ambiguity with a setext heading underline (`---`). - let (hs, he) = &theme.horizontal_rule; - format!("{}***{}", hs, he) - } - Self::Fragment(Fragment { values }) => values - .iter() - .filter(|value| !value.is_empty() && !value.is_empty_fragment()) - .map(|value| value.render_with_theme(options, theme)) - .join("\n"), - Self::Empty => String::new(), + Node::Callout(Callout { values, .. }) => Some(values), + Node::MdxJsxFlowElement(MdxJsxFlowElement { children, .. }) + | Node::MdxJsxTextElement(MdxJsxTextElement { children, .. }) => Some(children), + _ => None, } } @@ -1557,14 +1411,13 @@ impl Node { Self::Break { .. } => "break".into(), Self::Definition(_) => "definition".into(), Self::Delete(_) => "delete".into(), - Self::Heading(Heading { depth, .. }) => match depth { + Self::Heading(Heading { depth, .. }) => match depth.get() { 1 => "h1".into(), 2 => "h2".into(), 3 => "h3".into(), 4 => "h4".into(), 5 => "h5".into(), - 6 => "h6".into(), - _ => "h".into(), + _ => "h6".into(), }, Self::Emphasis(_) => "emphasis".into(), Self::Footnote(_) => "footnote".into(), @@ -1613,23 +1466,8 @@ impl Node { /// Sets the children nodes of the current node, if the node supports children. /// Nodes without children (e.g. `Text`, `Code`, `Image`) are left unchanged. pub fn set_children(&mut self, children: Vec) { - match self { - Node::Footnote(f) => f.values = children, - Node::Link(l) => l.values = children, - #[cfg(feature = "callout")] - Node::Callout(c) => c.values = children, - Node::Heading(h) => h.values = children, - Node::List(l) => l.values = children, - Node::TableCell(c) => c.values = children, - Node::TableRow(r) => r.values = children, - Node::Strong(s) => s.values = children, - Node::Blockquote(b) => b.values = children, - Node::Delete(d) => d.values = children, - Node::Emphasis(e) => e.values = children, - Node::Fragment(f) => f.values = children, - Node::MdxJsxFlowElement(m) => m.children = children, - Node::MdxJsxTextElement(m) => m.children = children, - _ => (), + if let Some(values) = self.children_mut() { + *values = children; } } @@ -1754,21 +1592,10 @@ impl Node { /// `into_with_children_value` would write into at `index`, leaving this node and other /// descendants untouched. Used to stop the renderer re-escaping an already-escaped leaf. pub fn clear_text_position_at(&mut self, index: usize) { - match self { - Self::Blockquote(v) => Self::clear_position_at(&mut v.values, index), - Self::Delete(v) => Self::clear_position_at(&mut v.values, index), - Self::Emphasis(v) => Self::clear_position_at(&mut v.values, index), - Self::List(v) => Self::clear_position_at(&mut v.values, index), - Self::TableCell(v) => Self::clear_position_at(&mut v.values, index), - Self::Strong(v) => Self::clear_position_at(&mut v.values, index), - Self::Heading(v) => Self::clear_position_at(&mut v.values, index), - Self::LinkRef(v) => Self::clear_position_at(&mut v.values, index), - Self::MdxJsxFlowElement(v) => Self::clear_position_at(&mut v.children, index), - Self::MdxJsxTextElement(v) => Self::clear_position_at(&mut v.children, index), - #[cfg(feature = "callout")] - Self::Callout(v) => Self::clear_position_at(&mut v.values, index), - Self::Text(text) => text.position = None, - _ => {} + if let Self::Text(text) = self { + text.position = None; + } else if let Some(values) = self.editable_values_mut() { + Self::clear_position_at(values, index); } } @@ -1871,6 +1698,35 @@ impl Node { matches!(self, Self::Blockquote(_)) } + /// True for the nodes that make up the text of a paragraph. + pub(crate) fn is_paragraph_text(&self) -> bool { + #[cfg(feature = "wikilink")] + if matches!(self, Self::WikiLink(_)) { + return true; + } + #[cfg(feature = "embed")] + if matches!(self, Self::Embed(_)) { + return true; + } + matches!( + self, + Self::Text(_) + | Self::Emphasis(_) + | Self::Strong(_) + | Self::Delete(_) + | Self::CodeInline(_) + | Self::MathInline(_) + | Self::Link(_) + | Self::LinkRef(_) + | Self::Image(_) + | Self::ImageRef(_) + | Self::FootnoteRef(_) + | Self::Html(_) + | Self::MdxJsxTextElement(_) + | Self::MdxTextExpression(_) + ) + } + /// True for block quotes and (when enabled) Obsidian-style callouts. pub(crate) fn is_blockquote_like(&self) -> bool { match self { @@ -1958,7 +1814,7 @@ impl Node { depth: heading_depth, .. }) = &self { - depth.is_none() || *heading_depth == depth.unwrap() + depth.is_none_or(|depth| heading_depth.get() == depth) } else { false } @@ -2161,5001 +2017,14 @@ impl Node { /// /// Prefer this over [`Self::with_children_value`] when the caller owns the node and does not /// need an unchanged copy. - pub fn into_with_children_value(self, value: &str, index: usize) -> Self { - match self { - Self::Blockquote(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::Blockquote(v) - } - Self::Delete(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::Delete(v) - } - Self::Emphasis(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::Emphasis(v) - } - Self::List(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::List(v) - } - Self::TableCell(mut v) => { - Self::replace_value_at(&mut v.values, index, value); + pub fn into_with_children_value(mut self, value: &str, index: usize) -> Self { + if let Some(values) = self.editable_values_mut() { + Self::replace_value_at(values, index, value); + } + self + } +} - Self::TableCell(v) - } - Self::Strong(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::Strong(v) - } - Self::LinkRef(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::LinkRef(v) - } - Self::Heading(mut v) => { - Self::replace_value_at(&mut v.values, index, value); - - Self::Heading(v) - } - Self::MdxJsxFlowElement(mut mdx) => { - Self::replace_value_at(&mut mdx.children, index, value); - - Self::MdxJsxFlowElement(MdxJsxFlowElement { - name: mdx.name, - attributes: mdx.attributes, - children: mdx.children, - ..mdx - }) - } - Self::MdxJsxTextElement(mut mdx) => { - Self::replace_value_at(&mut mdx.children, index, value); - - Self::MdxJsxTextElement(MdxJsxTextElement { - name: mdx.name, - attributes: mdx.attributes, - children: mdx.children, - ..mdx - }) - } - #[cfg(feature = "wikilink")] - a @ Self::WikiLink(_) => a, - #[cfg(feature = "callout")] - Self::Callout(mut c) => { - Self::replace_value_at(&mut c.values, index, value); - Self::Callout(c) - } - #[cfg(feature = "embed")] - a @ Self::Embed(_) => a, - a => a, - } - } - - /// Returns the value of the specified attribute, if present. - /// - /// `line`/`end_line` are handled here, not per-variant below, since `position` is common - /// to every node. - pub fn attr(&self, attr: &str) -> Option { - match attr { - attr_keys::LINE => return self.position().map(|p| AttrValue::Integer(p.start.line as i64)), - attr_keys::END_LINE => return self.position().map(|p| AttrValue::Integer(p.end.line as i64)), - _ => {} - } - - match self { - Node::Footnote(Footnote { ident, values, .. }) => match attr { - attr_keys::IDENT => Some(AttrValue::String(ident.clone())), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - Node::Html(Html { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - _ => None, - }, - Node::Text(Text { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - _ => None, - }, - Node::Code(Code { - value, - lang, - meta, - fence, - .. - }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - attr_keys::LANG => lang.clone().map(AttrValue::String), - attr_keys::META => meta.clone().map(AttrValue::String), - attr_keys::FENCE => Some(AttrValue::Boolean(*fence)), - _ => None, - }, - Node::CodeInline(CodeInline { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.to_string())), - _ => None, - }, - Node::MathInline(MathInline { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.to_string())), - _ => None, - }, - Node::Math(Math { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - _ => None, - }, - Node::Yaml(Yaml { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - _ => None, - }, - Node::Toml(Toml { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.clone())), - _ => None, - }, - Node::Image(Image { alt, url, title, .. }) => match attr { - attr_keys::ALT => Some(AttrValue::String(alt.clone())), - attr_keys::URL => Some(AttrValue::String(url.clone())), - attr_keys::TITLE => title.clone().map(AttrValue::String), - _ => None, - }, - Node::ImageRef(ImageRef { alt, ident, label, .. }) => match attr { - attr_keys::ALT => Some(AttrValue::String(alt.clone())), - attr_keys::IDENT => Some(AttrValue::String(ident.clone())), - attr_keys::LABEL => label.clone().map(AttrValue::String), - _ => None, - }, - Node::Link(Link { url, title, values, .. }) => match attr { - attr_keys::URL => Some(AttrValue::String(url.as_str().to_string())), - attr_keys::TITLE => title.as_ref().map(|t| AttrValue::String(t.to_value())), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - #[cfg(feature = "wikilink")] - Node::WikiLink(WikiLink { target, text, .. }) => match attr { - attr_keys::URL => Some(AttrValue::String(target.clone())), - attr_keys::VALUE => Some(AttrValue::String(text.clone().unwrap_or_else(|| target.clone()))), - _ => None, - }, - #[cfg(feature = "callout")] - Node::Callout(Callout { - kind, title, values, .. - }) => match attr { - attr_keys::KIND => Some(AttrValue::String(kind.clone())), - attr_keys::TITLE => title.clone().map(AttrValue::String), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - #[cfg(feature = "embed")] - Node::Embed(Embed { target, display, .. }) => match attr { - attr_keys::URL => Some(AttrValue::String(target.clone())), - attr_keys::VALUE => Some(AttrValue::String(display.clone().unwrap_or_else(|| target.clone()))), - _ => None, - }, - Node::LinkRef(LinkRef { ident, label, .. }) => match attr { - attr_keys::IDENT => Some(AttrValue::String(ident.clone())), - attr_keys::LABEL => label.clone().map(AttrValue::String), - _ => None, - }, - Node::FootnoteRef(FootnoteRef { ident, label, .. }) => match attr { - attr_keys::IDENT => Some(AttrValue::String(ident.clone())), - attr_keys::LABEL => label.clone().map(AttrValue::String), - _ => None, - }, - Node::Definition(Definition { - ident, - url, - title, - label, - .. - }) => match attr { - attr_keys::IDENT => Some(AttrValue::String(ident.clone())), - attr_keys::URL => Some(AttrValue::String(url.as_str().to_string())), - attr_keys::TITLE => title.as_ref().map(|t| AttrValue::String(t.to_value())), - attr_keys::LABEL => label.clone().map(AttrValue::String), - _ => None, - }, - Node::Heading(Heading { depth, values, .. }) => match attr { - attr_keys::DEPTH | attr_keys::LEVEL => Some(AttrValue::Integer(*depth as i64)), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - Node::List(List { - index, - level, - ordered, - checked, - values, - .. - }) => match attr { - attr_keys::INDEX => Some(AttrValue::Integer(*index as i64)), - attr_keys::LEVEL => Some(AttrValue::Integer(*level as i64)), - attr_keys::ORDERED => Some(AttrValue::Boolean(*ordered)), - attr_keys::CHECKED => checked.map(AttrValue::Boolean), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - Node::TableCell(TableCell { - column, row, values, .. - }) => match attr { - attr_keys::COLUMN => Some(AttrValue::Integer(*column as i64)), - attr_keys::ROW => Some(AttrValue::Integer(*row as i64)), - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - Node::TableAlign(TableAlign { align, .. }) => match attr { - attr_keys::ALIGN => Some(AttrValue::String( - align.iter().map(|a| a.to_string()).collect::>().join(","), - )), - _ => None, - }, - Node::MdxFlowExpression(MdxFlowExpression { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.to_string())), - _ => None, - }, - Node::MdxTextExpression(MdxTextExpression { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.to_string())), - _ => None, - }, - Node::MdxJsEsm(MdxJsEsm { value, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(value.to_string())), - _ => None, - }, - Node::MdxJsxFlowElement(MdxJsxFlowElement { name, children, .. }) => match attr { - attr_keys::NAME => name.clone().map(AttrValue::String), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(children.clone())), - _ => None, - }, - Node::MdxJsxTextElement(MdxJsxTextElement { name, children, .. }) => match attr { - attr_keys::NAME => name.as_ref().map(|n| AttrValue::String(n.to_string())), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(children.clone())), - _ => None, - }, - Node::Strong(Strong { values, .. }) - | Node::Blockquote(Blockquote { values, .. }) - | Node::Delete(Delete { values, .. }) - | Node::Emphasis(Emphasis { values, .. }) - | Node::TableRow(TableRow { values, .. }) - | Node::Fragment(Fragment { values, .. }) => match attr { - attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), - attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), - _ => None, - }, - Node::Break(_) | Node::HorizontalRule(_) | Node::Empty => None, - } - } - - /// Sets the value of the specified attribute for the node, if supported. - pub fn set_attr(&mut self, attr: &str, value: impl Into) { - let value = value.into(); - let value_str = value.as_string(); - - match self { - Node::Footnote(f) => { - if attr == attr_keys::IDENT { - f.ident = value_str; - } - } - Node::Html(h) => { - if attr == attr_keys::VALUE { - h.value = value_str; - } - } - Node::Text(t) => { - if attr == attr_keys::VALUE { - t.value = value_str; - } - } - Node::Code(c) => match attr { - attr_keys::VALUE => { - c.value = value_str; - } - attr_keys::LANG | "language" => { - c.lang = if value_str.is_empty() { None } else { Some(value_str) }; - } - attr_keys::META => { - c.meta = if value_str.is_empty() { None } else { Some(value_str) }; - } - attr_keys::FENCE => { - c.fence = match value { - AttrValue::Boolean(b) => b, - _ => value_str == "true", - }; - } - _ => (), - }, - Node::CodeInline(ci) => { - if attr == attr_keys::VALUE { - ci.value = value_str.into(); - } - } - Node::MathInline(mi) => { - if attr == attr_keys::VALUE { - mi.value = value_str.into(); - } - } - Node::Math(m) => { - if attr == attr_keys::VALUE { - m.value = value_str; - } - } - Node::Yaml(y) => { - if attr == attr_keys::VALUE { - y.value = value_str; - } - } - Node::Toml(t) => { - if attr == attr_keys::VALUE { - t.value = value_str; - } - } - Node::Image(i) => match attr { - attr_keys::ALT => { - i.alt = value_str; - } - attr_keys::URL => { - i.url = value_str; - } - attr_keys::TITLE => { - i.title = if value_str.is_empty() { None } else { Some(value_str) }; - } - _ => (), - }, - Node::ImageRef(i) => match attr { - attr_keys::ALT => { - i.alt = value_str; - } - attr_keys::IDENT => { - i.ident = value_str; - } - attr_keys::LABEL => { - i.label = if value_str.is_empty() { None } else { Some(value_str) }; - } - _ => (), - }, - Node::Link(l) => match attr { - attr_keys::URL => { - l.url = Url::new(value_str); - } - attr_keys::TITLE => { - l.title = if value_str.is_empty() { - None - } else { - Some(Title::new(value_str)) - }; - } - _ => (), - }, - Node::LinkRef(l) => match attr { - attr_keys::IDENT => { - l.ident = value_str; - } - attr_keys::LABEL => { - l.label = if value_str.is_empty() { None } else { Some(value_str) }; - } - _ => (), - }, - Node::FootnoteRef(f) => match attr { - attr_keys::IDENT => { - f.ident = value_str; - } - attr_keys::LABEL => { - f.label = if value_str.is_empty() { None } else { Some(value_str) }; - } - _ => (), - }, - Node::Definition(d) => match attr { - attr_keys::IDENT => { - d.ident = value_str; - } - attr_keys::URL => { - d.url = Url::new(value_str); - } - attr_keys::TITLE => { - d.title = if value_str.is_empty() { - None - } else { - Some(Title::new(value_str)) - }; - } - attr_keys::LABEL => { - d.label = if value_str.is_empty() { None } else { Some(value_str) }; - } - _ => (), - }, - Node::Heading(h) => match attr { - attr_keys::DEPTH | attr_keys::LEVEL => { - h.depth = match value { - AttrValue::Integer(i) => i as u8, - _ => value_str.parse::().unwrap_or(h.depth), - }; - } - _ => (), - }, - Node::List(l) => match attr { - attr_keys::INDEX => { - l.index = match value { - AttrValue::Integer(i) => i as usize, - _ => value_str.parse::().unwrap_or(l.index), - }; - } - attr_keys::LEVEL => { - l.level = match value { - AttrValue::Integer(i) => i as u8, - _ => value_str.parse::().unwrap_or(l.level), - }; - } - attr_keys::ORDERED => { - l.ordered = match value { - AttrValue::Boolean(b) => b, - _ => value_str == "true", - }; - } - attr_keys::CHECKED => { - l.checked = if value_str.is_empty() { - None - } else { - Some(match value { - AttrValue::Boolean(b) => b, - _ => value_str == "true", - }) - }; - } - _ => (), - }, - Node::TableCell(c) => match attr { - attr_keys::COLUMN => { - c.column = match value { - AttrValue::Integer(i) => i as usize, - _ => value_str.parse::().unwrap_or(c.column), - }; - } - attr_keys::ROW => { - c.row = match value { - AttrValue::Integer(i) => i as usize, - _ => value_str.parse::().unwrap_or(c.row), - }; - } - _ => (), - }, - Node::TableAlign(th) => { - if attr == attr_keys::ALIGN { - th.align = value_str.split(',').map(|s| s.trim().into()).collect(); - } - } - Node::MdxFlowExpression(m) => { - if attr == attr_keys::VALUE { - m.value = value_str.into(); - } - } - Node::MdxTextExpression(m) => { - if attr == attr_keys::VALUE { - m.value = value_str.into(); - } - } - Node::MdxJsEsm(m) => { - if attr == attr_keys::VALUE { - m.value = value_str.into(); - } - } - Node::MdxJsxFlowElement(m) => { - if attr == attr_keys::NAME { - m.name = if value_str.is_empty() { None } else { Some(value_str) }; - } - } - Node::MdxJsxTextElement(m) => { - if attr == attr_keys::NAME { - m.name = if value_str.is_empty() { - None - } else { - Some(value_str.into()) - }; - } - } - Node::Delete(_) - | Node::Blockquote(_) - | Node::Emphasis(_) - | Node::Strong(_) - | Node::TableRow(_) - | Node::Break(_) - | Node::HorizontalRule(_) - | Node::Fragment(_) - | Node::Empty => (), - #[cfg(feature = "wikilink")] - Node::WikiLink(w) => match attr { - attr_keys::URL => w.target = value_str, - attr_keys::VALUE => w.text = if value_str.is_empty() { None } else { Some(value_str) }, - _ => (), - }, - #[cfg(feature = "callout")] - Node::Callout(c) => match attr { - attr_keys::KIND => c.kind = value_str, - attr_keys::TITLE => c.title = if value_str.is_empty() { None } else { Some(value_str) }, - _ => (), - }, - #[cfg(feature = "embed")] - Node::Embed(e) => match attr { - attr_keys::URL => e.target = value_str, - attr_keys::VALUE => e.display = if value_str.is_empty() { None } else { Some(value_str) }, - _ => (), - }, - } - } - - /// Tries to parse a `Blockquote`'s converted nodes as an Obsidian callout. - /// - /// Returns a `Callout` node when the first text starts with `[!TYPE]`, otherwise - /// falls back to a plain `Blockquote`. - #[cfg(feature = "callout")] - fn try_parse_callout(values: Vec, position: Option) -> Node { - // Peek without cloning so plain blockquotes pay no allocation cost. - if !matches!(values.first(), Some(Node::Text(t)) if t.value.starts_with("[!")) { - return Node::Blockquote(Blockquote { values, position }); - } - - let mut iter = values.into_iter(); - // SAFETY: the peek above guarantees at least one Text node exists. - let first_text = match iter.next().unwrap() { - Node::Text(t) => t.value, - _ => unreachable!(), - }; - - let Some(rest) = first_text.strip_prefix("[!") else { - unreachable!() - }; - - let Some(bracket_end) = rest.find(']') else { - // Malformed `[!…` without closing `]` — treat as plain blockquote. - let mut values = vec![Node::Text(Text { - value: first_text, - position: None, - })]; - values.extend(iter); - return Node::Blockquote(Blockquote { values, position }); - }; - - let kind = rest[..bracket_end].to_string(); - let after_bracket = &rest[bracket_end + 1..]; - - let (title, remaining_body) = if let Some(nl) = after_bracket.find('\n') { - let t = after_bracket[..nl].trim(); - ( - if t.is_empty() { None } else { Some(t.to_string()) }, - after_bracket[nl + 1..].to_string(), - ) - } else { - let t = after_bracket.trim(); - (if t.is_empty() { None } else { Some(t.to_string()) }, String::new()) - }; - - let body = if !remaining_body.trim().is_empty() { - let rest_nodes: Vec = iter.collect(); - let mut b = Vec::with_capacity(rest_nodes.len() + 1); - b.push(Node::Text(Text { - value: remaining_body, - position: None, - })); - b.extend(rest_nodes); - b - } else { - iter.collect() - }; - - Node::Callout(Callout { - kind, - title, - values: body, - position, - }) - } - - /// Returns true if any `Text` descendant contains `[[`, covering both - /// embed (`![[`) and wikilink (`[[`) patterns. - #[cfg(any(feature = "embed", feature = "wikilink"))] - fn values_contain_links(values: &[Node]) -> bool { - values.iter().any(|n| match n { - Node::Text(t) => t.value.contains("[["), - Node::Heading(h) => Self::values_contain_links(&h.values), - Node::Blockquote(b) => Self::values_contain_links(&b.values), - #[cfg(feature = "callout")] - Node::Callout(c) => Self::values_contain_links(&c.values), - Node::List(l) => Self::values_contain_links(&l.values), - Node::Strong(s) => Self::values_contain_links(&s.values), - Node::Emphasis(e) => Self::values_contain_links(&e.values), - Node::Delete(d) => Self::values_contain_links(&d.values), - Node::TableCell(tc) => Self::values_contain_links(&tc.values), - Node::TableRow(tr) => Self::values_contain_links(&tr.values), - Node::Footnote(f) => Self::values_contain_links(&f.values), - _ => false, - }) - } - - /// Shared tree walker used by all expand functions. Recurses into container - /// nodes and delegates `Text` node processing to `parse_into`. - #[cfg(any(feature = "embed", feature = "wikilink"))] - fn expand_with(nodes: Vec, parse_into: fn(&str, Option, &mut Vec)) -> Vec { - let mut result = Vec::with_capacity(nodes.len()); - for node in nodes { - Self::expand_with_into(node, &mut result, parse_into); - } - result - } - - #[cfg(any(feature = "embed", feature = "wikilink"))] - fn expand_with_into(node: Node, out: &mut Vec, parse_into: fn(&str, Option, &mut Vec)) { - match node { - Node::Text(Text { ref value, .. }) if !value.contains("[[") => out.push(node), - Node::Text(Text { value, position }) => parse_into(&value, position, out), - Node::Heading(mut h) => { - if Self::values_contain_links(&h.values) { - h.values = Self::expand_with(h.values, parse_into); - } - out.push(Node::Heading(h)); - } - Node::Blockquote(mut b) => { - if Self::values_contain_links(&b.values) { - b.values = Self::expand_with(b.values, parse_into); - } - out.push(Node::Blockquote(b)); - } - #[cfg(feature = "callout")] - Node::Callout(mut c) => { - if Self::values_contain_links(&c.values) { - c.values = Self::expand_with(c.values, parse_into); - } - out.push(Node::Callout(c)); - } - Node::List(mut l) => { - if Self::values_contain_links(&l.values) { - l.values = Self::expand_with(l.values, parse_into); - } - out.push(Node::List(l)); - } - Node::Strong(mut s) => { - if Self::values_contain_links(&s.values) { - s.values = Self::expand_with(s.values, parse_into); - } - out.push(Node::Strong(s)); - } - Node::Emphasis(mut e) => { - if Self::values_contain_links(&e.values) { - e.values = Self::expand_with(e.values, parse_into); - } - out.push(Node::Emphasis(e)); - } - Node::Delete(mut d) => { - if Self::values_contain_links(&d.values) { - d.values = Self::expand_with(d.values, parse_into); - } - out.push(Node::Delete(d)); - } - Node::TableCell(mut tc) => { - if Self::values_contain_links(&tc.values) { - tc.values = Self::expand_with(tc.values, parse_into); - } - out.push(Node::TableCell(tc)); - } - Node::TableRow(mut tr) => { - if Self::values_contain_links(&tr.values) { - tr.values = Self::expand_with(tr.values, parse_into); - } - out.push(Node::TableRow(tr)); - } - Node::Footnote(mut f) => { - if Self::values_contain_links(&f.values) { - f.values = Self::expand_with(f.values, parse_into); - } - out.push(Node::Footnote(f)); - } - other => out.push(other), - } - } - - /// Recursively scans a node list and converts `![[target]]` / `![[target|display]]` - /// patterns inside `Text` nodes into `Embed` nodes. - #[cfg(feature = "embed")] - pub fn expand_embeds(nodes: Vec) -> Vec { - Self::expand_with(nodes, Self::parse_embeds_into) - } - - /// Splits a text string on `![[...]]` patterns, pushing a mix of `Text` - /// and `Embed` nodes into `out`. - #[cfg(feature = "embed")] - fn parse_embeds_into(text: &str, position: Option, out: &mut Vec) { - let mut last_end = 0; - let mut search_from = 0; - let mut found_any = false; - - while let Some(bang_rel) = text[search_from..].find("![[") { - let bang = search_from + bang_rel; - let inner_start = bang + 3; - - let Some(close_rel) = text[inner_start..].find("]]") else { - search_from = bang + 1; - continue; - }; - let close = inner_start + close_rel; - let content = &text[inner_start..close]; - - if content.contains('[') || content.contains(']') { - search_from = bang + 1; - continue; - } - - found_any = true; - if last_end < bang { - out.push(Node::Text(Text { - value: text[last_end..bang].to_string(), - position: position.clone(), - })); - } - let (target, display) = if let Some(pipe) = content.find('|') { - ( - content[..pipe].trim().to_string(), - Some(content[pipe + 1..].trim().to_string()), - ) - } else { - (content.trim().to_string(), None) - }; - out.push(Node::Embed(Embed { - target, - display, - position: position.clone(), - })); - last_end = close + 2; - search_from = close + 2; - } - - if !found_any { - out.push(Node::Text(Text { - value: text.to_string(), - position, - })); - return; - } - - if last_end < text.len() { - out.push(Node::Text(Text { - value: text[last_end..].to_string(), - position, - })); - } - } - - /// Recursively scans a node list and converts `[[target]]` / `[[target|text]]` - /// patterns inside `Text` nodes into `WikiLink` nodes. - #[cfg(feature = "wikilink")] - pub fn expand_wikilinks(nodes: Vec) -> Vec { - Self::expand_with(nodes, Self::parse_wikilinks_into) - } - - /// Splits a text string on `[[...]]` patterns, pushing a mix of `Text` - /// and `WikiLink` nodes into `out`. - #[cfg(feature = "wikilink")] - fn parse_wikilinks_into(text: &str, position: Option, out: &mut Vec) { - let mut last_end = 0; - let mut search_from = 0; - let mut found_any = false; - - while let Some(open_rel) = text[search_from..].find("[[") { - let open = search_from + open_rel; - let inner_start = open + 2; - - let Some(close_rel) = text[inner_start..].find("]]") else { - break; - }; - let close = inner_start + close_rel; - let content = &text[inner_start..close]; - - if content.contains('[') || content.contains(']') { - // nested brackets — skip past this '[' and retry - search_from = open + 1; - continue; - } - - found_any = true; - if last_end < open { - out.push(Node::Text(Text { - value: text[last_end..open].to_string(), - position: position.clone(), - })); - } - let (target, text_part) = if let Some(pipe) = content.find('|') { - (content[..pipe].trim(), Some(content[pipe + 1..].trim())) - } else { - (content.trim(), None) - }; - out.push(Node::WikiLink(WikiLink { - target: target.to_string(), - text: text_part.map(|t| t.to_string()), - position: position.clone(), - })); - last_end = close + 2; - search_from = close + 2; - } - - if !found_any { - out.push(Node::Text(Text { - value: text.to_string(), - position, - })); - return; - } - - if last_end < text.len() { - out.push(Node::Text(Text { - value: text[last_end..].to_string(), - position, - })); - } - } - - /// Splits a text string on `[[...]]` patterns, returning a mix of `Text` - /// and `WikiLink` nodes. Used only in tests. - #[cfg(all(feature = "wikilink", test))] - fn parse_wikilinks_in_text(text: &str, position: Option) -> Vec { - let mut result = Vec::new(); - Self::parse_wikilinks_into(text, position, &mut result); - result - } - - /// Recursively scans a node list and converts both `![[target]]` (embed) and - /// `[[target]]` (wikilink) patterns in a single tree traversal. More efficient - /// than running `expand_embeds` followed by `expand_wikilinks` separately, which - /// traverses the tree and scans each `Text` node twice. - #[cfg(all(feature = "embed", feature = "wikilink"))] - pub fn expand_inline_links(nodes: Vec) -> Vec { - Self::expand_with(nodes, Self::parse_inline_links_into) - } - - /// Splits a text string on `![[...]]` and `[[...]]` patterns in a single pass, - /// pushing a mix of `Text`, `Embed`, and `WikiLink` nodes into `out`. - /// A `[[` immediately preceded by `!` is treated as an embed; otherwise as a wikilink. - #[cfg(all(feature = "embed", feature = "wikilink"))] - fn parse_inline_links_into(text: &str, position: Option, out: &mut Vec) { - let mut last_end = 0; - let mut search_from = 0; - let mut found_any = false; - - while let Some(open_rel) = text[search_from..].find("[[") { - let open = search_from + open_rel; - let inner_start = open + 2; - - let Some(close_rel) = text[inner_start..].find("]]") else { - break; - }; - let close = inner_start + close_rel; - let content = &text[inner_start..close]; - - if content.contains('[') || content.contains(']') { - search_from = open + 1; - continue; - } - - let is_embed = text.as_bytes()[..open].last() == Some(&b'!'); - let token_start = if is_embed { open - 1 } else { open }; - - found_any = true; - if last_end < token_start { - out.push(Node::Text(Text { - value: text[last_end..token_start].to_string(), - position: position.clone(), - })); - } - - let (target, opt_part) = if let Some(pipe) = content.find('|') { - (content[..pipe].trim(), Some(content[pipe + 1..].trim())) - } else { - (content.trim(), None) - }; - - if is_embed { - out.push(Node::Embed(Embed { - target: target.to_string(), - display: opt_part.map(|s| s.to_string()), - position: position.clone(), - })); - } else { - out.push(Node::WikiLink(WikiLink { - target: target.to_string(), - text: opt_part.map(|s| s.to_string()), - position: position.clone(), - })); - } - - last_end = close + 2; - search_from = close + 2; - } - - if !found_any { - out.push(Node::Text(Text { - value: text.to_string(), - position, - })); - return; - } - - if last_end < text.len() { - out.push(Node::Text(Text { - value: text[last_end..].to_string(), - position, - })); - } - } - - pub(crate) fn from_mdast_node(node: mdast::Node) -> Vec { - match node { - mdast::Node::Root(root) => root - .children - .into_iter() - .flat_map(Self::from_mdast_node) - .collect::>(), - mdast::Node::ListItem(list_item) => list_item - .children - .into_iter() - .flat_map(Self::from_mdast_node) - .collect::>(), - mdast::Node::List(list) => Self::mdast_list_items(&list, 0), - mdast::Node::Table(table) => table - .children - .iter() - .enumerate() - .flat_map(|(row, n)| { - if let mdast::Node::TableRow(table_row) = n { - itertools::concat(vec![ - table_row - .children - .iter() - .enumerate() - .flat_map(|(column, node)| { - if let mdast::Node::TableCell(_) = node { - vec![Self::TableCell(TableCell { - row, - column, - values: Self::mdast_children_to_node(node.clone()), - position: node.position().map(|p| p.clone().into()), - })] - } else { - Vec::new() - } - }) - .collect(), - if row == 0 { - vec![Self::TableAlign(TableAlign { - align: table.align.iter().map(|a| (*a).into()).collect::>(), - position: n.position().map(|p| Position { - start: Point { - line: p.start.line + 1, - column: 1, - }, - end: Point { - line: p.start.line + 1, - column: 1, - }, - }), - })] - } else { - Vec::new() - }, - ]) - } else { - Vec::new() - } - }) - .collect(), - mdast::Node::Code(mdast::Code { - value, - position, - lang, - meta, - .. - }) => match lang { - Some(lang) => { - vec![Self::Code(Code { - value, - lang: Some(lang), - position: position.map(|p| p.clone().into()), - meta, - fence: true, - })] - } - None => { - let line_count = position - .as_ref() - .map(|p| p.end.line - p.start.line + 1) - .unwrap_or_default(); - let fence = value.lines().count() != line_count; - - vec![Self::Code(Code { - value, - lang, - position: position.map(|p| p.clone().into()), - meta, - fence, - })] - } - }, - mdast::Node::Blockquote(mdast::Blockquote { ref position, .. }) => { - let pos = position.clone().map(|p| p.into()); - let values = Self::mdast_children_to_node(node); - #[cfg(feature = "callout")] - { - vec![Self::try_parse_callout(values, pos)] - } - #[cfg(not(feature = "callout"))] - { - vec![Self::Blockquote(Blockquote { values, position: pos })] - } - } - mdast::Node::Definition(mdast::Definition { - url, - title, - identifier, - label, - position, - .. - }) => { - vec![Self::Definition(Definition { - ident: identifier, - url: Url(url), - label, - title: title.map(Title), - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Heading(mdast::Heading { - depth, ref position, .. - }) => { - let position = position.clone().map(|p| p.into()); - vec![Self::Heading(Heading { - values: Self::mdast_children_to_node(node), - depth, - position, - })] - } - mdast::Node::Break(mdast::Break { position }) => { - vec![Self::Break(Break { - position: position.map(|p| p.into()), - })] - } - mdast::Node::Delete(mdast::Delete { ref position, .. }) => { - let position = position.clone().map(|p| p.into()); - vec![Self::Delete(Delete { - values: Self::mdast_children_to_node(node), - position, - })] - } - mdast::Node::Emphasis(mdast::Emphasis { ref position, .. }) => { - let position = position.clone().map(|p| p.into()); - vec![Self::Emphasis(Emphasis { - values: Self::mdast_children_to_node(node), - position, - })] - } - mdast::Node::Strong(mdast::Strong { ref position, .. }) => { - let position = position.clone().map(|p| p.into()); - vec![Self::Strong(Strong { - values: Self::mdast_children_to_node(node), - position, - })] - } - mdast::Node::ThematicBreak(mdast::ThematicBreak { position, .. }) => { - vec![Self::HorizontalRule(HorizontalRule { - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Html(mdast::Html { value, position }) => { - vec![Self::Html(Html { - value, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Yaml(mdast::Yaml { value, position }) => { - vec![Self::Yaml(Yaml { - value, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Toml(mdast::Toml { value, position }) => { - vec![Self::Toml(Toml { - value, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Image(mdast::Image { - alt, - url, - title, - position, - }) => { - vec![Self::Image(Image { - alt, - url, - title, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::ImageReference(mdast::ImageReference { - alt, - identifier, - label, - position, - .. - }) => { - vec![Self::ImageRef(ImageRef { - alt, - ident: identifier, - label, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::InlineCode(mdast::InlineCode { value, position, .. }) => { - vec![Self::CodeInline(CodeInline { - value: value.into(), - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::InlineMath(mdast::InlineMath { value, position }) => { - vec![Self::MathInline(MathInline { - value: value.into(), - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Link(mdast::Link { - title, - url, - position, - children, - .. - }) => { - let converted: Vec = children.into_iter().flat_map(Self::from_mdast_node).collect(); - // Flatten nested links that arise from GFM autolink literal parsing. - // When the link text is a bare URL (e.g. `[https://x](https://x)`), - // markdown-rs parses the inner text as another Link node with the same - // URL, which causes double-nesting on re-serialisation. Unwrap any - // such inner link whose URL matches the outer one. - let values = converted - .into_iter() - .flat_map(|child| match child { - Self::Link(Link { - url: ref inner_url, - ref values, - .. - }) if inner_url.0 == url => values.clone(), - other => vec![other], - }) - .collect(); - vec![Self::Link(Link { - url: Url(url), - title: title.map(Title), - values, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::LinkReference(mdast::LinkReference { - identifier, - label, - position, - children, - .. - }) => { - vec![Self::LinkRef(LinkRef { - ident: identifier, - values: children.into_iter().flat_map(Self::from_mdast_node).collect::>(), - label, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Math(mdast::Math { value, position, .. }) => { - vec![Self::Math(Math { - value, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::FootnoteDefinition(mdast::FootnoteDefinition { - identifier, - position, - children, - .. - }) => { - vec![Self::Footnote(Footnote { - ident: identifier, - values: children.into_iter().flat_map(Self::from_mdast_node).collect::>(), - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::FootnoteReference(mdast::FootnoteReference { - identifier, - label, - position, - .. - }) => { - vec![Self::FootnoteRef(FootnoteRef { - ident: identifier, - label, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::MdxFlowExpression(mdx) => { - vec![Self::MdxFlowExpression(MdxFlowExpression { - value: mdx.value.into(), - position: mdx.position.map(|position| position.into()), - })] - } - mdast::Node::MdxJsxFlowElement(mdx) => { - vec![Self::MdxJsxFlowElement(MdxJsxFlowElement { - children: mdx - .children - .into_iter() - .flat_map(Self::from_mdast_node) - .collect::>(), - position: mdx.position.map(|p| p.clone().into()), - name: mdx.name, - attributes: mdx - .attributes - .iter() - .map(|attr| match attr { - mdast::AttributeContent::Expression(mdast::MdxJsxExpressionAttribute { value, .. }) => { - MdxAttributeContent::Expression(value.into()) - } - mdast::AttributeContent::Property(mdast::MdxJsxAttribute { value, name, .. }) => { - MdxAttributeContent::Property(MdxJsxAttribute { - name: name.into(), - value: value.as_ref().map(|value| match value { - mdast::AttributeValue::Literal(value) => { - MdxAttributeValue::Literal(value.into()) - } - mdast::AttributeValue::Expression(mdast::AttributeValueExpression { - value, - .. - }) => MdxAttributeValue::Expression(value.into()), - }), - }) - } - }) - .collect(), - })] - } - mdast::Node::MdxJsxTextElement(mdx) => { - vec![Self::MdxJsxTextElement(MdxJsxTextElement { - children: mdx - .children - .into_iter() - .flat_map(Self::from_mdast_node) - .collect::>(), - position: mdx.position.map(|p| p.clone().into()), - name: mdx.name.map(|name| name.into()), - attributes: mdx - .attributes - .iter() - .map(|attr| match attr { - mdast::AttributeContent::Expression(mdast::MdxJsxExpressionAttribute { value, .. }) => { - MdxAttributeContent::Expression(value.into()) - } - mdast::AttributeContent::Property(mdast::MdxJsxAttribute { value, name, .. }) => { - MdxAttributeContent::Property(MdxJsxAttribute { - name: name.into(), - value: value.as_ref().map(|value| match value { - mdast::AttributeValue::Literal(value) => { - MdxAttributeValue::Literal(value.into()) - } - mdast::AttributeValue::Expression(mdast::AttributeValueExpression { - value, - .. - }) => MdxAttributeValue::Expression(value.into()), - }), - }) - } - }) - .collect(), - })] - } - mdast::Node::MdxTextExpression(mdx) => { - vec![Self::MdxTextExpression(MdxTextExpression { - value: mdx.value.into(), - position: mdx.position.map(|position| position.into()), - })] - } - mdast::Node::MdxjsEsm(mdx) => { - vec![Self::MdxJsEsm(MdxJsEsm { - value: mdx.value.into(), - position: mdx.position.map(|position| position.into()), - })] - } - mdast::Node::Text(mdast::Text { position, value, .. }) => { - vec![Self::Text(Text { - value, - position: position.map(|p| p.clone().into()), - })] - } - mdast::Node::Paragraph(mdast::Paragraph { children, .. }) => { - children.into_iter().flat_map(Self::from_mdast_node).collect::>() - } - _ => Vec::new(), - } - } - - /// Converts the children of `node`, moving them out to avoid deep-cloning the subtree at every level. - fn mdast_children_to_node(mut node: mdast::Node) -> Vec { - match node.children_mut() { - Some(children) => std::mem::take(children) - .into_iter() - .flat_map(Self::from_mdast_node) - .collect::>(), - None => vec![EMPTY_NODE], - } - } - - fn mdast_list_items(list: &mdast::List, level: Level) -> Vec { - let mut result = Vec::new(); - for node in &list.children { - match node { - mdast::Node::ListItem(list_item) => { - result.push(Self::mdast_list_item_node(list_item, list, level)); - for child in &list_item.children { - match child { - mdast::Node::List(sub_list) => { - result.extend(Self::mdast_list_items(sub_list, level + 1)); - } - mdast::Node::ListItem(child_item) => { - result.push(Self::mdast_list_item_node(child_item, list, level + 1)); - } - _ => {} - } - } - } - mdast::Node::List(sub_list) => result.extend(Self::mdast_list_items(sub_list, level + 1)), - _ => {} - } - } - - result - .into_iter() - .scan(0usize, |next_index, node| { - // Only renumber this call's own level; deeper items flattened in from a - // recursive call already have their own, independently-numbered index. - Some(match node { - Self::List(mut l) if l.level == level => { - l.index = *next_index; - *next_index += 1; - Self::List(l) - } - other => other, - }) - }) - .collect() - } - - /// Converts one list item without recursively converting its nested list children. - /// - /// The enclosing [`Self::mdast_list_items`] call emits those children at their own level, - /// so skipping them here prevents constructing and discarding the same subtree first. - fn mdast_list_item_node(list_item: &mdast::ListItem, list: &mdast::List, level: Level) -> Node { - let values = list_item - .children - .iter() - .filter(|node| !matches!(node, mdast::Node::List(_))) - .flat_map(|node| Self::from_mdast_node(node.clone())) - .collect::>(); - let position = if values.is_empty() { - list_item.position.as_ref().map(|position| position.clone().into()) - } else { - let first_pos = values.first().and_then(Node::position); - let last_pos = values.last().and_then(Node::position); - match (first_pos, last_pos) { - (Some(start), Some(end)) => Some(Position { - start: start.start.clone(), - end: end.end.clone(), - }), - _ => list_item.position.as_ref().map(|position| position.clone().into()), - } - }; - - Self::List(List { - level, - index: 0, - ordered: list.ordered, - checked: list_item.checked, - start: list.start, - spread: list.spread, - values, - position, - }) - } - - fn mdx_attribute_content_to_string(attr: &MdxAttributeContent) -> SmolStr { - match attr { - MdxAttributeContent::Expression(value) => format!("{{{}}}", value).into(), - MdxAttributeContent::Property(property) => match &property.value { - Some(value) => match value { - MdxAttributeValue::Expression(value) => format!("{}={{{}}}", property.name, value).into(), - MdxAttributeValue::Literal(literal) => format!("{}=\"{}\"", property.name, literal).into(), - }, - None => property.name.clone(), - }, - } - } -} - -pub(crate) fn values_to_string(values: &[Node], options: &RenderOptions) -> String { - render_values(values, options, &ColorTheme::PLAIN) -} - -pub(crate) fn render_values(values: &[Node], options: &RenderOptions, theme: &ColorTheme<'_>) -> String { - let mut pre_position: Option = None; - values - .iter() - .map(|value| { - if let Some(pos) = value.position() { - let new_line_count = pre_position - .as_ref() - .map(|p: &Position| pos.start.line - p.end.line) - .unwrap_or_default(); - - let space = if new_line_count > 0 - && pre_position - .as_ref() - .map(|p| pos.start.line > p.end.line) - .unwrap_or_default() - { - " ".repeat(pos.start.column.saturating_sub(1)) - } else { - "".to_string() - }; - - pre_position = Some(pos); - - if space.is_empty() { - format!( - "{}{}", - "\n".repeat(new_line_count), - value.render_with_theme(options, theme) - ) - } else { - format!( - "{}{}", - "\n".repeat(new_line_count), - value - .render_with_theme(options, theme) - .lines() - .map(|line| format!("{}{}", space, line)) - .join("\n") - ) - } - } else { - pre_position = None; - value.render_with_theme(options, theme) - } - }) - .collect::() -} - -/// Like `render_values` but without column-offset–based indentation. -/// -/// Inside blockquotes and callouts, node positions include the `> ` prefix offset, -/// so applying column-based spacing causes double-indentation. This variant preserves -/// blank lines between values (via newline counting) but ignores the column position. -pub(crate) fn render_values_block(values: &[Node], options: &RenderOptions, theme: &ColorTheme<'_>) -> String { - let mut pre_position: Option = None; - values - .iter() - .map(|value| { - if let Some(pos) = value.position() { - let new_line_count = pre_position - .as_ref() - .map(|p: &Position| pos.start.line - p.end.line) - .unwrap_or_default(); - pre_position = Some(pos); - format!( - "{}{}", - "\n".repeat(new_line_count), - value.render_with_theme(options, theme) - ) - } else { - pre_position = None; - value.render_with_theme(options, theme) - } - }) - .collect::() -} - -fn values_to_value(values: &[Node]) -> String { - values.iter().map(|value| value.value()).collect::() -} - -/// Renders a link/image destination, auto-upgrading to `<...>` (escaping `\ < >`) -/// when the bare form would be unsafe (empty, or contains whitespace/control/`< >`); -/// otherwise bare, escaping `\ ( )` since unescaped parens must balance. -fn render_link_destination(url: &str, style: &UrlSurroundStyle) -> String { - let needs_angle = url.is_empty() - || url - .chars() - .any(|c| c.is_whitespace() || c.is_control() || c == '<' || c == '>'); - - if matches!(style, UrlSurroundStyle::Angle) || needs_angle { - let mut result = String::with_capacity(url.len() + 2); - result.push('<'); - for c in url.chars() { - if matches!(c, '\\' | '<' | '>') { - result.push('\\'); - } - result.push(c); - } - result.push('>'); - result - } else { - let mut result = String::with_capacity(url.len()); - for c in url.chars() { - if matches!(c, '\\' | '(' | ')') { - result.push('\\'); - } - result.push(c); - } - result - } -} - -/// Renders a link/image title, escaping `\` and the style's delimiter(s) so the -/// title's own quote/paren characters can't end it early. -fn render_link_title(title: &str, style: &TitleSurroundStyle) -> String { - let (open, close, delims): (char, char, &[char]) = match style { - TitleSurroundStyle::Double => ('"', '"', &['"']), - TitleSurroundStyle::Single => ('\'', '\'', &['\'']), - TitleSurroundStyle::Paren => ('(', ')', &['(', ')']), - }; - let mut result = String::with_capacity(title.len() + 2); - result.push(open); - for c in title.chars() { - if c == '\\' || delims.contains(&c) { - result.push('\\'); - } - result.push(c); - } - result.push(close); - result -} - -/// Escapes `\ [ ]` in reference labels/idents and image alt text: plain strings -/// (not walked as inline nodes) that still sit inside `[...]` syntax. -/// True if `s` needs escaping beyond `escape_label`'s narrow `\ [ ]` set — unsafe -/// for a reference's implicit shortcut form, since the definition's label won't match. -fn needs_broad_escaping(s: &str) -> bool { - s.bytes().any(|b| { - matches!( - b, - b'`' | b'*' | b'_' | b'|' | b'~' | b'$' | b'#' | b'-' | b'+' | b'>' | b'=' | b'.' | b')' | b'!' - ) - }) -} - -/// Shifts every line's leading indentation by `delta` spaces (never below zero), -/// leaving each line's own content untouched. -pub(crate) fn reindent_all_lines(content: &str, delta: isize) -> String { - if delta == 0 { - return content.to_string(); - } - content - .split('\n') - .map(|line| { - if line.is_empty() { - line.to_string() - } else { - let leading = line.bytes().take_while(|&b| b == b' ').count(); - let new_leading = (leading as isize + delta).max(0) as usize; - format!("{}{}", " ".repeat(new_leading), &line[leading..]) - } - }) - .collect::>() - .join("\n") -} - -/// Indents a `Code`/`Math`/`Yaml`/`Toml` first child's own continuation lines, -/// which `render_values` leaves flush-left since it only indents non-first children. -fn reindent_first_leaf_block( - content: String, - first: Option<&Node>, - options: &RenderOptions, - theme: &ColorTheme<'_>, - target_indent: usize, -) -> String { - let Some(first) = first else { return content }; - if !matches!(first, Node::Code(_) | Node::Math(_) | Node::Yaml(_) | Node::Toml(_)) { - return content; - } - let first_rendered = first.render_with_theme(options, theme); - if !first_rendered.contains('\n') { - return content; - } - let reindented_first = reindent_continuation(&first_rendered, target_indent as isize); - let mut lines: Vec<&str> = content.split('\n').collect(); - for (line, replacement) in lines.iter_mut().zip(reindented_first.split('\n')) { - *line = replacement; - } - lines.join("\n") -} - -/// Like [`reindent_all_lines`] but leaves the first line untouched, for shifting a -/// value's own continuation lines without disturbing its already-placed first line. -fn reindent_continuation(content: &str, delta: isize) -> String { - if delta == 0 || !content.contains('\n') { - return content.to_string(); - } - let mut lines = content.splitn(2, '\n'); - let first = lines.next().unwrap_or_default(); - let rest = lines.next().unwrap_or_default(); - format!("{}\n{}", first, reindent_all_lines(rest, delta)) -} - -/// The width of a list item's own marker + trailing space + checkbox (not -/// including ancestor indentation), e.g. 3 for `"1. "`, 4 for `"10. "` or `"- [x] "`. -pub(crate) fn list_own_prefix_width(ordered: bool, index: usize, start: Option, checked: Option) -> usize { - let marker_len = if ordered { - (start.unwrap_or(1) as usize + index).to_string().chars().count() + 1 - } else { - 1 - }; - let checkbox_len = if checked.is_some() { 4 } else { 0 }; - marker_len + 1 + checkbox_len -} - -/// Longest fence-char run in `value` + 1 (min 3); switches to tilde when `info` -/// has a backtick, since a backtick-fenced info string can't contain one. -fn code_fence(value: &str, info: &str) -> String { - let longest_run = |s: &str, ch: char| -> usize { - s.split('\n') - .flat_map(|line| line.split(|c| c != ch).map(str::len)) - .max() - .unwrap_or(0) - }; - if info.contains('`') { - let len = (longest_run(value, '~') + 1).max(3); - "~".repeat(len) - } else { - let len = (longest_run(value, '`') + 1).max(3); - "`".repeat(len) - } -} - -/// CommonMark reference-label normalization: collapse whitespace, trim, case-fold. -fn normalize_reference_identifier(s: &str) -> String { - s.split_whitespace().collect::>().join(" ").to_lowercase() -} - -/// Picks an inline code span delimiter one backtick longer than the longest -/// backtick run in `value`, so no run inside it can be mistaken for the closing. -fn code_span_fence(value: &str) -> String { - let mut max_run = 0; - let mut cur = 0; - for c in value.chars() { - if c == '`' { - cur += 1; - max_run = max_run.max(cur); - } else { - cur = 0; - } - } - "`".repeat(max_run + 1) -} - -fn escape_label(s: &str) -> String { - if !s.bytes().any(|b| matches!(b, b'\\' | b'[' | b']')) { - return s.to_string(); - } - let mut result = String::with_capacity(s.len()); - for c in s.chars() { - if matches!(c, '\\' | '[' | ']') { - result.push('\\'); - } - result.push(c); - } - result -} - -/// Re-escapes markdown-significant characters so plain text can't be reinterpreted as -/// syntax on re-parse. `# - + > =` only unsafe at line start, `. )` only after leading digits; -/// a leading `\t` and runs of 2+ `\n` (only possible via decoded entities) use `&#N;` instead. -fn escape_text(value: String) -> String { - // Skip the copy when nothing needs escaping; byte scan avoids UTF-8 decoding. - let needs_escaping = value.bytes().any(|b| { - matches!( - b, - b'\\' - | b'`' - | b'*' - | b'_' - | b'[' - | b']' - | b'|' - | b'~' - | b'$' - | b'#' - | b'-' - | b'+' - | b'>' - | b'<' - | b'=' - | b'.' - | b')' - | b'\n' - | b'\t' - ) - }); - if !needs_escaping { - return value; - } - - let mut result = String::with_capacity(value.len()); - let mut at_line_start = true; - // True while in an unbroken digit run since line start (ordered-list marker number). - let mut leading_digits = false; - let mut chars = value.chars().peekable(); - - while let Some(c) = chars.next() { - match c { - '\n' if chars.peek() == Some(&'\n') => { - result.push_str(" "); - at_line_start = true; - leading_digits = false; - continue; - } - '\t' if at_line_start => { - result.push_str(" "); - at_line_start = false; - leading_digits = false; - continue; - } - '\\' | '`' | '*' | '_' | '[' | ']' | '|' | '~' | '$' | '<' => result.push('\\'), - '#' | '-' | '+' | '>' | '=' if at_line_start => result.push('\\'), - '.' | ')' if leading_digits => result.push('\\'), - _ => {} - } - result.push(c); - - let is_digit = c.is_ascii_digit(); - leading_digits = if at_line_start { - is_digit - } else { - leading_digits && is_digit - }; - at_line_start = c == '\n'; - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - use rstest::rstest; - - #[test] - fn map_values_into_transforms_owned_fragments() { - let node = Node::Fragment(Fragment { - values: vec![Node::Text(Text { - value: "before".to_string(), - position: None, - })], - }); - - let mapped = node - .map_values_into(&mut |node| -> Result { - Ok(match node { - Node::Text(text) => Node::Text(Text { - value: text.value.to_uppercase(), - position: text.position.clone(), - }), - node => node.clone(), - }) - }) - .unwrap(); - - assert_eq!(mapped.to_string(), "BEFORE"); - } - - #[test] - fn map_values_into_owned_moves_nodes_through_the_callback() { - let node = Node::Fragment(Fragment { - values: vec![Node::Text(Text { - value: "before".to_string(), - position: None, - })], - }); - - let mapped = node - .map_values_into_owned(&mut |node| -> Result { - Ok(match node { - Node::Text(mut text) => { - text.value.make_ascii_uppercase(); - Node::Text(text) - } - node => node, - }) - }) - .unwrap(); - - assert_eq!(mapped.to_string(), "BEFORE"); - } - - #[rstest] - #[case::text(Node::Text(Text{value: "".to_string(), position: None}), - "test".to_string(), - Node::Text(Text{value: "test".to_string(), position: None }))] - #[case::blockquote(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None }))] - #[case::delete(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::Delete(Delete{values: vec!["test".to_string().into()], position: None }))] - #[case::emphasis(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None }))] - #[case::strong(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::Strong(Strong{values: vec!["test".to_string().into()], position: None }))] - #[case::heading(Node::Heading(Heading {depth: 1, values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None }))] - #[case::link(Node::Link(Link {url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None }), - "test".to_string(), - Node::Link(Link{url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None }))] - #[case::image(Node::Image(Image {alt: "test".to_string(), url: "test".to_string(), title: None, position: None }), - "test".to_string(), - Node::Image(Image{alt: "test".to_string(), url: "test".to_string(), title: None, position: None }))] - #[case::code(Node::Code(Code {value: "test".to_string(), lang: None, fence: true, meta: None, position: None }), - "test".to_string(), - Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None }))] - #[case::footnote_ref(Node::FootnoteRef(FootnoteRef {ident: "test".to_string(), label: None, position: None }), - "test".to_string(), - Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: Some("test".to_string()), position: None }))] - #[case::footnote(Node::Footnote(Footnote {ident: "test".to_string(), values: Vec::new(), position: None }), - "test".to_string(), - Node::Footnote(Footnote{ident: "test".to_string(), values: Vec::new(), position: None }))] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None }))] - #[case::list(Node::List(List{start: None, spread: false, index: 1, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::List(List{start: None, spread: false, index: 1, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None }))] - #[case::list(Node::List(List{start: None, spread: false, index: 2, level: 2, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None }), - "test".to_string(), - Node::List(List{start: None, spread: false, index: 2, level: 2, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None }))] - #[case::code_inline(Node::CodeInline(CodeInline{ value: "t".into(), position: None }), - "test".to_string(), - Node::CodeInline(CodeInline{ value: "test".into(), position: None }))] - #[case::math_inline(Node::MathInline(MathInline{ value: "t".into(), position: None }), - "test".to_string(), - Node::MathInline(MathInline{ value: "test".into(), position: None }))] - #[case::toml(Node::Toml(Toml{ value: "t".to_string(), position: None }), - "test".to_string(), - Node::Toml(Toml{ value: "test".to_string(), position: None }))] - #[case::yaml(Node::Yaml(Yaml{ value: "t".to_string(), position: None }), - "test".to_string(), - Node::Yaml(Yaml{ value: "test".to_string(), position: None }))] - #[case::html(Node::Html(Html{ value: "t".to_string(), position: None }), - "test".to_string(), - Node::Html(Html{ value: "test".to_string(), position: None }))] - #[case::table_row(Node::TableRow(TableRow{ values: vec![ - Node::TableCell(TableCell{values: vec!["test1".to_string().into()], row:0, column:1, position: None}), - Node::TableCell(TableCell{values: vec!["test2".to_string().into()], row:0, column:2, position: None}) - ] - , position: None }), - "test3,test4".to_string(), - Node::TableRow(TableRow{ values: vec![ - Node::TableCell(TableCell{values: vec!["test3".to_string().into()], row:0, column:1, position: None}), - Node::TableCell(TableCell{values: vec!["test4".to_string().into()], row:0, column:2, position: None}) - ] - , position: None }))] - #[case::table_cell(Node::TableCell(TableCell{values: vec!["test1".to_string().into()], row:0, column:1, position: None}), - "test2".to_string(), - Node::TableCell(TableCell{values: vec!["test2".to_string().into()], row:0, column:1, position: None}),)] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "test2".to_string(), values: vec![attr_keys::VALUE.to_string().into()], label: Some("test2".to_string()), position: None}), - "test2".to_string(), - Node::LinkRef(LinkRef{ident: "test2".to_string(), values: vec![attr_keys::VALUE.to_string().into()], label: Some("test2".to_string()), position: None}),)] - #[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test1".to_string(), label: None, position: None}), - "test2".to_string(), - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test2".to_string(), label: Some("test2".to_string()), position: None}),)] - #[case::definition(Node::Definition(Definition{ url: Url::new(attr_keys::URL.to_string()), title: None, ident: "test1".to_string(), label: None, position: None}), - "test2".to_string(), - Node::Definition(Definition{url: Url::new("test2".to_string()), title: None, ident: "test1".to_string(), label: None, position: None}),)] - #[case::break_(Node::Break(Break{ position: None}), - "test".to_string(), - Node::Break(Break{position: None}))] - #[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{ position: None}), - "test".to_string(), - Node::HorizontalRule(HorizontalRule{position: None}))] - #[case::mdx_flow_expression(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), - "updated".to_string(), - Node::MdxFlowExpression(MdxFlowExpression{value: "updated".into(), position: None}))] - #[case::mdx_text_expression(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), - "updated".to_string(), - Node::MdxTextExpression(MdxTextExpression{value: "updated".into(), position: None}))] - #[case::mdx_js_esm(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), - "updated".to_string(), - Node::MdxJsEsm(MdxJsEsm{value: "updated".into(), position: None}))] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec!["test".to_string().into()], - position: None - }), - "updated".to_string(), - Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec!["updated".to_string().into()], - position: None - }))] - #[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec!["test".to_string().into()], - position: None - }), - "updated".to_string(), - Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec!["updated".to_string().into()], - position: None - }))] - #[case(Node::Math(Math{ value: "x^2".to_string(), position: None }), - "test".to_string(), - Node::Math(Math{ value: "test".to_string(), position: None }))] - fn test_with_value(#[case] node: Node, #[case] input: String, #[case] expected: Node) { - assert_eq!(node.clone().with_value(input.as_str()), expected); - assert_eq!(node.into_with_value(input.as_str()), expected); - } - - #[rstest] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 0, - Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}))] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 1, - Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}))] - #[case(Node::Delete(Delete{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 0, - Node::Delete(Delete{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}))] - #[case(Node::Emphasis(Emphasis{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 1, - Node::Emphasis(Emphasis{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}))] - #[case(Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 0, - Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}))] - #[case(Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 1, - Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 0, - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}))] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), - "new", - 1, - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}))] - #[case(Node::Text(Text{value: "plain text".to_string(), position: None}), - "new", - 0, - Node::Text(Text{value: "plain text".to_string(), position: None}))] - #[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), - "new", - 0, - Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}) - ], position: None}), - "new", - 0, - Node::List(List{start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}) - ], position: None}), - "new", - 2, - Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}) - ], position: None}))] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], label: None, position: None}), "new", 0, Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], label: None, position: None}))] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }), - "new", - 0, - Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }))] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }), - "new", - 1, - Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], - position: None - }))] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }), - "new", - 0, - Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "new".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }))] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], - position: None - }), - "new", - 1, - Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: Vec::new(), - children: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "new".to_string(), position: None}) - ], - position: None - }))] - fn test_with_children_value(#[case] node: Node, #[case] value: &str, #[case] index: usize, #[case] expected: Node) { - assert_eq!(node.clone().with_children_value(value, index), expected); - assert_eq!(node.into_with_children_value(value, index), expected); - } - - #[rstest] - #[case(Node::Text(Text{value: "test".to_string(), position: None }), - "test".to_string())] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 2, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), - " - test".to_string())] - fn test_display(#[case] node: Node, #[case] expected: String) { - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[rstest] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), true)] - #[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), false)] - #[case(Node::MathInline(MathInline{value: "test".into(), position: None}), false)] - fn test_is_text(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_text(), expected); - } - - #[rstest] - #[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_inline_code(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_inline_code(), expected); - } - - #[rstest] - #[case(Node::MathInline(MathInline{value: "test".into(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_inline_math(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_inline_math(), expected); - } - - #[rstest] - #[case(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_strong(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_strong(), expected); - } - - #[rstest] - #[case(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_delete(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_delete(), expected); - } - - #[rstest] - #[case(Node::Link(Link{url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_link(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_link(), expected); - } - - #[rstest] - #[case(Node::LinkRef(LinkRef{ident: "test".to_string(), values: Vec::new(), label: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_link_ref(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_link_ref(), expected); - } - - #[rstest] - #[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "test".to_string(), title: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_image(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_image(), expected); - } - - #[rstest] - #[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test".to_string(), label: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_image_ref(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_image_ref(), expected); - } - - #[rstest] - #[case(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), true, Some("rust".into()))] - #[case(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), false, Some("python".into()))] - #[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), true, None)] - #[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), true, None)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false, None)] - fn test_is_code(#[case] node: Node, #[case] expected: bool, #[case] lang: Option) { - assert_eq!(node.is_code(lang), expected); - } - - #[rstest] - #[case(Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None}), true, Some(1))] - #[case(Node::Heading(Heading{depth: 2, values: vec!["test".to_string().into()], position: None}), false, Some(1))] - #[case(Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None}), true, None)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false, None)] - fn test_is_heading(#[case] node: Node, #[case] expected: bool, #[case] depth: Option) { - assert_eq!(node.is_heading(depth), expected); - } - - #[rstest] - #[case(Node::HorizontalRule(HorizontalRule{position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_horizontal_rule(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_horizontal_rule(), expected); - } - - #[rstest] - #[case(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_blockquote(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_blockquote(), expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), true)] - #[case(Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: Some("Newton".to_string()), position: None}), true)] - #[case(Node::Link(Link{url: Url::new("https://example.com".to_string()), values: Vec::new(), title: None, position: None}), false)] - #[case(Node::Link(Link{url: Url::new("relative.md".to_string()), values: Vec::new(), title: None, position: None}), false)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - #[case(Node::Text(Text{value: "[[target]]".to_string(), position: None}), false)] - fn test_is_wikilink(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_wikilink(), expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), true)] - #[case(Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: Some("Newton".to_string()), position: None}), true)] - #[case(Node::Link(Link{url: Url::new("https://example.com".to_string()), values: Vec::new(), title: None, position: None}), true)] - #[case(Node::Link(Link{url: Url::new("relative.md".to_string()), values: Vec::new(), title: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_link_includes_wikilink(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_link(), expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - // no wikilinks — returns original text node unchanged - #[case("plain text", vec![Node::Text(Text{value: "plain text".to_string(), position: None})])] - // only a wikilink - #[case("[[target]]", vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None})])] - // wikilink with display text - #[case("[[target|display]]", vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None})])] - // wikilink with spaces in target - #[case("[[Three laws of motion]]", vec![Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: None, position: None})])] - // wikilink with .md extension - #[case("[[target.md]]", vec![Node::WikiLink(WikiLink{target: "target.md".to_string(), text: None, position: None})])] - // wikilink at start - #[case("[[target]] after", vec![ - Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), - Node::Text(Text{value: " after".to_string(), position: None}), - ])] - // wikilink at end - #[case("before [[target]]", vec![ - Node::Text(Text{value: "before ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), - ])] - // wikilink in middle - #[case("before [[target]] after", vec![ - Node::Text(Text{value: "before ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), - Node::Text(Text{value: " after".to_string(), position: None}), - ])] - // multiple wikilinks - #[case("[[a]] and [[b]]", vec![ - Node::WikiLink(WikiLink{target: "a".to_string(), text: None, position: None}), - Node::Text(Text{value: " and ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "b".to_string(), text: None, position: None}), - ])] - // unclosed [[ treated as plain text - #[case("[[unclosed", vec![Node::Text(Text{value: "[[unclosed".to_string(), position: None})])] - // nested brackets invalid — treated as plain text - #[case("[[in[ner]]]", vec![Node::Text(Text{value: "[[in[ner]]]".to_string(), position: None})])] - // multibyte characters in surrounding text - #[case("日本語 [[ターゲット]] テキスト", vec![ - Node::Text(Text{value: "日本語 ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "ターゲット".to_string(), text: None, position: None}), - Node::Text(Text{value: " テキスト".to_string(), position: None}), - ])] - // multibyte characters inside wikilink target and display text - #[case("[[ページ|表示名]]", vec![ - Node::WikiLink(WikiLink{target: "ページ".to_string(), text: Some("表示名".to_string()), position: None}), - ])] - fn test_parse_wikilinks_in_text(#[case] input: &str, #[case] expected: Vec) { - let result = Node::parse_wikilinks_in_text(input, None); - assert_eq!(result, expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "url", Some(AttrValue::String("target".to_string())))] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "value", Some(AttrValue::String("target".to_string())))] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None}), "url", Some(AttrValue::String("target".to_string())))] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None}), "value", Some(AttrValue::String("display".to_string())))] - #[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "title", None)] - fn test_wikilink_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { - assert_eq!(node.attr(attr), expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - // no display text: with_value sets target - #[case( - Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), - "new-target", - Node::WikiLink(WikiLink{target: "new-target".to_string(), text: None, position: None}) - )] - // with display text: with_value sets text, not target - #[case( - Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("Display Text".to_string()), position: None}), - "DISPLAY TEXT", - Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("DISPLAY TEXT".to_string()), position: None}) - )] - fn test_wikilink_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { - assert_eq!(node.clone().with_value(value), expected); - assert_eq!(node.into_with_value(value), expected); - } - - #[cfg(feature = "wikilink")] - #[rstest] - // footnote with no wikilinks — values unchanged - #[case( - Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "plain text".to_string(), position: None})], position: None}), - vec![Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "plain text".to_string(), position: None})], position: None})] - )] - // footnote with a wikilink in text — expanded to WikiLink node - #[case( - Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "[[target]]".to_string(), position: None})], position: None}), - vec![Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None})], position: None})] - )] - // footnote with wikilink and display text - #[case( - Node::Footnote(Footnote{ident: "2".to_string(), values: vec![Node::Text(Text{value: "[[target|display]]".to_string(), position: None})], position: None}), - vec![Node::Footnote(Footnote{ident: "2".to_string(), values: vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None})], position: None})] - )] - // footnote with mixed text and wikilink - #[case( - Node::Footnote(Footnote{ident: "3".to_string(), values: vec![Node::Text(Text{value: "see [[note]]".to_string(), position: None})], position: None}), - vec![Node::Footnote(Footnote{ident: "3".to_string(), values: vec![ - Node::Text(Text{value: "see ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "note".to_string(), text: None, position: None}), - ], position: None})] - )] - fn test_expand_wikilinks_footnote(#[case] input: Node, #[case] expected: Vec) { - let result = Node::expand_wikilinks(vec![input]); - assert_eq!(result, expected); - } - - #[cfg(all(feature = "embed", feature = "wikilink"))] - #[rstest] - // embed only - #[case("![[note.md]]", vec![ - Node::Embed(Embed{target: "note.md".to_string(), display: None, position: None}), - ])] - // wikilink only - #[case("[[target]]", vec![ - Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), - ])] - // embed and wikilink in same text - #[case("![[embed]] and [[link]]", vec![ - Node::Embed(Embed{target: "embed".to_string(), display: None, position: None}), - Node::Text(Text{value: " and ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), - ])] - // wikilink before embed - #[case("[[link]] and ![[embed]]", vec![ - Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), - Node::Text(Text{value: " and ".to_string(), position: None}), - Node::Embed(Embed{target: "embed".to_string(), display: None, position: None}), - ])] - // embed with display hint - #[case("![[image.png|400]]", vec![ - Node::Embed(Embed{target: "image.png".to_string(), display: Some("400".to_string()), position: None}), - ])] - // wikilink with display text - #[case("[[page|display]]", vec![ - Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("display".to_string()), position: None}), - ])] - // surrounding text - #[case("before ![[note]] after [[link]] end", vec![ - Node::Text(Text{value: "before ".to_string(), position: None}), - Node::Embed(Embed{target: "note".to_string(), display: None, position: None}), - Node::Text(Text{value: " after ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), - Node::Text(Text{value: " end".to_string(), position: None}), - ])] - // plain text with no links - #[case("just plain text", vec![ - Node::Text(Text{value: "just plain text".to_string(), position: None}), - ])] - // multibyte characters - #[case("日本語 ![[ファイル]] と [[ページ]]", vec![ - Node::Text(Text{value: "日本語 ".to_string(), position: None}), - Node::Embed(Embed{target: "ファイル".to_string(), display: None, position: None}), - Node::Text(Text{value: " と ".to_string(), position: None}), - Node::WikiLink(WikiLink{target: "ページ".to_string(), text: None, position: None}), - ])] - fn test_parse_inline_links_into(#[case] input: &str, #[case] expected: Vec) { - let mut result = Vec::new(); - Node::parse_inline_links_into(input, None, &mut result); - assert_eq!(result, expected); - } - - #[rstest] - #[case(Node::Html(Html{value: "
test
".to_string(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_html(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_html(), expected); - } - - #[rstest] - #[case(Node::node_values( - &Node::Strong(Strong{values: vec!["test".to_string().into()], position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::Text(Text{value: "test".to_string(), position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::Delete(Delete{values: vec!["test".to_string().into()], position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None})), - vec!["test".to_string().into()])] - #[case(Node::node_values( - &Node::List(List{values: vec!["test".to_string().into()], ordered: false, level: 1, checked: Some(false), index: 0, start: None, spread: false, position: None})), - vec!["test".to_string().into()])] - fn test_node_value(#[case] actual: Vec, #[case] expected: Vec) { - assert_eq!(actual, expected); - } - - #[rstest] - #[case(Node::Footnote(Footnote{ident: "test".to_string(), values: Vec::new(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_footnote(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_footnote(), expected); - } - - #[rstest] - #[case(Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_footnote_ref(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_footnote_ref(), expected); - } - - #[rstest] - #[case(Node::Math(Math{value: "x^2".to_string(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_math(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_math(), expected); - } - - #[rstest] - #[case(Node::Break(Break{position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_break(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_break(), expected); - } - - #[rstest] - #[case(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_yaml(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_yaml(), expected); - } - - #[rstest] - #[case(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_toml(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_toml(), expected); - } - - #[rstest] - #[case(Node::Definition(Definition{ident: attr_keys::IDENT.to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_definition(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_definition(), expected); - } - - #[rstest] - #[case(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_emphasis(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_emphasis(), expected); - } - - #[rstest] - #[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_mdx_flow_expression(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_mdx_flow_expression(), expected); - } - - #[rstest] - #[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_mdx_text_expression(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_mdx_text_expression(), expected); - } - - #[rstest] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_mdx_jsx_flow_element(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_mdx_jsx_flow_element(), expected); - } - - #[rstest] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_mdx_jsx_text_element(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_mdx_jsx_text_element(), expected); - } - - #[rstest] - #[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_msx_js_esm(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_mdx_js_esm(), expected); - } - - #[rstest] - #[case::text(Node::Text(Text{value: "test".to_string(), position: None }), RenderOptions::default(), "test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 2, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " - test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions { list_style: ListStyle::Plus, ..Default::default() }, " + test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions { list_style: ListStyle::Star, ..Default::default() }, " * [x] test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " - [ ] test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: true, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " 1. test")] - #[case::list(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: Some(false), ordered: true, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " 1. [ ] test")] - #[case::table_row(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None})], position: None}), RenderOptions::default(), "|test|")] - #[case::table_row(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None})], position: None}), RenderOptions::default(), "|test|")] - #[case::table_cell(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "test")] - #[case::table_cell(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "test")] - #[case::table_align(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right, TableAlignKind::Center, TableAlignKind::None], position: None}), RenderOptions::default(), "|:---|---:|:---:|---|")] - #[case::block_quote(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "> test")] - #[case::block_quote(Node::Blockquote(Blockquote{values: vec!["test\ntest2".to_string().into()], position: None}), RenderOptions::default(), "> test\n> test2")] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), RenderOptions::default(), "```rust\ncode\n```")] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "```\ncode\n```")] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), RenderOptions::default(), " code")] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: Some("meta".to_string()), position: None}), RenderOptions::default(), "```rust meta\ncode\n```")] - #[case::code_empty_body_no_blank_line(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "```\n```")] - #[case::code_fence_escalates_past_backtick_run_in_body(Node::Code(Code{value: "aaa\n```".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "````\naaa\n```\n````")] - #[case::code_fence_escalates_past_longer_run(Node::Code(Code{value: "`````".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "``````\n`````\n``````")] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: url")] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: url \"title\"")] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new("".to_string()), title: None, label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: <>")] - #[case::delete(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "~~test~~")] - #[case::emphasis(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "*test*")] - #[case::emphasis_nested_in_emphasis_alternates_delimiter(Node::Emphasis(Emphasis{values: vec![Node::Emphasis(Emphasis{values: vec!["foo".to_string().into()], position: None})], position: None}), RenderOptions::default(), "_*foo*_")] - #[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![attr_keys::LABEL.to_string().into()], position: None}), RenderOptions::default(), "[^id]: label")] - #[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: attr_keys::LABEL.to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[^label]")] - #[case::heading(Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "# test")] - #[case::heading(Node::Heading(Heading{depth: 3, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "### test")] - #[case::heading_multiline_h1_stays_setext(Node::Heading(Heading{depth: 1, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "Foo\nBar\n===")] - #[case::heading_multiline_h2_stays_setext(Node::Heading(Heading{depth: 2, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "Foo\nBar\n---")] - #[case::heading_multiline_h3_joins_with_space(Node::Heading(Heading{depth: 3, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "### Foo Bar")] - #[case::heading_trailing_hash_escaped(Node::Heading(Heading{depth: 1, values: vec!["foo #".to_string().into()], position: None}), RenderOptions::default(), "# foo \\#")] - #[case::heading_trailing_hash_run_escaped(Node::Heading(Heading{depth: 3, values: vec!["foo ###".to_string().into()], position: None}), RenderOptions::default(), "### foo \\###")] - #[case::heading_trailing_hash_after_multibyte_char(Node::Heading(Heading{depth: 1, values: vec!["foo あ#".to_string().into()], position: None}), RenderOptions::default(), "# foo あ\\#")] - #[case::heading_no_trailing_hash_unaffected(Node::Heading(Heading{depth: 1, values: vec!["foo bar".to_string().into()], position: None}), RenderOptions::default(), "# foo bar")] - #[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), RenderOptions::default(), "
test
")] - #[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), RenderOptions::default(), "![alt](url)")] - #[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "url with space".to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), RenderOptions::default(), "![alt]( \"title\")")] - #[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("id".to_string()), position: None}), RenderOptions::default(), "![alt][id]")] - #[case::image_ref(Node::ImageRef(ImageRef{alt: "id".to_string(), ident: "id".to_string(), label: Some("id".to_string()), position: None}), RenderOptions::default(), "![id]")] - #[case::code_inline(Node::CodeInline(CodeInline{value: "code".into(), position: None}), RenderOptions::default(), "`code`")] - #[case::code_inline_trailing_backtick_needs_padding(Node::CodeInline(CodeInline{value: "\\[\\`".into(), position: None}), RenderOptions::default(), "`` \\[\\` ``")] - #[case::code_inline_internal_backtick_escalates_fence(Node::CodeInline(CodeInline{value: "foo ` bar".into(), position: None}), RenderOptions::default(), "``foo ` bar``")] - #[case::code_inline_both_ends_backtick_needs_longer_padded_fence(Node::CodeInline(CodeInline{value: "``".into(), position: None}), RenderOptions::default(), "``` `` ```")] - #[case::code_inline_leading_trailing_space_needs_extra_padding(Node::CodeInline(CodeInline{value: " `` ".into(), position: None}), RenderOptions::default(), "``` `` ```")] - #[case::code_inline_all_spaces_untouched(Node::CodeInline(CodeInline{value: " ".into(), position: None}), RenderOptions::default(), "` `")] - #[case::math_inline(Node::MathInline(MathInline{value: "x^2".into(), position: None}), RenderOptions::default(), "$x^2$")] - #[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](url \"title\")")] - #[case::link(Node::Link(Link{url: Url::new("".to_string()), title: None, values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](<>)")] - #[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](url)")] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["id".to_string().into()], label: Some("id".to_string()), position: None}), RenderOptions::default(), "[id]")] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["open".to_string().into()], label: Some("id".to_string()), position: None}), RenderOptions::default(), "[open][id]")] - #[case::math(Node::Math(Math{value: "x^2".to_string(), position: None}), RenderOptions::default(), "$$\nx^2\n$$")] - #[case::strong(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "**test**")] - #[case::yaml(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), RenderOptions::default(), "---\nkey: value\n---")] - #[case::toml(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), RenderOptions::default(), "+++\nkey = \"value\"\n+++")] - #[case::break_(Node::Break(Break{position: None}), RenderOptions::default(), "\\\n")] - #[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{position: None}), RenderOptions::default(), "***")] - #[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: vec![ - MdxAttributeContent::Property(MdxJsxAttribute { - name: "className".into(), - value: Some(MdxAttributeValue::Literal("container".into())) - }) - ], - children: vec![ - "content".to_string().into() - ], - position: None - }), RenderOptions::default(), "
content
")] - #[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: vec![ - MdxAttributeContent::Property(MdxJsxAttribute { - name: "className".into(), - value: Some(MdxAttributeValue::Literal("container".into())) - }) - ], - children: Vec::new(), - position: None - }), RenderOptions::default(), "
")] - #[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: Vec::new(), - position: None - }), RenderOptions::default(), "
")] - #[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: vec![ - MdxAttributeContent::Expression("...props".into()) - ], - children: vec![ - "inline".to_string().into() - ], - position: None - }), RenderOptions::default(), "inline")] - #[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: vec![ - MdxAttributeContent::Expression("...props".into()) - ], - children: vec![ - ], - position: None - }), RenderOptions::default(), "")] - #[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ - name: Some("span".into()), - attributes: vec![ - ], - children: vec![ - ], - position: None - }), RenderOptions::default(), "")] - #[case(Node::MdxTextExpression(MdxTextExpression{ - value: "count + 1".into(), - position: None, - }), RenderOptions::default(), "{count + 1}")] - #[case(Node::MdxJsEsm(MdxJsEsm{ - value: "import React from 'react'".into(), - position: None, - }), RenderOptions::default(), "import React from 'react'")] - #[case::fragment_empty(Node::Fragment(Fragment{values: vec![]}), RenderOptions::default(), "")] - #[case::fragment_single(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "hello".to_string(), position: None}) - ]}), RenderOptions::default(), "hello")] - #[case::fragment_multiple(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "hello".to_string(), position: None}), - Node::Text(Text{value: "world".to_string(), position: None}) - ]}), RenderOptions::default(), "hello\nworld")] - #[case::fragment_filters_empty(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "hello".to_string(), position: None}), - Node::Empty, - Node::Text(Text{value: "world".to_string(), position: None}) - ]}), RenderOptions::default(), "hello\nworld")] - #[case::fragment_all_empty(Node::Fragment(Fragment{values: vec![ - Node::Empty, - Node::Empty, - ]}), RenderOptions::default(), "")] - #[case::fragment_empty_text_as_blank_line(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "hello".to_string(), position: None}), - Node::Text(Text{value: "".to_string(), position: None}), - Node::Text(Text{value: "world".to_string(), position: None}) - ]}), RenderOptions::default(), "hello\n\nworld")] - #[case::fragment_filters_nested_empty_fragment(Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![Node::Empty, Node::Empty]}), - Node::Text(Text{value: "hello".to_string(), position: None}), - Node::Fragment(Fragment{values: vec![Node::Empty]}), - ]}), RenderOptions::default(), "hello")] - #[case::fragment_filters_deeply_nested_empty_fragment(Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![Node::Empty]}), - Node::Empty, - ]}), - Node::Text(Text{value: "hello".to_string(), position: None}), - ]}), RenderOptions::default(), "hello")] - #[case::fragment_keeps_nested_fragment_with_blank_line_text(Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![Node::Empty]}), - Node::Text(Text{value: "hello".to_string(), position: None}), - Node::Fragment(Fragment{values: vec![Node::Text(Text{value: "".to_string(), position: None})]}), - Node::Text(Text{value: "world".to_string(), position: None}), - ]}), RenderOptions::default(), "hello\n\nworld")] - #[cfg_attr(feature = "wikilink", case::wikilink(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), RenderOptions::default(), "[[target]]"))] - #[cfg_attr(feature = "wikilink", case::wikilink_with_text(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display text".to_string()), position: None}), RenderOptions::default(), "[[target|display text]]"))] - #[cfg_attr(feature = "wikilink", case::wikilink_same_text(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("target".to_string()), position: None}), RenderOptions::default(), "[[target]]"))] - fn test_to_string_with(#[case] node: Node, #[case] options: RenderOptions, #[case] expected: &str) { - assert_eq!(node.to_string_with(&options), expected); - } - - #[test] - fn test_node_partial_ord() { - let node1 = Node::Text(Text { - value: "test1".to_string(), - position: Some(Position { - start: Point { line: 1, column: 1 }, - end: Point { line: 1, column: 5 }, - }), - }); - - let node2 = Node::Text(Text { - value: "test2".to_string(), - position: Some(Position { - start: Point { line: 1, column: 6 }, - end: Point { line: 1, column: 10 }, - }), - }); - - let node3 = Node::Text(Text { - value: "test3".to_string(), - position: Some(Position { - start: Point { line: 2, column: 1 }, - end: Point { line: 2, column: 5 }, - }), - }); - - assert_eq!(node1.partial_cmp(&node2), Some(std::cmp::Ordering::Less)); - assert_eq!(node2.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); - - assert_eq!(node1.partial_cmp(&node3), Some(std::cmp::Ordering::Less)); - assert_eq!(node3.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); - - let node4 = Node::Text(Text { - value: "test4".to_string(), - position: None, - }); - - assert_eq!(node1.partial_cmp(&node4), Some(std::cmp::Ordering::Less)); - assert_eq!(node4.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); - - let node5 = Node::Text(Text { - value: "test5".to_string(), - position: None, - }); - - assert_eq!(node4.partial_cmp(&node5), Some(std::cmp::Ordering::Equal)); - - let node6 = Node::Code(Code { - value: "code".to_string(), - lang: None, - fence: true, - meta: None, - position: None, - }); - - assert_eq!(node6.partial_cmp(&node4), Some(std::cmp::Ordering::Less)); - assert_eq!(node4.partial_cmp(&node6), Some(std::cmp::Ordering::Greater)); - } - - #[rstest] - #[case(Node::Blockquote(Blockquote{values: Vec::new(), position: None}), "blockquote")] - #[case(Node::Break(Break{position: None}), "break")] - #[case(Node::Definition(Definition{ident: "".to_string(), url: Url::new("".to_string()), title: None, label: None, position: None}), "definition")] - #[case(Node::Delete(Delete{values: Vec::new(), position: None}), "delete")] - #[case(Node::Heading(Heading{depth: 1, values: Vec::new(), position: None}), "h1")] - #[case(Node::Heading(Heading{depth: 2, values: Vec::new(), position: None}), "h2")] - #[case(Node::Heading(Heading{depth: 3, values: Vec::new(), position: None}), "h3")] - #[case(Node::Heading(Heading{depth: 4, values: Vec::new(), position: None}), "h4")] - #[case(Node::Heading(Heading{depth: 5, values: Vec::new(), position: None}), "h5")] - #[case(Node::Heading(Heading{depth: 6, values: Vec::new(), position: None}), "h6")] - #[case(Node::Heading(Heading{depth: 7, values: Vec::new(), position: None}), "h")] - #[case(Node::Emphasis(Emphasis{values: Vec::new(), position: None}), "emphasis")] - #[case(Node::Footnote(Footnote{ident: "".to_string(), values: Vec::new(), position: None}), "footnote")] - #[case(Node::FootnoteRef(FootnoteRef{ident: "".to_string(), label: None, position: None}), "footnoteref")] - #[case(Node::Html(Html{value: "".to_string(), position: None}), "html")] - #[case(Node::Yaml(Yaml{value: "".to_string(), position: None}), "yaml")] - #[case(Node::Toml(Toml{value: "".to_string(), position: None}), "toml")] - #[case(Node::Image(Image{alt: "".to_string(), url: "".to_string(), title: None, position: None}), "image")] - #[case(Node::ImageRef(ImageRef{alt: "".to_string(), ident: "".to_string(), label: None, position: None}), "image_ref")] - #[case(Node::CodeInline(CodeInline{value: "".into(), position: None}), "code_inline")] - #[case(Node::MathInline(MathInline{value: "".into(), position: None}), "math_inline")] - #[case(Node::Link(Link{url: Url::new("".to_string()), title: None, values: Vec::new(), position: None}), "link")] - #[case(Node::LinkRef(LinkRef{ident: "".to_string(), values: Vec::new(), label: None, position: None}), "link_ref")] - #[case(Node::Math(Math{value: "".to_string(), position: None}), "math")] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: Vec::new(), position: None}), "list")] - #[case(Node::TableAlign(TableAlign{align: Vec::new(), position: None}), "table_align")] - #[case(Node::TableRow(TableRow{values: Vec::new(), position: None}), "table_row")] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: None}), "table_cell")] - #[case(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: None}), "code")] - #[case(Node::Strong(Strong{values: Vec::new(), position: None}), "strong")] - #[case(Node::HorizontalRule(HorizontalRule{position: None}), "Horizontal_rule")] - #[case(Node::MdxFlowExpression(MdxFlowExpression{value: "".into(), position: None}), "mdx_flow_expression")] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), "mdx_jsx_flow_element")] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), "mdx_jsx_text_element")] - #[case(Node::MdxTextExpression(MdxTextExpression{value: "".into(), position: None}), "mdx_text_expression")] - #[case(Node::MdxJsEsm(MdxJsEsm{value: "".into(), position: None}), "mdx_js_esm")] - #[case(Node::Text(Text{value: "".to_string(), position: None}), "text")] - fn test_name(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.name(), expected); - } - - #[rstest] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), "test")] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Blockquote(Blockquote{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Heading(Heading{depth: 1, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Footnote(Footnote{ident: "test".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: None, position: None}), "test")] - #[case(Node::Html(Html{value: "test".to_string(), position: None}), "test")] - #[case(Node::Yaml(Yaml{value: "test".to_string(), position: None}), "test")] - #[case(Node::Toml(Toml{value: "test".to_string(), position: None}), "test")] - #[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "test".to_string(), title: None, position: None}), "test")] - #[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test".to_string(), label: None, position: None}), "test")] - #[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), "test")] - #[case(Node::MathInline(MathInline{value: "test".into(), position: None}), "test")] - #[case(Node::Link(Link{url: Url::new("test".to_string()), title: None, values: Vec::new(), position: None}), "test")] - #[case(Node::LinkRef(LinkRef{ident: "test".to_string(), values: Vec::new(), label: None, position: None}), "test")] - #[case(Node::Math(Math{value: "test".to_string(), position: None}), "test")] - #[case(Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None}), "test")] - #[case(Node::Strong(Strong{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None})], position: None}), "test")] - #[case(Node::Break(Break{position: None}), "")] - #[case(Node::HorizontalRule(HorizontalRule{position: None}), "")] - #[case(Node::TableAlign(TableAlign{align: Vec::new(), position: None}), "")] - #[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), "test")] - #[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), "test")] - #[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), "test")] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some(attr_keys::NAME.to_string()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] - #[case(Node::Definition(Definition{ident: "test".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), attr_keys::URL)] - #[case(Node::Fragment(Fragment {values: vec![Node::Text(Text{value: "test".to_string(), position: None})]}), "test")] - fn test_value(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.value(), expected); - } - - #[rstest] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), None)] - #[case(Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Blockquote(Blockquote{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Delete(Delete{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Heading(Heading{depth: 1, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Emphasis(Emphasis{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Footnote(Footnote{ident: "".to_string(), values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::FootnoteRef(FootnoteRef{ident: "".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Html(Html{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Yaml(Yaml{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Toml(Toml{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Image(Image{alt: "".to_string(), url: "".to_string(), title: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::ImageRef(ImageRef{alt: "".to_string(), ident: "".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::CodeInline(CodeInline{value: "".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MathInline(MathInline{value: "".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Link(Link{url: Url("".to_string()), title: None, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::LinkRef(LinkRef{ident: "".to_string(), values: Vec::new(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Math(Math{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Strong(Strong{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::TableRow(TableRow{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::TableAlign(TableAlign{align: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Break(Break{position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::HorizontalRule(HorizontalRule{position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Definition(Definition{ident: "".to_string(), url: Url("".to_string()), title: None, label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Fragment(Fragment{values: Vec::new()}), None)] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test1".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), - Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}) - ]}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}))] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), - Node::Text(Text{value: "test2".to_string(), position: None}) - ]}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test".to_string(), position: None}), - Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}) - ]}), Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}}))] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}), - Node::Text(Text{value: "test".to_string(), position: None}) - ]}), Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}}))] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test".to_string(), position: None}), - Node::Text(Text{value: "test2".to_string(), position: None}) - ]}), None)] - #[case(Node::Empty, None)] - fn test_position(#[case] node: Node, #[case] expected: Option) { - assert_eq!(node.position(), expected); - } - - #[rstest] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}) - ], position: None}), 1, None)] - #[case(Node::Delete(Delete{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] - #[case(Node::Emphasis(Emphasis{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] - #[case(Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}) - ], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] - #[case(Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}) - ], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "cell content".to_string(), position: None}) - ], position: None}), 0, Some(Node::Text(Text{value: "cell content".to_string(), position: None})))] - #[case(Node::TableRow(TableRow{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: None}), - Node::TableCell(TableCell{column: 1, row: 0, values: Vec::new(), position: None}) - ], position: None}), 1, Some(Node::TableCell(TableCell{column: 1, row: 0, values: Vec::new(), position: None})))] - #[case(Node::Text(Text{value: "plain text".to_string(), position: None}), 0, None)] - #[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), 0, None)] - #[case(Node::Html(Html{value: "
".to_string(), position: None}), 0, None)] - fn test_find_at_index(#[case] node: Node, #[case] index: usize, #[case] expected: Option) { - assert_eq!(node.find_at_index(index), expected); - } - - #[rstest] - #[case(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Heading(Heading{depth: 1, values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["test".to_string().into()], label: None, position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Footnote(Footnote{ident: "id".to_string(), values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::TableRow(TableRow{values: vec!["test".to_string().into()], position: None}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Fragment(Fragment{values: vec!["test".to_string().into()]}), - Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), - Node::Empty)] - #[case(Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None}), - Node::Empty)] - #[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), - Node::Empty)] - #[case(Node::Empty, Node::Empty)] - fn test_to_fragment(#[case] node: Node, #[case] expected: Node) { - assert_eq!(node.clone().to_fragment(), expected); - assert_eq!(node.into_fragment(), expected); - } - - // Regression coverage for the 0.6.2 blank-line bug: `eval_markdown_node` - // replaces a non-matching `select()` result with `child_node.to_fragment()`, - // not `Node::Empty` directly. For container nodes this yields a `Fragment` - // wrapping the (now `Empty`) children, which must still render to "" with - // no stray blank lines, while a partially-matching container must render - // only the surviving content. - #[rstest] - #[case::blockquote_all_empty(Node::Blockquote(Blockquote{values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::blockquote_mixed(Node::Blockquote(Blockquote{values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::delete_all_empty(Node::Delete(Delete{values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::delete_mixed(Node::Delete(Delete{values: vec![Node::Text(Text{value: "kept".to_string(), position: None}), Node::Empty], position: None}), "kept")] - #[case::heading_all_empty(Node::Heading(Heading{depth: 1, values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::heading_mixed(Node::Heading(Heading{depth: 1, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::emphasis_all_empty(Node::Emphasis(Emphasis{values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::emphasis_mixed(Node::Emphasis(Emphasis{values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::list_all_empty(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::list_mixed(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::strong_all_empty(Node::Strong(Strong{values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::strong_mixed(Node::Strong(Strong{values: vec![Node::Text(Text{value: "kept".to_string(), position: None}), Node::Empty], position: None}), "kept")] - #[case::link_all_empty(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::link_mixed(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::link_ref_all_empty(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Empty, Node::Empty], label: None, position: None}), "")] - #[case::link_ref_mixed(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], label: None, position: None}), "kept")] - #[case::footnote_all_empty(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::footnote_mixed(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - #[case::table_cell_all_empty(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Empty, Node::Empty], position: None}), "")] - #[case::table_cell_mixed(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] - fn test_to_fragment_then_render_skips_non_matching(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.to_fragment().to_string_with(&RenderOptions::default()), expected); - } - - #[rstest] - #[case( - &mut Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Delete(Delete{values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Delete(Delete{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Emphasis(Emphasis{values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Emphasis(Emphasis{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], label: None, position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], label: None, position: None}) - )] - #[case( - &mut Node::Footnote(Footnote{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Footnote(Footnote{ident: "id".to_string(), values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::TableRow(TableRow{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "old".to_string(), position: None}) - ], position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - ]}), - Node::TableRow(TableRow{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ], position: None}) - ], position: None}) - )] - #[case( - &mut Node::Text(Text{value: "old".to_string(), position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new".to_string(), position: None}) - ]}), - Node::Text(Text{value: "old".to_string(), position: None}) - )] - #[case( - &mut Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "text1".to_string(), position: None}), - Node::Text(Text{value: "text2".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new1".to_string(), position: None}), - Node::Text(Text{value: "new2".to_string(), position: None}) - ]}), - Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "new1".to_string(), position: None}), - Node::Text(Text{value: "new2".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "text1".to_string(), position: None}), - Node::Text(Text{value: "text2".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Empty, - Node::Text(Text{value: "new2".to_string(), position: None}) - ]}), - Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "text1".to_string(), position: None}), - Node::Text(Text{value: "new2".to_string(), position: None}) - ], position: None}) - )] - #[case( - &mut Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "text1".to_string(), position: None}), - Node::Text(Text{value: "text2".to_string(), position: None}) - ], position: None}), - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "new1".to_string(), position: None}), - Node::Fragment(Fragment{values: Vec::new()}) - ]}), - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ - Node::Text(Text{value: "new1".to_string(), position: None}), - Node::Text(Text{value: "text2".to_string(), position: None}) - ], position: None}) - )] - fn test_apply_fragment(#[case] node: &mut Node, #[case] fragment: Node, #[case] expected: Node) { - node.apply_fragment(fragment); - assert_eq!(*node, expected); - } - - #[rstest] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}}, - Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}})}))] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::List(List{start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] - #[case(Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Html(Html{value: "
test
".to_string(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}}, - Node::Html(Html{value: "
test
".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}})}))] - #[case(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}}, - Node::Yaml(Yaml{value: "key: value".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}})}))] - #[case(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}}, - Node::Toml(Toml{value: "key = \"value\"".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}})}))] - #[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 12}}, - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 12}})}))] - #[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] - #[case(Node::CodeInline(CodeInline{value: "code".into(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, - Node::CodeInline(CodeInline{value: "code".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] - #[case(Node::MathInline(MathInline{value: "x^2".into(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::MathInline(MathInline{value: "x^2".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "text".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] - #[case(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Text(Text{value: "text".to_string(), position: None})], label: None, position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})})], label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] - #[case(Node::Math(Math{value: "x^2".to_string(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}}, - Node::Math(Math{value: "x^2".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}})}))] - #[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}}, - Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}})}))] - #[case(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}}, - Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}})}))] - #[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, - Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] - #[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, - Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] - #[case(Node::MdxJsEsm(MdxJsEsm{value: "import React from 'react'".into(), position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 25}}, - Node::MdxJsEsm(MdxJsEsm{value: "import React from 'react'".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 25}})}))] - #[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "text".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}, - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})}))] - #[case(Node::Break(Break{position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 2}}, - Node::Break(Break{position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 2}})}))] - #[case(Node::Empty, - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Empty)] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test1".to_string(), position: None}), - Node::Text(Text{value: "test2".to_string(), position: None}) - ]}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "test1".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}), - Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) - ]}))] - #[case(Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Blockquote(Blockquote{values: vec![ - Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) - ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Heading(Heading{depth: 1, values: vec![ - Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) - ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "test".to_string(), position: None})], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, - Node::Strong(Strong{values: vec![ - Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) - ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] - #[case(Node::TableRow(TableRow{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "cell".to_string(), position: None}) - ], position: None}) - ], position: None}), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, - Node::TableRow(TableRow{values: vec![ - Node::TableCell(TableCell{column: 0, row: 0, values: vec![ - Node::Text(Text{value: "cell".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) - ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) - ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] - #[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![Node::Text(Text{value: "content".to_string(), position: None})], - position: None - }), - Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}, - Node::MdxJsxFlowElement(MdxJsxFlowElement{ - name: Some("div".to_string()), - attributes: Vec::new(), - children: vec![Node::Text(Text{value: "content".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})})], - position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}) - }))] - fn test_set_position(#[case] mut node: Node, #[case] position: Position, #[case] expected: Node) { - node.set_position(Some(position)); - assert_eq!(node, expected); - } - - fn some_position() -> Option { - Some(Position { - start: Point { line: 1, column: 1 }, - end: Point { line: 1, column: 5 }, - }) - } - - #[test] - fn test_strip_positions_top_level() { - let mut node = Node::Text(Text { - value: "test".to_string(), - position: some_position(), - }); - node.strip_positions(); - assert_eq!(node.position(), None); - } - - #[test] - fn test_strip_positions_recurses_into_children() { - let mut node = Node::Blockquote(Blockquote { - values: vec![Node::Strong(Strong { - values: vec![Node::Text(Text { - value: "test".to_string(), - position: some_position(), - })], - position: some_position(), - })], - position: some_position(), - }); - - node.strip_positions(); - - assert_eq!(node.position(), None); - let strong = &node.children()[0]; - assert_eq!(strong.position(), None); - assert_eq!(strong.children()[0].position(), None); - } - - #[test] - fn test_clear_text_position_at_leaf_only() { - let mut node = Node::Heading(Heading { - depth: 1, - values: vec![Node::Text(Text { - value: "title".to_string(), - position: some_position(), - })], - position: some_position(), - }); - - node.clear_text_position_at(0); - - assert_eq!(node.position(), some_position(), "outer node keeps its position"); - assert_eq!(node.children()[0].position(), None, "escaped leaf loses its position"); - } - - #[test] - fn test_clear_text_position_at_selected_index() { - let mut node = Node::List(List { - start: None, - spread: false, - index: 0, - level: 0, - checked: None, - ordered: false, - values: vec![ - Node::Text(Text { - value: "a".to_string(), - position: some_position(), - }), - Node::Text(Text { - value: "b".to_string(), - position: some_position(), - }), - ], - position: some_position(), - }); - - node.clear_text_position_at(1); - - assert_eq!( - node.children()[0].position(), - some_position(), - "untouched sibling keeps its position" - ); - assert_eq!(node.children()[1].position(), None, "selected leaf loses its position"); - } - - #[rstest] - #[case(Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::List(List{start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None}), true)] - #[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] - fn test_is_list(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_list(), expected); - } - - #[rstest] - #[case(Url::new("https://example.com".to_string()), RenderOptions{link_url_style: UrlSurroundStyle::None, ..Default::default()}, "https://example.com")] - #[case(Url::new("https://example.com".to_string()), RenderOptions{link_url_style: UrlSurroundStyle::Angle, ..Default::default()}, "")] - #[case(Url::new("".to_string()), RenderOptions::default(), "<>")] - fn test_url_to_string_with(#[case] url: Url, #[case] options: RenderOptions, #[case] expected: &str) { - assert_eq!(url.to_string_with(&options), expected); - } - - #[rstest] - #[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions::default(), "\"title\"")] - #[case(Title::new(r#"title with "quotes""#.to_string()), RenderOptions::default(), r#""title with \"quotes\"""#)] - #[case(Title::new("title with spaces".to_string()), RenderOptions::default(), "\"title with spaces\"")] - #[case(Title::new("".to_string()), RenderOptions::default(), "\"\"")] - #[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Single, ..Default::default()}, "'title'")] - #[case(Title::new("title with 'quotes'".to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Double, ..Default::default()}, "\"title with 'quotes'\"")] - #[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Paren, ..Default::default()}, "(title)")] - fn test_title_to_string_with(#[case] title: Title, #[case] options: RenderOptions, #[case] expected: &str) { - assert_eq!(title.to_string_with(&options), expected); - } - - #[rstest] - #[case(Node::Fragment(Fragment{values: vec![]}), true)] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Text(Text{value: "not_empty".to_string(), position: None}) - ]}), false)] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![]}), - Node::Fragment(Fragment{values: vec![]}) - ]}), true)] - #[case(Node::Fragment(Fragment{values: vec![ - Node::Fragment(Fragment{values: vec![]}), - Node::Text(Text{value: "not_empty".to_string(), position: None}) - ]}), false)] - #[case(Node::Text(Text{value: "not_fragment".to_string(), position: None}), false)] - fn test_is_empty_fragment(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_empty_fragment(), expected); - } - - #[rstest] - #[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: Vec::new(), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] - #[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: Vec::new(), position: None}), "unknown", None)] - #[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("
test
".to_string())))] - #[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), "unknown", None)] - #[case::text(Node::Text(Text{value: "text".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("text".to_string())))] - #[case::text(Node::Text(Text{value: "text".to_string(), position: None}), "unknown", None)] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::VALUE, Some(AttrValue::String("code".to_string())))] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::LANG, Some(AttrValue::String("rust".to_string())))] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), "meta", Some(AttrValue::String("meta".to_string())))] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::FENCE, Some(AttrValue::Boolean(true)))] - #[case::code(Node::Code(Code{value: "code".to_string(), lang: None, meta: None, fence: false, position: None}), attr_keys::FENCE, Some(AttrValue::Boolean(false)))] - #[case::code_inline(Node::CodeInline(CodeInline{value: "inline".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("inline".to_string())))] - #[case::math_inline(Node::MathInline(MathInline{value: "math".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("math".to_string())))] - #[case::math(Node::Math(Math{value: "math".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("math".to_string())))] - #[case::yaml(Node::Yaml(Yaml{value: "yaml".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("yaml".to_string())))] - #[case::toml(Node::Toml(Toml{value: "toml".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("toml".to_string())))] - #[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::ALT, Some(AttrValue::String(attr_keys::ALT.to_string())))] - #[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] - #[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] - #[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::ALT, Some(AttrValue::String(attr_keys::ALT.to_string())))] - #[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] - #[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] - #[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: Vec::new(), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] - #[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: Vec::new(), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: Vec::new(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] - #[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: Vec::new(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] - #[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] - #[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] - #[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] - #[case::heading(Node::Heading(Heading{depth: 3, values: Vec::new(), position: None}), "depth", Some(AttrValue::Integer(3)))] - #[case::list(Node::List(List{start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "index", Some(AttrValue::Integer(2)))] - #[case::list(Node::List(List{start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "level", Some(AttrValue::Integer(1)))] - #[case::list(Node::List(List{start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "ordered", Some(AttrValue::Boolean(true)))] - #[case::list(Node::List(List{start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), attr_keys::CHECKED, Some(AttrValue::Boolean(true)))] - #[case::table_cell(Node::TableCell(TableCell{column: 1, row: 2, values: Vec::new(), position: None}), "column", Some(AttrValue::Integer(1)))] - #[case::table_cell(Node::TableCell(TableCell{column: 1, row: 2, values: Vec::new(), position: None}), "row", Some(AttrValue::Integer(2)))] - #[case::table_align(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), "align", Some(AttrValue::String(":---,---:".to_string())))] - #[case::mdx_flow_expression(Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("expr".to_string())))] - #[case::mdx_flow_expression(Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("expr".to_string())))] - #[case::mdx_js_esm(Node::MdxJsEsm(MdxJsEsm{value: "esm".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("esm".to_string())))] - #[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), attr_keys::NAME, Some(AttrValue::String("div".to_string())))] - #[case::mdx_jsx_flow_element(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), attr_keys::NAME, Some(AttrValue::String("span".to_string())))] - #[case::break_(Node::Break(Break{position: None}), attr_keys::VALUE, None)] - #[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{position: None}), attr_keys::VALUE, None)] - #[case::fragment(Node::Fragment(Fragment{values: Vec::new()}), attr_keys::VALUE, Some(AttrValue::String("".to_string())))] - #[case::heading(Node::Heading(Heading{depth: 1, values: vec![Node::Text(Text{value: "heading text".to_string(), position: None})], position: None}), attr_keys::VALUE, Some(AttrValue::String("heading text".to_string())))] - #[case::heading(Node::Heading(Heading{depth: 2, values: vec![], position: None}), attr_keys::VALUE, Some(AttrValue::String("".to_string())))] - #[case::heading(Node::Heading(Heading{depth: 3, values: vec![ - Node::Text(Text{value: "first".to_string(), position: None}), - Node::Text(Text{value: "second".to_string(), position: None}), - ], position: None}), attr_keys::VALUE, Some(AttrValue::String("firstsecond".to_string())))] - #[case( - Node::List(List { - index: 0, - level: 1, - checked: None, - ordered: false, - start: None, spread: false, - values: vec![ - Node::Text(Text { value: "item1".to_string(), position: None }), - Node::Text(Text { value: "item2".to_string(), position: None }), - ], - position: None, - }), - attr_keys::VALUE, - Some(AttrValue::String("item1item2".to_string())) - )] - #[case( - Node::TableCell(TableCell { - column: 1, - row: 2, - values: vec![Node::Text(Text { - value: "cell_value".to_string(), - position: None, - })], - position: None, - }), - attr_keys::VALUE, - Some(AttrValue::String("cell_value".to_string())) - )] - #[case::footnote( - Node::Footnote(Footnote { - ident: "id".to_string(), - values: vec![Node::Text(Text { - value: "footnote value".to_string(), - position: None, - })], - position: None, - }), - attr_keys::VALUE, - Some(AttrValue::String("footnote value".to_string())) - )] - #[case::link( - Node::Link(Link { - url: Url::new("https://example.com".to_string()), - title: Some(Title::new("Example".to_string())), - values: vec![Node::Text(Text { - value: "link text".to_string(), - position: None, - })], - position: None, - }), - attr_keys::VALUE, - Some(AttrValue::String("link text".to_string())) - )] - #[case::empty(Node::Empty, attr_keys::VALUE, None)] - #[case::heading( - Node::Heading(Heading { - depth: 1, - values: vec![ - Node::Text(Text { - value: "child1".to_string(), - position: None, - }), - Node::Text(Text { - value: "child2".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::CHILDREN, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "child1".to_string(), - position: None, - }), - Node::Text(Text { - value: "child2".to_string(), - position: None, - }), - ])) - )] - #[case::list( - Node::List(List { - index: 0, - level: 1, - checked: None, - ordered: false, - start: None, spread: false, - values: vec![ - Node::Text(Text { - value: "item1".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::CHILDREN, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "item1".to_string(), - position: None, - }), - ])) - )] - #[case::blockquote( - Node::Blockquote(Blockquote { - values: vec![ - Node::Text(Text { - value: "quote".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::VALUES, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "quote".to_string(), - position: None, - }), - ])) - )] - #[case::link( - Node::Link(Link { - url: Url::new(attr_keys::URL.to_string()), - title: None, - values: vec![ - Node::Text(Text { - value: "link".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::VALUES, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "link".to_string(), - position: None, - }), - ])) - )] - #[case::table_cell( - Node::TableCell(TableCell { - column: 0, - row: 0, - values: vec![ - Node::Text(Text { - value: "cell".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::CHILDREN, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "cell".to_string(), - position: None, - }), - ])) - )] - #[case::strong( - Node::Strong(Strong { - values: vec![ - Node::Text(Text { - value: "bold".to_string(), - position: None, - }), - ], - position: None, - }), - attr_keys::CHILDREN, - Some(AttrValue::Array(vec![ - Node::Text(Text { - value: "bold".to_string(), - position: None, - }), - ])) - )] - #[case::em( - Node::Emphasis(Emphasis { - values: vec![], - position: None, - }), - attr_keys::CHILDREN, - Some(AttrValue::Array(vec![])) - )] - fn test_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { - assert_eq!(node.attr(attr), expected); - } - - #[rstest] - #[case::heading_with_position( - Node::Heading(Heading { - depth: 1, - values: vec![], - position: Some(Position { - start: Point { line: 3, column: 1 }, - end: Point { line: 5, column: 4 }, - }), - }), - attr_keys::LINE, - Some(AttrValue::Integer(3)) - )] - #[case::code_end_line( - Node::Code(Code { - value: "x".to_string(), - lang: None, - meta: None, - fence: true, - position: Some(Position { - start: Point { line: 3, column: 1 }, - end: Point { line: 5, column: 4 }, - }), - }), - attr_keys::END_LINE, - Some(AttrValue::Integer(5)) - )] - #[case::synthetic_node_has_no_line( - Node::Heading(Heading { - depth: 1, - values: vec![], - position: None, - }), - attr_keys::LINE, - None - )] - #[case::synthetic_node_has_no_end_line( - Node::Heading(Heading { - depth: 1, - values: vec![], - position: None, - }), - attr_keys::END_LINE, - None - )] - fn test_line_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { - assert_eq!(node.attr(attr), expected); - } - - #[rstest] - #[case::heading( - Node::Heading(Heading{depth: 1, values: vec![], position: None}), - vec![Node::Text(Text{value: "child".to_string(), position: None})], - Node::Heading(Heading{depth: 1, values: vec![Node::Text(Text{value: "child".to_string(), position: None})], position: None}) - )] - #[case::list( - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![], position: None}), - vec![Node::Text(Text{value: "item".to_string(), position: None})], - Node::List(List{start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Text(Text{value: "item".to_string(), position: None})], position: None}) - )] - #[case::blockquote( - Node::Blockquote(Blockquote{values: vec![], position: None}), - vec![Node::Text(Text{value: "quote".to_string(), position: None})], - Node::Blockquote(Blockquote{values: vec![Node::Text(Text{value: "quote".to_string(), position: None})], position: None}) - )] - #[case::link( - Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![], position: None}), - vec![Node::Text(Text{value: "link".to_string(), position: None})], - Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "link".to_string(), position: None})], position: None}) - )] - #[case::footnote( - Node::Footnote(Footnote{ident: "1".to_string(), values: vec![], position: None}), - vec![Node::Text(Text{value: "note".to_string(), position: None})], - Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "note".to_string(), position: None})], position: None}) - )] - #[case::table_cell( - Node::TableCell(TableCell{column: 0, row: 0, values: vec![], position: None}), - vec![Node::Text(Text{value: "cell".to_string(), position: None})], - Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: None})], position: None}) - )] - #[case::table_row( - Node::TableRow(TableRow{values: vec![], position: None}), - vec![Node::Text(Text{value: "row".to_string(), position: None})], - Node::TableRow(TableRow{values: vec![Node::Text(Text{value: "row".to_string(), position: None})], position: None}) - )] - #[case::strong( - Node::Strong(Strong{values: vec![], position: None}), - vec![Node::Text(Text{value: "bold".to_string(), position: None})], - Node::Strong(Strong{values: vec![Node::Text(Text{value: "bold".to_string(), position: None})], position: None}) - )] - #[case::delete( - Node::Delete(Delete{values: vec![], position: None}), - vec![Node::Text(Text{value: "del".to_string(), position: None})], - Node::Delete(Delete{values: vec![Node::Text(Text{value: "del".to_string(), position: None})], position: None}) - )] - #[case::emphasis( - Node::Emphasis(Emphasis{values: vec![], position: None}), - vec![Node::Text(Text{value: "em".to_string(), position: None})], - Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "em".to_string(), position: None})], position: None}) - )] - #[case::fragment( - Node::Fragment(Fragment{values: vec![]}), - vec![Node::Text(Text{value: "frag".to_string(), position: None})], - Node::Fragment(Fragment{values: vec![Node::Text(Text{value: "frag".to_string(), position: None})]}) - )] - #[case::mdx_jsx_flow_element( - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: vec![], position: None}), - vec![Node::Text(Text{value: "mdx".to_string(), position: None})], - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "mdx".to_string(), position: None})], position: None}) - )] - #[case::mdx_jsx_text_element( - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![], position: None}), - vec![Node::Text(Text{value: "mdx".to_string(), position: None})], - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "mdx".to_string(), position: None})], position: None}) - )] - #[case::leaf_node_is_noop( - Node::Text(Text{value: "leaf".to_string(), position: None}), - vec![Node::Text(Text{value: "ignored".to_string(), position: None})], - Node::Text(Text{value: "leaf".to_string(), position: None}) - )] - fn test_set_children(#[case] mut node: Node, #[case] children: Vec, #[case] expected: Node) { - node.set_children(children.clone()); - assert_eq!(node, expected); - assert_eq!( - node.children(), - if matches!(expected, Node::Text(_)) { - Vec::new() - } else { - children - } - ); - } - - #[rstest] - #[case( - Node::Text(Text{value: "old".to_string(), position: None}), - attr_keys::VALUE, - "new", - Node::Text(Text{value: "new".to_string(), position: None}) - )] - #[case( - Node::Code(Code{value: "old".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), - attr_keys::VALUE, - "new_code", - Node::Code(Code{value: "new_code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}) - )] - #[case( - Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), - attr_keys::LANG, - "python", - Node::Code(Code{value: "code".to_string(), lang: Some("python".to_string()), fence: true, meta: None, position: None}) - )] - #[case( - Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), - attr_keys::FENCE, - "true", - Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}) - )] - #[case( - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), - attr_keys::ALT, - "new_alt", - Node::Image(Image{alt: "new_alt".to_string(), url: attr_keys::URL.to_string(), title: None, position: None}) - )] - #[case( - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), - attr_keys::URL, - "new_url", - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "new_url".to_string(), title: None, position: None}) - )] - #[case( - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), - attr_keys::TITLE, - "new_title", - Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some("new_title".to_string()), position: None}) - )] - #[case( - Node::Heading(Heading{depth: 2, values: vec![], position: None}), - "depth", - "3", - Node::Heading(Heading{depth: 3, values: vec![], position: None}) - )] - #[case( - Node::List(List{start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec![], position: None}), - attr_keys::CHECKED, - "false", - Node::List(List{start: None, spread: false, index: 1, level: 2, checked: Some(false), ordered: false, values: vec![], position: None}) - )] - #[case( - Node::List(List{start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec![], position: None}), - "ordered", - "true", - Node::List(List{start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: true, values: vec![], position: None}) - )] - #[case( - Node::TableCell(TableCell{column: 1, row: 2, values: vec![], position: None}), - "column", - "3", - Node::TableCell(TableCell{column: 3, row: 2, values: vec![], position: None}) - )] - #[case( - Node::TableCell(TableCell{column: 1, row: 2, values: vec![], position: None}), - "row", - "5", - Node::TableCell(TableCell{column: 1, row: 5, values: vec![], position: None}) - )] - #[case( - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), - attr_keys::IDENT, - "new_id", - Node::Definition(Definition{ident: "new_id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}) - )] - #[case( - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), - attr_keys::URL, - "new_url", - Node::Definition(Definition{ident: "id".to_string(), url: Url::new("new_url".to_string()), title: None, label: None, position: None}) - )] - #[case( - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), - attr_keys::LABEL, - "new_label", - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), - attr_keys::TITLE, - "new_title", - Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new("new_title".to_string())), label: None, position: None}) - )] - #[case( - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::ALT, - "new_alt", - Node::ImageRef(ImageRef{alt: "new_alt".to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case( - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::IDENT, - "new_id", - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "new_id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case( - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::LABEL, - "new_label", - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: None}), - attr_keys::LABEL, - "new_label", - Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::IDENT, - "new_id", - Node::LinkRef(LinkRef{ident: "new_id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case( - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::LABEL, - "new_label", - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: None, position: None}), - attr_keys::LABEL, - "new_label", - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), - "unknown", - "ignored", - Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case( - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::IDENT, - "new_id", - Node::FootnoteRef(FootnoteRef{ident: "new_id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case( - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - attr_keys::LABEL, - "new_label", - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: None, position: None}), - attr_keys::LABEL, - "new_label", - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) - )] - #[case( - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), - "unknown", - "ignored", - Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) - )] - #[case(Node::Empty, attr_keys::VALUE, "ignored", Node::Empty)] - #[case( - Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), - "align", - "---,:---:", - Node::TableAlign(TableAlign{align: vec![TableAlignKind::None, TableAlignKind::Center], position: None}) - )] - #[case( - Node::TableAlign(TableAlign{align: vec![], position: None}), - "align", - ":---,---:", - Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}) - )] - #[case( - Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left], position: None}), - "unknown", - "ignored", - Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left], position: None}) - )] - #[case( - Node::MdxFlowExpression(MdxFlowExpression{value: "old".into(), position: None}), - attr_keys::VALUE, - "new_expr", - Node::MdxFlowExpression(MdxFlowExpression{value: "new_expr".into(), position: None}) - )] - #[case( - Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}), - "unknown", - "ignored", - Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}) - )] - #[case( - Node::MdxTextExpression(MdxTextExpression{value: "old".into(), position: None}), - attr_keys::VALUE, - "new_expr", - Node::MdxTextExpression(MdxTextExpression{value: "new_expr".into(), position: None}) - )] - #[case( - Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}), - "unknown", - "ignored", - Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}) - )] - #[case( - Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}), - attr_keys::VALUE, - "import y", - Node::MdxJsEsm(MdxJsEsm{value: "import y".into(), position: None}) - )] - #[case( - Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}), - "unknown", - "ignored", - Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}) - )] - #[case( - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), - attr_keys::NAME, - "section", - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("section".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - #[case( - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), - attr_keys::NAME, - "main", - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("main".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - #[case( - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), - "unknown", - "ignored", - Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - #[case( - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), - attr_keys::NAME, - "b", - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("b".into()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - #[case( - Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), - attr_keys::NAME, - "i", - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("i".into()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - #[case( - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), - "unknown", - "ignored", - Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}) - )] - fn test_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { - node.set_attr(attr, value); - assert_eq!(node, expected); - } - - #[rstest] - #[case(AttrValue::String("test".to_string()), AttrValue::String("test".to_string()), true)] - #[case(AttrValue::String("test".to_string()), AttrValue::String("other".to_string()), false)] - #[case(AttrValue::Integer(42), AttrValue::Integer(42), true)] - #[case(AttrValue::Integer(42), AttrValue::Integer(0), false)] - #[case(AttrValue::Boolean(true), AttrValue::Boolean(true), true)] - #[case(AttrValue::Boolean(true), AttrValue::Boolean(false), false)] - #[case(AttrValue::String("42".to_string()), AttrValue::Integer(42), false)] - #[case(AttrValue::Boolean(false), AttrValue::Integer(0), false)] - fn test_attr_value_eq(#[case] a: AttrValue, #[case] b: AttrValue, #[case] expected: bool) { - assert_eq!(a == b, expected); - } - - #[rstest] - #[case(AttrValue::String("test".to_string()), "test")] - #[case(AttrValue::Integer(42), "42")] - #[case(AttrValue::Boolean(true), "true")] - fn test_attr_value_as_str(#[case] value: AttrValue, #[case] expected: &str) { - assert_eq!(&value.as_string(), expected); - } - - #[rstest] - #[case(AttrValue::Integer(42), Some(42))] - #[case(AttrValue::String("42".to_string()), Some(42))] - #[case(AttrValue::Boolean(false), Some(0))] - fn test_attr_value_as_i64(#[case] value: AttrValue, #[case] expected: Option) { - assert_eq!(value.as_i64(), expected); - } - - // --- Callout tests --- - - #[cfg(feature = "callout")] - #[rstest] - // basic [!NOTE] with body on next line - #[case( - vec![Node::Text(Text { value: "[!NOTE]\nbody text".to_string(), position: None })], - Node::Callout(Callout { - kind: "NOTE".to_string(), - title: None, - values: vec![Node::Text(Text { value: "body text".to_string(), position: None })], - position: None, - }) - )] - // [!WARNING] with custom title - #[case( - vec![Node::Text(Text { value: "[!WARNING] My Title\nbody".to_string(), position: None })], - Node::Callout(Callout { - kind: "WARNING".to_string(), - title: Some("My Title".to_string()), - values: vec![Node::Text(Text { value: "body".to_string(), position: None })], - position: None, - }) - )] - // [!TIP] with no body - #[case( - vec![Node::Text(Text { value: "[!TIP]".to_string(), position: None })], - Node::Callout(Callout { kind: "TIP".to_string(), title: None, values: vec![], position: None }) - )] - // lowercase kind is preserved as-is - #[case( - vec![Node::Text(Text { value: "[!note]\ncontent".to_string(), position: None })], - Node::Callout(Callout { - kind: "note".to_string(), - title: None, - values: vec![Node::Text(Text { value: "content".to_string(), position: None })], - position: None, - }) - )] - // not a callout — plain text stays Blockquote - #[case( - vec![Node::Text(Text { value: "plain quote".to_string(), position: None })], - Node::Blockquote(Blockquote { - values: vec![Node::Text(Text { value: "plain quote".to_string(), position: None })], - position: None, - }) - )] - // [! without closing ] — stays Blockquote - #[case( - vec![Node::Text(Text { value: "[!UNCLOSED".to_string(), position: None })], - Node::Blockquote(Blockquote { - values: vec![Node::Text(Text { value: "[!UNCLOSED".to_string(), position: None })], - position: None, - }) - )] - // first node is not Text — stays Blockquote - #[case( - vec![Node::Code(Code { value: "code".to_string(), lang: None, fence: true, meta: None, position: None })], - Node::Blockquote(Blockquote { - values: vec![Node::Code(Code { value: "code".to_string(), lang: None, fence: true, meta: None, position: None })], - position: None, - }) - )] - // multi-paragraph callout (header-only node + separate body node) - #[case( - vec![ - Node::Text(Text { value: "[!NOTE]".to_string(), position: None }), - Node::Text(Text { value: "second paragraph".to_string(), position: None }), - ], - Node::Callout(Callout { - kind: "NOTE".to_string(), - title: None, - values: vec![Node::Text(Text { value: "second paragraph".to_string(), position: None })], - position: None, - }) - )] - fn test_try_parse_callout(#[case] values: Vec, #[case] expected: Node) { - let result = Node::try_parse_callout(values, None); - assert_eq!(result, expected); - } - - #[cfg(feature = "callout")] - #[rstest] - // basic render - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, - values: vec![Node::Text(Text { value: "content".to_string(), position: None })], - position: None }), - "> [!NOTE]\n> content" - )] - // with title - #[case( - Node::Callout(Callout { kind: "WARNING".to_string(), title: Some("Heads up".to_string()), - values: vec![Node::Text(Text { value: "watch out".to_string(), position: None })], - position: None }), - "> [!WARNING] Heads up\n> watch out" - )] - // empty body - #[case( - Node::Callout(Callout { kind: "TIP".to_string(), title: None, values: vec![], position: None }), - "> [!TIP]" - )] - // multiline body: single Text with embedded '\n' — each line prefixed with "> " - #[case( - Node::Callout(Callout { kind: "INFO".to_string(), title: None, - values: vec![Node::Text(Text { value: "line one\nline two".to_string(), position: None })], - position: None }), - "> [!INFO]\n> line one\n> line two" - )] - // two separate position-less Text values are concatenated inline (no implicit newline) - #[case( - Node::Callout(Callout { kind: "INFO".to_string(), title: None, - values: vec![ - Node::Text(Text { value: "part a".to_string(), position: None }), - Node::Text(Text { value: "part b".to_string(), position: None }), - ], - position: None }), - "> [!INFO]\n> part apart b" - )] - fn test_callout_render(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[cfg(feature = "callout")] - #[rstest] - #[case(Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), "kind", Some(AttrValue::String("NOTE".to_string())))] - #[case(Node::Callout(Callout { kind: "WARNING".to_string(), title: Some("Title".to_string()), values: vec![], position: None }), "title", Some(AttrValue::String("Title".to_string())))] - #[case(Node::Callout(Callout { kind: "TIP".to_string(), title: None, values: vec![], position: None }), "title", None)] - #[case(Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![Node::Text(Text { value: "body".to_string(), position: None })], position: None }), "value", Some(AttrValue::String("body".to_string())))] - #[case(Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![Node::Text(Text { value: "body".to_string(), position: None })], position: None }), "children", Some(AttrValue::Array(vec![Node::Text(Text { value: "body".to_string(), position: None })])))] - fn test_callout_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { - assert_eq!(node.attr(attr), expected); - } - - #[cfg(feature = "callout")] - #[rstest] - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), - "kind", "WARNING", - Node::Callout(Callout { kind: "WARNING".to_string(), title: None, values: vec![], position: None }) - )] - // set_attr stores kind as-is without case conversion - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), - "kind", "tip", - Node::Callout(Callout { kind: "tip".to_string(), title: None, values: vec![], position: None }) - )] - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), - "title", "My title", - Node::Callout(Callout { kind: "NOTE".to_string(), title: Some("My title".to_string()), values: vec![], position: None }) - )] - // empty string clears title - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: Some("old".to_string()), values: vec![], position: None }), - "title", "", - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }) - )] - fn test_callout_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { - node.set_attr(attr, value); - assert_eq!(node, expected); - } - - #[cfg(feature = "callout")] - #[rstest] - // with_value changes first body node - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, - values: vec![Node::Text(Text { value: "old".to_string(), position: None })], - position: None }), - "new", - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, - values: vec![Node::Text(Text { value: "new".to_string(), position: None })], - position: None }) - )] - fn test_callout_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { - assert_eq!(node.clone().with_value(value), expected); - assert_eq!(node.into_with_value(value), expected); - } - - #[cfg(feature = "callout")] - #[rstest] - #[case(Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), "callout")] - fn test_callout_name(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.name(), expected); - } - - #[cfg(feature = "callout")] - #[rstest] - #[case( - Node::Callout(Callout { kind: "NOTE".to_string(), title: None, - values: vec![Node::Text(Text { value: "body".to_string(), position: None })], - position: None }), - "body" - )] - fn test_callout_value(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.value(), expected); - } - - #[cfg(feature = "callout")] - #[rstest] - #[case(Node::Callout(Callout { kind: "NOTE".to_string(), title: None, values: vec![], position: None }), true)] - #[case(Node::Blockquote(Blockquote { values: vec![], position: None }), false)] - #[case(Node::Text(Text { value: "test".to_string(), position: None }), false)] - fn test_is_callout(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_callout(), expected); - } - - #[cfg(feature = "callout")] - #[test] - fn test_callout_end_to_end() { - use crate::Markdown; - // plain blockquote stays as Blockquote - let plain = Markdown::from_markdown_str("> just a quote").unwrap(); - assert!(plain.nodes[0].is_blockquote()); - - // [!NOTE] is parsed as Callout - let note = Markdown::from_markdown_str("> [!NOTE]\n> body").unwrap(); - assert!(note.nodes[0].is_callout()); - assert_eq!(note.nodes[0].attr("kind"), Some(AttrValue::String("NOTE".to_string()))); - - // [!WARNING] with title - let warn = Markdown::from_markdown_str("> [!WARNING] Watch out\n> content").unwrap(); - assert!(warn.nodes[0].is_callout()); - assert_eq!( - warn.nodes[0].attr("title"), - Some(AttrValue::String("Watch out".to_string())) - ); - } - - // --- Embed tests --- - - #[cfg(feature = "embed")] - #[rstest] - // plain embed - #[case("![[target]]", vec![Node::Embed(Embed { target: "target".to_string(), display: None, position: None })])] - // embed with display hint (image size) - #[case("![[image.png|400]]", vec![Node::Embed(Embed { target: "image.png".to_string(), display: Some("400".to_string()), position: None })])] - // embed with section reference in target - #[case("![[note#Heading]]", vec![Node::Embed(Embed { target: "note#Heading".to_string(), display: None, position: None })])] - // embed at start of text - #[case("![[note]] rest", vec![ - Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), - Node::Text(Text { value: " rest".to_string(), position: None }), - ])] - // embed in middle - #[case("before ![[note]] after", vec![ - Node::Text(Text { value: "before ".to_string(), position: None }), - Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), - Node::Text(Text { value: " after".to_string(), position: None }), - ])] - // multiple embeds - #[case("![[a]] and ![[b]]", vec![ - Node::Embed(Embed { target: "a".to_string(), display: None, position: None }), - Node::Text(Text { value: " and ".to_string(), position: None }), - Node::Embed(Embed { target: "b".to_string(), display: None, position: None }), - ])] - // multibyte target - #[case("![[ノート]]", vec![Node::Embed(Embed { target: "ノート".to_string(), display: None, position: None })])] - // no embed — plain text unchanged - #[case("plain text", vec![Node::Text(Text { value: "plain text".to_string(), position: None })])] - // unclosed embed — treated as plain text - #[case("![[unclosed", vec![Node::Text(Text { value: "![[unclosed".to_string(), position: None })])] - // lone ! before non-embed — treated as plain text - #[case("! not an embed", vec![Node::Text(Text { value: "! not an embed".to_string(), position: None })])] - fn test_parse_embeds_in_text(#[case] input: &str, #[case] expected: Vec) { - let mut result = Vec::new(); - Node::parse_embeds_into(input, None, &mut result); - assert_eq!(result, expected); - } - - #[cfg(feature = "embed")] - #[rstest] - // plain embed renders without display - #[case( - Node::Embed(Embed { target: "note.md".to_string(), display: None, position: None }), - "![[note.md]]" - )] - // embed with display hint - #[case( - Node::Embed(Embed { target: "image.png".to_string(), display: Some("400".to_string()), position: None }), - "![[image.png|400]]" - )] - // embed with section reference - #[case( - Node::Embed(Embed { target: "note#Intro".to_string(), display: None, position: None }), - "![[note#Intro]]" - )] - fn test_embed_render(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[cfg(feature = "embed")] - #[rstest] - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "url", Some(AttrValue::String("note".to_string())))] - // value returns target when no display - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "value", Some(AttrValue::String("note".to_string())))] - // url always returns target regardless of display - #[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "url", Some(AttrValue::String("note".to_string())))] - // value returns display when present - #[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "value", Some(AttrValue::String("400".to_string())))] - // unknown attr returns None - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "unknown", None)] - fn test_embed_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { - assert_eq!(node.attr(attr), expected); - } - - #[cfg(feature = "embed")] - #[rstest] - // set url changes target - #[case( - Node::Embed(Embed { target: "old".to_string(), display: None, position: None }), - "url", "new", - Node::Embed(Embed { target: "new".to_string(), display: None, position: None }) - )] - // set value changes display - #[case( - Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), - "value", "800", - Node::Embed(Embed { target: "note".to_string(), display: Some("800".to_string()), position: None }) - )] - // empty value clears display - #[case( - Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), - "value", "", - Node::Embed(Embed { target: "note".to_string(), display: None, position: None }) - )] - fn test_embed_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { - node.set_attr(attr, value); - assert_eq!(node, expected); - } - - #[cfg(feature = "embed")] - #[rstest] - // no display: with_value sets target - #[case( - Node::Embed(Embed { target: "old".to_string(), display: None, position: None }), - "new", - Node::Embed(Embed { target: "new".to_string(), display: None, position: None }) - )] - // with display: with_value sets display - #[case( - Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), - "800", - Node::Embed(Embed { target: "note".to_string(), display: Some("800".to_string()), position: None }) - )] - fn test_embed_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { - assert_eq!(node.clone().with_value(value), expected); - assert_eq!(node.into_with_value(value), expected); - } - - #[cfg(feature = "embed")] - #[rstest] - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "embed")] - fn test_embed_name(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.name(), expected); - } - - #[cfg(feature = "embed")] - #[rstest] - // value returns display when present - #[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "400")] - // value returns target when no display - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "note")] - fn test_embed_value(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.value(), expected); - } - - #[cfg(feature = "embed")] - #[rstest] - #[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), true)] - #[case(Node::Text(Text { value: "test".to_string(), position: None }), false)] - #[case(Node::Image(Image { alt: "".to_string(), url: "img.png".to_string(), title: None, position: None }), false)] - fn test_is_embed(#[case] node: Node, #[case] expected: bool) { - assert_eq!(node.is_embed(), expected); - } - - #[cfg(feature = "embed")] - #[test] - fn test_embed_end_to_end() { - use crate::Markdown; - let md = Markdown::from_markdown_str("See ![[note.md]] for details.").unwrap(); - let embed = md.nodes.iter().find(|n| n.is_embed()); - assert!(embed.is_some()); - assert_eq!( - embed.unwrap().attr("url"), - Some(AttrValue::String("note.md".to_string())) - ); - } - - #[cfg(all(feature = "callout", feature = "wikilink"))] - #[rstest] - // callout body containing a wikilink - #[case("> [!NOTE]\n> See [[note]]", 1, "> [!NOTE]\n> See [[note]]\n")] - fn test_callout_with_wikilink(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) { - use crate::Markdown; - let md = Markdown::from_markdown_str(input).unwrap(); - assert_eq!(md.nodes.len(), expected_nodes); - assert_eq!(md.to_string(), expected_output); - } - - #[cfg(feature = "callout")] - #[rstest] - // callout body with bold - #[case("> [!NOTE]\n> **important**", 1, "> [!NOTE]\n> **important**\n")] - // callout body with inline code - #[case("> [!NOTE]\n> Use `code`", 1, "> [!NOTE]\n> Use `code`\n")] - // multiple callouts in one document - #[case( - "> [!NOTE]\n> first\n\n> [!WARNING]\n> second", - 2, - "> [!NOTE]\n> first\n\n> [!WARNING]\n> second\n" - )] - // callout after heading - #[case("# Title\n\n> [!NOTE]\n> body", 2, "# Title\n\n> [!NOTE]\n> body\n")] - // callout body with a multi-item list — second item must not be over-indented - #[case("> [!NOTE]\n> - item 1\n> - item 2", 1, "> [!NOTE]\n> - item 1\n> - item 2\n")] - fn test_callout_combined_with_other_nodes( - #[case] input: &str, - #[case] expected_nodes: usize, - #[case] expected_output: &str, - ) { - use crate::Markdown; - let md = Markdown::from_markdown_str(input).unwrap(); - assert_eq!(md.nodes.len(), expected_nodes); - assert_eq!(md.to_string(), expected_output); - } - - #[cfg(feature = "embed")] - #[rstest] - // embed inside list item - #[case("- ![[note.md]]", 1, "- ![[note.md]]\n")] - // embed inside heading - #[case("# See ![[note]]", 1, "# See ![[note]]\n")] - // embed inside bold - #[case("**![[note]]**", 1, "**![[note]]**\n")] - fn test_embed_combined_with_other_nodes( - #[case] input: &str, - #[case] expected_nodes: usize, - #[case] expected_output: &str, - ) { - use crate::Markdown; - let md = Markdown::from_markdown_str(input).unwrap(); - assert_eq!(md.nodes.len(), expected_nodes); - assert_eq!(md.to_string(), expected_output); - } - - #[cfg(all(feature = "callout", feature = "embed"))] - #[rstest] - // wikilink inside callout body + embed as separate node - #[case("> [!NOTE]\n> [[link]]\n\n![[embed]]", 2, "> [!NOTE]\n> [[link]]\n\n![[embed]]\n")] - fn test_callout_and_embed_together( - #[case] input: &str, - #[case] expected_nodes: usize, - #[case] expected_output: &str, - ) { - use crate::Markdown; - let md = Markdown::from_markdown_str(input).unwrap(); - assert_eq!(md.nodes.len(), expected_nodes); - assert_eq!(md.to_string(), expected_output); - } - - #[cfg(all(feature = "embed", feature = "wikilink"))] - #[test] - fn test_embed_does_not_conflict_with_wikilink() { - use crate::Markdown; - // ![[embed]] must become Embed, not WikiLink with preceding "!" - let md = Markdown::from_markdown_str("![[embed]] and [[link]]").unwrap(); - let embed = md.nodes.iter().find(|n| n.is_embed()); - let wikilink = md.nodes.iter().find(|n| n.is_wikilink()); - assert!(embed.is_some(), "expected Embed node"); - assert!(wikilink.is_some(), "expected WikiLink node"); - } - - #[rstest] - #[case::heading_marker("## not a heading", "\\## not a heading")] - #[case::heading_seven_hashes_still_escaped("####### x", "\\####### x")] - #[case::emphasis_star("*not emphasis*", "\\*not emphasis\\*")] - #[case::emphasis_underscore("_not emphasis_", "\\_not emphasis\\_")] - #[case::code_span("`not code`", "\\`not code\\`")] - #[case::link_brackets("[not a link](x)", "\\[not a link\\](x)")] - #[case::list_marker("- not a list item", "\\- not a list item")] - #[case::blockquote_marker("> not a quote", "\\> not a quote")] - #[case::table_pipe("| not a table |", "\\| not a table \\|")] - #[case::strikethrough("~not strikethrough~", "\\~not strikethrough\\~")] - #[case::math("$not math$", "\\$not math\\$")] - #[case::plus_list_marker("+ not a list item", "\\+ not a list item")] - #[case::ordered_list_dot("5. not a list", "5\\. not a list")] - #[case::ordered_list_paren("5) not a list", "5\\) not a list")] - #[case::setext_h1_underline("Foo\n===", "Foo\n\\===")] - #[case::hash_not_at_line_start("a#b", "a#b")] - #[case::dash_not_at_line_start("a-b", "a-b")] - #[case::plus_not_at_line_start("a+b", "a+b")] - #[case::gt_not_at_line_start("a>b", "a>b")] - #[case::eq_not_at_line_start("a=b", "a=b")] - #[case::dot_not_after_leading_digits("a5. b", "a5. b")] - #[case::dot_after_broken_digit_run("5x3. b", "5x3. b")] - #[case::escapes_after_embedded_newline("a\n# b", "a\n\\# b")] - #[case::plain_text_unchanged("plain text", "plain text")] - #[case::literal_backslash_is_doubled("a\\b", "a\\\\b")] - #[case::empty_string("", "")] - #[case::bang_before_bracket_only_bracket_escaped("![bar]", "!\\[bar\\]")] - #[case::bang_at_value_end_not_escaped("foo!", "foo!")] - #[case::consecutive_newlines_become_entity("foo\n\nbar", "foo \nbar")] - #[case::three_consecutive_newlines("foo\n\n\nbar", "foo \nbar")] - #[case::single_newline_unchanged("foo\nbar", "foo\nbar")] - #[case::leading_tab_becomes_entity("\tfoo", " foo")] - #[case::tab_not_at_line_start_unchanged("foo\tbar", "foo\tbar")] - #[case::angle_bracket_escaped("
not a tag", "\\
not a tag")] - // `&` is deliberately left unescaped: see KNOWN_FAILURES in gfm_roundtrip_fidelity.rs (#591). - #[case::ampersand_not_escaped("a&b", "a&b")] - fn test_escape_text(#[case] input: &str, #[case] expected: &str) { - assert_eq!(escape_text(input.to_string()), expected); - } - - #[rstest] - #[case::escaped_when_parsed_from_source( - Node::Text(Text { - value: "*a*".to_string(), - position: Some(Position { - start: Point { line: 1, column: 1 }, - end: Point { line: 1, column: 4 }, - }), - }), - "\\*a\\*" - )] - #[case::untouched_when_built_programmatically( - Node::Text(Text { value: "*a*".to_string(), position: None }), - "*a*" - )] - fn test_text_escaping_gated_by_position(#[case] node: Node, #[case] expected: &str) { - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[rstest] - #[case::bare_safe("https://example.com/x", UrlSurroundStyle::None, "https://example.com/x")] - #[case::space_forces_angle("my uri", UrlSurroundStyle::None, "")] - #[case::empty_forces_angle("", UrlSurroundStyle::None, "<>")] - #[case::unbalanced_paren_escaped("b)c", UrlSurroundStyle::None, "b\\)c")] - #[case::both_parens_escaped("foo(and(bar)", UrlSurroundStyle::None, "foo\\(and\\(bar\\)")] - #[case::forced_angle_style("plain", UrlSurroundStyle::Angle, "")] - #[case::angle_escapes_inner_angle_and_backslash("foo\\>", UrlSurroundStyle::None, ">")] - fn test_render_link_destination(#[case] url: &str, #[case] style: UrlSurroundStyle, #[case] expected: &str) { - assert_eq!(render_link_destination(url, &style), expected); - } - - #[rstest] - #[case::double_quote_escaped(r#"a "b" c"#, TitleSurroundStyle::Double, r#""a \"b\" c""#)] - #[case::single_quote_escaped("a 'b' c", TitleSurroundStyle::Single, "'a \\'b\\' c'")] - #[case::paren_escapes_both("a (b) c", TitleSurroundStyle::Paren, "(a \\(b\\) c)")] - #[case::backslash_escaped("a\\b", TitleSurroundStyle::Double, "\"a\\\\b\"")] - fn test_render_link_title(#[case] title: &str, #[case] style: TitleSurroundStyle, #[case] expected: &str) { - assert_eq!(render_link_title(title, &style), expected); - } - - #[rstest] - #[case::plain("plain", "plain")] - #[case::brackets("[a][b]", "\\[a\\]\\[b\\]")] - #[case::backslash("a\\b", "a\\\\b")] - fn test_escape_label(#[case] input: &str, #[case] expected: &str) { - assert_eq!(escape_label(input), expected); - } - - #[rstest] - #[case::shortcut_kept_when_ident_matches_label_present("foo", "foo", Some("foo".to_string()), "[foo]")] - #[case::shortcut_kept_when_ident_matches_label_absent("foo", "foo", None, "[foo]")] - #[case::full_form_when_ident_differs("foo", "bar", Some("bar".to_string()), "[foo][bar]")] - #[case::full_form_when_plain_needs_broad_escaping("foo*bar", "foo*bar", Some("foo*bar".to_string()), "[foo*bar][foo*bar]")] - fn test_link_ref_shortcut_vs_full( - #[case] text: &str, - #[case] ident: &str, - #[case] label: Option, - #[case] expected: &str, - ) { - let node = Node::LinkRef(LinkRef { - ident: ident.to_string(), - values: vec![text.to_string().into()], - label, - position: None, - }); - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[test] - fn test_link_ref_display_escaping_does_not_leak_into_matching_label() { - // The display text (first bracket) is escaped for safety; the matching key - // (second bracket) must stay the definition-compatible, narrowly-escaped label. - let node = Node::LinkRef(LinkRef { - ident: "foo*bar".to_string(), - values: vec![Node::Text(Text { - value: "foo*bar".to_string(), - position: Some(Position { - start: Point { line: 1, column: 1 }, - end: Point { line: 1, column: 8 }, - }), - })], - label: Some("foo*bar".to_string()), - position: None, - }); - assert_eq!(node.to_string_with(&RenderOptions::default()), "[foo\\*bar][foo*bar]"); - } - - #[rstest] - #[case::shortcut_kept_when_ident_matches("foo bar", "foo bar", "![foo bar]")] - #[case::full_form_when_ident_differs("foo bar", "foo*bar*", "![foo bar][foo*bar*]")] - fn test_image_ref_shortcut_vs_full(#[case] alt: &str, #[case] ident: &str, #[case] expected: &str) { - let node = Node::ImageRef(ImageRef { - alt: alt.to_string(), - ident: ident.to_string(), - label: None, - position: None, - }); - assert_eq!(node.to_string_with(&RenderOptions::default()), expected); - } - - #[rstest] - #[case::no_newline_unchanged("abc", 5, "abc")] - #[case::zero_delta_unchanged("a\n b", 0, "a\n b")] - #[case::positive_delta_adds_spaces("a\n b", 2, "a\n b")] - #[case::negative_delta_removes_spaces_clamped_at_zero("a\n b", -5, "a\nb")] - #[case::first_line_never_shifted(" a\nb", 3, " a\n b")] - #[case::empty_lines_untouched("a\n\nb", 2, "a\n\n b")] - fn test_reindent_continuation(#[case] content: &str, #[case] delta: isize, #[case] expected: &str) { - assert_eq!(reindent_continuation(content, delta), expected); - } - - #[rstest] - #[case::no_newline_still_shifted("abc", 3, " abc")] - #[case::zero_delta_unchanged("a\n b", 0, "a\n b")] - #[case::first_line_also_shifted("a\nb", 2, " a\n b")] - #[case::negative_delta_clamped_at_zero(" a\n b", -5, "a\nb")] - fn test_reindent_all_lines(#[case] content: &str, #[case] delta: isize, #[case] expected: &str) { - assert_eq!(reindent_all_lines(content, delta), expected); - } - - #[rstest] - #[case::code_first_child_indented( - Some(Node::Code(Code { value: "foo".to_string(), lang: None, fence: true, meta: None, position: None })), - "```\nfoo\n```".to_string(), - 3, - "```\n foo\n ```" - )] - #[case::empty_code_first_child_closing_fence_indented( - Some(Node::Code(Code { value: "".to_string(), lang: None, fence: true, meta: None, position: None })), - "```\n```".to_string(), - 3, - "```\n ```" - )] - #[case::non_leaf_first_child_untouched( - Some(Node::Text(Text { value: "foo".to_string(), position: None })), - "foo".to_string(), - 3, - "foo" - )] - #[case::no_first_child_untouched(None, "".to_string(), 3, "")] - fn test_reindent_first_leaf_block( - #[case] first: Option, - #[case] content: String, - #[case] target_indent: usize, - #[case] expected: &str, - ) { - assert_eq!( - reindent_first_leaf_block( - content, - first.as_ref(), - &RenderOptions::default(), - &ColorTheme::PLAIN, - target_indent - ), - expected - ); - } - - #[rstest] - #[case::unordered_bullet(false, 0, None, None, 2)] - #[case::unordered_checked(false, 0, None, Some(true), 6)] - #[case::ordered_single_digit(true, 0, None, None, 3)] - #[case::ordered_double_digit(true, 9, None, None, 4)] - #[case::ordered_with_start(true, 0, Some(10), None, 4)] - fn test_list_own_prefix_width( - #[case] ordered: bool, - #[case] index: usize, - #[case] start: Option, - #[case] checked: Option, - #[case] expected: usize, - ) { - assert_eq!(list_own_prefix_width(ordered, index, start, checked), expected); - } - - #[rstest] - #[case::no_backticks("", 1)] - #[case::single_run("foo`bar", 2)] - #[case::longer_run("foo``bar", 3)] - fn test_code_span_fence(#[case] value: &str, #[case] expected_len: usize) { - assert_eq!(code_span_fence(value), "`".repeat(expected_len)); - } - - #[rstest] - #[case::no_backticks_minimum_three("plain", "", 3)] - #[case::body_run_escalates("aaa\n```", "", 4)] - fn test_code_fence(#[case] value: &str, #[case] info: &str, #[case] expected_len: usize) { - assert_eq!(code_fence(value, info), "`".repeat(expected_len)); - } - - #[rstest] - #[case::info_backtick_switches_to_tilde("plain", "a`b", 3)] - #[case::info_backtick_run_switches_to_tilde("plain", "a``b", 3)] - #[case::body_tilde_run_escalates_within_tilde_fence("aaa\n~~~", "a`b", 4)] - fn test_code_fence_tilde(#[case] value: &str, #[case] info: &str, #[case] expected_len: usize) { - assert_eq!(code_fence(value, info), "~".repeat(expected_len)); - } - - proptest! { - // Every backslash escape_text inserts precedes exactly the character it - // protects, and pre-existing backslashes are doubled, so blindly dropping - // each backslash and keeping the character after it must recover the - // original string, for any input. Tabs and repeated newlines go through a - // separate entity-based path (see test_escape_text), excluded here. - #[test] - fn escape_text_is_losslessly_invertible( - s in prop::collection::vec(any::(), 0..60) - .prop_map(|cs| cs.into_iter().collect::()) - .prop_filter("excludes entity-escaped tab/newline-run cases", |s| { - !s.contains('\t') && !s.contains("\n\n") - }) - ) { - let escaped = escape_text(s.clone()); - prop_assert_eq!(naive_unescape(&escaped), s); - } - - // Rendering must never panic, whatever the heading text (arbitrary Unicode, - // possibly ending in a run of `#`) — this is a UTF-8 char-boundary regression - // guard for the trailing-`#` escape logic. - #[test] - fn heading_render_never_panics_on_unicode_text( - body in prop::collection::vec(any::(), 0..20).prop_map(|cs| cs.into_iter().collect::()), - hashes in 0usize..5, - depth in 1u8..=6, - ) { - let value = format!("{}{}", body, "#".repeat(hashes)); - let node = Node::Heading(Heading { depth, values: vec![value.into()], position: None }); - let _ = node.to_string_with(&RenderOptions::default()); - } - - // A list item whose first child is a code block with arbitrary Unicode - // content must render without panicking, regardless of target indent. - #[test] - fn list_with_code_first_child_never_panics_on_unicode_text( - code_value in prop::collection::vec(any::(), 0..30).prop_map(|cs| cs.into_iter().collect::()), - target_indent in 0usize..10, - ) { - let code = Node::Code(Code { value: code_value, lang: None, fence: true, meta: None, position: None }); - let rendered = code.render_with_theme(&RenderOptions::default(), &ColorTheme::PLAIN); - let _ = reindent_first_leaf_block( - rendered, - Some(&code), - &RenderOptions::default(), - &ColorTheme::PLAIN, - target_indent, - ); - } - - #[test] - fn escape_label_is_losslessly_invertible( - s in prop::collection::vec(any::(), 0..40).prop_map(|cs| cs.into_iter().collect::()) - ) { - let escaped = escape_label(&s); - prop_assert_eq!(naive_unescape(&escaped), s); - } - - // A Link's destination, rendered through the real writer and re-parsed by the - // real markdown parser, must recover the original URL string — the concrete - // bug class from the GFM spec-suite fidelity check (spaces/parens/angle - // brackets silently corrupting or breaking the link on round-trip). - #[test] - fn link_url_round_trips_through_parser( - url in prop::collection::vec( - prop::sample::select(vec!['a', 'b', '1', ' ', '(', ')', '<', '>', '\\']), - 1..12, - ).prop_map(|cs| cs.into_iter().collect::()) - ) { - let doc = format!("[text]({})\n", render_link_destination(&url, &UrlSurroundStyle::None)); - let parsed = crate::Markdown::from_markdown_str(&doc).unwrap(); - let link = parsed.nodes.iter().find_map(|n| match n { - Node::Link(l) => Some(l.url.as_str().to_string()), - _ => None, - }); - prop_assert_eq!(link, Some(url)); - } - - // Shifting continuation lines out by `delta` and back by the same amount must - // restore the original when the lines start with no indentation of their own, - // since the second shift can never be clamped away. - #[test] - fn reindent_continuation_add_then_remove_round_trips( - body in "[a-zA-Z]{0,5}(\n[a-zA-Z]{0,5}){0,4}", - delta in 0isize..20, - ) { - let shifted = reindent_continuation(&body, delta); - let restored = reindent_continuation(&shifted, -delta); - prop_assert_eq!(restored, body); - } - } - - fn naive_unescape(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\\' - && let Some(next) = chars.next() - { - result.push(next); - continue; - } - result.push(c); - } - result - } +fn values_to_value(values: &[Node]) -> String { + values.iter().map(|value| value.value()).collect::() } diff --git a/crates/mq-markdown/src/node/attr.rs b/crates/mq-markdown/src/node/attr.rs new file mode 100644 index 000000000..69fdc21b6 --- /dev/null +++ b/crates/mq-markdown/src/node/attr.rs @@ -0,0 +1,468 @@ +//! Reading and writing node attributes by name. + +use super::*; + +impl Node { + /// Returns the value of the specified attribute, if present. + /// + /// `line`/`end_line` are handled here, not per-variant below, since `position` is common + /// to every node. + pub fn attr(&self, attr: &str) -> Option { + match attr { + attr_keys::LINE => return self.position().map(|p| AttrValue::Integer(p.start.line as i64)), + attr_keys::END_LINE => return self.position().map(|p| AttrValue::Integer(p.end.line as i64)), + _ => {} + } + + match self { + Node::Footnote(Footnote { ident, values, .. }) => match attr { + attr_keys::IDENT => Some(AttrValue::String(ident.clone())), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + Node::Html(Html { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + _ => None, + }, + Node::Text(Text { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + _ => None, + }, + Node::Code(Code { + value, + lang, + meta, + fence, + .. + }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + attr_keys::LANG => lang.clone().map(AttrValue::String), + attr_keys::META => meta.clone().map(AttrValue::String), + attr_keys::FENCE => Some(AttrValue::Boolean(*fence)), + _ => None, + }, + Node::CodeInline(CodeInline { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.to_string())), + _ => None, + }, + Node::MathInline(MathInline { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.to_string())), + _ => None, + }, + Node::Math(Math { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + _ => None, + }, + Node::Yaml(Yaml { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + _ => None, + }, + Node::Toml(Toml { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.clone())), + _ => None, + }, + Node::Image(Image { alt, url, title, .. }) => match attr { + attr_keys::ALT => Some(AttrValue::String(alt.clone())), + attr_keys::URL => Some(AttrValue::String(url.clone())), + attr_keys::TITLE => title.clone().map(AttrValue::String), + _ => None, + }, + Node::ImageRef(ImageRef { alt, ident, label, .. }) => match attr { + attr_keys::ALT => Some(AttrValue::String(alt.clone())), + attr_keys::IDENT => Some(AttrValue::String(ident.clone())), + attr_keys::LABEL => label.clone().map(AttrValue::String), + _ => None, + }, + Node::Link(Link { url, title, values, .. }) => match attr { + attr_keys::URL => Some(AttrValue::String(url.as_str().to_string())), + attr_keys::TITLE => title.as_ref().map(|t| AttrValue::String(t.to_value())), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + #[cfg(feature = "wikilink")] + Node::WikiLink(WikiLink { target, text, .. }) => match attr { + attr_keys::URL => Some(AttrValue::String(target.clone())), + attr_keys::VALUE => Some(AttrValue::String(text.clone().unwrap_or_else(|| target.clone()))), + _ => None, + }, + #[cfg(feature = "callout")] + Node::Callout(Callout { + kind, + fold, + title, + values, + .. + }) => match attr { + attr_keys::KIND => Some(AttrValue::String(kind.clone())), + attr_keys::FOLD => fold.map(|fold| AttrValue::String(fold.to_string())), + attr_keys::TITLE => title.clone().map(AttrValue::String), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + #[cfg(feature = "embed")] + Node::Embed(Embed { target, display, .. }) => match attr { + attr_keys::URL => Some(AttrValue::String(target.clone())), + attr_keys::VALUE => Some(AttrValue::String(display.clone().unwrap_or_else(|| target.clone()))), + _ => None, + }, + Node::LinkRef(LinkRef { ident, label, .. }) => match attr { + attr_keys::IDENT => Some(AttrValue::String(ident.clone())), + attr_keys::LABEL => label.clone().map(AttrValue::String), + _ => None, + }, + Node::FootnoteRef(FootnoteRef { ident, label, .. }) => match attr { + attr_keys::IDENT => Some(AttrValue::String(ident.clone())), + attr_keys::LABEL => label.clone().map(AttrValue::String), + _ => None, + }, + Node::Definition(Definition { + ident, + url, + title, + label, + .. + }) => match attr { + attr_keys::IDENT => Some(AttrValue::String(ident.clone())), + attr_keys::URL => Some(AttrValue::String(url.as_str().to_string())), + attr_keys::TITLE => title.as_ref().map(|t| AttrValue::String(t.to_value())), + attr_keys::LABEL => label.clone().map(AttrValue::String), + _ => None, + }, + Node::Heading(Heading { depth, values, .. }) => match attr { + attr_keys::DEPTH | attr_keys::LEVEL => Some(AttrValue::Integer(i64::from(depth.get()))), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + Node::List(List { + index, + level, + ordered, + checked, + values, + .. + }) => match attr { + attr_keys::INDEX => Some(AttrValue::Integer(*index as i64)), + attr_keys::LEVEL => Some(AttrValue::Integer(*level as i64)), + attr_keys::ORDERED => Some(AttrValue::Boolean(*ordered)), + attr_keys::CHECKED => checked.map(AttrValue::Boolean), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + Node::TableCell(TableCell { + column, row, values, .. + }) => match attr { + attr_keys::COLUMN => Some(AttrValue::Integer(*column as i64)), + attr_keys::ROW => Some(AttrValue::Integer(*row as i64)), + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + Node::TableAlign(TableAlign { align, .. }) => match attr { + attr_keys::ALIGN => Some(AttrValue::String( + align.iter().map(|a| a.to_string()).collect::>().join(","), + )), + _ => None, + }, + Node::MdxFlowExpression(MdxFlowExpression { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.to_string())), + _ => None, + }, + Node::MdxTextExpression(MdxTextExpression { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.to_string())), + _ => None, + }, + Node::MdxJsEsm(MdxJsEsm { value, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(value.to_string())), + _ => None, + }, + Node::MdxJsxFlowElement(MdxJsxFlowElement { name, children, .. }) => match attr { + attr_keys::NAME => name.clone().map(AttrValue::String), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(children.clone())), + _ => None, + }, + Node::MdxJsxTextElement(MdxJsxTextElement { name, children, .. }) => match attr { + attr_keys::NAME => name.as_ref().map(|n| AttrValue::String(n.to_string())), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(children.clone())), + _ => None, + }, + Node::Strong(Strong { values, .. }) + | Node::Blockquote(Blockquote { values, .. }) + | Node::Delete(Delete { values, .. }) + | Node::Emphasis(Emphasis { values, .. }) + | Node::TableRow(TableRow { values, .. }) + | Node::Fragment(Fragment { values, .. }) => match attr { + attr_keys::VALUE => Some(AttrValue::String(values_to_string(values, &RenderOptions::default()))), + attr_keys::VALUES | attr_keys::CHILDREN => Some(AttrValue::Array(values.clone())), + _ => None, + }, + Node::Break(_) | Node::HorizontalRule(_) | Node::Empty => None, + } + } + + /// Sets the value of the specified attribute for the node, if supported. + pub fn set_attr(&mut self, attr: &str, value: impl Into) { + let value = value.into(); + let value_str = value.as_string(); + + match self { + Node::Footnote(f) => { + if attr == attr_keys::IDENT { + f.ident = value_str; + } + } + Node::Html(h) => { + if attr == attr_keys::VALUE { + h.value = value_str; + } + } + Node::Text(t) => { + if attr == attr_keys::VALUE { + t.value = value_str; + } + } + Node::Code(c) => match attr { + attr_keys::VALUE => { + c.value = value_str; + } + attr_keys::LANG | "language" => { + c.lang = if value_str.is_empty() { None } else { Some(value_str) }; + } + attr_keys::META => { + c.meta = if value_str.is_empty() { None } else { Some(value_str) }; + } + attr_keys::FENCE => { + c.fence = match value { + AttrValue::Boolean(b) => b, + _ => value_str == "true", + }; + } + _ => (), + }, + Node::CodeInline(ci) => { + if attr == attr_keys::VALUE { + ci.value = value_str.into(); + } + } + Node::MathInline(mi) => { + if attr == attr_keys::VALUE { + mi.value = value_str.into(); + } + } + Node::Math(m) => { + if attr == attr_keys::VALUE { + m.value = value_str; + } + } + Node::Yaml(y) => { + if attr == attr_keys::VALUE { + y.value = value_str; + } + } + Node::Toml(t) => { + if attr == attr_keys::VALUE { + t.value = value_str; + } + } + Node::Image(i) => match attr { + attr_keys::ALT => { + i.alt = value_str; + } + attr_keys::URL => { + i.url = value_str; + } + attr_keys::TITLE => { + i.title = if value_str.is_empty() { None } else { Some(value_str) }; + } + _ => (), + }, + Node::ImageRef(i) => match attr { + attr_keys::ALT => { + i.alt = value_str; + } + attr_keys::IDENT => { + i.ident = value_str; + } + attr_keys::LABEL => { + i.label = if value_str.is_empty() { None } else { Some(value_str) }; + } + _ => (), + }, + Node::Link(l) => match attr { + attr_keys::URL => { + l.url = Url::new(value_str); + } + attr_keys::TITLE => { + l.title = if value_str.is_empty() { + None + } else { + Some(Title::new(value_str)) + }; + } + _ => (), + }, + Node::LinkRef(l) => match attr { + attr_keys::IDENT => { + l.ident = value_str; + } + attr_keys::LABEL => { + l.label = if value_str.is_empty() { None } else { Some(value_str) }; + } + _ => (), + }, + Node::FootnoteRef(f) => match attr { + attr_keys::IDENT => { + f.ident = value_str; + } + attr_keys::LABEL => { + f.label = if value_str.is_empty() { None } else { Some(value_str) }; + } + _ => (), + }, + Node::Definition(d) => match attr { + attr_keys::IDENT => { + d.ident = value_str; + } + attr_keys::URL => { + d.url = Url::new(value_str); + } + attr_keys::TITLE => { + d.title = if value_str.is_empty() { + None + } else { + Some(Title::new(value_str)) + }; + } + attr_keys::LABEL => { + d.label = if value_str.is_empty() { None } else { Some(value_str) }; + } + _ => (), + }, + Node::Heading(h) => match attr { + attr_keys::DEPTH | attr_keys::LEVEL => { + // A depth outside of 1 to 6 leaves the heading as it is. + let depth = match value { + AttrValue::Integer(i) => u8::try_from(i).ok(), + _ => value_str.parse::().ok(), + }; + if let Some(depth) = depth.and_then(HeadingDepth::new) { + h.depth = depth; + } + } + _ => (), + }, + Node::List(l) => match attr { + attr_keys::INDEX => { + l.index = match value { + AttrValue::Integer(i) => i as usize, + _ => value_str.parse::().unwrap_or(l.index), + }; + } + attr_keys::LEVEL => { + l.level = match value { + AttrValue::Integer(i) => i as u8, + _ => value_str.parse::().unwrap_or(l.level), + }; + } + attr_keys::ORDERED => { + l.ordered = match value { + AttrValue::Boolean(b) => b, + _ => value_str == "true", + }; + } + attr_keys::CHECKED => { + l.checked = if value_str.is_empty() { + None + } else { + Some(match value { + AttrValue::Boolean(b) => b, + _ => value_str == "true", + }) + }; + } + _ => (), + }, + Node::TableCell(c) => match attr { + attr_keys::COLUMN => { + c.column = match value { + AttrValue::Integer(i) => i as usize, + _ => value_str.parse::().unwrap_or(c.column), + }; + } + attr_keys::ROW => { + c.row = match value { + AttrValue::Integer(i) => i as usize, + _ => value_str.parse::().unwrap_or(c.row), + }; + } + _ => (), + }, + Node::TableAlign(th) => { + if attr == attr_keys::ALIGN { + th.align = value_str.split(',').map(|s| s.trim().into()).collect(); + } + } + Node::MdxFlowExpression(m) => { + if attr == attr_keys::VALUE { + m.value = value_str.into(); + } + } + Node::MdxTextExpression(m) => { + if attr == attr_keys::VALUE { + m.value = value_str.into(); + } + } + Node::MdxJsEsm(m) => { + if attr == attr_keys::VALUE { + m.value = value_str.into(); + } + } + Node::MdxJsxFlowElement(m) => { + if attr == attr_keys::NAME { + m.name = if value_str.is_empty() { None } else { Some(value_str) }; + } + } + Node::MdxJsxTextElement(m) => { + if attr == attr_keys::NAME { + m.name = if value_str.is_empty() { + None + } else { + Some(value_str.into()) + }; + } + } + Node::Delete(_) + | Node::Blockquote(_) + | Node::Emphasis(_) + | Node::Strong(_) + | Node::TableRow(_) + | Node::Break(_) + | Node::HorizontalRule(_) + | Node::Fragment(_) + | Node::Empty => (), + #[cfg(feature = "wikilink")] + Node::WikiLink(w) => match attr { + attr_keys::URL => w.target = value_str, + attr_keys::VALUE => w.text = if value_str.is_empty() { None } else { Some(value_str) }, + _ => (), + }, + #[cfg(feature = "callout")] + Node::Callout(c) => match attr { + attr_keys::KIND => c.kind = value_str, + attr_keys::FOLD => c.fold = value_str.chars().next().filter(|c| matches!(c, '+' | '-')), + attr_keys::TITLE => c.title = if value_str.is_empty() { None } else { Some(value_str) }, + _ => (), + }, + #[cfg(feature = "embed")] + Node::Embed(e) => match attr { + attr_keys::URL => e.target = value_str, + attr_keys::VALUE => e.display = if value_str.is_empty() { None } else { Some(value_str) }, + _ => (), + }, + } + } +} diff --git a/crates/mq-markdown/src/node/attr_value.rs b/crates/mq-markdown/src/node/attr_value.rs index 8df80b775..be45a10b3 100644 --- a/crates/mq-markdown/src/node/attr_value.rs +++ b/crates/mq-markdown/src/node/attr_value.rs @@ -22,6 +22,8 @@ pub mod attr_keys { pub(crate) const COLUMN: &str = "column"; #[cfg(feature = "callout")] pub(crate) const KIND: &str = "kind"; + #[cfg(feature = "callout")] + pub(crate) const FOLD: &str = "fold"; pub(crate) const ROW: &str = "row"; pub(crate) const LINE: &str = "line"; pub(crate) const END_LINE: &str = "end_line"; diff --git a/crates/mq-markdown/src/node/expand.rs b/crates/mq-markdown/src/node/expand.rs new file mode 100644 index 000000000..388844c04 --- /dev/null +++ b/crates/mq-markdown/src/node/expand.rs @@ -0,0 +1,339 @@ +//! Turning Obsidian links and inline links written in text into nodes. + +use super::*; + +impl Node { + /// Returns true if any `Text` descendant contains `[[`, covering both + /// embed (`![[`) and wikilink (`[[`) patterns. + #[cfg(any(feature = "embed", feature = "wikilink"))] + fn values_contain_links(values: &[Node]) -> bool { + values.iter().any(|n| match n { + Node::Text(t) => t.value.contains("[["), + Node::Heading(h) => Self::values_contain_links(&h.values), + Node::Blockquote(b) => Self::values_contain_links(&b.values), + #[cfg(feature = "callout")] + Node::Callout(c) => Self::values_contain_links(&c.values), + Node::List(l) => Self::values_contain_links(&l.values), + Node::Strong(s) => Self::values_contain_links(&s.values), + Node::Emphasis(e) => Self::values_contain_links(&e.values), + Node::Delete(d) => Self::values_contain_links(&d.values), + Node::TableCell(tc) => Self::values_contain_links(&tc.values), + Node::TableRow(tr) => Self::values_contain_links(&tr.values), + Node::Footnote(f) => Self::values_contain_links(&f.values), + _ => false, + }) + } + + /// Shared tree walker used by all expand functions. Recurses into container + /// nodes and delegates `Text` node processing to `parse_into`. + #[cfg(any(feature = "embed", feature = "wikilink"))] + fn expand_with(nodes: Vec, parse_into: fn(&str, Option, &mut Vec)) -> Vec { + let mut result = Vec::with_capacity(nodes.len()); + for node in nodes { + Self::expand_with_into(node, &mut result, parse_into); + } + result + } + + #[cfg(any(feature = "embed", feature = "wikilink"))] + fn expand_with_into(node: Node, out: &mut Vec, parse_into: fn(&str, Option, &mut Vec)) { + match node { + Node::Text(Text { ref value, .. }) if !value.contains("[[") => out.push(node), + Node::Text(Text { value, position }) => parse_into(&value, position, out), + Node::Heading(mut h) => { + if Self::values_contain_links(&h.values) { + h.values = Self::expand_with(h.values, parse_into); + } + out.push(Node::Heading(h)); + } + Node::Blockquote(mut b) => { + if Self::values_contain_links(&b.values) { + b.values = Self::expand_with(b.values, parse_into); + } + out.push(Node::Blockquote(b)); + } + #[cfg(feature = "callout")] + Node::Callout(mut c) => { + if Self::values_contain_links(&c.values) { + c.values = Self::expand_with(c.values, parse_into); + } + out.push(Node::Callout(c)); + } + Node::List(mut l) => { + if Self::values_contain_links(&l.values) { + l.values = Self::expand_with(l.values, parse_into); + } + out.push(Node::List(l)); + } + Node::Strong(mut s) => { + if Self::values_contain_links(&s.values) { + s.values = Self::expand_with(s.values, parse_into); + } + out.push(Node::Strong(s)); + } + Node::Emphasis(mut e) => { + if Self::values_contain_links(&e.values) { + e.values = Self::expand_with(e.values, parse_into); + } + out.push(Node::Emphasis(e)); + } + Node::Delete(mut d) => { + if Self::values_contain_links(&d.values) { + d.values = Self::expand_with(d.values, parse_into); + } + out.push(Node::Delete(d)); + } + Node::TableCell(mut tc) => { + if Self::values_contain_links(&tc.values) { + tc.values = Self::expand_with(tc.values, parse_into); + } + out.push(Node::TableCell(tc)); + } + Node::TableRow(mut tr) => { + if Self::values_contain_links(&tr.values) { + tr.values = Self::expand_with(tr.values, parse_into); + } + out.push(Node::TableRow(tr)); + } + Node::Footnote(mut f) => { + if Self::values_contain_links(&f.values) { + f.values = Self::expand_with(f.values, parse_into); + } + out.push(Node::Footnote(f)); + } + other => out.push(other), + } + } + + /// Recursively scans a node list and converts `![[target]]` / `![[target|display]]` + /// patterns inside `Text` nodes into `Embed` nodes. + #[cfg(feature = "embed")] + pub fn expand_embeds(nodes: Vec) -> Vec { + Self::expand_with(nodes, Self::parse_embeds_into) + } + + /// Splits a text string on `![[...]]` patterns, pushing a mix of `Text` + /// and `Embed` nodes into `out`. + #[cfg(feature = "embed")] + pub(super) fn parse_embeds_into(text: &str, position: Option, out: &mut Vec) { + let mut last_end = 0; + let mut search_from = 0; + let mut found_any = false; + + while let Some(bang_rel) = text[search_from..].find("![[") { + let bang = search_from + bang_rel; + let inner_start = bang + 3; + + let Some(close_rel) = text[inner_start..].find("]]") else { + search_from = bang + 1; + continue; + }; + let close = inner_start + close_rel; + let content = &text[inner_start..close]; + + if content.contains('[') || content.contains(']') { + search_from = bang + 1; + continue; + } + + found_any = true; + if last_end < bang { + out.push(Node::Text(Text { + value: text[last_end..bang].to_string(), + position: position.clone(), + })); + } + let (target, display) = if let Some(pipe) = content.find('|') { + ( + content[..pipe].trim().to_string(), + Some(content[pipe + 1..].trim().to_string()), + ) + } else { + (content.trim().to_string(), None) + }; + out.push(Node::Embed(Embed { + target, + display, + position: position.clone(), + })); + last_end = close + 2; + search_from = close + 2; + } + + if !found_any { + out.push(Node::Text(Text { + value: text.to_string(), + position, + })); + return; + } + + if last_end < text.len() { + out.push(Node::Text(Text { + value: text[last_end..].to_string(), + position, + })); + } + } + + /// Recursively scans a node list and converts `[[target]]` / `[[target|text]]` + /// patterns inside `Text` nodes into `WikiLink` nodes. + #[cfg(feature = "wikilink")] + pub fn expand_wikilinks(nodes: Vec) -> Vec { + Self::expand_with(nodes, Self::parse_wikilinks_into) + } + + /// Splits a text string on `[[...]]` patterns, pushing a mix of `Text` + /// and `WikiLink` nodes into `out`. + #[cfg(feature = "wikilink")] + fn parse_wikilinks_into(text: &str, position: Option, out: &mut Vec) { + let mut last_end = 0; + let mut search_from = 0; + let mut found_any = false; + + while let Some(open_rel) = text[search_from..].find("[[") { + let open = search_from + open_rel; + let inner_start = open + 2; + + let Some(close_rel) = text[inner_start..].find("]]") else { + break; + }; + let close = inner_start + close_rel; + let content = &text[inner_start..close]; + + if content.contains('[') || content.contains(']') { + // nested brackets: skip past this '[' and retry + search_from = open + 1; + continue; + } + + found_any = true; + if last_end < open { + out.push(Node::Text(Text { + value: text[last_end..open].to_string(), + position: position.clone(), + })); + } + let (target, text_part) = if let Some(pipe) = content.find('|') { + (content[..pipe].trim(), Some(content[pipe + 1..].trim())) + } else { + (content.trim(), None) + }; + out.push(Node::WikiLink(WikiLink { + target: target.to_string(), + text: text_part.map(|t| t.to_string()), + position: position.clone(), + })); + last_end = close + 2; + search_from = close + 2; + } + + if !found_any { + out.push(Node::Text(Text { + value: text.to_string(), + position, + })); + return; + } + + if last_end < text.len() { + out.push(Node::Text(Text { + value: text[last_end..].to_string(), + position, + })); + } + } + + /// Splits a text string on `[[...]]` patterns, returning a mix of `Text` + /// and `WikiLink` nodes. Used only in tests. + #[cfg(all(feature = "wikilink", test))] + pub(super) fn parse_wikilinks_in_text(text: &str, position: Option) -> Vec { + let mut result = Vec::new(); + Self::parse_wikilinks_into(text, position, &mut result); + result + } + + /// Recursively scans a node list and converts both `![[target]]` (embed) and + /// `[[target]]` (wikilink) patterns in a single tree traversal. More efficient + /// than running `expand_embeds` followed by `expand_wikilinks` separately, which + /// traverses the tree and scans each `Text` node twice. + #[cfg(all(feature = "embed", feature = "wikilink"))] + pub fn expand_inline_links(nodes: Vec) -> Vec { + Self::expand_with(nodes, Self::parse_inline_links_into) + } + + /// Splits a text string on `![[...]]` and `[[...]]` patterns in a single pass, + /// pushing a mix of `Text`, `Embed`, and `WikiLink` nodes into `out`. + /// A `[[` immediately preceded by `!` is treated as an embed; otherwise as a wikilink. + #[cfg(all(feature = "embed", feature = "wikilink"))] + pub(super) fn parse_inline_links_into(text: &str, position: Option, out: &mut Vec) { + let mut last_end = 0; + let mut search_from = 0; + let mut found_any = false; + + while let Some(open_rel) = text[search_from..].find("[[") { + let open = search_from + open_rel; + let inner_start = open + 2; + + let Some(close_rel) = text[inner_start..].find("]]") else { + break; + }; + let close = inner_start + close_rel; + let content = &text[inner_start..close]; + + if content.contains('[') || content.contains(']') { + search_from = open + 1; + continue; + } + + let is_embed = text.as_bytes()[..open].last() == Some(&b'!'); + let token_start = if is_embed { open - 1 } else { open }; + + found_any = true; + if last_end < token_start { + out.push(Node::Text(Text { + value: text[last_end..token_start].to_string(), + position: position.clone(), + })); + } + + let (target, opt_part) = if let Some(pipe) = content.find('|') { + (content[..pipe].trim(), Some(content[pipe + 1..].trim())) + } else { + (content.trim(), None) + }; + + if is_embed { + out.push(Node::Embed(Embed { + target: target.to_string(), + display: opt_part.map(|s| s.to_string()), + position: position.clone(), + })); + } else { + out.push(Node::WikiLink(WikiLink { + target: target.to_string(), + text: opt_part.map(|s| s.to_string()), + position: position.clone(), + })); + } + + last_end = close + 2; + search_from = close + 2; + } + + if !found_any { + out.push(Node::Text(Text { + value: text.to_string(), + position, + })); + return; + } + + if last_end < text.len() { + out.push(Node::Text(Text { + value: text[last_end..].to_string(), + position, + })); + } + } +} diff --git a/crates/mq-markdown/src/node/render.rs b/crates/mq-markdown/src/node/render.rs new file mode 100644 index 000000000..f3e8ea88d --- /dev/null +++ b/crates/mq-markdown/src/node/render.rs @@ -0,0 +1,1481 @@ +//! Rendering nodes back to Markdown. + +use super::*; +use std::cell::Cell; + +thread_local! { + /// Set while the output is read again as Markdown to make HTML of it, not as MDX. + static HTML_TARGET: Cell = const { Cell::new(false) }; +} + +/// Runs `render` with the JSX elements written so that their children are Markdown in HTML. +pub(crate) fn for_html(render: impl FnOnce() -> R) -> R { + struct Reset(bool); + impl Drop for Reset { + fn drop(&mut self) { + HTML_TARGET.set(self.0); + } + } + let _reset = Reset(HTML_TARGET.replace(true)); + render() +} + +impl Node { + pub fn to_string_with(&self, options: &RenderOptions) -> String { + self.render_with_theme(options, &ColorTheme::PLAIN) + } + + /// Returns a colored string representation of this node using ANSI escape codes. + #[cfg(feature = "color")] + pub fn to_colored_string_with(&self, options: &RenderOptions) -> String { + self.render_with_theme(options, &ColorTheme::COLORED) + } + + pub(crate) fn render_with_theme(&self, options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + match self { + Self::List(List { + level, + checked, + values, + ordered, + index, + start, + marker, + .. + }) => { + let marker = if *ordered { + let delimiter = if *marker == Some(ListMarker::Paren) { ')' } else { '.' }; + format!("{}{}", start.unwrap_or(1) as usize + *index, delimiter) + } else if let Some(style) = &options.list_style { + style.to_string() + } else { + marker.and_then(ListMarker::style).unwrap_or_default().to_string() + }; + let checkbox = (*checked).map(|it| if it { "[x] " } else { "[ ] " }).unwrap_or(""); + let prefix_width = *level as usize * 2 + list_own_prefix_width(*ordered, *index, *start); + // Lines after the first are indented to the content of the item, whatever column they + // had in the source, which is not reliable after an escape. + let content = render_values_block(values, options, theme); + let content = indent_continuation(&content, prefix_width); + let (ms, me) = &theme.list_marker; + format!( + "{}{}{}{} {}{}", + " ".repeat(*level as usize), + ms, + marker, + me, + checkbox, + content + ) + } + Self::TableRow(TableRow { values, .. }) => { + let (ts, te) = &theme.table_separator; + let cells = values + .iter() + .map(|cell| cell.render_with_theme(options, theme)) + .collect::>() + .join("|"); + format!("{}|{}{}|", ts, te, cells) + } + Self::TableCell(TableCell { values, .. }) => render_cell_values(values, options, theme), + Self::TableAlign(TableAlign { align, .. }) => { + let (ts, te) = &theme.table_separator; + format!("{}|{}|{}", ts, align.iter().map(|a| a.to_string()).join("|"), te) + } + Self::Blockquote(Blockquote { values, .. }) => { + let (bs, be) = &theme.blockquote_marker; + render_values_block(values, options, theme) + .split('\n') + .map(|line| format!("{}> {}{}", bs, be, line)) + .join("\n") + } + #[cfg(feature = "callout")] + Self::Callout(Callout { + kind, + fold, + title, + values, + .. + }) => { + let (bs, be) = &theme.blockquote_marker; + let fold = fold.map(String::from).unwrap_or_default(); + let header = match title.as_deref() { + Some(t) if !t.is_empty() => format!("[!{}]{} {}", kind, fold, t), + _ => format!("[!{}]{}", kind, fold), + }; + let header_line = format!("{}> {}{}", bs, be, header); + if values.is_empty() { + return header_line; + } + let body = render_values_block(values, options, theme); + if body.trim().is_empty() { + header_line + } else { + let body_lines = body + .split('\n') + .map(|line| format!("{}> {}{}", bs, be, line)) + .join("\n"); + format!("{}\n{}", header_line, body_lines) + } + } + #[cfg(feature = "embed")] + Self::Embed(Embed { target, display, .. }) => match display.as_deref() { + Some(d) if !d.is_empty() => format!("![[{}|{}]]", target, d), + _ => format!("![[{}]]", target), + }, + Self::Code(Code { + value, + lang, + fence, + meta, + position, + }) => { + let (cs, ce) = &theme.code; + if lang.is_some() || *fence { + let meta = meta.as_deref().map(|meta| format!(" {}", meta)).unwrap_or_default(); + let info = format!("{}{}", lang.as_deref().unwrap_or(""), meta); + // Empty body skips the content line so it doesn't gain a blank one. + let fence_str = code_fence(value, &info); + if value.is_empty() { + // Blank lines are not in the value, but the lines of the block tell of them. + let blank = position + .as_ref() + .map_or(0, |p| p.end.line.saturating_sub(p.start.line).saturating_sub(1)); + format!("{}{}{}\n{}{}{}", cs, fence_str, info, "\n".repeat(blank), fence_str, ce) + } else { + format!("{}{}{}\n{}\n{}{}", cs, fence_str, info, value, fence_str, ce) + } + } else { + value.lines().map(|line| format!("{} {}{}", cs, line, ce)).join("\n") + } + } + Self::Definition(Definition { + ident, + label, + url, + title, + .. + }) => { + let (us, ue) = &theme.link_url; + format!( + "[{}]: {}{}{}{}", + definition_label(label.as_deref(), ident), + us, + url.to_string_with(options), + ue, + title + .as_ref() + .map(|title| format!(" {}", title.to_string_with(options))) + .unwrap_or_default() + ) + } + Self::Delete(Delete { values, .. }) => { + let (ds, de) = &theme.delete; + format!("{}~~{}~~{}", ds, render_values(values, options, theme), de) + } + Self::Emphasis(Emphasis { values, .. }) => { + render_emphasis(values, emphasis_delimiter(values), options, theme) + } + Self::Footnote(Footnote { values, ident, .. }) => { + format!( + "[^{}]: {}", + escape_brackets(ident), + render_values(values, options, theme) + ) + } + Self::FootnoteRef(FootnoteRef { label, ident, .. }) => { + format!("[^{}]", reference_label(label.as_deref(), ident)) + } + Self::Heading(Heading { depth, values, .. }) => { + let (hs, he) = &theme.heading; + let text = render_values(values, options, theme); + // A line ending at either end is not part of the heading text, so it is written as a + // character reference. + let text = { + let inner = text.trim_matches('\n'); + let leading = text.len() - text.trim_start_matches('\n').len(); + let trailing = text.len() - text.trim_end_matches('\n').len(); + if inner.is_empty() || (leading == 0 && trailing == 0) { + text.clone() + } else { + format!("{}{}{}", " ".repeat(leading), inner, " ".repeat(trailing)) + } + }; + // Multi-line content must stay setext for depths 1-2; ATX has no setext form. + if text.contains('\n') && *depth <= HeadingDepth::H2 { + let underline = if *depth == HeadingDepth::H1 { "===" } else { "---" }; + format!("{}{}\n{}{}", hs, text, underline, he) + } else { + // A trailing `#` run reads back as an ATX closing sequence and gets + // stripped as syntax rather than kept as heading text. + let text = text.replace('\n', " "); + let text = match text.rfind(|c: char| c != '#') { + Some(i) => { + let hash_start = i + text[i..].chars().next().map_or(1, char::len_utf8); + if hash_start < text.len() { + format!("{}\\{}", &text[..hash_start], &text[hash_start..]) + } else { + text + } + } + None if !text.is_empty() => format!("\\{text}"), + _ => text, + }; + format!("{}{} {}{}", hs, "#".repeat(usize::from(depth.get())), text, he) + } + } + Self::Html(Html { value, .. }) => { + let (hs, he) = &theme.html; + format!("{}{}{}", hs, value, he) + } + Self::Image(Image { alt, url, title, .. }) => { + let (is, ie) = &theme.image; + format!( + "{}![{}]({}{}){}", + is, + escape_label(alt), + render_link_destination(url, &options.link_url_style), + title + .as_deref() + .map(|it| format!(" {}", render_link_title(it, &options.link_title_style))) + .unwrap_or_default(), + ie + ) + } + // Shortcut is safe only if `alt` normalizes back to `ident`; `ident` is raw + // (already correctly escaped), so escape_label would double it up. + Self::ImageRef(ImageRef { alt, ident, label, .. }) => { + let (is, ie) = &theme.image; + let mismatched = crate::parser::normalize(alt) != ident.as_str(); + if mismatched || needs_broad_escaping(alt) { + format!( + "{}![{}][{}]{}", + is, + escape_label(alt), + reference_label(label.as_deref(), ident), + ie + ) + } else { + format!("{}![{}]{}", is, escape_label(alt), ie) + } + } + Self::CodeInline(CodeInline { value, .. }) => { + let (cs, ce) = &theme.code_inline; + let fence = code_span_fence(value); + // Padding avoids fusing with an edge backtick and protects a genuine + // leading+trailing space from the parser's own space-stripping rule. + let all_spaces = value.chars().all(|c| c == ' '); + if value.starts_with('`') + || value.ends_with('`') + || (!all_spaces && value.starts_with(' ') && value.ends_with(' ')) + { + format!("{}{} {} {}{}", cs, fence, value, fence, ce) + } else { + format!("{}{}{}{}{}", cs, fence, value, fence, ce) + } + } + Self::MathInline(MathInline { value, .. }) => { + let (ms, me) = &theme.math; + let fence = math_span_fence(value); + // Padding keeps a dollar sign at an end from fusing with the fence, and a genuine space at + // both ends from being removed when it is read. + let all_spaces = value.chars().all(|c| c == ' '); + if value.starts_with('$') + || value.ends_with('$') + || (!all_spaces && value.starts_with(' ') && value.ends_with(' ')) + { + format!("{}{} {} {}{}", ms, fence, value, fence, me) + } else { + format!("{}{}{}{}{}", ms, fence, value, fence, me) + } + } + Self::Link(Link { url, title, values, .. }) => { + let (ls, le) = &theme.link; + if title.is_none() + && let Some(target) = autolink_target(url.as_str(), values) + { + return format!("{ls}<{target}>{le}"); + } + format!( + "{}[{}]({}{}){}", + ls, + render_values(values, options, theme), + url.to_string_with(options), + title + .as_ref() + .map(|title| format!(" {}", title.to_string_with(options))) + .unwrap_or_default(), + le + ) + } + #[cfg(feature = "wikilink")] + Self::WikiLink(WikiLink { target, text, .. }) => { + let (ls, le) = &theme.link; + match text.as_deref() { + Some(t) if t != target.as_str() => format!("{}[[{}|{}]]{}", ls, target, t, le), + _ => format!("{}[[{}]]{}", ls, target, le), + } + } + // Same reasoning as ImageRef, plus the same broad-escaping fallback. + Self::LinkRef(LinkRef { + values, ident, label, .. + }) => { + let (ls, le) = &theme.link; + let rendered = render_values(values, options, theme); + let plain = values_to_value(values); + // The label of a shortcut reference is its text as written, markup included. + let written = render_values(values, options, &ColorTheme::PLAIN); + let mismatched = crate::parser::normalize(&written) != ident.as_str(); + + if mismatched || needs_broad_escaping(&plain) { + format!( + "{}[{}][{}]{}", + ls, + rendered, + reference_label(label.as_deref(), ident), + le + ) + } else { + format!("{}[{}]{}", ls, rendered, le) + } + } + Self::Math(Math { value, .. }) => { + let (ms, me) = &theme.math; + let fence = "$".repeat((longest_run(value, '$') + 1).max(2)); + if value.is_empty() { + format!("{}{}\n{}{}", ms, fence, fence, me) + } else { + format!("{}{}\n{}\n{}{}", ms, fence, value, fence, me) + } + } + // Only escape text parsed from real markdown (has a position); programmatic + // values like JSON output or attr lookups must stay untouched. + Self::Text(Text { value, position }) => { + if position.is_some() { + escape_text_with(value.clone(), options.mdx) + } else { + value.clone() + } + } + Self::MdxFlowExpression(mdx_flow_expression) => { + format!("{{{}}}", indent_expression(&mdx_flow_expression.value)) + } + Self::MdxJsxFlowElement(mdx_jsx_flow_element) => { + let name = mdx_jsx_flow_element.name.as_deref().unwrap_or_default(); + let attributes = if mdx_jsx_flow_element.attributes.is_empty() { + "".to_string() + } else { + format!( + " {}", + mdx_jsx_flow_element + .attributes + .iter() + .map(Self::mdx_attribute_content_to_string) + .join(" ") + ) + }; + + if mdx_jsx_flow_element.children.is_empty() { + // A fragment has no self-closing form. + if name.is_empty() { + format!("<{}>", attributes) + } else { + format!("<{}{} />", name, attributes,) + } + } else { + // Each tag and the children sit on their own lines, or the children would read as + // inline content of the tag. + let children = render_values_block(&mdx_jsx_flow_element.children, options, theme); + // The lines of an expression are indented as they are written, which indenting them + // again would add to each time they are read. + if HTML_TARGET.get() { + // Blank lines end the tags as HTML blocks, so the children are Markdown. + return format!("<{}{}>\n\n{}\n\n", name, attributes, children, name); + } + let children = if mdx_jsx_flow_element.children.iter().any(has_multiline_expression) { + children + } else { + indent_continuation(&format!(" {}", children), 2) + }; + format!("<{}{}>\n{}\n", name, attributes, children, name) + } + } + Self::MdxJsxTextElement(mdx_jsx_text_element) => { + let name = mdx_jsx_text_element.name.as_deref().unwrap_or_default(); + let attributes = if mdx_jsx_text_element.attributes.is_empty() { + "".to_string() + } else { + format!( + " {}", + mdx_jsx_text_element + .attributes + .iter() + .map(Self::mdx_attribute_content_to_string) + .join(" ") + ) + }; + + if mdx_jsx_text_element.children.is_empty() { + // A fragment has no self-closing form. + if name.is_empty() { + format!("<{}>", attributes) + } else { + format!("<{}{} />", name, attributes,) + } + } else { + format!( + "<{}{}>{}", + name, + attributes, + render_values(&mdx_jsx_text_element.children, options, theme), + name + ) + } + } + Self::MdxTextExpression(mdx_text_expression) => { + format!("{{{}}}", indent_expression(&mdx_text_expression.value)) + } + Self::MdxJsEsm(mdxjs_esm) => mdxjs_esm.value.to_string(), + Self::Strong(Strong { values, .. }) => render_strong(values, "**", options, theme), + Self::Yaml(Yaml { value, .. }) => { + let (fs, fe) = &theme.frontmatter; + format!("{}---\n{}\n---{}", fs, value, fe) + } + Self::Toml(Toml { value, .. }) => { + let (fs, fe) = &theme.frontmatter; + format!("{}+++\n{}\n+++{}", fs, value, fe) + } + Self::Break(_) => "\\\n".to_string(), + Self::HorizontalRule(HorizontalRule { marker, .. }) => { + let (hs, he) = &theme.horizontal_rule; + let marker = match marker { + Some(HorizontalRuleMarker::Dash) => '-', + Some(HorizontalRuleMarker::Underscore) => '_', + _ => '*', + }; + format!("{hs}{marker}{marker}{marker}{he}") + } + Self::Fragment(Fragment { values }) => values + .iter() + .filter(|value| !value.is_empty() && !value.is_empty_fragment()) + .map(|value| value.render_with_theme(options, theme)) + .join("\n"), + Self::Empty => String::new(), + } + } + + fn mdx_attribute_content_to_string(attr: &MdxAttributeContent) -> SmolStr { + match attr { + MdxAttributeContent::Expression(value) => format!("{{{}}}", indent_expression(value)).into(), + MdxAttributeContent::Property(property) => match &property.value { + Some(value) => match value { + MdxAttributeValue::Expression(value) => { + format!("{}={{{}}}", property.name, indent_expression(value)).into() + } + MdxAttributeValue::Literal(literal) => { + format!("{}={}", property.name, quote_attribute_value(literal)).into() + } + }, + None => property.name.clone(), + }, + } + } +} + +/// Whether `node` or a node in it is an expression of more than one line. +fn has_multiline_expression(node: &Node) -> bool { + match node { + Node::MdxFlowExpression(expression) => expression.value.contains('\n'), + Node::MdxTextExpression(expression) => expression.value.contains('\n'), + Node::MdxJsxTextElement(MdxJsxTextElement { attributes, .. }) + | Node::MdxJsxFlowElement(MdxJsxFlowElement { attributes, .. }) + if attributes.iter().any(|attribute| match attribute { + MdxAttributeContent::Expression(value) => value.contains('\n'), + MdxAttributeContent::Property(MdxJsxAttribute { + value: Some(MdxAttributeValue::Expression(value)), + .. + }) => value.contains('\n'), + _ => false, + }) => + { + true + } + other => other.children().iter().any(has_multiline_expression), + } +} + +/// Quotes the value of a JSX attribute with the quote it does not contain. Character references that the +/// value holds as text are written with their `&` as a reference. +fn quote_attribute_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for (index, c) in value.char_indices() { + if c == '&' && starts_reference(&value[index + 1..]) { + escaped.push_str("&"); + } else { + escaped.push(c); + } + } + if !escaped.contains('"') { + format!("\"{escaped}\"") + } else if !escaped.contains('\'') { + format!("'{escaped}'") + } else { + format!("\"{}\"", escaped.replace('"', """)) + } +} + +/// The content of an expression as it is written: the lines after the first are indented, as the +/// indentation of up to two characters is removed from them when they are read. +fn indent_expression(value: &str) -> String { + value + .split('\n') + .enumerate() + .map(|(index, line)| { + if index == 0 || line.is_empty() || line == "\r" { + line.to_string() + } else { + format!(" {line}") + } + }) + .collect::>() + .join("\n") +} + +pub(crate) fn values_to_string(values: &[Node], options: &RenderOptions) -> String { + render_values(values, options, &ColorTheme::PLAIN) +} + +/// Indents every line, the first included, by `width` spaces, except empty ones. +pub(crate) fn indent_lines(content: &str, width: usize) -> String { + let pad = " ".repeat(width); + content + .split('\n') + .map(|line| { + if line.is_empty() { + line.to_string() + } else { + format!("{pad}{line}") + } + }) + .collect::>() + .join("\n") +} + +/// Indents every line after the first by `width` spaces, except empty ones. +fn indent_continuation(content: &str, width: usize) -> String { + let Some((first, rest)) = content.split_once('\n') else { + return content.to_string(); + }; + let pad = " ".repeat(width); + let mut result = String::with_capacity(content.len() + width * 2); + result.push_str(first); + for line in rest.split('\n') { + result.push('\n'); + if !line.is_empty() { + result.push_str(&pad); + result.push_str(line); + } + } + result +} + +/// The delimiter of emphasis around `values`. Emphasis nested at an edge takes the other one, or the +/// two runs fuse into `**` and read back as strong emphasis. +fn emphasis_delimiter(values: &[Node]) -> &'static str { + let edge = [values.first(), values.last()] + .into_iter() + .flatten() + .find_map(|node| match node { + Node::Emphasis(Emphasis { values, .. }) => Some(values), + _ => None, + }); + match edge.map(|inner| emphasis_delimiter(inner)) { + Some("_") => "*", + Some(_) => "_", + None => "*", + } +} + +/// Renders strong emphasis around `values` with the delimiter `delim`. +fn render_strong(values: &[Node], delim: &str, options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + let (ss, se) = &theme.strong; + let last = values.len().saturating_sub(1); + let content = values + .iter() + .enumerate() + .map(|(index, value)| match value { + // Emphasis at an end uses the other delimiter, or `*` and `**` fuse into `***`. + // An underscore does not open or close next to a letter or digit, so those keep `*`. + Node::Emphasis(Emphasis { values: inner, .. }) + if (index == 0 || index == last) + && !index + .checked_sub(1) + .is_some_and(|before| ends_with_word_char(&values[before])) + && !values.get(index + 1).is_some_and(starts_with_word_char) => + { + render_emphasis(inner, "_", options, theme) + } + value => value.render_with_theme(options, theme), + }) + .collect::(); + format!("{ss}{delim}{content}{delim}{se}") +} + +fn render_emphasis(values: &[Node], delim: &str, options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + let (es, ee) = &theme.emphasis; + format!("{es}{delim}{}{delim}{ee}", render_values(values, options, theme)) +} + +/// Renders the content of a table cell. A pipe that is not escaped yet, as in code, would end the cell. +pub(crate) fn render_cell_values(values: &[Node], options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + let rendered = render_values(values, options, theme); + if !rendered.contains('|') { + return rendered; + } + let mut result = String::with_capacity(rendered.len() + 4); + let mut escaped = false; + for c in rendered.chars() { + if c == '|' && !escaped { + result.push('\\'); + } + escaped = c == '\\' && !escaped; + result.push(c); + } + result +} + +fn ends_with_word_char(node: &Node) -> bool { + matches!(node, Node::Text(Text { value, .. }) if value.ends_with(|c: char| c.is_alphanumeric())) +} + +fn starts_with_word_char(node: &Node) -> bool { + matches!(node, Node::Text(Text { value, .. }) if value.starts_with(|c: char| c.is_alphanumeric())) +} + +/// Whether `second` starts where `first` ends, so the two are written next to each other. +/// Nodes without positions are taken to touch. +fn touches(first: &Node, second: &Node) -> bool { + match (first.position(), second.position()) { + (Some(first), Some(second)) => first.end == second.start, + _ => true, + } +} + +/// Renders `value`, escaping a `!` that ends its text when `next` starts a link, which would make an image. +/// +/// `previous` is what this function returned for the last of `before`, with `value` as `next`. +pub(crate) fn render_before( + value: &Node, + before: &[Node], + previous: Option<&str>, + next: Option<&Node>, + options: &RenderOptions, + theme: &ColorTheme<'_>, +) -> String { + // Emphasis right after other emphasis takes the other delimiter, or the two fuse into one run. + if let Node::Emphasis(Emphasis { values, .. }) = value + && matches!(before.last(), Some(Node::Emphasis(_) | Node::Strong(_))) + && before.last().is_some_and(|last| touches(last, value)) + && !matches!(values.first(), Some(Node::Emphasis(_))) + && !matches!(values.last(), Some(Node::Emphasis(_))) + && !next.is_some_and(|next| touches(value, next) && starts_with_word_char(next)) + { + let delim = if previous.is_some_and(|previous| previous.ends_with('_')) { + "*" + } else { + "_" + }; + return render_emphasis(values, delim, options, theme); + } + // Strong right after emphasis that ends in `*` takes `__`, or the runs of `*` fuse. + if let Node::Strong(Strong { values, .. }) = value + && matches!(before.last(), Some(Node::Emphasis(_) | Node::Strong(_))) + && before.last().is_some_and(|last| touches(last, value)) + && !next.is_some_and(|next| touches(value, next) && starts_with_word_char(next)) + && previous.is_some_and(|previous| previous.ends_with('*')) + { + return render_strong(values, "__", options, theme); + } + // A reference followed by a colon at the start of a line would read back as a definition. + let after_reference = matches!(before.last(), Some(Node::LinkRef(_) | Node::FootnoteRef(_))) + && before.last().is_some_and(|last| touches(last, value)); + let mut rendered = value.render_with_theme(options, theme); + let starts_link = next.is_some_and(|next| touches(value, next)) + && match next { + Some(Node::Link(_) | Node::LinkRef(_)) => true, + #[cfg(feature = "wikilink")] + Some(Node::WikiLink(_)) => true, + _ => false, + }; + if after_reference && matches!(value, Node::Text(_)) && rendered.starts_with(':') { + rendered.insert(0, '\\'); + } + if starts_link && matches!(value, Node::Text(Text { position: Some(_), .. })) && rendered.ends_with('!') { + rendered.insert(rendered.len() - 1, '\\'); + } + rendered +} + +pub(crate) fn render_values(values: &[Node], options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + let mut pre_position: Option = None; + let mut previous: Option = None; + values + .iter() + .enumerate() + .map(|(index, value)| { + let raw = render_before( + value, + &values[..index], + previous.as_deref(), + values.get(index + 1), + options, + theme, + ); + let rendered = || raw.as_str(); + if let Some(pos) = value.position() { + let new_line_count = pre_position + .as_ref() + .map(|p: &Position| pos.start.line.saturating_sub(p.end.line)) + .unwrap_or_default(); + + let space = if new_line_count > 0 + && pre_position + .as_ref() + .map(|p| pos.start.line > p.end.line) + .unwrap_or_default() + { + " ".repeat(pos.start.column.saturating_sub(1)) + } else { + "".to_string() + }; + + pre_position = Some(pos); + + let piece = if space.is_empty() { + format!("{}{}", "\n".repeat(new_line_count), rendered()) + } else { + format!( + "{}{}", + "\n".repeat(new_line_count), + rendered().lines().map(|line| format!("{}{}", space, line)).join("\n") + ) + }; + previous = Some(raw); + piece + } else { + pre_position = None; + let piece = raw.clone(); + previous = Some(raw); + piece + } + }) + .collect::() +} + +/// The number of line endings to write before `node` after `previous`, raised to a blank line where +/// the node would otherwise read back as part of `previous`. +pub(crate) fn separated_from(count: usize, previous: Option<&Node>, node: &Node, rendered: &str) -> usize { + if count >= 2 { + return count; + } + // A single line ending after a block quote reads back as lazy continuation. + let lazy = previous.is_some_and(Node::is_blockquote_like); + // A line of dashes right under paragraph text reads back as a setext heading. + let setext = matches!(node, Node::HorizontalRule(_)) + && rendered.starts_with("---") + && previous.is_some_and(Node::is_paragraph_text); + // A footnote ends where the next line starts, so a block after it needs a blank line. + let footnote = matches!(previous, Some(Node::Footnote(_))) && !matches!(node, Node::Footnote(_)); + if lazy || setext || footnote { 2 } else { count } +} + +/// Writes an item, or the rest of one that follows the items nested in it, inside a container. +/// +/// `indents` holds the column of the content of each open item. It is kept out of the container loop +/// so that the frame of that recursion stays small. +#[inline(never)] +fn render_list_item( + node: &Node, + item: &List, + rest: bool, + indents: &mut Vec, + options: &RenderOptions, + theme: &ColorTheme<'_>, +) -> String { + if rest { + // Indented to the content of the item and written without a marker. + indents.truncate(item.level as usize + 1); + let indent = indents.last().copied().unwrap_or(0); + return indent_lines(&render_values_block(&item.values, options, theme), indent); + } + // Nested items are indented to the content of the item that holds them. + indents.truncate(item.level as usize); + let own_indent = indents.last().copied().unwrap_or(0); + let delta = own_indent as isize - item.level as isize * 2; + indents.push(own_indent + list_own_prefix_width(item.ordered, item.index, item.start)); + reindent_all_lines(&node.render_with_theme(options, theme), delta) +} + +/// Writes what separates `node` from the one written before it, and trims `rendered` when it follows +/// the marker that holds it. Not inlined, to keep the frame of the container loop small. +#[inline(never)] +fn separate( + result: &mut String, + rendered: &mut String, + pre_position: &mut Option, + position: Option, + previous: Option<&Node>, + node: &Node, +) { + if let Some(pos) = position { + let new_line_count = pre_position + .as_ref() + .map(|p: &Position| pos.start.line.saturating_sub(p.end.line)) + .unwrap_or_default(); + let new_line_count = separated_from(new_line_count, previous, node, rendered); + *pre_position = Some(pos); + result.push_str(&"\n".repeat(new_line_count)); + // An item that starts on the line of the marker that holds it follows it directly. + if new_line_count == 0 && matches!((previous, node), (Some(Node::List(_)), Node::List(_))) { + *rendered = rendered.trim_start().to_string(); + } + } else { + // Text never follows text inside one paragraph, so without positions that is a new one. + if pre_position.is_none() && matches!((previous, node), (Some(Node::Text(_)), Node::Text(_))) { + result.push_str("\n\n"); + } + *pre_position = None; + } +} + +/// Like `render_values` but without column-offset–based indentation. +/// +/// Inside blockquotes and callouts, node positions include the `> ` prefix offset, +/// so applying column-based spacing causes double-indentation. This variant preserves +/// blank lines between values (via newline counting) but ignores the column position. +pub(crate) fn render_values_block(values: &[Node], options: &RenderOptions, theme: &ColorTheme<'_>) -> String { + // The children of an item that come after the items nested in it, as at the top level. + let reordered = crate::markdown::list_order::reorder(values); + let (values, continuation) = match &reordered { + Some(reordered) => (reordered.nodes.as_slice(), reordered.continuation.as_slice()), + None => (values, [].as_slice()), + }; + let mut result = String::new(); + let mut pre_position: Option = None; + let mut index = 0; + // The column of the content of the open items, one for each level. + let mut list_indent_stack: Vec = Vec::new(); + // The last node written, to tell paragraphs from the inline nodes of one when there are no positions. + let mut previous: Option<&Node> = None; + // What `render_before` returned for `previous`, or `None` when it is part of a table. + let mut previous_rendered: Option = None; + + while index < values.len() { + let first = &values[index]; + // The cells of a table are laid out together, as at the top level. + let (mut rendered, position, next) = if is_table_part(first) { + list_indent_stack.clear(); + render_table_run(values, index, options, theme) + } else if let Node::List(item) = first { + let rest = continuation.get(index) == Some(&true); + ( + render_list_item(first, item, rest, &mut list_indent_stack, options, theme), + first.position(), + index + 1, + ) + } else { + list_indent_stack.clear(); + ( + render_before( + first, + &values[..index], + previous_rendered.as_deref(), + values.get(index + 1), + options, + theme, + ), + first.position(), + index + 1, + ) + }; + index = next; + + separate(&mut result, &mut rendered, &mut pre_position, position, previous, first); + previous = Some(&values[index - 1]); + result.push_str(&rendered); + previous_rendered = (!is_table_part(first)).then_some(rendered); + } + result +} + +fn is_table_part(node: &Node) -> bool { + matches!(node, Node::TableCell(_) | Node::TableAlign(_)) +} + +/// Renders the cells and the delimiter row from `values[index]` on as one table, with its position and +/// the index after it. Apart from the recursion, so that nesting does not pay for its locals. +#[inline(never)] +fn render_table_run( + values: &[Node], + index: usize, + options: &RenderOptions, + theme: &ColorTheme<'_>, +) -> (String, Option, usize) { + let end = values[index..] + .iter() + .position(|node| !is_table_part(node)) + .map_or(values.len(), |offset| index + offset); + let run = &values[index..end]; + let table = crate::Markdown { + nodes: run.to_vec(), + options: options.clone(), + }; + let position = match (run[0].position(), run[run.len() - 1].position()) { + (Some(first), Some(last)) => Some(Position { + start: first.start, + end: last.end, + }), + _ => None, + }; + ( + table.render_with_theme(theme).trim_end_matches('\n').to_string(), + position, + end, + ) +} + +/// Renders a link/image destination, auto-upgrading to `<...>` (escaping `\ < >`) +/// when the bare form would be unsafe (empty, or contains whitespace/control/`< >`); +/// otherwise bare, escaping `\ ( )` since unescaped parens must balance. +pub(super) fn render_link_destination(url: &str, style: &UrlSurroundStyle) -> String { + let needs_angle = url.is_empty() + || url + .chars() + .any(|c| c.is_whitespace() || c.is_control() || c == '<' || c == '>'); + + if matches!(style, UrlSurroundStyle::Angle) || needs_angle { + let mut result = String::with_capacity(url.len() + 2); + result.push('<'); + for c in url.chars() { + if matches!(c, '\\' | '<' | '>') { + result.push('\\'); + } + result.push(c); + } + result.push('>'); + result + } else { + let mut result = String::with_capacity(url.len()); + for c in url.chars() { + if matches!(c, '\\' | '(' | ')') { + result.push('\\'); + } + result.push(c); + } + result + } +} + +/// Renders a link/image title, escaping `\` and the style's delimiter(s) so the +/// title's own quote/paren characters can't end it early. +pub(super) fn render_link_title(title: &str, style: &TitleSurroundStyle) -> String { + let (open, close, delims): (char, char, &[char]) = match style { + TitleSurroundStyle::Double => ('"', '"', &['"']), + TitleSurroundStyle::Single => ('\'', '\'', &['\'']), + TitleSurroundStyle::Paren => ('(', ')', &['(', ')']), + }; + let mut result = String::with_capacity(title.len() + 2); + result.push(open); + for c in title.chars() { + if c == '\\' || delims.contains(&c) { + result.push('\\'); + } + result.push(c); + } + result.push(close); + result +} + +/// Escapes `\ [ ]` in reference labels/idents and image alt text: plain strings +/// (not walked as inline nodes) that still sit inside `[...]` syntax. +/// True if `s` needs escaping beyond `escape_label`'s narrow `\ [ ]` set: unsafe +/// for a reference's implicit shortcut form, since the definition's label won't match. +fn needs_broad_escaping(s: &str) -> bool { + s.bytes().any(|b| { + matches!( + b, + b'`' | b'*' | b'_' | b'|' | b'~' | b'$' | b'#' | b'-' | b'+' | b'>' | b'=' | b'.' | b')' | b'!' + ) + }) +} + +/// Shifts every line's leading indentation by `delta` spaces (never below zero), +/// leaving each line's own content untouched. +pub(crate) fn reindent_all_lines(content: &str, delta: isize) -> String { + if delta == 0 { + return content.to_string(); + } + content + .split('\n') + .map(|line| { + if line.is_empty() { + line.to_string() + } else { + let leading = line.bytes().take_while(|&b| b == b' ').count(); + let new_leading = (leading as isize + delta).max(0) as usize; + format!("{}{}", " ".repeat(new_leading), &line[leading..]) + } + }) + .collect::>() + .join("\n") +} + +/// The width of a list item's own marker and trailing space (not including ancestor indentation), +/// e.g. 2 for `"- "`, 3 for `"1. "`, 4 for `"10. "`. A checkbox is not part of it, as the content of +/// the item starts at the checkbox, so children are indented to the marker alone. +pub(crate) fn list_own_prefix_width(ordered: bool, index: usize, start: Option) -> usize { + let marker_len = if ordered { + (start.unwrap_or(1) as usize + index).to_string().chars().count() + 1 + } else { + 1 + }; + marker_len + 1 +} + +/// The length of the longest run of `ch` in `s`. +fn longest_run(s: &str, ch: char) -> usize { + s.split('\n') + .flat_map(|line| line.split(|c| c != ch).map(str::len)) + .max() + .unwrap_or(0) +} + +/// Longest fence-char run in `value` + 1 (min 3); switches to tilde when `info` +/// has a backtick, since a backtick-fenced info string can't contain one. +fn code_fence(value: &str, info: &str) -> String { + if info.contains('`') { + let len = (longest_run(value, '~') + 1).max(3); + "~".repeat(len) + } else { + let len = (longest_run(value, '`') + 1).max(3); + "`".repeat(len) + } +} + +/// The label to write in a definition: the identifier as it was written when the label is only that +/// without its escapes and character references, which would not read back as the same identifier. +fn definition_label(label: Option<&str>, ident: &str) -> String { + match label { + Some(label) if crate::parser::normalize(label) == ident => escape_label(label), + Some(label) if crate::parser::normalize(label) == crate::parser::normalize(&crate::parser::unescape(ident)) => { + ident.to_string() + } + Some(label) => escape_label(label), + None => ident.to_string(), + } +} + +/// The label to write in the second brackets of a full reference. The label as written is kept when it +/// still resolves to `ident`. +fn reference_label(label: Option<&str>, ident: &str) -> String { + match label { + Some(label) if crate::parser::normalize(label) == ident => escape_label(label), + _ => ident.to_string(), + } +} + +/// Picks an inline code span delimiter one backtick longer than the longest +/// backtick run in `value`, so no run inside it can be mistaken for the closing. +fn code_span_fence(value: &str) -> String { + let mut max_run = 0; + let mut cur = 0; + for c in value.chars() { + if c == '`' { + cur += 1; + max_run = max_run.max(cur); + } else { + cur = 0; + } + } + "`".repeat(max_run + 1) +} + +/// The shortest run of dollar signs, from one, that is not the length of a run in `value`, so that no +/// run inside a math span can be mistaken for the closing. +fn math_span_fence(value: &str) -> String { + let mut lengths = std::collections::HashSet::new(); + let mut current = 0; + for c in value.chars() { + if c == '$' { + current += 1; + } else if current > 0 { + lengths.insert(current); + current = 0; + } + } + if current > 0 { + lengths.insert(current); + } + "$".repeat((1..).find(|length| !lengths.contains(length)).unwrap_or(1)) +} + +/// Escapes the brackets that are not escaped yet, for an identifier that keeps the escapes it was written with. +fn escape_brackets(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\\' if chars.peek().is_some_and(char::is_ascii_punctuation) => { + result.push(c); + result.extend(chars.next()); + } + '[' | ']' => { + result.push('\\'); + result.push(c); + } + _ => result.push(c), + } + } + result +} + +fn escape_label(s: &str) -> String { + if !s.bytes().any(|b| matches!(b, b'\\' | b'[' | b']')) { + return s.to_string(); + } + let mut result = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '\\' | '[' | ']') { + result.push('\\'); + } + result.push(c); + } + result +} + +/// The text of an autolink for a link whose only content is its own destination, when writing that +/// text as a link label would need escapes. A URL in a label is read as it is, so `\_` would stay. +fn autolink_target(url: &str, values: &[Node]) -> Option { + let [Node::Text(Text { value, .. })] = values else { + return None; + }; + let target = if value == url { + url + } else if url.strip_prefix("mailto:") == Some(value.as_str()) && crate::parser::is_autolink_email(value) { + value.as_str() + } else { + return None; + }; + let plain = !target + .chars() + .any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>')); + let scheme = target.split_once(':').is_some_and(|(scheme, _)| { + (2..=32).contains(&scheme.len()) + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-')) + }); + let email = value != url; + (plain && (scheme || email) && escape_text(target.to_string()) != target).then(|| target.to_string()) +} + +/// Whether `rest`, the text after an `&`, reads as the rest of a character reference. +fn starts_reference(rest: &str) -> bool { + let name = match rest.strip_prefix('#') { + Some(number) => match number.strip_prefix(['x', 'X']) { + Some(hex) => hex + .split_once(';') + .map(|(digits, _)| (digits, char::is_ascii_hexdigit as fn(&char) -> bool)), + None => number + .split_once(';') + .map(|(digits, _)| (digits, char::is_ascii_digit as fn(&char) -> bool)), + }, + None => rest + .split_once(';') + .map(|(name, _)| (name, char::is_ascii_alphanumeric as fn(&char) -> bool)), + }; + name.is_some_and(|(name, is_valid)| !name.is_empty() && name.chars().all(|c| is_valid(&c))) +} + +fn is_email_local(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+') +} + +/// Whether `rest`, the text after an `@`, starts with a domain that makes an email address of the text +/// before it. +fn is_email_domain(rest: &str) -> bool { + let domain = rest + .split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))) + .next() + .unwrap_or_default(); + domain + .split_once('.') + .is_some_and(|(label, after)| !label.is_empty() && after.starts_with(|c: char| c.is_ascii_alphanumeric())) +} + +/// Re-escapes markdown-significant characters so plain text can't be reinterpreted as +/// syntax on re-parse. `# - + > =` only unsafe at line start, `. )` only after leading digits; +/// a leading `\t` and runs of 2+ `\n` (only possible via decoded entities) use `&#N;` instead. +fn escape_text(value: String) -> String { + escape_text_with(value, false) +} + +/// Whether `text` starts with the `import ` or `export ` that makes a block ESM in MDX. +fn starts_esm(text: &str) -> bool { + text.starts_with("import ") || text.starts_with("export ") +} + +/// `escape_text`, and for MDX also the braces and the `import` or `export` that starts a line. +fn escape_text_with(value: String, mdx: bool) -> String { + // Skip the copy when nothing needs escaping; byte scan avoids UTF-8 decoding. + let needs_escaping = value.bytes().any(|b| { + matches!( + b, + b'\\' + | b'`' + | b'*' + | b'_' + | b'[' + | b']' + | b'|' + | b'~' + | b'$' + | b'#' + | b'-' + | b'+' + | b'>' + | b'<' + | b'=' + | b'.' + | b')' + | b'\n' + | b'\t' + | b'&' + | b'@' + ) + }); + let mdx_special = mdx && (value.contains('{') || value.contains("import ") || value.contains("export ")); + if !needs_escaping && !mdx_special { + return value; + } + + let mut result = String::with_capacity(value.len()); + let mut at_line_start = true; + // True while in an unbroken digit run since line start (ordered-list marker number). + let mut leading_digits = false; + let mut chars = value + .char_indices() + .map(|(index, c)| (c, &value[index + c.len_utf8()..])) + .peekable(); + let mut previous = None; + + while let Some((c, rest)) = chars.next() { + match c { + '\n' if chars.peek().is_some_and(|(next, _)| *next == '\n') => { + result.push_str(" "); + at_line_start = true; + leading_digits = false; + continue; + } + '\t' if at_line_start => { + result.push_str(" "); + at_line_start = false; + leading_digits = false; + continue; + } + '\\' | '`' | '*' | '_' | '[' | ']' | '|' | '~' | '$' | '<' => result.push('\\'), + '{' if mdx => result.push('\\'), + // The first letter as a character reference keeps the line from starting an ESM block. + 'i' | 'e' if mdx && at_line_start && starts_esm(&value[value.len() - rest.len() - 1..]) => { + result.push_str(if c == 'i' { "i" } else { "e" }); + at_line_start = false; + leading_digits = false; + previous = Some(c); + continue; + } + '#' | '-' | '+' | '>' | '=' if at_line_start => result.push('\\'), + '.' | ')' if leading_digits => result.push('\\'), + '&' if starts_reference(rest) => result.push('\\'), + '@' if previous.is_some_and(is_email_local) && is_email_domain(rest) => result.push('\\'), + _ => {} + } + result.push(c); + previous = Some(c); + + let is_digit = c.is_ascii_digit(); + leading_digits = if at_line_start { + is_digit + } else { + leading_digits && is_digit + }; + at_line_start = c == '\n'; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use rstest::rstest; + + #[rstest] + #[case::heading_marker("## not a heading", "\\## not a heading")] + #[case::heading_seven_hashes_still_escaped("####### x", "\\####### x")] + #[case::emphasis_star("*not emphasis*", "\\*not emphasis\\*")] + #[case::emphasis_underscore("_not emphasis_", "\\_not emphasis\\_")] + #[case::code_span("`not code`", "\\`not code\\`")] + #[case::link_brackets("[not a link](x)", "\\[not a link\\](x)")] + #[case::list_marker("- not a list item", "\\- not a list item")] + #[case::blockquote_marker("> not a quote", "\\> not a quote")] + #[case::table_pipe("| not a table |", "\\| not a table \\|")] + #[case::strikethrough("~not strikethrough~", "\\~not strikethrough\\~")] + #[case::math("$not math$", "\\$not math\\$")] + #[case::plus_list_marker("+ not a list item", "\\+ not a list item")] + #[case::ordered_list_dot("5. not a list", "5\\. not a list")] + #[case::ordered_list_paren("5) not a list", "5\\) not a list")] + #[case::setext_h1_underline("Foo\n===", "Foo\n\\===")] + #[case::hash_not_at_line_start("a#b", "a#b")] + #[case::dash_not_at_line_start("a-b", "a-b")] + #[case::plus_not_at_line_start("a+b", "a+b")] + #[case::gt_not_at_line_start("a>b", "a>b")] + #[case::eq_not_at_line_start("a=b", "a=b")] + #[case::dot_not_after_leading_digits("a5. b", "a5. b")] + #[case::dot_after_broken_digit_run("5x3. b", "5x3. b")] + #[case::escapes_after_embedded_newline("a\n# b", "a\n\\# b")] + #[case::plain_text_unchanged("plain text", "plain text")] + #[case::literal_backslash_is_doubled("a\\b", "a\\\\b")] + #[case::empty_string("", "")] + #[case::bang_before_bracket_only_bracket_escaped("![bar]", "!\\[bar\\]")] + #[case::bang_at_value_end_not_escaped("foo!", "foo!")] + #[case::consecutive_newlines_become_entity("foo\n\nbar", "foo \nbar")] + #[case::three_consecutive_newlines("foo\n\n\nbar", "foo \nbar")] + #[case::single_newline_unchanged("foo\nbar", "foo\nbar")] + #[case::leading_tab_becomes_entity("\tfoo", " foo")] + #[case::tab_not_at_line_start_unchanged("foo\tbar", "foo\tbar")] + #[case::angle_bracket_escaped("
not a tag", "\\
not a tag")] + // `&` is deliberately left unescaped: see KNOWN_FAILURES in gfm_roundtrip_fidelity.rs (#591). + #[case::ampersand_not_escaped("a&b", "a&b")] + fn test_escape_text(#[case] input: &str, #[case] expected: &str) { + assert_eq!(escape_text(input.to_string()), expected); + } + + #[rstest] + #[case::plain("plain", "plain")] + #[case::brackets("[a][b]", "\\[a\\]\\[b\\]")] + #[case::backslash("a\\b", "a\\\\b")] + fn test_escape_label(#[case] input: &str, #[case] expected: &str) { + assert_eq!(escape_label(input), expected); + } + + #[rstest] + #[case::no_newline_unchanged("abc", 2, "abc")] + #[case::continuation_indented("a\nb", 2, "a\n b")] + #[case::empty_lines_untouched("a\n\nb", 2, "a\n\n b")] + #[case::first_line_never_indented(" a\nb", 3, " a\n b")] + fn test_indent_continuation(#[case] content: &str, #[case] width: usize, #[case] expected: &str) { + assert_eq!(indent_continuation(content, width), expected); + } + + #[rstest] + #[case::no_backticks("", 1)] + #[case::single_run("foo`bar", 2)] + #[case::longer_run("foo``bar", 3)] + fn test_code_span_fence(#[case] value: &str, #[case] expected_len: usize) { + assert_eq!(code_span_fence(value), "`".repeat(expected_len)); + } + + #[rstest] + #[case::no_backticks_minimum_three("plain", "", 3)] + #[case::body_run_escalates("aaa\n```", "", 4)] + fn test_code_fence(#[case] value: &str, #[case] info: &str, #[case] expected_len: usize) { + assert_eq!(code_fence(value, info), "`".repeat(expected_len)); + } + + #[rstest] + #[case::info_backtick_switches_to_tilde("plain", "a`b", 3)] + #[case::info_backtick_run_switches_to_tilde("plain", "a``b", 3)] + #[case::body_tilde_run_escalates_within_tilde_fence("aaa\n~~~", "a`b", 4)] + fn test_code_fence_tilde(#[case] value: &str, #[case] info: &str, #[case] expected_len: usize) { + assert_eq!(code_fence(value, info), "~".repeat(expected_len)); + } + + proptest! { + // Every backslash escape_text inserts precedes exactly the character it + // protects, and pre-existing backslashes are doubled, so blindly dropping + // each backslash and keeping the character after it must recover the + // original string, for any input. Tabs and repeated newlines go through a + // separate entity-based path (see test_escape_text), excluded here. + #[test] + fn escape_text_is_losslessly_invertible( + s in prop::collection::vec(any::(), 0..60) + .prop_map(|cs| cs.into_iter().collect::()) + .prop_filter("excludes entity-escaped tab/newline-run cases", |s| { + !s.contains('\t') && !s.contains("\n\n") + }) + ) { + let escaped = escape_text(s.clone()); + prop_assert_eq!(naive_unescape(&escaped), s); + } + + // Rendering must never panic, whatever the heading text (arbitrary Unicode, + // possibly ending in a run of `#`): this is a UTF-8 char-boundary regression + // guard for the trailing-`#` escape logic. + #[test] + fn heading_render_never_panics_on_unicode_text( + body in prop::collection::vec(any::(), 0..20).prop_map(|cs| cs.into_iter().collect::()), + hashes in 0usize..5, + depth in 1u8..=6, + ) { + let value = format!("{}{}", body, "#".repeat(hashes)); + let node = Node::Heading(Heading { depth: HeadingDepth::saturating(i64::from(depth)), values: vec![value.into()], position: None }); + let _ = node.to_string_with(&RenderOptions::default()); + } + + #[test] + fn escape_label_is_losslessly_invertible( + s in prop::collection::vec(any::(), 0..40).prop_map(|cs| cs.into_iter().collect::()) + ) { + let escaped = escape_label(&s); + prop_assert_eq!(naive_unescape(&escaped), s); + } + + // A Link's destination, rendered through the real writer and re-parsed by the + // real markdown parser, must recover the original URL string: the concrete + // bug class from the GFM spec-suite fidelity check (spaces/parens/angle + // brackets silently corrupting or breaking the link on round-trip). + #[test] + fn link_url_round_trips_through_parser( + url in prop::collection::vec( + prop::sample::select(vec!['a', 'b', '1', ' ', '(', ')', '<', '>', '\\']), + 1..12, + ).prop_map(|cs| cs.into_iter().collect::()) + ) { + let doc = format!("[text]({})\n", render_link_destination(&url, &UrlSurroundStyle::None)); + let parsed = crate::Markdown::from_markdown_str(&doc).unwrap(); + let link = parsed.nodes.iter().find_map(|n| match n { + Node::Link(l) => Some(l.url.as_str().to_string()), + _ => None, + }); + prop_assert_eq!(link, Some(url)); + } + + } + + fn naive_unescape(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' + && let Some(next) = chars.next() + { + result.push(next); + continue; + } + result.push(c); + } + result + } +} diff --git a/crates/mq-markdown/src/node/tests.rs b/crates/mq-markdown/src/node/tests.rs new file mode 100644 index 000000000..f248337a2 --- /dev/null +++ b/crates/mq-markdown/src/node/tests.rs @@ -0,0 +1,3105 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case::dash('-', Some(ListMarker::Dash))] +#[case::plus('+', Some(ListMarker::Plus))] +#[case::star('*', Some(ListMarker::Star))] +#[case::period('.', Some(ListMarker::Period))] +#[case::paren(')', Some(ListMarker::Paren))] +#[case::underscore('_', None)] +#[case::digit('1', None)] +fn list_marker_from_char(#[case] char: char, #[case] expected: Option) { + assert_eq!(ListMarker::from_char(char), expected); + assert_eq!(expected.map(ListMarker::as_char), expected.map(|_| char)); +} + +#[rstest] +#[case::dash(ListMarker::Dash, Some(ListStyle::Dash))] +#[case::plus(ListMarker::Plus, Some(ListStyle::Plus))] +#[case::star(ListMarker::Star, Some(ListStyle::Star))] +#[case::period(ListMarker::Period, None)] +#[case::paren(ListMarker::Paren, None)] +fn list_marker_style(#[case] marker: ListMarker, #[case] expected: Option) { + assert_eq!(marker.style(), expected); +} + +#[rstest] +#[case::dash(ListStyle::Dash, ListMarker::Dash)] +#[case::plus(ListStyle::Plus, ListMarker::Plus)] +#[case::star(ListStyle::Star, ListMarker::Star)] +fn list_marker_from_style(#[case] style: ListStyle, #[case] expected: ListMarker) { + assert_eq!(ListMarker::from(style), expected); +} + +#[rstest] +#[case::star('*', Some(HorizontalRuleMarker::Star))] +#[case::dash('-', Some(HorizontalRuleMarker::Dash))] +#[case::underscore('_', Some(HorizontalRuleMarker::Underscore))] +#[case::plus('+', None)] +fn horizontal_rule_marker_from_char(#[case] char: char, #[case] expected: Option) { + assert_eq!(HorizontalRuleMarker::from_char(char), expected); + assert_eq!(expected.map(HorizontalRuleMarker::as_char), expected.map(|_| char)); +} + +#[rstest] +#[case::bullet_dash(false, Some(ListMarker::Dash), "- a")] +#[case::bullet_plus(false, Some(ListMarker::Plus), "+ a")] +#[case::bullet_star(false, Some(ListMarker::Star), "* a")] +#[case::bullet_unknown(false, None, "- a")] +#[case::bullet_with_an_ordered_marker(false, Some(ListMarker::Paren), "- a")] +#[case::ordered_period(true, Some(ListMarker::Period), "1. a")] +#[case::ordered_paren(true, Some(ListMarker::Paren), "1) a")] +#[case::ordered_unknown(true, None, "1. a")] +#[case::ordered_with_a_bullet_marker(true, Some(ListMarker::Star), "1. a")] +fn list_renders_its_marker(#[case] ordered: bool, #[case] marker: Option, #[case] expected: &str) { + let node = Node::List(List { + index: 0, + level: 0, + checked: None, + values: vec![Node::Text(Text { + value: "a".into(), + position: None, + })], + ordered, + start: None, + spread: false, + marker, + position: None, + }); + assert_eq!( + node.render_with_theme(&RenderOptions::default(), &ColorTheme::PLAIN), + expected + ); +} + +#[rstest] +#[case::star(Some(HorizontalRuleMarker::Star), "***")] +#[case::dash(Some(HorizontalRuleMarker::Dash), "---")] +#[case::underscore(Some(HorizontalRuleMarker::Underscore), "___")] +#[case::unknown(None, "***")] +fn horizontal_rule_renders_its_marker(#[case] marker: Option, #[case] expected: &str) { + let node = Node::HorizontalRule(HorizontalRule { marker, position: None }); + assert_eq!( + node.render_with_theme(&RenderOptions::default(), &ColorTheme::PLAIN), + expected + ); +} + +/// The JSON of the markers is the character they are written with, as it was when they were chars. +#[cfg(feature = "json")] +#[rstest] +#[case::dash(ListMarker::Dash, "\"-\"")] +#[case::plus(ListMarker::Plus, "\"+\"")] +#[case::star(ListMarker::Star, "\"*\"")] +#[case::period(ListMarker::Period, "\".\"")] +#[case::paren(ListMarker::Paren, "\")\"")] +fn list_marker_json(#[case] marker: ListMarker, #[case] expected: &str) { + assert_eq!(serde_json::to_string(&marker).unwrap(), expected); + assert_eq!(serde_json::from_str::(expected).unwrap(), marker); +} + +#[cfg(feature = "json")] +#[rstest] +#[case::star(HorizontalRuleMarker::Star, "\"*\"")] +#[case::dash(HorizontalRuleMarker::Dash, "\"-\"")] +#[case::underscore(HorizontalRuleMarker::Underscore, "\"_\"")] +fn horizontal_rule_marker_json(#[case] marker: HorizontalRuleMarker, #[case] expected: &str) { + assert_eq!(serde_json::to_string(&marker).unwrap(), expected); + assert_eq!(serde_json::from_str::(expected).unwrap(), marker); +} + +#[cfg(feature = "json")] +#[test] +fn list_json_keeps_the_marker_as_a_string() { + let json = + r#"{"type":"list","values":[],"index":0,"level":0,"ordered":false,"checked":null,"spread":false,"marker":"+"}"#; + let list = serde_json::from_str::(json).unwrap(); + assert_eq!(list.marker, Some(ListMarker::Plus)); + assert!(serde_json::to_string(&list).unwrap().contains(r#""marker":"+""#)); +} + +#[test] +fn map_values_into_transforms_owned_fragments() { + let node = Node::Fragment(Fragment { + values: vec![Node::Text(Text { + value: "before".to_string(), + position: None, + })], + }); + + let mapped = node + .map_values_into(&mut |node| -> Result { + Ok(match node { + Node::Text(text) => Node::Text(Text { + value: text.value.to_uppercase(), + position: text.position.clone(), + }), + node => node.clone(), + }) + }) + .unwrap(); + + assert_eq!(mapped.to_string(), "BEFORE"); +} + +#[test] +fn map_values_into_owned_moves_nodes_through_the_callback() { + let node = Node::Fragment(Fragment { + values: vec![Node::Text(Text { + value: "before".to_string(), + position: None, + })], + }); + + let mapped = node + .map_values_into_owned(&mut |node| -> Result { + Ok(match node { + Node::Text(mut text) => { + text.value.make_ascii_uppercase(); + Node::Text(text) + } + node => node, + }) + }) + .unwrap(); + + assert_eq!(mapped.to_string(), "BEFORE"); +} + +#[rstest] +#[case::text(Node::Text(Text{value: "".to_string(), position: None}), + "test".to_string(), + Node::Text(Text{value: "test".to_string(), position: None }))] +#[case::blockquote(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None }))] +#[case::delete(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::Delete(Delete{values: vec!["test".to_string().into()], position: None }))] +#[case::emphasis(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None }))] +#[case::strong(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::Strong(Strong{values: vec!["test".to_string().into()], position: None }))] +#[case::heading(Node::Heading(Heading {depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None }))] +#[case::link(Node::Link(Link {url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None }), + "test".to_string(), + Node::Link(Link{url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None }))] +#[case::image(Node::Image(Image {alt: "test".to_string(), url: "test".to_string(), title: None, position: None }), + "test".to_string(), + Node::Image(Image{alt: "test".to_string(), url: "test".to_string(), title: None, position: None }))] +#[case::code(Node::Code(Code {value: "test".to_string(), lang: None, fence: true, meta: None, position: None }), + "test".to_string(), + Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None }))] +#[case::footnote_ref(Node::FootnoteRef(FootnoteRef {ident: "test".to_string(), label: None, position: None }), + "test".to_string(), + Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: Some("test".to_string()), position: None }))] +#[case::footnote(Node::Footnote(Footnote {ident: "test".to_string(), values: Vec::new(), position: None }), + "test".to_string(), + Node::Footnote(Footnote{ident: "test".to_string(), values: Vec::new(), position: None }))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None }))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None }))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 2, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None }), + "test".to_string(), + Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 2, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None }))] +#[case::code_inline(Node::CodeInline(CodeInline{ value: "t".into(), position: None }), + "test".to_string(), + Node::CodeInline(CodeInline{ value: "test".into(), position: None }))] +#[case::math_inline(Node::MathInline(MathInline{ value: "t".into(), position: None }), + "test".to_string(), + Node::MathInline(MathInline{ value: "test".into(), position: None }))] +#[case::toml(Node::Toml(Toml{ value: "t".to_string(), position: None }), + "test".to_string(), + Node::Toml(Toml{ value: "test".to_string(), position: None }))] +#[case::yaml(Node::Yaml(Yaml{ value: "t".to_string(), position: None }), + "test".to_string(), + Node::Yaml(Yaml{ value: "test".to_string(), position: None }))] +#[case::html(Node::Html(Html{ value: "t".to_string(), position: None }), + "test".to_string(), + Node::Html(Html{ value: "test".to_string(), position: None }))] +#[case::table_row(Node::TableRow(TableRow{ values: vec![ + Node::TableCell(TableCell{values: vec!["test1".to_string().into()], row:0, column:1, position: None}), + Node::TableCell(TableCell{values: vec!["test2".to_string().into()], row:0, column:2, position: None}) + ] + , position: None }), + "test3,test4".to_string(), + Node::TableRow(TableRow{ values: vec![ + Node::TableCell(TableCell{values: vec!["test3".to_string().into()], row:0, column:1, position: None}), + Node::TableCell(TableCell{values: vec!["test4".to_string().into()], row:0, column:2, position: None}) + ] + , position: None }))] +#[case::table_cell(Node::TableCell(TableCell{values: vec!["test1".to_string().into()], row:0, column:1, position: None}), + "test2".to_string(), + Node::TableCell(TableCell{values: vec!["test2".to_string().into()], row:0, column:1, position: None}),)] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "test2".to_string(), values: vec![attr_keys::VALUE.to_string().into()], label: Some("test2".to_string()), position: None}), + "test2".to_string(), + Node::LinkRef(LinkRef{ident: "test2".to_string(), values: vec![attr_keys::VALUE.to_string().into()], label: Some("test2".to_string()), position: None}),)] +#[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test1".to_string(), label: None, position: None}), + "test2".to_string(), + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test2".to_string(), label: Some("test2".to_string()), position: None}),)] +#[case::definition(Node::Definition(Definition{ url: Url::new(attr_keys::URL.to_string()), title: None, ident: "test1".to_string(), label: None, position: None}), + "test2".to_string(), + Node::Definition(Definition{url: Url::new("test2".to_string()), title: None, ident: "test1".to_string(), label: None, position: None}),)] +#[case::break_(Node::Break(Break{ position: None}), + "test".to_string(), + Node::Break(Break{position: None}))] +#[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{ marker: None, position: None}), + "test".to_string(), + Node::HorizontalRule(HorizontalRule{ marker: None,position: None}))] +#[case::mdx_flow_expression(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), + "updated".to_string(), + Node::MdxFlowExpression(MdxFlowExpression{value: "updated".into(), position: None}))] +#[case::mdx_text_expression(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), + "updated".to_string(), + Node::MdxTextExpression(MdxTextExpression{value: "updated".into(), position: None}))] +#[case::mdx_js_esm(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), + "updated".to_string(), + Node::MdxJsEsm(MdxJsEsm{value: "updated".into(), position: None}))] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec!["test".to_string().into()], + position: None + }), + "updated".to_string(), + Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec!["updated".to_string().into()], + position: None + }))] +#[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec!["test".to_string().into()], + position: None + }), + "updated".to_string(), + Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec!["updated".to_string().into()], + position: None + }))] +#[case(Node::Math(Math{ value: "x^2".to_string(), position: None }), + "test".to_string(), + Node::Math(Math{ value: "test".to_string(), position: None }))] +fn test_with_value(#[case] node: Node, #[case] input: String, #[case] expected: Node) { + assert_eq!(node.clone().with_value(input.as_str()), expected); + assert_eq!(node.into_with_value(input.as_str()), expected); +} + +#[rstest] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 0, + Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], position: None}))] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 1, + Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}))] +#[case(Node::Delete(Delete{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 0, + Node::Delete(Delete{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], position: None}))] +#[case(Node::Emphasis(Emphasis{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 1, + Node::Emphasis(Emphasis{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}))] +#[case(Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 0, + Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], position: None}))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 1, + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 0, + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], position: None}))] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), + "new", + 1, + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}))] +#[case(Node::Text(Text{value: "plain text".to_string(), position: None}), + "new", + 0, + Node::Text(Text{value: "plain text".to_string(), position: None}))] +#[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), + "new", + 0, + Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}) +], position: None}), + "new", + 0, + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}) +], position: None}), + "new", + 2, + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}) + ], position: None}))] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], label: None, position: None}), "new", 0, Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], label: None, position: None}))] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }), + "new", + 0, + Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }))] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }), + "new", + 1, + Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], + position: None + }))] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }), + "new", + 0, + Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "new".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }))] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) + ], + position: None + }), + "new", + 1, + Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: Vec::new(), + children: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "new".to_string(), position: None}) + ], + position: None + }))] +fn test_with_children_value(#[case] node: Node, #[case] value: &str, #[case] index: usize, #[case] expected: Node) { + assert_eq!(node.clone().with_children_value(value, index), expected); + assert_eq!(node.into_with_children_value(value, index), expected); +} + +#[rstest] +#[case(Node::Text(Text{value: "test".to_string(), position: None }), + "test".to_string())] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 2, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), + " - test".to_string())] +fn test_display(#[case] node: Node, #[case] expected: String) { + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[rstest] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), true)] +#[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), false)] +#[case(Node::MathInline(MathInline{value: "test".into(), position: None}), false)] +fn test_is_text(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_text(), expected); +} + +#[rstest] +#[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_inline_code(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_inline_code(), expected); +} + +#[rstest] +#[case(Node::MathInline(MathInline{value: "test".into(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_inline_math(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_inline_math(), expected); +} + +#[rstest] +#[case(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_strong(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_strong(), expected); +} + +#[rstest] +#[case(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_delete(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_delete(), expected); +} + +#[rstest] +#[case(Node::Link(Link{url: Url::new("test".to_string()), values: Vec::new(), title: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_link(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_link(), expected); +} + +#[rstest] +#[case(Node::LinkRef(LinkRef{ident: "test".to_string(), values: Vec::new(), label: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_link_ref(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_link_ref(), expected); +} + +#[rstest] +#[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "test".to_string(), title: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_image(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_image(), expected); +} + +#[rstest] +#[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test".to_string(), label: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_image_ref(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_image_ref(), expected); +} + +#[rstest] +#[case(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), true, Some("rust".into()))] +#[case(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), false, Some("python".into()))] +#[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), true, None)] +#[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), true, None)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false, None)] +fn test_is_code(#[case] node: Node, #[case] expected: bool, #[case] lang: Option) { + assert_eq!(node.is_code(lang), expected); +} + +#[rstest] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None}), true, Some(1))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H2, values: vec!["test".to_string().into()], position: None}), false, Some(1))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None}), true, None)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false, None)] +fn test_is_heading(#[case] node: Node, #[case] expected: bool, #[case] depth: Option) { + assert_eq!(node.is_heading(depth), expected); +} + +#[rstest] +#[case(Node::HorizontalRule(HorizontalRule{ marker: None,position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_horizontal_rule(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_horizontal_rule(), expected); +} + +#[rstest] +#[case(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_blockquote(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_blockquote(), expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), true)] +#[case(Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: Some("Newton".to_string()), position: None}), true)] +#[case(Node::Link(Link{url: Url::new("https://example.com".to_string()), values: Vec::new(), title: None, position: None}), false)] +#[case(Node::Link(Link{url: Url::new("relative.md".to_string()), values: Vec::new(), title: None, position: None}), false)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +#[case(Node::Text(Text{value: "[[target]]".to_string(), position: None}), false)] +fn test_is_wikilink(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_wikilink(), expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), true)] +#[case(Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: Some("Newton".to_string()), position: None}), true)] +#[case(Node::Link(Link{url: Url::new("https://example.com".to_string()), values: Vec::new(), title: None, position: None}), true)] +#[case(Node::Link(Link{url: Url::new("relative.md".to_string()), values: Vec::new(), title: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_link_includes_wikilink(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_link(), expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +// no wikilinks: returns original text node unchanged +#[case("plain text", vec![Node::Text(Text{value: "plain text".to_string(), position: None})])] +// only a wikilink +#[case("[[target]]", vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None})])] +// wikilink with display text +#[case("[[target|display]]", vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None})])] +// wikilink with spaces in target +#[case("[[Three laws of motion]]", vec![Node::WikiLink(WikiLink{target: "Three laws of motion".to_string(), text: None, position: None})])] +// wikilink with .md extension +#[case("[[target.md]]", vec![Node::WikiLink(WikiLink{target: "target.md".to_string(), text: None, position: None})])] +// wikilink at start +#[case("[[target]] after", vec![ + Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), + Node::Text(Text{value: " after".to_string(), position: None}), +])] +// wikilink at end +#[case("before [[target]]", vec![ + Node::Text(Text{value: "before ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), +])] +// wikilink in middle +#[case("before [[target]] after", vec![ + Node::Text(Text{value: "before ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), + Node::Text(Text{value: " after".to_string(), position: None}), +])] +// multiple wikilinks +#[case("[[a]] and [[b]]", vec![ + Node::WikiLink(WikiLink{target: "a".to_string(), text: None, position: None}), + Node::Text(Text{value: " and ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "b".to_string(), text: None, position: None}), +])] +// unclosed [[ treated as plain text +#[case("[[unclosed", vec![Node::Text(Text{value: "[[unclosed".to_string(), position: None})])] +// nested brackets invalid: treated as plain text +#[case("[[in[ner]]]", vec![Node::Text(Text{value: "[[in[ner]]]".to_string(), position: None})])] +// multibyte characters in surrounding text +#[case("日本語 [[ターゲット]] テキスト", vec![ + Node::Text(Text{value: "日本語 ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "ターゲット".to_string(), text: None, position: None}), + Node::Text(Text{value: " テキスト".to_string(), position: None}), +])] +// multibyte characters inside wikilink target and display text +#[case("[[ページ|表示名]]", vec![ + Node::WikiLink(WikiLink{target: "ページ".to_string(), text: Some("表示名".to_string()), position: None}), +])] +fn test_parse_wikilinks_in_text(#[case] input: &str, #[case] expected: Vec) { + let result = Node::parse_wikilinks_in_text(input, None); + assert_eq!(result, expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "url", Some(AttrValue::String("target".to_string())))] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "value", Some(AttrValue::String("target".to_string())))] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None}), "url", Some(AttrValue::String("target".to_string())))] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None}), "value", Some(AttrValue::String("display".to_string())))] +#[case(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), "title", None)] +fn test_wikilink_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { + assert_eq!(node.attr(attr), expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +// no display text: with_value sets target +#[case( + Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), + "new-target", + Node::WikiLink(WikiLink{target: "new-target".to_string(), text: None, position: None}) +)] +// with display text: with_value sets text, not target +#[case( + Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("Display Text".to_string()), position: None}), + "DISPLAY TEXT", + Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("DISPLAY TEXT".to_string()), position: None}) +)] +fn test_wikilink_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { + assert_eq!(node.clone().with_value(value), expected); + assert_eq!(node.into_with_value(value), expected); +} + +#[cfg(feature = "wikilink")] +#[rstest] +// footnote with no wikilinks: values unchanged +#[case( + Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "plain text".to_string(), position: None})], position: None}), + vec![Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "plain text".to_string(), position: None})], position: None})] +)] +// footnote with a wikilink in text: expanded to WikiLink node +#[case( + Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "[[target]]".to_string(), position: None})], position: None}), + vec![Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None})], position: None})] +)] +// footnote with wikilink and display text +#[case( + Node::Footnote(Footnote{ident: "2".to_string(), values: vec![Node::Text(Text{value: "[[target|display]]".to_string(), position: None})], position: None}), + vec![Node::Footnote(Footnote{ident: "2".to_string(), values: vec![Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display".to_string()), position: None})], position: None})] +)] +// footnote with mixed text and wikilink +#[case( + Node::Footnote(Footnote{ident: "3".to_string(), values: vec![Node::Text(Text{value: "see [[note]]".to_string(), position: None})], position: None}), + vec![Node::Footnote(Footnote{ident: "3".to_string(), values: vec![ + Node::Text(Text{value: "see ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "note".to_string(), text: None, position: None}), + ], position: None})] +)] +fn test_expand_wikilinks_footnote(#[case] input: Node, #[case] expected: Vec) { + let result = Node::expand_wikilinks(vec![input]); + assert_eq!(result, expected); +} + +#[cfg(all(feature = "embed", feature = "wikilink"))] +#[rstest] +// embed only +#[case("![[note.md]]", vec![ + Node::Embed(Embed{target: "note.md".to_string(), display: None, position: None}), +])] +// wikilink only +#[case("[[target]]", vec![ + Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), +])] +// embed and wikilink in same text +#[case("![[embed]] and [[link]]", vec![ + Node::Embed(Embed{target: "embed".to_string(), display: None, position: None}), + Node::Text(Text{value: " and ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), +])] +// wikilink before embed +#[case("[[link]] and ![[embed]]", vec![ + Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), + Node::Text(Text{value: " and ".to_string(), position: None}), + Node::Embed(Embed{target: "embed".to_string(), display: None, position: None}), +])] +// embed with display hint +#[case("![[image.png|400]]", vec![ + Node::Embed(Embed{target: "image.png".to_string(), display: Some("400".to_string()), position: None}), +])] +// wikilink with display text +#[case("[[page|display]]", vec![ + Node::WikiLink(WikiLink{target: "page".to_string(), text: Some("display".to_string()), position: None}), +])] +// surrounding text +#[case("before ![[note]] after [[link]] end", vec![ + Node::Text(Text{value: "before ".to_string(), position: None}), + Node::Embed(Embed{target: "note".to_string(), display: None, position: None}), + Node::Text(Text{value: " after ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "link".to_string(), text: None, position: None}), + Node::Text(Text{value: " end".to_string(), position: None}), +])] +// plain text with no links +#[case("just plain text", vec![ + Node::Text(Text{value: "just plain text".to_string(), position: None}), +])] +// multibyte characters +#[case("日本語 ![[ファイル]] と [[ページ]]", vec![ + Node::Text(Text{value: "日本語 ".to_string(), position: None}), + Node::Embed(Embed{target: "ファイル".to_string(), display: None, position: None}), + Node::Text(Text{value: " と ".to_string(), position: None}), + Node::WikiLink(WikiLink{target: "ページ".to_string(), text: None, position: None}), +])] +fn test_parse_inline_links_into(#[case] input: &str, #[case] expected: Vec) { + let mut result = Vec::new(); + Node::parse_inline_links_into(input, None, &mut result); + assert_eq!(result, expected); +} + +#[rstest] +#[case(Node::Html(Html{value: "
test
".to_string(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_html(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_html(), expected); +} + +#[rstest] +#[case(Node::node_values( + &Node::Strong(Strong{values: vec!["test".to_string().into()], position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::Text(Text{value: "test".to_string(), position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::Delete(Delete{values: vec!["test".to_string().into()], position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None})), + vec!["test".to_string().into()])] +#[case(Node::node_values( + &Node::List(List{ marker: None,values: vec!["test".to_string().into()], ordered: false, level: 1, checked: Some(false), index: 0, start: None, spread: false, position: None})), + vec!["test".to_string().into()])] +fn test_node_value(#[case] actual: Vec, #[case] expected: Vec) { + assert_eq!(actual, expected); +} + +#[rstest] +#[case(Node::Footnote(Footnote{ident: "test".to_string(), values: Vec::new(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_footnote(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_footnote(), expected); +} + +#[rstest] +#[case(Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_footnote_ref(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_footnote_ref(), expected); +} + +#[rstest] +#[case(Node::Math(Math{value: "x^2".to_string(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_math(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_math(), expected); +} + +#[rstest] +#[case(Node::Break(Break{position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_break(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_break(), expected); +} + +#[rstest] +#[case(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_yaml(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_yaml(), expected); +} + +#[rstest] +#[case(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_toml(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_toml(), expected); +} + +#[rstest] +#[case(Node::Definition(Definition{ident: attr_keys::IDENT.to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_definition(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_definition(), expected); +} + +#[rstest] +#[case(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_emphasis(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_emphasis(), expected); +} + +#[rstest] +#[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_mdx_flow_expression(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_mdx_flow_expression(), expected); +} + +#[rstest] +#[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_mdx_text_expression(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_mdx_text_expression(), expected); +} + +#[rstest] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_mdx_jsx_flow_element(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_mdx_jsx_flow_element(), expected); +} + +#[rstest] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_mdx_jsx_text_element(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_mdx_jsx_text_element(), expected); +} + +#[rstest] +#[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_msx_js_esm(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_mdx_js_esm(), expected); +} + +#[rstest] +#[case::text(Node::Text(Text{value: "test".to_string(), position: None }), RenderOptions::default(), "test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 2, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " - test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions { list_style: Some(ListStyle::Plus), ..Default::default() }, " + test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions { list_style: Some(ListStyle::Star), ..Default::default() }, " * [x] test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: Some(false), ordered: false, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " - [ ] test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: true, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " 1. test")] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: Some(false), ordered: true, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), " 1. [ ] test")] +#[case::table_row(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None})], position: None}), RenderOptions::default(), "|test|")] +#[case::table_row(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None})], position: None}), RenderOptions::default(), "|test|")] +#[case::table_cell(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "test")] +#[case::table_cell(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "test")] +#[case::table_align(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right, TableAlignKind::Center, TableAlignKind::None], position: None}), RenderOptions::default(), "|:---|---:|:---:|---|")] +#[case::block_quote(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "> test")] +#[case::block_quote(Node::Blockquote(Blockquote{values: vec!["test\ntest2".to_string().into()], position: None}), RenderOptions::default(), "> test\n> test2")] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), RenderOptions::default(), "```rust\ncode\n```")] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "```\ncode\n```")] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), RenderOptions::default(), " code")] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: Some("meta".to_string()), position: None}), RenderOptions::default(), "```rust meta\ncode\n```")] +#[case::code_empty_body_no_blank_line(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "```\n```")] +#[case::code_fence_escalates_past_backtick_run_in_body(Node::Code(Code{value: "aaa\n```".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "````\naaa\n```\n````")] +#[case::code_fence_escalates_past_longer_run(Node::Code(Code{value: "`````".to_string(), lang: None, fence: true, meta: None, position: None}), RenderOptions::default(), "``````\n`````\n``````")] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: url")] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: url \"title\"")] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new("".to_string()), title: None, label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[label]: <>")] +#[case::delete(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "~~test~~")] +#[case::emphasis(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "*test*")] +#[case::emphasis_nested_in_emphasis_alternates_delimiter(Node::Emphasis(Emphasis{values: vec![Node::Emphasis(Emphasis{values: vec!["foo".to_string().into()], position: None})], position: None}), RenderOptions::default(), "_*foo*_")] +#[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![attr_keys::LABEL.to_string().into()], position: None}), RenderOptions::default(), "[^id]: label")] +#[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: attr_keys::LABEL.to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), RenderOptions::default(), "[^label]")] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "# test")] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H3, values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "### test")] +#[case::heading_multiline_h1_stays_setext(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "Foo\nBar\n===")] +#[case::heading_multiline_h2_stays_setext(Node::Heading(Heading{depth: HeadingDepth::H2, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "Foo\nBar\n---")] +#[case::heading_multiline_h3_joins_with_space(Node::Heading(Heading{depth: HeadingDepth::H3, values: vec!["Foo\nBar".to_string().into()], position: None}), RenderOptions::default(), "### Foo Bar")] +#[case::heading_trailing_hash_escaped(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["foo #".to_string().into()], position: None}), RenderOptions::default(), "# foo \\#")] +#[case::heading_trailing_hash_run_escaped(Node::Heading(Heading{depth: HeadingDepth::H3, values: vec!["foo ###".to_string().into()], position: None}), RenderOptions::default(), "### foo \\###")] +#[case::heading_trailing_hash_after_multibyte_char(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["foo あ#".to_string().into()], position: None}), RenderOptions::default(), "# foo あ\\#")] +#[case::heading_no_trailing_hash_unaffected(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["foo bar".to_string().into()], position: None}), RenderOptions::default(), "# foo bar")] +#[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), RenderOptions::default(), "
test
")] +#[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), RenderOptions::default(), "![alt](url)")] +#[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "url with space".to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), RenderOptions::default(), "![alt]( \"title\")")] +#[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("id".to_string()), position: None}), RenderOptions::default(), "![alt][id]")] +#[case::image_ref(Node::ImageRef(ImageRef{alt: "id".to_string(), ident: "id".to_string(), label: Some("id".to_string()), position: None}), RenderOptions::default(), "![id]")] +#[case::code_inline(Node::CodeInline(CodeInline{value: "code".into(), position: None}), RenderOptions::default(), "`code`")] +#[case::code_inline_trailing_backtick_needs_padding(Node::CodeInline(CodeInline{value: "\\[\\`".into(), position: None}), RenderOptions::default(), "`` \\[\\` ``")] +#[case::code_inline_internal_backtick_escalates_fence(Node::CodeInline(CodeInline{value: "foo ` bar".into(), position: None}), RenderOptions::default(), "``foo ` bar``")] +#[case::code_inline_both_ends_backtick_needs_longer_padded_fence(Node::CodeInline(CodeInline{value: "``".into(), position: None}), RenderOptions::default(), "``` `` ```")] +#[case::code_inline_leading_trailing_space_needs_extra_padding(Node::CodeInline(CodeInline{value: " `` ".into(), position: None}), RenderOptions::default(), "``` `` ```")] +#[case::code_inline_all_spaces_untouched(Node::CodeInline(CodeInline{value: " ".into(), position: None}), RenderOptions::default(), "` `")] +#[case::math_inline(Node::MathInline(MathInline{value: "x^2".into(), position: None}), RenderOptions::default(), "$x^2$")] +#[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](url \"title\")")] +#[case::link(Node::Link(Link{url: Url::new("".to_string()), title: None, values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](<>)")] +#[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![attr_keys::VALUE.to_string().into()], position: None}), RenderOptions::default(), "[value](url)")] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["id".to_string().into()], label: Some("id".to_string()), position: None}), RenderOptions::default(), "[id]")] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["open".to_string().into()], label: Some("id".to_string()), position: None}), RenderOptions::default(), "[open][id]")] +#[case::math(Node::Math(Math{value: "x^2".to_string(), position: None}), RenderOptions::default(), "$$\nx^2\n$$")] +#[case::strong(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), RenderOptions::default(), "**test**")] +#[case::yaml(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), RenderOptions::default(), "---\nkey: value\n---")] +#[case::toml(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), RenderOptions::default(), "+++\nkey = \"value\"\n+++")] +#[case::break_(Node::Break(Break{position: None}), RenderOptions::default(), "\\\n")] +#[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{ marker: None,position: None}), RenderOptions::default(), "***")] +#[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: vec![ + MdxAttributeContent::Property(MdxJsxAttribute { + name: "className".into(), + value: Some(MdxAttributeValue::Literal("container".into())) + }) + ], + children: vec![ + "content".to_string().into() + ], + position: None +}), RenderOptions::default(), "
\n content\n
")] +#[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: vec![ + MdxAttributeContent::Property(MdxJsxAttribute { + name: "className".into(), + value: Some(MdxAttributeValue::Literal("container".into())) + }) + ], + children: Vec::new(), + position: None +}), RenderOptions::default(), "
")] +#[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: Vec::new(), + position: None +}), RenderOptions::default(), "
")] +#[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: vec![ + MdxAttributeContent::Expression("...props".into()) + ], + children: vec![ + "inline".to_string().into() + ], + position: None +}), RenderOptions::default(), "inline")] +#[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: vec![ + MdxAttributeContent::Expression("...props".into()) + ], + children: vec![ + ], + position: None +}), RenderOptions::default(), "")] +#[case::mdx_jsx_text_element(Node::MdxJsxTextElement(MdxJsxTextElement{ + name: Some("span".into()), + attributes: vec![ + ], + children: vec![ + ], + position: None +}), RenderOptions::default(), "")] +#[case(Node::MdxTextExpression(MdxTextExpression{ + value: "count + 1".into(), + position: None, +}), RenderOptions::default(), "{count + 1}")] +#[case(Node::MdxJsEsm(MdxJsEsm{ + value: "import React from 'react'".into(), + position: None, +}), RenderOptions::default(), "import React from 'react'")] +#[case::fragment_empty(Node::Fragment(Fragment{values: vec![]}), RenderOptions::default(), "")] +#[case::fragment_single(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "hello".to_string(), position: None}) +]}), RenderOptions::default(), "hello")] +#[case::fragment_multiple(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "hello".to_string(), position: None}), + Node::Text(Text{value: "world".to_string(), position: None}) +]}), RenderOptions::default(), "hello\nworld")] +#[case::fragment_filters_empty(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "hello".to_string(), position: None}), + Node::Empty, + Node::Text(Text{value: "world".to_string(), position: None}) +]}), RenderOptions::default(), "hello\nworld")] +#[case::fragment_all_empty(Node::Fragment(Fragment{values: vec![ + Node::Empty, + Node::Empty, +]}), RenderOptions::default(), "")] +#[case::fragment_empty_text_as_blank_line(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "hello".to_string(), position: None}), + Node::Text(Text{value: "".to_string(), position: None}), + Node::Text(Text{value: "world".to_string(), position: None}) +]}), RenderOptions::default(), "hello\n\nworld")] +#[case::fragment_filters_nested_empty_fragment(Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![Node::Empty, Node::Empty]}), + Node::Text(Text{value: "hello".to_string(), position: None}), + Node::Fragment(Fragment{values: vec![Node::Empty]}), +]}), RenderOptions::default(), "hello")] +#[case::fragment_filters_deeply_nested_empty_fragment(Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![Node::Empty]}), + Node::Empty, + ]}), + Node::Text(Text{value: "hello".to_string(), position: None}), +]}), RenderOptions::default(), "hello")] +#[case::fragment_keeps_nested_fragment_with_blank_line_text(Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![Node::Empty]}), + Node::Text(Text{value: "hello".to_string(), position: None}), + Node::Fragment(Fragment{values: vec![Node::Text(Text{value: "".to_string(), position: None})]}), + Node::Text(Text{value: "world".to_string(), position: None}), +]}), RenderOptions::default(), "hello\n\nworld")] +#[cfg_attr(feature = "wikilink", case::wikilink(Node::WikiLink(WikiLink{target: "target".to_string(), text: None, position: None}), RenderOptions::default(), "[[target]]"))] +#[cfg_attr(feature = "wikilink", case::wikilink_with_text(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("display text".to_string()), position: None}), RenderOptions::default(), "[[target|display text]]"))] +#[cfg_attr(feature = "wikilink", case::wikilink_same_text(Node::WikiLink(WikiLink{target: "target".to_string(), text: Some("target".to_string()), position: None}), RenderOptions::default(), "[[target]]"))] +fn test_to_string_with(#[case] node: Node, #[case] options: RenderOptions, #[case] expected: &str) { + assert_eq!(node.to_string_with(&options), expected); +} + +#[test] +fn test_node_partial_ord() { + let node1 = Node::Text(Text { + value: "test1".to_string(), + position: Some(Position { + start: Point { line: 1, column: 1 }, + end: Point { line: 1, column: 5 }, + }), + }); + + let node2 = Node::Text(Text { + value: "test2".to_string(), + position: Some(Position { + start: Point { line: 1, column: 6 }, + end: Point { line: 1, column: 10 }, + }), + }); + + let node3 = Node::Text(Text { + value: "test3".to_string(), + position: Some(Position { + start: Point { line: 2, column: 1 }, + end: Point { line: 2, column: 5 }, + }), + }); + + assert_eq!(node1.partial_cmp(&node2), Some(std::cmp::Ordering::Less)); + assert_eq!(node2.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); + + assert_eq!(node1.partial_cmp(&node3), Some(std::cmp::Ordering::Less)); + assert_eq!(node3.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); + + let node4 = Node::Text(Text { + value: "test4".to_string(), + position: None, + }); + + assert_eq!(node1.partial_cmp(&node4), Some(std::cmp::Ordering::Less)); + assert_eq!(node4.partial_cmp(&node1), Some(std::cmp::Ordering::Greater)); + + let node5 = Node::Text(Text { + value: "test5".to_string(), + position: None, + }); + + assert_eq!(node4.partial_cmp(&node5), Some(std::cmp::Ordering::Equal)); + + let node6 = Node::Code(Code { + value: "code".to_string(), + lang: None, + fence: true, + meta: None, + position: None, + }); + + assert_eq!(node6.partial_cmp(&node4), Some(std::cmp::Ordering::Less)); + assert_eq!(node4.partial_cmp(&node6), Some(std::cmp::Ordering::Greater)); +} + +#[rstest] +#[case(Node::Blockquote(Blockquote{values: Vec::new(), position: None}), "blockquote")] +#[case(Node::Break(Break{position: None}), "break")] +#[case(Node::Definition(Definition{ident: "".to_string(), url: Url::new("".to_string()), title: None, label: None, position: None}), "definition")] +#[case(Node::Delete(Delete{values: Vec::new(), position: None}), "delete")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: Vec::new(), position: None}), "h1")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H2, values: Vec::new(), position: None}), "h2")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H3, values: Vec::new(), position: None}), "h3")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H4, values: Vec::new(), position: None}), "h4")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H5, values: Vec::new(), position: None}), "h5")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H6, values: Vec::new(), position: None}), "h6")] +#[case(Node::Emphasis(Emphasis{values: Vec::new(), position: None}), "emphasis")] +#[case(Node::Footnote(Footnote{ident: "".to_string(), values: Vec::new(), position: None}), "footnote")] +#[case(Node::FootnoteRef(FootnoteRef{ident: "".to_string(), label: None, position: None}), "footnoteref")] +#[case(Node::Html(Html{value: "".to_string(), position: None}), "html")] +#[case(Node::Yaml(Yaml{value: "".to_string(), position: None}), "yaml")] +#[case(Node::Toml(Toml{value: "".to_string(), position: None}), "toml")] +#[case(Node::Image(Image{alt: "".to_string(), url: "".to_string(), title: None, position: None}), "image")] +#[case(Node::ImageRef(ImageRef{alt: "".to_string(), ident: "".to_string(), label: None, position: None}), "image_ref")] +#[case(Node::CodeInline(CodeInline{value: "".into(), position: None}), "code_inline")] +#[case(Node::MathInline(MathInline{value: "".into(), position: None}), "math_inline")] +#[case(Node::Link(Link{url: Url::new("".to_string()), title: None, values: Vec::new(), position: None}), "link")] +#[case(Node::LinkRef(LinkRef{ident: "".to_string(), values: Vec::new(), label: None, position: None}), "link_ref")] +#[case(Node::Math(Math{value: "".to_string(), position: None}), "math")] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: Vec::new(), position: None}), "list")] +#[case(Node::TableAlign(TableAlign{align: Vec::new(), position: None}), "table_align")] +#[case(Node::TableRow(TableRow{values: Vec::new(), position: None}), "table_row")] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: None}), "table_cell")] +#[case(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: None}), "code")] +#[case(Node::Strong(Strong{values: Vec::new(), position: None}), "strong")] +#[case(Node::HorizontalRule(HorizontalRule{ marker: None,position: None}), "Horizontal_rule")] +#[case(Node::MdxFlowExpression(MdxFlowExpression{value: "".into(), position: None}), "mdx_flow_expression")] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), "mdx_jsx_flow_element")] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), "mdx_jsx_text_element")] +#[case(Node::MdxTextExpression(MdxTextExpression{value: "".into(), position: None}), "mdx_text_expression")] +#[case(Node::MdxJsEsm(MdxJsEsm{value: "".into(), position: None}), "mdx_js_esm")] +#[case(Node::Text(Text{value: "".to_string(), position: None}), "text")] +fn test_name(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.name(), expected); +} + +#[rstest] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), "test")] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Blockquote(Blockquote{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Footnote(Footnote{ident: "test".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::FootnoteRef(FootnoteRef{ident: "test".to_string(), label: None, position: None}), "test")] +#[case(Node::Html(Html{value: "test".to_string(), position: None}), "test")] +#[case(Node::Yaml(Yaml{value: "test".to_string(), position: None}), "test")] +#[case(Node::Toml(Toml{value: "test".to_string(), position: None}), "test")] +#[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "test".to_string(), title: None, position: None}), "test")] +#[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "test".to_string(), label: None, position: None}), "test")] +#[case(Node::CodeInline(CodeInline{value: "test".into(), position: None}), "test")] +#[case(Node::MathInline(MathInline{value: "test".into(), position: None}), "test")] +#[case(Node::Link(Link{url: Url::new("test".to_string()), title: None, values: Vec::new(), position: None}), "test")] +#[case(Node::LinkRef(LinkRef{ident: "test".to_string(), values: Vec::new(), label: None, position: None}), "test")] +#[case(Node::Math(Math{value: "test".to_string(), position: None}), "test")] +#[case(Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None}), "test")] +#[case(Node::Strong(Strong{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::TableRow(TableRow{values: vec![Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None})], position: None}), "test")] +#[case(Node::Break(Break{position: None}), "")] +#[case(Node::HorizontalRule(HorizontalRule{ marker: None,position: None}), "")] +#[case(Node::TableAlign(TableAlign{align: Vec::new(), position: None}), "")] +#[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), "test")] +#[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), "test")] +#[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: None}), "test")] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some(attr_keys::NAME.to_string()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), "test")] +#[case(Node::Definition(Definition{ident: "test".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), attr_keys::URL)] +#[case(Node::Fragment(Fragment {values: vec![Node::Text(Text{value: "test".to_string(), position: None})]}), "test")] +fn test_value(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.value(), expected); +} + +#[rstest] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), None)] +#[case(Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Blockquote(Blockquote{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Delete(Delete{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Emphasis(Emphasis{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Footnote(Footnote{ident: "".to_string(), values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::FootnoteRef(FootnoteRef{ident: "".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Html(Html{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Yaml(Yaml{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Toml(Toml{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Image(Image{alt: "".to_string(), url: "".to_string(), title: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::ImageRef(ImageRef{alt: "".to_string(), ident: "".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::CodeInline(CodeInline{value: "".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MathInline(MathInline{value: "".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Link(Link{url: Url("".to_string()), title: None, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::LinkRef(LinkRef{ident: "".to_string(), values: Vec::new(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Math(Math{value: "".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Code(Code{value: "".to_string(), lang: None, fence: true, meta: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Strong(Strong{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::TableRow(TableRow{values: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::TableAlign(TableAlign{align: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Break(Break{position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::HorizontalRule(HorizontalRule{ marker: None,position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MdxJsEsm(MdxJsEsm{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Definition(Definition{ident: "".to_string(), url: Url("".to_string()), title: None, label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Fragment(Fragment{values: Vec::new()}), None)] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test1".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), + Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}) +]}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}))] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}), + Node::Text(Text{value: "test2".to_string(), position: None}) +]}), Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}))] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test".to_string(), position: None}), + Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}) +]}), Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}}))] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}})}), + Node::Text(Text{value: "test".to_string(), position: None}) +]}), Some(Position{start: Point{line: 1, column: 6}, end: Point{line: 1, column: 10}}))] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test".to_string(), position: None}), + Node::Text(Text{value: "test2".to_string(), position: None}) +]}), None)] +#[case(Node::Empty, None)] +fn test_position(#[case] node: Node, #[case] expected: Option) { + assert_eq!(node.position(), expected); +} + +#[rstest] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}) +], position: None}), 1, None)] +#[case(Node::Delete(Delete{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] +#[case(Node::Emphasis(Emphasis{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] +#[case(Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}) +], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 0, Some(Node::Text(Text{value: "first".to_string(), position: None})))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}) +], position: None}), 1, Some(Node::Text(Text{value: "second".to_string(), position: None})))] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "cell content".to_string(), position: None}) +], position: None}), 0, Some(Node::Text(Text{value: "cell content".to_string(), position: None})))] +#[case(Node::TableRow(TableRow{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: Vec::new(), position: None}), + Node::TableCell(TableCell{column: 1, row: 0, values: Vec::new(), position: None}) +], position: None}), 1, Some(Node::TableCell(TableCell{column: 1, row: 0, values: Vec::new(), position: None})))] +#[case(Node::Text(Text{value: "plain text".to_string(), position: None}), 0, None)] +#[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), 0, None)] +#[case(Node::Html(Html{value: "
".to_string(), position: None}), 0, None)] +fn test_find_at_index(#[case] node: Node, #[case] index: usize, #[case] expected: Option) { + assert_eq!(node.find_at_index(index), expected); +} + +#[rstest] +#[case(Node::Blockquote(Blockquote{values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Delete(Delete{values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Emphasis(Emphasis{values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Strong(Strong{values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec!["test".to_string().into()], label: None, position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Footnote(Footnote{ident: "id".to_string(), values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::TableRow(TableRow{values: vec!["test".to_string().into()], position: None}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Fragment(Fragment{values: vec!["test".to_string().into()]}), + Node::Fragment(Fragment{values: vec!["test".to_string().into()]}))] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), + Node::Empty)] +#[case(Node::Code(Code{value: "test".to_string(), lang: None, fence: true, meta: None, position: None}), + Node::Empty)] +#[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), + Node::Empty)] +#[case(Node::Empty, Node::Empty)] +fn test_to_fragment(#[case] node: Node, #[case] expected: Node) { + assert_eq!(node.clone().to_fragment(), expected); + assert_eq!(node.into_fragment(), expected); +} + +// Regression coverage for the 0.6.2 blank-line bug: `eval_markdown_node` +// replaces a non-matching `select()` result with `child_node.to_fragment()`, +// not `Node::Empty` directly. For container nodes this yields a `Fragment` +// wrapping the (now `Empty`) children, which must still render to "" with +// no stray blank lines, while a partially-matching container must render +// only the surviving content. +#[rstest] +#[case::blockquote_all_empty(Node::Blockquote(Blockquote{values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::blockquote_mixed(Node::Blockquote(Blockquote{values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::delete_all_empty(Node::Delete(Delete{values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::delete_mixed(Node::Delete(Delete{values: vec![Node::Text(Text{value: "kept".to_string(), position: None}), Node::Empty], position: None}), "kept")] +#[case::heading_all_empty(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::heading_mixed(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::emphasis_all_empty(Node::Emphasis(Emphasis{values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::emphasis_mixed(Node::Emphasis(Emphasis{values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::list_all_empty(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::list_mixed(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::strong_all_empty(Node::Strong(Strong{values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::strong_mixed(Node::Strong(Strong{values: vec![Node::Text(Text{value: "kept".to_string(), position: None}), Node::Empty], position: None}), "kept")] +#[case::link_all_empty(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::link_mixed(Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::link_ref_all_empty(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Empty, Node::Empty], label: None, position: None}), "")] +#[case::link_ref_mixed(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], label: None, position: None}), "kept")] +#[case::footnote_all_empty(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::footnote_mixed(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +#[case::table_cell_all_empty(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Empty, Node::Empty], position: None}), "")] +#[case::table_cell_mixed(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Empty, Node::Text(Text{value: "kept".to_string(), position: None})], position: None}), "kept")] +fn test_to_fragment_then_render_skips_non_matching(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.to_fragment().to_string_with(&RenderOptions::default()), expected); +} + +#[rstest] +#[case( + &mut Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Delete(Delete{values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Delete(Delete{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Emphasis(Emphasis{values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Emphasis(Emphasis{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Link(Link{url: Url(attr_keys::URL.to_string()), title: None, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], label: None, position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], label: None, position: None}) +)] +#[case( + &mut Node::Footnote(Footnote{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Footnote(Footnote{ident: "id".to_string(), values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::TableRow(TableRow{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "old".to_string(), position: None}) + ], position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) + ]}), + Node::TableRow(TableRow{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ], position: None}) + ], position: None}) +)] +#[case( + &mut Node::Text(Text{value: "old".to_string(), position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new".to_string(), position: None}) + ]}), + Node::Text(Text{value: "old".to_string(), position: None}) +)] +#[case( + &mut Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "text1".to_string(), position: None}), + Node::Text(Text{value: "text2".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new1".to_string(), position: None}), + Node::Text(Text{value: "new2".to_string(), position: None}) + ]}), + Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "new1".to_string(), position: None}), + Node::Text(Text{value: "new2".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "text1".to_string(), position: None}), + Node::Text(Text{value: "text2".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Empty, + Node::Text(Text{value: "new2".to_string(), position: None}) + ]}), + Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "text1".to_string(), position: None}), + Node::Text(Text{value: "new2".to_string(), position: None}) + ], position: None}) +)] +#[case( + &mut Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "text1".to_string(), position: None}), + Node::Text(Text{value: "text2".to_string(), position: None}) + ], position: None}), + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "new1".to_string(), position: None}), + Node::Fragment(Fragment{values: Vec::new()}) + ]}), + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![ + Node::Text(Text{value: "new1".to_string(), position: None}), + Node::Text(Text{value: "text2".to_string(), position: None}) + ], position: None}) +)] +fn test_apply_fragment(#[case] node: &mut Node, #[case] fragment: Node, #[case] expected: Node) { + node.apply_fragment(fragment); + assert_eq!(*node, expected); +} + +#[rstest] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}}, + Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}})}))] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 1, checked: None, ordered: false, values: vec![], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] +#[case(Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Delete(Delete{values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Footnote(Footnote{ident: "id".to_string(), values: vec![Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Html(Html{value: "
test
".to_string(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}}, + Node::Html(Html{value: "
test
".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}})}))] +#[case(Node::Yaml(Yaml{value: "key: value".to_string(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}}, + Node::Yaml(Yaml{value: "key: value".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}})}))] +#[case(Node::Toml(Toml{value: "key = \"value\"".to_string(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}}, + Node::Toml(Toml{value: "key = \"value\"".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 4}})}))] +#[case(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 12}}, + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 12}})}))] +#[case(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] +#[case(Node::CodeInline(CodeInline{value: "code".into(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, + Node::CodeInline(CodeInline{value: "code".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] +#[case(Node::MathInline(MathInline{value: "x^2".into(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::MathInline(MathInline{value: "x^2".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "text".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] +#[case(Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Text(Text{value: "text".to_string(), position: None})], label: None, position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})})], label: None, position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] +#[case(Node::Math(Math{value: "x^2".to_string(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}}, + Node::Math(Math{value: "x^2".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 3, column: 3}})}))] +#[case(Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}}, + Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 6}})}))] +#[case(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}}, + Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 15}})}))] +#[case(Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, + Node::MdxFlowExpression(MdxFlowExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] +#[case(Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}}, + Node::MdxTextExpression(MdxTextExpression{value: "test".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 7}})}))] +#[case(Node::MdxJsEsm(MdxJsEsm{value: "import React from 'react'".into(), position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 25}}, + Node::MdxJsEsm(MdxJsEsm{value: "import React from 'react'".into(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 25}})}))] +#[case(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "text".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}, + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "text".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})})], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})}))] +#[case(Node::Break(Break{position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 2}}, + Node::Break(Break{position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 2}})}))] +#[case(Node::Empty, + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Empty)] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test1".to_string(), position: None}), + Node::Text(Text{value: "test2".to_string(), position: None}) + ]}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "test1".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}), + Node::Text(Text{value: "test2".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) + ]}))] +#[case(Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Blockquote(Blockquote{values: vec![ + Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) + ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![ + Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) + ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "test".to_string(), position: None})], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}}, + Node::Strong(Strong{values: vec![ + Node::Text(Text{value: "test".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}) + ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 5}})}))] +#[case(Node::TableRow(TableRow{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "cell".to_string(), position: None}) + ], position: None}) + ], position: None}), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}}, + Node::TableRow(TableRow{values: vec![ + Node::TableCell(TableCell{column: 0, row: 0, values: vec![ + Node::Text(Text{value: "cell".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) + ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}) + ], position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 10}})}))] +#[case(Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![Node::Text(Text{value: "content".to_string(), position: None})], + position: None + }), + Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}, + Node::MdxJsxFlowElement(MdxJsxFlowElement{ + name: Some("div".to_string()), + attributes: Vec::new(), + children: vec![Node::Text(Text{value: "content".to_string(), position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}})})], + position: Some(Position{start: Point{line: 1, column: 1}, end: Point{line: 1, column: 20}}) + }))] +fn test_set_position(#[case] mut node: Node, #[case] position: Position, #[case] expected: Node) { + node.set_position(Some(position)); + assert_eq!(node, expected); +} + +fn some_position() -> Option { + Some(Position { + start: Point { line: 1, column: 1 }, + end: Point { line: 1, column: 5 }, + }) +} + +#[test] +fn test_strip_positions_top_level() { + let mut node = Node::Text(Text { + value: "test".to_string(), + position: some_position(), + }); + node.strip_positions(); + assert_eq!(node.position(), None); +} + +#[test] +fn test_strip_positions_recurses_into_children() { + let mut node = Node::Blockquote(Blockquote { + values: vec![Node::Strong(Strong { + values: vec![Node::Text(Text { + value: "test".to_string(), + position: some_position(), + })], + position: some_position(), + })], + position: some_position(), + }); + + node.strip_positions(); + + assert_eq!(node.position(), None); + let strong = &node.children()[0]; + assert_eq!(strong.position(), None); + assert_eq!(strong.children()[0].position(), None); +} + +#[test] +fn test_clear_text_position_at_leaf_only() { + let mut node = Node::Heading(Heading { + depth: HeadingDepth::H1, + values: vec![Node::Text(Text { + value: "title".to_string(), + position: some_position(), + })], + position: some_position(), + }); + + node.clear_text_position_at(0); + + assert_eq!(node.position(), some_position(), "outer node keeps its position"); + assert_eq!(node.children()[0].position(), None, "escaped leaf loses its position"); +} + +#[test] +fn test_clear_text_position_at_selected_index() { + let mut node = Node::List(List { + marker: None, + start: None, + spread: false, + index: 0, + level: 0, + checked: None, + ordered: false, + values: vec![ + Node::Text(Text { + value: "a".to_string(), + position: some_position(), + }), + Node::Text(Text { + value: "b".to_string(), + position: some_position(), + }), + ], + position: some_position(), + }); + + node.clear_text_position_at(1); + + assert_eq!( + node.children()[0].position(), + some_position(), + "untouched sibling keeps its position" + ); + assert_eq!(node.children()[1].position(), None, "selected leaf loses its position"); +} + +#[rstest] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec!["test".to_string().into()], position: None}), true)] +#[case(Node::Text(Text{value: "test".to_string(), position: None}), false)] +fn test_is_list(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_list(), expected); +} + +#[rstest] +#[case(Url::new("https://example.com".to_string()), RenderOptions{link_url_style: UrlSurroundStyle::None, ..Default::default()}, "https://example.com")] +#[case(Url::new("https://example.com".to_string()), RenderOptions{link_url_style: UrlSurroundStyle::Angle, ..Default::default()}, "")] +#[case(Url::new("".to_string()), RenderOptions::default(), "<>")] +fn test_url_to_string_with(#[case] url: Url, #[case] options: RenderOptions, #[case] expected: &str) { + assert_eq!(url.to_string_with(&options), expected); +} + +#[rstest] +#[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions::default(), "\"title\"")] +#[case(Title::new(r#"title with "quotes""#.to_string()), RenderOptions::default(), r#""title with \"quotes\"""#)] +#[case(Title::new("title with spaces".to_string()), RenderOptions::default(), "\"title with spaces\"")] +#[case(Title::new("".to_string()), RenderOptions::default(), "\"\"")] +#[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Single, ..Default::default()}, "'title'")] +#[case(Title::new("title with 'quotes'".to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Double, ..Default::default()}, "\"title with 'quotes'\"")] +#[case(Title::new(attr_keys::TITLE.to_string()), RenderOptions{link_title_style: TitleSurroundStyle::Paren, ..Default::default()}, "(title)")] +fn test_title_to_string_with(#[case] title: Title, #[case] options: RenderOptions, #[case] expected: &str) { + assert_eq!(title.to_string_with(&options), expected); +} + +#[rstest] +#[case(Node::Fragment(Fragment{values: vec![]}), true)] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Text(Text{value: "not_empty".to_string(), position: None}) +]}), false)] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![]}), + Node::Fragment(Fragment{values: vec![]}) +]}), true)] +#[case(Node::Fragment(Fragment{values: vec![ + Node::Fragment(Fragment{values: vec![]}), + Node::Text(Text{value: "not_empty".to_string(), position: None}) +]}), false)] +#[case(Node::Text(Text{value: "not_fragment".to_string(), position: None}), false)] +fn test_is_empty_fragment(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_empty_fragment(), expected); +} + +#[rstest] +#[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: Vec::new(), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] +#[case::footnote(Node::Footnote(Footnote{ident: "id".to_string(), values: Vec::new(), position: None}), "unknown", None)] +#[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("
test
".to_string())))] +#[case::html(Node::Html(Html{value: "
test
".to_string(), position: None}), "unknown", None)] +#[case::text(Node::Text(Text{value: "text".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("text".to_string())))] +#[case::text(Node::Text(Text{value: "text".to_string(), position: None}), "unknown", None)] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::VALUE, Some(AttrValue::String("code".to_string())))] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::LANG, Some(AttrValue::String("rust".to_string())))] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), "meta", Some(AttrValue::String("meta".to_string())))] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), meta: Some("meta".to_string()), fence: true, position: None}), attr_keys::FENCE, Some(AttrValue::Boolean(true)))] +#[case::code(Node::Code(Code{value: "code".to_string(), lang: None, meta: None, fence: false, position: None}), attr_keys::FENCE, Some(AttrValue::Boolean(false)))] +#[case::code_inline(Node::CodeInline(CodeInline{value: "inline".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("inline".to_string())))] +#[case::math_inline(Node::MathInline(MathInline{value: "math".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("math".to_string())))] +#[case::math(Node::Math(Math{value: "math".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("math".to_string())))] +#[case::yaml(Node::Yaml(Yaml{value: "yaml".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("yaml".to_string())))] +#[case::toml(Node::Toml(Toml{value: "toml".to_string(), position: None}), attr_keys::VALUE, Some(AttrValue::String("toml".to_string())))] +#[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::ALT, Some(AttrValue::String(attr_keys::ALT.to_string())))] +#[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] +#[case::image(Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] +#[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::ALT, Some(AttrValue::String(attr_keys::ALT.to_string())))] +#[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] +#[case::image_ref(Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] +#[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: Vec::new(), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] +#[case::link(Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), values: Vec::new(), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: Vec::new(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] +#[case::link_ref(Node::LinkRef(LinkRef{ident: "id".to_string(), values: Vec::new(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] +#[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] +#[case::footnote_ref(Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::IDENT, Some(AttrValue::String("id".to_string())))] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::URL, Some(AttrValue::String(attr_keys::URL.to_string())))] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::TITLE, Some(AttrValue::String(attr_keys::TITLE.to_string())))] +#[case::definition(Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new(attr_keys::TITLE.to_string())), label: Some(attr_keys::LABEL.to_string()), position: None}), attr_keys::LABEL, Some(AttrValue::String(attr_keys::LABEL.to_string())))] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H3, values: Vec::new(), position: None}), "depth", Some(AttrValue::Integer(3)))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "index", Some(AttrValue::Integer(2)))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "level", Some(AttrValue::Integer(1)))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), "ordered", Some(AttrValue::Boolean(true)))] +#[case::list(Node::List(List{ marker: None,start: None, spread: false, index: 2, level: 1, checked: Some(true), ordered: true, values: Vec::new(), position: None}), attr_keys::CHECKED, Some(AttrValue::Boolean(true)))] +#[case::table_cell(Node::TableCell(TableCell{column: 1, row: 2, values: Vec::new(), position: None}), "column", Some(AttrValue::Integer(1)))] +#[case::table_cell(Node::TableCell(TableCell{column: 1, row: 2, values: Vec::new(), position: None}), "row", Some(AttrValue::Integer(2)))] +#[case::table_align(Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), "align", Some(AttrValue::String(":---,---:".to_string())))] +#[case::mdx_flow_expression(Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("expr".to_string())))] +#[case::mdx_flow_expression(Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("expr".to_string())))] +#[case::mdx_js_esm(Node::MdxJsEsm(MdxJsEsm{value: "esm".into(), position: None}), attr_keys::VALUE, Some(AttrValue::String("esm".to_string())))] +#[case::mdx_jsx_flow_element(Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), attr_keys::NAME, Some(AttrValue::String("div".to_string())))] +#[case::mdx_jsx_flow_element(Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), attr_keys::NAME, Some(AttrValue::String("span".to_string())))] +#[case::break_(Node::Break(Break{position: None}), attr_keys::VALUE, None)] +#[case::horizontal_rule(Node::HorizontalRule(HorizontalRule{ marker: None,position: None}), attr_keys::VALUE, None)] +#[case::fragment(Node::Fragment(Fragment{values: Vec::new()}), attr_keys::VALUE, Some(AttrValue::String("".to_string())))] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![Node::Text(Text{value: "heading text".to_string(), position: None})], position: None}), attr_keys::VALUE, Some(AttrValue::String("heading text".to_string())))] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H2, values: vec![], position: None}), attr_keys::VALUE, Some(AttrValue::String("".to_string())))] +#[case::heading(Node::Heading(Heading{depth: HeadingDepth::H3, values: vec![ + Node::Text(Text{value: "first".to_string(), position: None}), + Node::Text(Text{value: "second".to_string(), position: None}), +], position: None}), attr_keys::VALUE, Some(AttrValue::String("firstsecond".to_string())))] +#[case( + Node::List(List { marker: None, + index: 0, + level: 1, + checked: None, + ordered: false, + start: None, spread: false, + values: vec![ + Node::Text(Text { value: "item1".to_string(), position: None }), + Node::Text(Text { value: "item2".to_string(), position: None }), + ], + position: None, + }), + attr_keys::VALUE, + Some(AttrValue::String("item1item2".to_string())) +)] +#[case( + Node::TableCell(TableCell { + column: 1, + row: 2, + values: vec![Node::Text(Text { + value: "cell_value".to_string(), + position: None, + })], + position: None, + }), + attr_keys::VALUE, + Some(AttrValue::String("cell_value".to_string())) +)] +#[case::footnote( + Node::Footnote(Footnote { + ident: "id".to_string(), + values: vec![Node::Text(Text { + value: "footnote value".to_string(), + position: None, + })], + position: None, + }), + attr_keys::VALUE, + Some(AttrValue::String("footnote value".to_string())) +)] +#[case::link( + Node::Link(Link { + url: Url::new("https://example.com".to_string()), + title: Some(Title::new("Example".to_string())), + values: vec![Node::Text(Text { + value: "link text".to_string(), + position: None, + })], + position: None, + }), + attr_keys::VALUE, + Some(AttrValue::String("link text".to_string())) +)] +#[case::empty(Node::Empty, attr_keys::VALUE, None)] +#[case::heading( + Node::Heading(Heading { + depth: HeadingDepth::H1, + values: vec![ + Node::Text(Text { + value: "child1".to_string(), + position: None, + }), + Node::Text(Text { + value: "child2".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::CHILDREN, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "child1".to_string(), + position: None, + }), + Node::Text(Text { + value: "child2".to_string(), + position: None, + }), + ])) + )] +#[case::list( + Node::List(List { marker: None, + index: 0, + level: 1, + checked: None, + ordered: false, + start: None, spread: false, + values: vec![ + Node::Text(Text { + value: "item1".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::CHILDREN, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "item1".to_string(), + position: None, + }), + ])) + )] +#[case::blockquote( + Node::Blockquote(Blockquote { + values: vec![ + Node::Text(Text { + value: "quote".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::VALUES, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "quote".to_string(), + position: None, + }), + ])) + )] +#[case::link( + Node::Link(Link { + url: Url::new(attr_keys::URL.to_string()), + title: None, + values: vec![ + Node::Text(Text { + value: "link".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::VALUES, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "link".to_string(), + position: None, + }), + ])) + )] +#[case::table_cell( + Node::TableCell(TableCell { + column: 0, + row: 0, + values: vec![ + Node::Text(Text { + value: "cell".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::CHILDREN, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "cell".to_string(), + position: None, + }), + ])) + )] +#[case::strong( + Node::Strong(Strong { + values: vec![ + Node::Text(Text { + value: "bold".to_string(), + position: None, + }), + ], + position: None, + }), + attr_keys::CHILDREN, + Some(AttrValue::Array(vec![ + Node::Text(Text { + value: "bold".to_string(), + position: None, + }), + ])) + )] +#[case::em( + Node::Emphasis(Emphasis { + values: vec![], + position: None, + }), + attr_keys::CHILDREN, + Some(AttrValue::Array(vec![])) + )] +fn test_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { + assert_eq!(node.attr(attr), expected); +} + +#[rstest] +#[case::heading_with_position( + Node::Heading(Heading { + depth: HeadingDepth::H1, + values: vec![], + position: Some(Position { + start: Point { line: 3, column: 1 }, + end: Point { line: 5, column: 4 }, + }), + }), + attr_keys::LINE, + Some(AttrValue::Integer(3)) +)] +#[case::code_end_line( + Node::Code(Code { + value: "x".to_string(), + lang: None, + meta: None, + fence: true, + position: Some(Position { + start: Point { line: 3, column: 1 }, + end: Point { line: 5, column: 4 }, + }), + }), + attr_keys::END_LINE, + Some(AttrValue::Integer(5)) +)] +#[case::synthetic_node_has_no_line( + Node::Heading(Heading { + depth: HeadingDepth::H1, + values: vec![], + position: None, + }), + attr_keys::LINE, + None +)] +#[case::synthetic_node_has_no_end_line( + Node::Heading(Heading { + depth: HeadingDepth::H1, + values: vec![], + position: None, + }), + attr_keys::END_LINE, + None +)] +fn test_line_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { + assert_eq!(node.attr(attr), expected); +} + +#[rstest] +#[case::heading( + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![], position: None}), + vec![Node::Text(Text{value: "child".to_string(), position: None})], + Node::Heading(Heading{depth: HeadingDepth::H1, values: vec![Node::Text(Text{value: "child".to_string(), position: None})], position: None}) +)] +#[case::list( + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![], position: None}), + vec![Node::Text(Text{value: "item".to_string(), position: None})], + Node::List(List{ marker: None,start: None, spread: false, index: 0, level: 0, checked: None, ordered: false, values: vec![Node::Text(Text{value: "item".to_string(), position: None})], position: None}) +)] +#[case::blockquote( + Node::Blockquote(Blockquote{values: vec![], position: None}), + vec![Node::Text(Text{value: "quote".to_string(), position: None})], + Node::Blockquote(Blockquote{values: vec![Node::Text(Text{value: "quote".to_string(), position: None})], position: None}) +)] +#[case::link( + Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![], position: None}), + vec![Node::Text(Text{value: "link".to_string(), position: None})], + Node::Link(Link{url: Url::new(attr_keys::URL.to_string()), title: None, values: vec![Node::Text(Text{value: "link".to_string(), position: None})], position: None}) +)] +#[case::footnote( + Node::Footnote(Footnote{ident: "1".to_string(), values: vec![], position: None}), + vec![Node::Text(Text{value: "note".to_string(), position: None})], + Node::Footnote(Footnote{ident: "1".to_string(), values: vec![Node::Text(Text{value: "note".to_string(), position: None})], position: None}) +)] +#[case::table_cell( + Node::TableCell(TableCell{column: 0, row: 0, values: vec![], position: None}), + vec![Node::Text(Text{value: "cell".to_string(), position: None})], + Node::TableCell(TableCell{column: 0, row: 0, values: vec![Node::Text(Text{value: "cell".to_string(), position: None})], position: None}) +)] +#[case::table_row( + Node::TableRow(TableRow{values: vec![], position: None}), + vec![Node::Text(Text{value: "row".to_string(), position: None})], + Node::TableRow(TableRow{values: vec![Node::Text(Text{value: "row".to_string(), position: None})], position: None}) +)] +#[case::strong( + Node::Strong(Strong{values: vec![], position: None}), + vec![Node::Text(Text{value: "bold".to_string(), position: None})], + Node::Strong(Strong{values: vec![Node::Text(Text{value: "bold".to_string(), position: None})], position: None}) +)] +#[case::delete( + Node::Delete(Delete{values: vec![], position: None}), + vec![Node::Text(Text{value: "del".to_string(), position: None})], + Node::Delete(Delete{values: vec![Node::Text(Text{value: "del".to_string(), position: None})], position: None}) +)] +#[case::emphasis( + Node::Emphasis(Emphasis{values: vec![], position: None}), + vec![Node::Text(Text{value: "em".to_string(), position: None})], + Node::Emphasis(Emphasis{values: vec![Node::Text(Text{value: "em".to_string(), position: None})], position: None}) +)] +#[case::fragment( + Node::Fragment(Fragment{values: vec![]}), + vec![Node::Text(Text{value: "frag".to_string(), position: None})], + Node::Fragment(Fragment{values: vec![Node::Text(Text{value: "frag".to_string(), position: None})]}) +)] +#[case::mdx_jsx_flow_element( + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: vec![], position: None}), + vec![Node::Text(Text{value: "mdx".to_string(), position: None})], + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "mdx".to_string(), position: None})], position: None}) +)] +#[case::mdx_jsx_text_element( + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![], position: None}), + vec![Node::Text(Text{value: "mdx".to_string(), position: None})], + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: vec![Node::Text(Text{value: "mdx".to_string(), position: None})], position: None}) +)] +#[case::leaf_node_is_noop( + Node::Text(Text{value: "leaf".to_string(), position: None}), + vec![Node::Text(Text{value: "ignored".to_string(), position: None})], + Node::Text(Text{value: "leaf".to_string(), position: None}) +)] +fn test_set_children(#[case] mut node: Node, #[case] children: Vec, #[case] expected: Node) { + node.set_children(children.clone()); + assert_eq!(node, expected); + assert_eq!( + node.children(), + if matches!(expected, Node::Text(_)) { + Vec::new() + } else { + children + } + ); +} + +#[rstest] +#[case( + Node::Text(Text{value: "old".to_string(), position: None}), + attr_keys::VALUE, + "new", + Node::Text(Text{value: "new".to_string(), position: None}) +)] +#[case( + Node::Code(Code{value: "old".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), + attr_keys::VALUE, + "new_code", + Node::Code(Code{value: "new_code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}) +)] +#[case( + Node::Code(Code{value: "code".to_string(), lang: Some("rust".to_string()), fence: true, meta: None, position: None}), + attr_keys::LANG, + "python", + Node::Code(Code{value: "code".to_string(), lang: Some("python".to_string()), fence: true, meta: None, position: None}) +)] +#[case( + Node::Code(Code{value: "code".to_string(), lang: None, fence: false, meta: None, position: None}), + attr_keys::FENCE, + "true", + Node::Code(Code{value: "code".to_string(), lang: None, fence: true, meta: None, position: None}) +)] +#[case( + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), + attr_keys::ALT, + "new_alt", + Node::Image(Image{alt: "new_alt".to_string(), url: attr_keys::URL.to_string(), title: None, position: None}) +)] +#[case( + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: None, position: None}), + attr_keys::URL, + "new_url", + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: "new_url".to_string(), title: None, position: None}) +)] +#[case( + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some(attr_keys::TITLE.to_string()), position: None}), + attr_keys::TITLE, + "new_title", + Node::Image(Image{alt: attr_keys::ALT.to_string(), url: attr_keys::URL.to_string(), title: Some("new_title".to_string()), position: None}) +)] +#[case( + Node::Heading(Heading{depth: HeadingDepth::H2, values: vec![], position: None}), + "depth", + "3", + Node::Heading(Heading{depth: HeadingDepth::H3, values: vec![], position: None}) +)] +#[case( + Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec![], position: None}), + attr_keys::CHECKED, + "false", + Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 2, checked: Some(false), ordered: false, values: vec![], position: None}) +)] +#[case( + Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: false, values: vec![], position: None}), + "ordered", + "true", + Node::List(List{ marker: None,start: None, spread: false, index: 1, level: 2, checked: Some(true), ordered: true, values: vec![], position: None}) +)] +#[case( + Node::TableCell(TableCell{column: 1, row: 2, values: vec![], position: None}), + "column", + "3", + Node::TableCell(TableCell{column: 3, row: 2, values: vec![], position: None}) +)] +#[case( + Node::TableCell(TableCell{column: 1, row: 2, values: vec![], position: None}), + "row", + "5", + Node::TableCell(TableCell{column: 1, row: 5, values: vec![], position: None}) +)] +#[case( + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), + attr_keys::IDENT, + "new_id", + Node::Definition(Definition{ident: "new_id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}) +)] +#[case( + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), + attr_keys::URL, + "new_url", + Node::Definition(Definition{ident: "id".to_string(), url: Url::new("new_url".to_string()), title: None, label: None, position: None}) +)] +#[case( + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), + attr_keys::LABEL, + "new_label", + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: None, label: None, position: None}), + attr_keys::TITLE, + "new_title", + Node::Definition(Definition{ident: "id".to_string(), url: Url::new(attr_keys::URL.to_string()), title: Some(Title::new("new_title".to_string())), label: None, position: None}) +)] +#[case( + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::ALT, + "new_alt", + Node::ImageRef(ImageRef{alt: "new_alt".to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case( + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::IDENT, + "new_id", + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "new_id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case( + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::LABEL, + "new_label", + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: None, position: None}), + attr_keys::LABEL, + "new_label", + Node::ImageRef(ImageRef{alt: attr_keys::ALT.to_string(), ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::IDENT, + "new_id", + Node::LinkRef(LinkRef{ident: "new_id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case( + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::LABEL, + "new_label", + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: None, position: None}), + attr_keys::LABEL, + "new_label", + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}), + "unknown", + "ignored", + Node::LinkRef(LinkRef{ident: "id".to_string(), values: vec![], label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case( + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::IDENT, + "new_id", + Node::FootnoteRef(FootnoteRef{ident: "new_id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case( + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + attr_keys::LABEL, + "new_label", + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: None, position: None}), + attr_keys::LABEL, + "new_label", + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some("new_label".to_string()), position: None}) +)] +#[case( + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}), + "unknown", + "ignored", + Node::FootnoteRef(FootnoteRef{ident: "id".to_string(), label: Some(attr_keys::LABEL.to_string()), position: None}) +)] +#[case(Node::Empty, attr_keys::VALUE, "ignored", Node::Empty)] +#[case( + Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}), + "align", + "---,:---:", + Node::TableAlign(TableAlign{align: vec![TableAlignKind::None, TableAlignKind::Center], position: None}) +)] +#[case( + Node::TableAlign(TableAlign{align: vec![], position: None}), + "align", + ":---,---:", + Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left, TableAlignKind::Right], position: None}) +)] +#[case( + Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left], position: None}), + "unknown", + "ignored", + Node::TableAlign(TableAlign{align: vec![TableAlignKind::Left], position: None}) +)] +#[case( + Node::MdxFlowExpression(MdxFlowExpression{value: "old".into(), position: None}), + attr_keys::VALUE, + "new_expr", + Node::MdxFlowExpression(MdxFlowExpression{value: "new_expr".into(), position: None}) +)] +#[case( + Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}), + "unknown", + "ignored", + Node::MdxFlowExpression(MdxFlowExpression{value: "expr".into(), position: None}) +)] +#[case( + Node::MdxTextExpression(MdxTextExpression{value: "old".into(), position: None}), + attr_keys::VALUE, + "new_expr", + Node::MdxTextExpression(MdxTextExpression{value: "new_expr".into(), position: None}) +)] +#[case( + Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}), + "unknown", + "ignored", + Node::MdxTextExpression(MdxTextExpression{value: "expr".into(), position: None}) +)] +#[case( + Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}), + attr_keys::VALUE, + "import y", + Node::MdxJsEsm(MdxJsEsm{value: "import y".into(), position: None}) +)] +#[case( + Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}), + "unknown", + "ignored", + Node::MdxJsEsm(MdxJsEsm{value: "import x".into(), position: None}) +)] +#[case( + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), + attr_keys::NAME, + "section", + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("section".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +#[case( + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), + attr_keys::NAME, + "main", + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("main".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +#[case( + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}), + "unknown", + "ignored", + Node::MdxJsxFlowElement(MdxJsxFlowElement{name: Some("div".to_string()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +#[case( + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), + attr_keys::NAME, + "b", + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("b".into()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +#[case( + Node::MdxJsxTextElement(MdxJsxTextElement{name: None, attributes: Vec::new(), children: Vec::new(), position: None}), + attr_keys::NAME, + "i", + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("i".into()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +#[case( + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}), + "unknown", + "ignored", + Node::MdxJsxTextElement(MdxJsxTextElement{name: Some("span".into()), attributes: Vec::new(), children: Vec::new(), position: None}) +)] +fn test_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { + node.set_attr(attr, value); + assert_eq!(node, expected); +} + +#[rstest] +#[case(AttrValue::String("test".to_string()), AttrValue::String("test".to_string()), true)] +#[case(AttrValue::String("test".to_string()), AttrValue::String("other".to_string()), false)] +#[case(AttrValue::Integer(42), AttrValue::Integer(42), true)] +#[case(AttrValue::Integer(42), AttrValue::Integer(0), false)] +#[case(AttrValue::Boolean(true), AttrValue::Boolean(true), true)] +#[case(AttrValue::Boolean(true), AttrValue::Boolean(false), false)] +#[case(AttrValue::String("42".to_string()), AttrValue::Integer(42), false)] +#[case(AttrValue::Boolean(false), AttrValue::Integer(0), false)] +fn test_attr_value_eq(#[case] a: AttrValue, #[case] b: AttrValue, #[case] expected: bool) { + assert_eq!(a == b, expected); +} + +#[rstest] +#[case(AttrValue::String("test".to_string()), "test")] +#[case(AttrValue::Integer(42), "42")] +#[case(AttrValue::Boolean(true), "true")] +fn test_attr_value_as_str(#[case] value: AttrValue, #[case] expected: &str) { + assert_eq!(&value.as_string(), expected); +} + +#[rstest] +#[case(AttrValue::Integer(42), Some(42))] +#[case(AttrValue::String("42".to_string()), Some(42))] +#[case(AttrValue::Boolean(false), Some(0))] +fn test_attr_value_as_i64(#[case] value: AttrValue, #[case] expected: Option) { + assert_eq!(value.as_i64(), expected); +} + +// --- Callout tests --- + +#[cfg(feature = "callout")] +#[rstest] +// basic render +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, + values: vec![Node::Text(Text { value: "content".to_string(), position: None })], + position: None }), + "> [!NOTE]\n> content" +)] +// with title +#[case( + Node::Callout(Callout { fold: None, kind: "WARNING".to_string(), title: Some("Heads up".to_string()), + values: vec![Node::Text(Text { value: "watch out".to_string(), position: None })], + position: None }), + "> [!WARNING] Heads up\n> watch out" +)] +// empty body +#[case( + Node::Callout(Callout { fold: None, kind: "TIP".to_string(), title: None, values: vec![], position: None }), + "> [!TIP]" +)] +// multiline body: single Text with embedded '\n': each line prefixed with "> " +#[case( + Node::Callout(Callout { fold: None, kind: "INFO".to_string(), title: None, + values: vec![Node::Text(Text { value: "line one\nline two".to_string(), position: None })], + position: None }), + "> [!INFO]\n> line one\n> line two" +)] +// two separate position-less Text values are paragraphs: the inline nodes of one never are two Texts +#[case( + Node::Callout(Callout { fold: None, kind: "INFO".to_string(), title: None, + values: vec![ + Node::Text(Text { value: "part a".to_string(), position: None }), + Node::Text(Text { value: "part b".to_string(), position: None }), + ], + position: None }), + "> [!INFO]\n> part a\n> \n> part b" +)] +fn test_callout_render(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[cfg(feature = "callout")] +#[rstest] +#[case(Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), "kind", Some(AttrValue::String("NOTE".to_string())))] +#[case(Node::Callout(Callout { fold: None, kind: "WARNING".to_string(), title: Some("Title".to_string()), values: vec![], position: None }), "title", Some(AttrValue::String("Title".to_string())))] +#[case(Node::Callout(Callout { fold: None, kind: "TIP".to_string(), title: None, values: vec![], position: None }), "title", None)] +#[case(Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![Node::Text(Text { value: "body".to_string(), position: None })], position: None }), "value", Some(AttrValue::String("body".to_string())))] +#[case(Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![Node::Text(Text { value: "body".to_string(), position: None })], position: None }), "children", Some(AttrValue::Array(vec![Node::Text(Text { value: "body".to_string(), position: None })])))] +fn test_callout_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { + assert_eq!(node.attr(attr), expected); +} + +#[cfg(feature = "callout")] +#[rstest] +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), + "kind", "WARNING", + Node::Callout(Callout { fold: None, kind: "WARNING".to_string(), title: None, values: vec![], position: None }) +)] +// set_attr stores kind as-is without case conversion +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), + "kind", "tip", + Node::Callout(Callout { fold: None, kind: "tip".to_string(), title: None, values: vec![], position: None }) +)] +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), + "title", "My title", + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: Some("My title".to_string()), values: vec![], position: None }) +)] +// empty string clears title +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: Some("old".to_string()), values: vec![], position: None }), + "title", "", + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }) +)] +fn test_callout_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { + node.set_attr(attr, value); + assert_eq!(node, expected); +} + +#[cfg(feature = "callout")] +#[rstest] +// with_value changes first body node +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, + values: vec![Node::Text(Text { value: "old".to_string(), position: None })], + position: None }), + "new", + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, + values: vec![Node::Text(Text { value: "new".to_string(), position: None })], + position: None }) +)] +fn test_callout_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { + assert_eq!(node.clone().with_value(value), expected); + assert_eq!(node.into_with_value(value), expected); +} + +#[cfg(feature = "callout")] +#[rstest] +#[case(Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), "callout")] +fn test_callout_name(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.name(), expected); +} + +#[cfg(feature = "callout")] +#[rstest] +#[case( + Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, + values: vec![Node::Text(Text { value: "body".to_string(), position: None })], + position: None }), + "body" +)] +fn test_callout_value(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.value(), expected); +} + +#[cfg(feature = "callout")] +#[rstest] +#[case(Node::Callout(Callout { fold: None, kind: "NOTE".to_string(), title: None, values: vec![], position: None }), true)] +#[case(Node::Blockquote(Blockquote { values: vec![], position: None }), false)] +#[case(Node::Text(Text { value: "test".to_string(), position: None }), false)] +fn test_is_callout(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_callout(), expected); +} + +#[cfg(feature = "callout")] +#[test] +fn test_callout_end_to_end() { + use crate::Markdown; + // plain blockquote stays as Blockquote + let plain = Markdown::from_markdown_str("> just a quote").unwrap(); + assert!(plain.nodes[0].is_blockquote()); + + // [!NOTE] is parsed as Callout + let note = Markdown::from_markdown_str("> [!NOTE]\n> body").unwrap(); + assert!(note.nodes[0].is_callout()); + assert_eq!(note.nodes[0].attr("kind"), Some(AttrValue::String("NOTE".to_string()))); + + // [!WARNING] with title + let warn = Markdown::from_markdown_str("> [!WARNING] Watch out\n> content").unwrap(); + assert!(warn.nodes[0].is_callout()); + assert_eq!( + warn.nodes[0].attr("title"), + Some(AttrValue::String("Watch out".to_string())) + ); +} + +// --- Embed tests --- + +#[cfg(feature = "embed")] +#[rstest] +// plain embed +#[case("![[target]]", vec![Node::Embed(Embed { target: "target".to_string(), display: None, position: None })])] +// embed with display hint (image size) +#[case("![[image.png|400]]", vec![Node::Embed(Embed { target: "image.png".to_string(), display: Some("400".to_string()), position: None })])] +// embed with section reference in target +#[case("![[note#Heading]]", vec![Node::Embed(Embed { target: "note#Heading".to_string(), display: None, position: None })])] +// embed at start of text +#[case("![[note]] rest", vec![ + Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), + Node::Text(Text { value: " rest".to_string(), position: None }), +])] +// embed in middle +#[case("before ![[note]] after", vec![ + Node::Text(Text { value: "before ".to_string(), position: None }), + Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), + Node::Text(Text { value: " after".to_string(), position: None }), +])] +// multiple embeds +#[case("![[a]] and ![[b]]", vec![ + Node::Embed(Embed { target: "a".to_string(), display: None, position: None }), + Node::Text(Text { value: " and ".to_string(), position: None }), + Node::Embed(Embed { target: "b".to_string(), display: None, position: None }), +])] +// multibyte target +#[case("![[ノート]]", vec![Node::Embed(Embed { target: "ノート".to_string(), display: None, position: None })])] +// no embed: plain text unchanged +#[case("plain text", vec![Node::Text(Text { value: "plain text".to_string(), position: None })])] +// unclosed embed: treated as plain text +#[case("![[unclosed", vec![Node::Text(Text { value: "![[unclosed".to_string(), position: None })])] +// lone ! before non-embed: treated as plain text +#[case("! not an embed", vec![Node::Text(Text { value: "! not an embed".to_string(), position: None })])] +fn test_parse_embeds_in_text(#[case] input: &str, #[case] expected: Vec) { + let mut result = Vec::new(); + Node::parse_embeds_into(input, None, &mut result); + assert_eq!(result, expected); +} + +#[cfg(feature = "embed")] +#[rstest] +// plain embed renders without display +#[case( + Node::Embed(Embed { target: "note.md".to_string(), display: None, position: None }), + "![[note.md]]" +)] +// embed with display hint +#[case( + Node::Embed(Embed { target: "image.png".to_string(), display: Some("400".to_string()), position: None }), + "![[image.png|400]]" +)] +// embed with section reference +#[case( + Node::Embed(Embed { target: "note#Intro".to_string(), display: None, position: None }), + "![[note#Intro]]" +)] +fn test_embed_render(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[cfg(feature = "embed")] +#[rstest] +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "url", Some(AttrValue::String("note".to_string())))] +// value returns target when no display +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "value", Some(AttrValue::String("note".to_string())))] +// url always returns target regardless of display +#[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "url", Some(AttrValue::String("note".to_string())))] +// value returns display when present +#[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "value", Some(AttrValue::String("400".to_string())))] +// unknown attr returns None +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "unknown", None)] +fn test_embed_attr(#[case] node: Node, #[case] attr: &str, #[case] expected: Option) { + assert_eq!(node.attr(attr), expected); +} + +#[cfg(feature = "embed")] +#[rstest] +// set url changes target +#[case( + Node::Embed(Embed { target: "old".to_string(), display: None, position: None }), + "url", "new", + Node::Embed(Embed { target: "new".to_string(), display: None, position: None }) +)] +// set value changes display +#[case( + Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), + "value", "800", + Node::Embed(Embed { target: "note".to_string(), display: Some("800".to_string()), position: None }) +)] +// empty value clears display +#[case( + Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), + "value", "", + Node::Embed(Embed { target: "note".to_string(), display: None, position: None }) +)] +fn test_embed_set_attr(#[case] mut node: Node, #[case] attr: &str, #[case] value: &str, #[case] expected: Node) { + node.set_attr(attr, value); + assert_eq!(node, expected); +} + +#[cfg(feature = "embed")] +#[rstest] +// no display: with_value sets target +#[case( + Node::Embed(Embed { target: "old".to_string(), display: None, position: None }), + "new", + Node::Embed(Embed { target: "new".to_string(), display: None, position: None }) +)] +// with display: with_value sets display +#[case( + Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), + "800", + Node::Embed(Embed { target: "note".to_string(), display: Some("800".to_string()), position: None }) +)] +fn test_embed_with_value(#[case] node: Node, #[case] value: &str, #[case] expected: Node) { + assert_eq!(node.clone().with_value(value), expected); + assert_eq!(node.into_with_value(value), expected); +} + +#[cfg(feature = "embed")] +#[rstest] +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "embed")] +fn test_embed_name(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.name(), expected); +} + +#[cfg(feature = "embed")] +#[rstest] +// value returns display when present +#[case(Node::Embed(Embed { target: "note".to_string(), display: Some("400".to_string()), position: None }), "400")] +// value returns target when no display +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), "note")] +fn test_embed_value(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.value(), expected); +} + +#[cfg(feature = "embed")] +#[rstest] +#[case(Node::Embed(Embed { target: "note".to_string(), display: None, position: None }), true)] +#[case(Node::Text(Text { value: "test".to_string(), position: None }), false)] +#[case(Node::Image(Image { alt: "".to_string(), url: "img.png".to_string(), title: None, position: None }), false)] +fn test_is_embed(#[case] node: Node, #[case] expected: bool) { + assert_eq!(node.is_embed(), expected); +} + +#[cfg(feature = "embed")] +#[test] +fn test_embed_end_to_end() { + use crate::Markdown; + let md = Markdown::from_markdown_str("See ![[note.md]] for details.").unwrap(); + let embed = md.nodes.iter().find(|n| n.is_embed()); + assert!(embed.is_some()); + assert_eq!( + embed.unwrap().attr("url"), + Some(AttrValue::String("note.md".to_string())) + ); +} + +#[cfg(all(feature = "callout", feature = "wikilink"))] +#[rstest] +// callout body containing a wikilink +#[case("> [!NOTE]\n> See [[note]]", 1, "> [!NOTE]\n> See [[note]]\n")] +fn test_callout_with_wikilink(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) { + use crate::Markdown; + let md = Markdown::from_markdown_str(input).unwrap(); + assert_eq!(md.nodes.len(), expected_nodes); + assert_eq!(md.to_string(), expected_output); +} + +#[cfg(feature = "callout")] +#[rstest] +// callout body with bold +#[case("> [!NOTE]\n> **important**", 1, "> [!NOTE]\n> **important**\n")] +// callout body with inline code +#[case("> [!NOTE]\n> Use `code`", 1, "> [!NOTE]\n> Use `code`\n")] +// multiple callouts in one document +#[case( + "> [!NOTE]\n> first\n\n> [!WARNING]\n> second", + 2, + "> [!NOTE]\n> first\n\n> [!WARNING]\n> second\n" +)] +// callout after heading +#[case("# Title\n\n> [!NOTE]\n> body", 2, "# Title\n\n> [!NOTE]\n> body\n")] +// callout body with a multi-item list: second item must not be over-indented +#[case("> [!NOTE]\n> - item 1\n> - item 2", 1, "> [!NOTE]\n> - item 1\n> - item 2\n")] +fn test_callout_combined_with_other_nodes( + #[case] input: &str, + #[case] expected_nodes: usize, + #[case] expected_output: &str, +) { + use crate::Markdown; + let md = Markdown::from_markdown_str(input).unwrap(); + assert_eq!(md.nodes.len(), expected_nodes); + assert_eq!(md.to_string(), expected_output); +} + +#[cfg(feature = "embed")] +#[rstest] +// embed inside list item +#[case("- ![[note.md]]", 1, "- ![[note.md]]\n")] +// embed inside heading +#[case("# See ![[note]]", 1, "# See ![[note]]\n")] +// embed inside bold +#[case("**![[note]]**", 1, "**![[note]]**\n")] +fn test_embed_combined_with_other_nodes( + #[case] input: &str, + #[case] expected_nodes: usize, + #[case] expected_output: &str, +) { + use crate::Markdown; + let md = Markdown::from_markdown_str(input).unwrap(); + assert_eq!(md.nodes.len(), expected_nodes); + assert_eq!(md.to_string(), expected_output); +} + +#[cfg(all(feature = "callout", feature = "embed"))] +#[rstest] +// wikilink inside callout body + embed as separate node +#[case("> [!NOTE]\n> [[link]]\n\n![[embed]]", 2, "> [!NOTE]\n> [[link]]\n\n![[embed]]\n")] +fn test_callout_and_embed_together(#[case] input: &str, #[case] expected_nodes: usize, #[case] expected_output: &str) { + use crate::Markdown; + let md = Markdown::from_markdown_str(input).unwrap(); + assert_eq!(md.nodes.len(), expected_nodes); + assert_eq!(md.to_string(), expected_output); +} + +#[cfg(all(feature = "embed", feature = "wikilink"))] +#[test] +fn test_embed_does_not_conflict_with_wikilink() { + use crate::Markdown; + // ![[embed]] must become Embed, not WikiLink with preceding "!" + let md = Markdown::from_markdown_str("![[embed]] and [[link]]").unwrap(); + let embed = md.nodes.iter().find(|n| n.is_embed()); + let wikilink = md.nodes.iter().find(|n| n.is_wikilink()); + assert!(embed.is_some(), "expected Embed node"); + assert!(wikilink.is_some(), "expected WikiLink node"); +} + +#[rstest] +#[case::escaped_when_parsed_from_source( + Node::Text(Text { + value: "*a*".to_string(), + position: Some(Position { + start: Point { line: 1, column: 1 }, + end: Point { line: 1, column: 4 }, + }), + }), + "\\*a\\*" +)] +#[case::untouched_when_built_programmatically( + Node::Text(Text { value: "*a*".to_string(), position: None }), + "*a*" +)] +fn test_text_escaping_gated_by_position(#[case] node: Node, #[case] expected: &str) { + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[rstest] +#[case::bare_safe("https://example.com/x", UrlSurroundStyle::None, "https://example.com/x")] +#[case::space_forces_angle("my uri", UrlSurroundStyle::None, "")] +#[case::empty_forces_angle("", UrlSurroundStyle::None, "<>")] +#[case::unbalanced_paren_escaped("b)c", UrlSurroundStyle::None, "b\\)c")] +#[case::both_parens_escaped("foo(and(bar)", UrlSurroundStyle::None, "foo\\(and\\(bar\\)")] +#[case::forced_angle_style("plain", UrlSurroundStyle::Angle, "")] +#[case::angle_escapes_inner_angle_and_backslash("foo\\>", UrlSurroundStyle::None, ">")] +fn test_render_link_destination(#[case] url: &str, #[case] style: UrlSurroundStyle, #[case] expected: &str) { + assert_eq!(render_link_destination(url, &style), expected); +} + +#[rstest] +#[case::double_quote_escaped(r#"a "b" c"#, TitleSurroundStyle::Double, r#""a \"b\" c""#)] +#[case::single_quote_escaped("a 'b' c", TitleSurroundStyle::Single, "'a \\'b\\' c'")] +#[case::paren_escapes_both("a (b) c", TitleSurroundStyle::Paren, "(a \\(b\\) c)")] +#[case::backslash_escaped("a\\b", TitleSurroundStyle::Double, "\"a\\\\b\"")] +fn test_render_link_title(#[case] title: &str, #[case] style: TitleSurroundStyle, #[case] expected: &str) { + assert_eq!(render_link_title(title, &style), expected); +} + +#[rstest] +#[case::shortcut_kept_when_ident_matches_label_present("foo", "foo", Some("foo".to_string()), "[foo]")] +#[case::shortcut_kept_when_ident_matches_label_absent("foo", "foo", None, "[foo]")] +#[case::full_form_when_ident_differs("foo", "bar", Some("bar".to_string()), "[foo][bar]")] +#[case::full_form_when_plain_needs_broad_escaping("foo*bar", "foo*bar", Some("foo*bar".to_string()), "[foo*bar][foo*bar]")] +fn test_link_ref_shortcut_vs_full( + #[case] text: &str, + #[case] ident: &str, + #[case] label: Option, + #[case] expected: &str, +) { + let node = Node::LinkRef(LinkRef { + ident: ident.to_string(), + values: vec![text.to_string().into()], + label, + position: None, + }); + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[test] +fn test_link_ref_display_escaping_does_not_leak_into_matching_label() { + // The display text (first bracket) is escaped for safety; the matching key + // (second bracket) must stay the definition-compatible, narrowly-escaped label. + let node = Node::LinkRef(LinkRef { + ident: "foo*bar".to_string(), + values: vec![Node::Text(Text { + value: "foo*bar".to_string(), + position: Some(Position { + start: Point { line: 1, column: 1 }, + end: Point { line: 1, column: 8 }, + }), + })], + label: Some("foo*bar".to_string()), + position: None, + }); + assert_eq!(node.to_string_with(&RenderOptions::default()), "[foo\\*bar][foo*bar]"); +} + +#[rstest] +#[case::shortcut_kept_when_ident_matches("foo bar", "foo bar", "![foo bar]")] +#[case::full_form_when_ident_differs("foo bar", "foo*bar*", "![foo bar][foo*bar*]")] +fn test_image_ref_shortcut_vs_full(#[case] alt: &str, #[case] ident: &str, #[case] expected: &str) { + let node = Node::ImageRef(ImageRef { + alt: alt.to_string(), + ident: ident.to_string(), + label: None, + position: None, + }); + assert_eq!(node.to_string_with(&RenderOptions::default()), expected); +} + +#[rstest] +#[case::no_newline_still_shifted("abc", 3, " abc")] +#[case::zero_delta_unchanged("a\n b", 0, "a\n b")] +#[case::first_line_also_shifted("a\nb", 2, " a\n b")] +#[case::negative_delta_clamped_at_zero(" a\n b", -5, "a\nb")] +fn test_reindent_all_lines(#[case] content: &str, #[case] delta: isize, #[case] expected: &str) { + assert_eq!(reindent_all_lines(content, delta), expected); +} + +#[rstest] +#[case::unordered_bullet(false, 0, None, 2)] +#[case::ordered_single_digit(true, 0, None, 3)] +#[case::ordered_double_digit(true, 9, None, 4)] +#[case::ordered_with_start(true, 0, Some(10), 4)] +fn test_list_own_prefix_width( + #[case] ordered: bool, + #[case] index: usize, + #[case] start: Option, + #[case] expected: usize, +) { + assert_eq!(list_own_prefix_width(ordered, index, start), expected); +} + +#[rstest] +#[case(0, None)] +#[case(1, Some(HeadingDepth::H1))] +#[case(6, Some(HeadingDepth::H6))] +#[case(7, None)] +fn heading_depth_new(#[case] depth: u8, #[case] expected: Option) { + assert_eq!(HeadingDepth::new(depth), expected); + assert_eq!(HeadingDepth::try_from(depth).ok(), expected); +} + +#[rstest] +#[case(i64::MIN, HeadingDepth::H1)] +#[case(0, HeadingDepth::H1)] +#[case(3, HeadingDepth::H3)] +#[case(257, HeadingDepth::H6)] +fn heading_depth_saturating(#[case] depth: i64, #[case] expected: HeadingDepth) { + assert_eq!(HeadingDepth::saturating(depth), expected); +} + +#[rstest] +#[case(HeadingDepth::H2, 3, Some(HeadingDepth::H5))] +#[case(HeadingDepth::H6, 1, None)] +#[case(HeadingDepth::H6, u8::MAX, None)] +fn heading_depth_checked_add(#[case] depth: HeadingDepth, #[case] levels: u8, #[case] expected: Option) { + assert_eq!(depth.checked_add(levels), expected); +} + +#[rstest] +#[case(HeadingDepth::H4, 2, HeadingDepth::H2)] +#[case(HeadingDepth::H2, u8::MAX, HeadingDepth::H1)] +fn heading_depth_saturating_sub(#[case] depth: HeadingDepth, #[case] levels: u8, #[case] expected: HeadingDepth) { + assert_eq!(depth.saturating_sub(levels), expected); +} + +#[rstest] +#[case::integer(AttrValue::Integer(3), HeadingDepth::H3)] +#[case::string(AttrValue::String("4".to_string()), HeadingDepth::H4)] +#[case::too_deep(AttrValue::Integer(7), HeadingDepth::H2)] +#[case::wraps_as_byte(AttrValue::Integer(257), HeadingDepth::H2)] +#[case::negative(AttrValue::Integer(-1), HeadingDepth::H2)] +#[case::zero_string(AttrValue::String("0".to_string()), HeadingDepth::H2)] +fn set_attr_keeps_heading_depth_in_range(#[case] value: AttrValue, #[case] expected: HeadingDepth) { + let mut node = Node::Heading(Heading { + depth: HeadingDepth::H2, + values: vec!["a".to_string().into()], + position: None, + }); + node.set_attr(attr_keys::DEPTH, value); + assert!(matches!(node, Node::Heading(Heading { depth, .. }) if depth == expected)); +} + +#[cfg(feature = "json")] +#[test] +fn heading_depth_is_a_number_in_json() { + let heading = Heading { + depth: HeadingDepth::H3, + values: Vec::new(), + position: None, + }; + let json = serde_json::to_string(&heading).unwrap(); + assert!(json.contains("\"depth\":3"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), heading); + assert!(serde_json::from_str::(&json.replace("\"depth\":3", "\"depth\":7")).is_err()); +} + +#[test] +fn heading_depth_compares_with_u8() { + assert!(HeadingDepth::H2 == 2u8); + assert!(2u8 == HeadingDepth::H2); + assert!(HeadingDepth::H2 < 3u8); + assert!(3u8 > HeadingDepth::H2); + assert_eq!(usize::from(HeadingDepth::H3), 3); + assert_eq!(i64::from(HeadingDepth::H3), 3); +} diff --git a/crates/mq-markdown/src/node_kind.rs b/crates/mq-markdown/src/node_kind.rs index b63d119d2..d8d56369b 100644 --- a/crates/mq-markdown/src/node_kind.rs +++ b/crates/mq-markdown/src/node_kind.rs @@ -255,8 +255,8 @@ impl Node { Node::Embed(_) => NodeKind::Embed, Node::Definition(_) => NodeKind::Definition, Node::Delete(_) => NodeKind::Delete, - Node::Heading(Heading { depth, .. }) => match depth { - 0 | 1 => NodeKind::H1, + Node::Heading(Heading { depth, .. }) => match depth.get() { + 1 => NodeKind::H1, 2 => NodeKind::H2, 3 => NodeKind::H3, 4 => NodeKind::H4, diff --git a/crates/mq-markdown/src/parser.rs b/crates/mq-markdown/src/parser.rs new file mode 100644 index 000000000..80fb0d500 --- /dev/null +++ b/crates/mq-markdown/src/parser.rs @@ -0,0 +1,722 @@ +//! Markdown parser that builds [`Node`] values directly. +//! +//! Parsing runs in phases: [`block`] resolves the block structure line by line into a tree, +//! [`resolve`] collects the definitions and turns the tree into nodes, and [`inline`] parses the raw +//! text of paragraphs, headings and table cells along the way. +//! +//! It reads `CommonMark`, GFM, frontmatter, math and MDX (without a JavaScript parser, so expressions +//! only need balanced braces outside of strings and comments). HTML is rendered by [`render_html`]. +mod block; +#[cfg(feature = "callout")] +mod callout; +mod code; +mod definition; +pub(crate) mod error; +mod flavor; +mod html_flow; +mod inline; +mod line; +mod mdx; +mod mdx_flow; +mod render_html; +mod resolve; +mod scan; +mod table; +mod tree; + +use crate::node::Node; +use flavor::Flavor; + +pub use error::{MdxDiagnostic, MdxError, MdxErrorKind, MdxFound, MdxPlace}; +pub(crate) use inline::{is_autolink_email, normalize, unescape}; + +/// Parses `content` into a flat list of nodes. +#[cfg(test)] +pub(crate) fn parse(content: &str) -> miette::Result> { + parse_with(content, true) +} + +/// Parses `content` into a flat list of nodes, with frontmatter at the start when `frontmatter` is set. +pub(crate) fn parse_with(content: &str, frontmatter: bool) -> miette::Result> { + resolve::resolve(block::parse(content, Flavor::Markdown, frontmatter), Flavor::Markdown) + .map_err(|error| miette::Report::new(error.into_diagnostic(content))) +} + +/// Renders `content` as HTML. +pub(crate) fn to_html(content: &str) -> String { + render_html::render(content) +} + +/// Parses `content` as MDX: no indented code, HTML, autolinks or GFM, but expressions and JSX. +#[cfg(test)] +pub(crate) fn parse_mdx(content: &str) -> miette::Result> { + parse_mdx_with(content, true) +} + +/// Parses `content` as MDX, with frontmatter at the start when `frontmatter` is set. +pub(crate) fn parse_mdx_with(content: &str, frontmatter: bool) -> miette::Result> { + resolve::resolve(block::parse(content, Flavor::Mdx, frontmatter), Flavor::Mdx) + .map_err(|error| miette::Report::new(error.into_diagnostic(content))) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner}; + + fn env_number(name: &str) -> Option { + std::env::var(name).ok()?.parse().ok() + } + + /// Lines built from container prefixes and block-level bodies only, so inline syntax never + /// appears. Constructs whose mdast positions depend on quirks of the following lines are left to the cases of `tests/parser_tree_tests.rs`: empty items and quotes, + /// unclosed fences, indented code next to containers, ordered lists that do not start at 1, and + /// lazy continuation inside nested containers, more than one container marker on a line, and runs + /// of setext underlines (`===` is only used by those cases). + fn block_input() -> impl Strategy { + // At most one container marker per line; deeper nesting comes from indentation. + let indent = prop::sample::select(vec!["", " ", " ", " "]); + let marker = prop::sample::select(vec!["", "> ", ">", "- ", "* ", "+ ", "1. ", "1) "]); + let body = prop::sample::select(vec![ + "a", + "b", + "# a", + "#", + "## b ##", + "---", + "***", + "| a | b |", + "|-|:-:|", + "|1|2|3|", + "a|b", + "[a]: /u", + "[a]: /u 'T'", + "[^a]: x", + "", + "x [a] [b][a] [^a]", + "*a* `b`", + "a ", + "a\\", + "+++", + ]); + let block = (indent, marker, body).prop_map(|(indent, marker, body)| format!("{indent}{marker}{body}")); + let blank = prop::sample::select(vec!["", " "]).prop_map(str::to_string); + // Fences are always closed here. + let fence = ( + prop::sample::select(vec!["", " ", " ", " "]), + prop::sample::select(vec![ + ("```", "```"), + ("```rust x", "````"), + ("~~~", "~~~"), + ("$$", "$$"), + ("$$ m", "$$$"), + ]), + prop::sample::select(vec!["a", "", "> b", "- c"]), + ) + .prop_map(|(indent, (open, close), content)| format!("{indent}{open}\n{indent}{content}\n{indent}{close}")); + let line = prop_oneof![8 => block, 2 => blank, 1 => fence]; + (prop::collection::vec(line, 0..8), any::()).prop_map(|(lines, trailing_eol)| { + let mut input = lines.join("\n"); + if trailing_eol { + input.push('\n'); + } + input + }) + } + + /// Lines of inline syntax. Each line starts with `x` so that no line is a block on its own. + fn inline_input() -> impl Strategy { + let token = prop::sample::select(vec![ + "a", + "b", + "foo", + " ", + " ", + "*", + "**", + "***", + "_", + "__", + "~", + "~~", + "`", + "``", + "[", + "]", + "(", + ")", + "![", + "<", + ">", + "\\", + "&", + "#", + "$", + "$$", + "http://a.b", + "www.a.b", + "a@b.co", + "\"", + "'", + ".", + ",", + "!", + "-", + "/", + "é", + "あ", + "\n", + "](", + "](x)", + "][", + "]:", + "
", + "", + "", + "", + ]); + prop::collection::vec(token, 1..14).prop_map(|tokens| { + let mut input = String::from("x"); + for token in tokens { + input.push_str(token); + if token == "\n" { + input.push('x'); + } + } + input + }) + } + + /// Lines of MDX: tags, expressions and the Markdown around them. + fn mdx_input() -> impl Strategy { + let token = prop::sample::select(vec![ + "<", + ">", + "/", + "{", + "}", + "a", + "b", + "c", + " ", + " ", + "=", + "\"", + "'", + ".", + ":", + "-", + "*", + "_", + "`", + "[", + "]", + "(", + ")", + "&", + "", + "", + "", + "", + "<>", + "", + "{x}", + "{x {y}}", + "# ", + "- ", + "> ", + "\\", + "!", + "|", + "\n", + "\n\n", + "\n ", + "", + "", + "", + "{...c}", + "é", + "1. ", + ]); + prop::collection::vec(token, 1..16).prop_map(|tokens| tokens.concat()) + } + + /// Markdown syntax mixed with multibyte text: CJK, emoji, combining marks, `ß`, full-width and + /// ideographic punctuation and spaces, and characters that change case or width. + fn unicode_input() -> impl Strategy { + let word = prop::sample::select(vec![ + "あ", + "日本語", + "テスト", + "🎉", + "👨‍👩‍👧", + "é", + "e\u{301}", + "ß", + "Dž", + "İ", + "fi", + "www", + "www.あ.com", + "wwß", + "ww\u{301}", + "http://あ.jp/パス", + "https://例え.jp", + "a@あ.com", + "あ@b.com", + "。", + "、", + "「", + "」", + "(", + ")", + "!", + "?", + "*", + "_", + "`", + "#", + "\u{3000}", + "\u{a0}", + "\u{200b}", + "\u{2028}", + "\u{feff}", + "\u{fe0f}", + "ا", + "א", + "𠮷", + "\u{10ffff}", + "\u{0}", + "x", + "foo", + ]); + let syntax = prop::sample::select(vec![ + " ", + " ", + "\t", + "*", + "**", + "_", + "__", + "~", + "~~", + "`", + "``", + "[", + "]", + "(", + ")", + "![", + "<", + ">", + "&", + "&", + "あ", + "🎉", + "&あ;", + "\\", + "|", + ":", + "-", + "#", + "^", + "\"", + "'", + "{", + "}", + "/", + "=", + "://", + ".", + "\n", + "\n\n", + "\n> ", + "\n- ", + "\n1. ", + "\n# ", + "\n| ", + "\n|-|-|\n", + "\n ", + "\n\t", + "\r\n", + "\n[a]: ", + "\n[^a]: ", + ]); + let token = prop_oneof![3 => word, 2 => syntax]; + prop::collection::vec(token, 1..24).prop_map(|tokens| tokens.concat()) + } + + /// Any characters at all, with extra weight on the ones that matter to Markdown. + fn arbitrary_input() -> impl Strategy { + let ch = prop_oneof![ + 4 => any::(), + 3 => prop::sample::select(vec!['*', '_', '`', '[', ']', '(', ')', '<', '>', '&', '|', '\\', '#', '-', '~', ' ', '\t', '\n', '!', 'w', 'h', '@', ':', '/', '.', '{', '}']), + 3 => prop::sample::select(vec!['あ', 'é', 'ß', '🎉', '\u{301}', '\u{3000}', '\u{a0}', '。', '*']), + ]; + prop::collection::vec(ch, 0..40).prop_map(|chars| chars.into_iter().collect()) + } + + /// Closed fences whose info string and content are multibyte, alone or inside a container. + fn unicode_fence_input() -> impl Strategy { + let text = prop::sample::select(vec![ + "あ", + "日本語 x", + "🎉", + "é", + "ß", + "www", + "a b", + "", + "> あ", + "- 🎉", + "\u{3000}", + ]); + ( + prop::sample::select(vec!["", "> ", "- ", " ", "1. "]), + prop::sample::select(vec![("```", "```"), ("~~~", "~~~"), ("````", "````"), ("$$", "$$")]), + text.clone(), + text.clone(), + text, + ) + .prop_map(|(container, (open, close), info, first, second)| { + let pad = if container == "> " { + "> ".to_string() + } else { + " ".repeat(container.len()) + }; + let lead = |line: &str, first: bool| { + if first { + format!("{container}{line}") + } else { + format!("{pad}{line}") + } + }; + [ + lead(&format!("{open}{info}"), true), + lead(first, false), + lead(second, false), + lead(close, false), + ] + .join("\n") + }) + } + + /// Checks what has to hold for any input: nothing panics, and every position lies inside the + /// document and does not end before it starts. + fn assert_valid(input: &str) { + let lines = 1 + input.matches(['\n', '\r']).count() - input.matches("\r\n").count(); + let mut stack = parse(input).expect("Markdown always parses"); + stack.extend(parse_mdx(input).unwrap_or_default()); + while let Some(node) = stack.pop() { + if let Some(position) = node.position() { + let (start, end) = (&position.start, &position.end); + let ordered = (start.line, start.column) <= (end.line, end.column); + assert!( + ordered && start.line >= 1 && start.column >= 1 && end.line <= lines, + "{node:?} has a position outside of {input:?}" + ); + } + stack.extend(node.children()); + } + let _ = to_html(input); + } + + /// Runs `strategy` with a fixed seed so the outcome never varies between runs. `PROPTEST_CASES` + /// and `PARSER_TEST_SEED` widen the exploration. + fn check(strategy: impl Strategy) { + let config = ProptestConfig { + cases: env_number("PROPTEST_CASES").unwrap_or(3000), + failure_persistence: None, + ..ProptestConfig::default() + }; + let seed = env_number("PARSER_TEST_SEED").unwrap_or(7); + let mut runner = TestRunner::new_with_rng(config, TestRng::from_seed(RngAlgorithm::ChaCha, &[seed; 32])); + let result = runner.run(&strategy, |input| { + assert_valid(&input); + Ok(()) + }); + if let Err(error) = result { + panic!("{error}"); + } + } + + #[test] + fn block_structure_is_valid() { + check(block_input()); + } + + #[test] + fn inline_syntax_is_valid() { + check(inline_input()); + } + + #[test] + fn mdx_syntax_is_valid() { + check(mdx_input()); + } + + #[test] + fn multibyte_syntax_is_valid() { + check(unicode_input()); + } + + #[test] + fn multibyte_fences_are_valid() { + check(unicode_fence_input()); + } + + #[test] + fn arbitrary_text_is_valid() { + check(arbitrary_input()); + } + + /// Every control character and unusual space or separator at each syntax position. + #[test] + fn special_characters_are_valid() { + let chars = (0u32..=0x9f) + .chain([ + 0xa0, 0xad, 0x1680, 0x180e, 0x2000, 0x200a, 0x200b, 0x200d, 0x2028, 0x2029, 0x202f, 0x205f, 0x2060, + 0x3000, 0xfeff, 0xfffd, 0xfe0f, + ]) + .filter_map(char::from_u32); + let templates = [ + "[a]: {}", + "[a]: x{}y", + "[a]: <{}>", + "[a]: /u '{}'", + "[a]: /u {}", + "[a]\n\n[a]: {}x\n\n[a]", + "[a]({})", + "[a](x{}y)", + "[a](<{}>)", + "[a](/u '{}')", + "[a](/u \"{}\")", + "![{}](x)", + "[{}](x)", + "[x{}]: y", + "", + "", + "<{}a>", + "", + "", + "", + "", + "http://a{}b.c", + "www.a{}b.c", + "www.a.b/{}x", + "x@y{}z.com", + "{}x@y.com", + "a{}b", + "{}a", + "a{}", + "a {}", + "{} a", + "# {}", + "# a{}", + "#{}a", + "{}# a", + "> {}", + "- {}", + "-{}a", + "1.{}a", + "1. {}", + "```{}", + "```a{}\nx\n```", + "```\n{}\n```", + "~~~{}", + "$${}", + "$$\n{}\n$$", + "`{}`", + "`a{}b`", + "*{}*", + "*a{}*", + "{}*a*", + "*a*{}", + "**{}**", + "_a{}_", + "_{}a_", + "~~a{}~~", + "~~{}~~", + "a\\{}", + "&{};", + "&a{};", + "&#{};", + "&#x{};", + "&{}", + "{}\n---", + "a{}\n---", + "a\n{}---", + "---{}", + "***{}", + "- - {}", + "| a{} | b |\n|-|-|", + "| {} |\n|-|\n| {} |", + "|{}|\n|{}|", + "[^a]: {}", + "[^a{}]: x", + "[^a]\n\n[^a]: {}x", + "x\n\n {}", + "x\n\n a{}", + "", + "
{}", + "
", + "", + "", + "---\n{}\n---\na", + "+++\n{}\n+++\na", + "{} \nb", + "a{}\nb", + "a\n{}b", + ]; + for char in chars { + for template in templates { + assert_valid(&template.replace("{}", &char.to_string())); + } + } + } + + const SPEC_URL: &str = + "https://raw.githubusercontent.com/github/cmark-gfm/828322d1ee4facdab56f0d3edccb13e9af90dcd2/test/spec.txt"; + + const EXTENSIONS_SPEC_URL: &str = "https://raw.githubusercontent.com/github/cmark-gfm/828322d1ee4facdab56f0d3edccb13e9af90dcd2/test/extensions.txt"; + + const COMMONMARK_SPEC_URL: &str = "https://raw.githubusercontent.com/commonmark/commonmark-spec/0.31.2/spec.txt"; + + /// The markdown and the HTML of each example of the `CommonMark` and GFM `spec.txt`, with its number. + fn spec_examples(text: &str) -> Vec<(usize, String, String)> { + let fence = "`".repeat(32); + let open = format!("{fence} example"); + let mut examples = Vec::new(); + let mut lines = text.lines(); + + while let Some(line) = lines.next() { + if line != open { + continue; + } + let markdown = lines.by_ref().take_while(|line| *line != ".").collect::>(); + let html = lines.by_ref().take_while(|line| *line != fence).collect::>(); + // `→` stands for a tab in the spec. + let text = |lines: Vec<&str>| lines.join("\n").replace('\u{2192}', "\t"); + let html = text(html); + let html = if html.is_empty() { html } else { html + "\n" }; + examples.push((examples.len() + 1, text(markdown) + "\n", html)); + } + examples + } + + /// Examples of the GFM spec, which is based on `CommonMark` 0.29, that are not rendered as in the + /// spec, by number: + /// + /// - a document that starts with `---` has frontmatter (66, 68) + /// - nested `strong` of runs of `*` and `_`, which `CommonMark` 0.30 and later render as nested + /// elements where 0.29 merged them (388, 416, 424, 425, 426, 463, 464, 465, 467) + /// - links with a protocol other than http, https, irc, ircs, mailto and xmpp have no `href`, as + /// as mdast to HTML does by default (496, 594, 595, 597) + /// - GFM autolink literals, which the spec does not have (598, 604, 607, 608) + /// - the GFM tag filter, which writes the tags `script`, `style` and `textarea` as text (140, 141, + /// 142, 145, 147) + const KNOWN_DIFFERENCES: &[usize] = &[ + 66, 68, 140, 141, 142, 145, 147, 388, 416, 424, 425, 426, 463, 464, 465, 467, 496, 594, 595, 597, 598, 604, + 607, 608, + ]; + + /// Examples of the GFM spec with `[[...]]` in them that the `wikilink` feature reads as a wikilink + /// (544, 555), and the one that either the `wikilink` or the `embed` feature changes (586). + const GFM_WIKILINK_DIFFERENCES: &[usize] = &[544, 555]; + const GFM_OBSIDIAN_DIFFERENCES: &[usize] = &[586]; + + /// The same for the `CommonMark` 0.31.2 spec (548, 559 and 590). + const COMMONMARK_WIKILINK_DIFFERENCES: &[usize] = &[548, 559]; + const COMMONMARK_OBSIDIAN_DIFFERENCES: &[usize] = &[590]; + + /// The examples that differ from the spec: `known`, and those that the enabled features read as + /// wikilinks and embeds. + fn expected_differences(known: &[usize], wikilink: &[usize], either: &[usize]) -> Vec { + let mut all = known.to_vec(); + if cfg!(feature = "wikilink") { + all.extend_from_slice(wikilink); + } + if cfg!(any(feature = "wikilink", feature = "embed")) { + all.extend_from_slice(either); + } + all.sort_unstable(); + all + } + + /// Examples of the `CommonMark` 0.31.2 spec that are not rendered as in the spec, by number: + /// + /// - a document that starts with `---` has frontmatter (96, 98) + /// - links with a protocol other than http, https, irc, ircs, mailto and xmpp have no `href` (500, + /// 598, 599, 601) + /// - GFM autolink literals (602, 608, 611, 612) + /// - the GFM tag filter, which writes the tags `script`, `style` and `textarea` as text (170, 171, 172, + /// 173, 176, 178) + const COMMONMARK_KNOWN_DIFFERENCES: &[usize] = &[ + 96, 98, 170, 171, 172, 173, 176, 178, 500, 598, 599, 601, 602, 608, 611, 612, + ]; + + /// Examples of the GFM extensions spec that are not rendered as in the spec, by number: + /// + /// - input that must only not crash, whose output the spec leaves out (20) + /// - the HTML of footnotes, which is the one of the current micromark and GitHub (23, 24, 25) + const EXTENSIONS_KNOWN_DIFFERENCES: &[usize] = &[20, 23, 24, 25]; + + /// Fetches `url`, or reads the file in the environment variable `var`. + fn spec_text(var: &str, url: &str) -> String { + match std::env::var(var) { + Ok(path) => std::fs::read_to_string(path).unwrap(), + Err(_) => ureq::get(url).call().unwrap().into_body().read_to_string().unwrap(), + } + } + + /// The numbers of the examples of `text` that are not rendered as in the spec. + fn differing_examples(text: &str, minimum: usize) -> Vec { + let examples = spec_examples(text); + assert!(examples.len() > minimum, "only {} examples were found", examples.len()); + examples + .iter() + .filter(|(_, markdown, html)| to_html(markdown) != *html) + .map(|(number, ..)| *number) + .collect() + } + + /// Renders every example of the spec to HTML, from `SPEC_FILE` or fetched over the network. + #[test] + #[ignore = "fetches spec.txt over the network; set SPEC_FILE to use a local copy"] + fn spec_examples_render_as_in_the_spec() { + assert_eq!( + differing_examples(&spec_text("SPEC_FILE", SPEC_URL), 600), + expected_differences(KNOWN_DIFFERENCES, GFM_WIKILINK_DIFFERENCES, GFM_OBSIDIAN_DIFFERENCES), + "examples that are not rendered as in the spec" + ); + } + + /// Renders every example of the `CommonMark` 0.31.2 spec to HTML, from `COMMONMARK_SPEC_FILE` or + /// fetched over the network. + #[test] + #[ignore = "fetches spec.txt over the network; set COMMONMARK_SPEC_FILE to use a local copy"] + fn commonmark_spec_examples_render_as_in_the_spec() { + assert_eq!( + differing_examples(&spec_text("COMMONMARK_SPEC_FILE", COMMONMARK_SPEC_URL), 600), + expected_differences( + COMMONMARK_KNOWN_DIFFERENCES, + COMMONMARK_WIKILINK_DIFFERENCES, + COMMONMARK_OBSIDIAN_DIFFERENCES + ), + "examples that are not rendered as in the spec" + ); + } + + /// Renders every example of the GFM extensions spec to HTML, from `EXTENSIONS_SPEC_FILE` or fetched + /// over the network. + #[test] + #[ignore = "fetches extensions.txt over the network; set EXTENSIONS_SPEC_FILE to use a local copy"] + fn extensions_spec_examples_render_as_in_the_spec() { + assert_eq!( + differing_examples(&spec_text("EXTENSIONS_SPEC_FILE", EXTENSIONS_SPEC_URL), 20), + EXTENSIONS_KNOWN_DIFFERENCES, + "examples that are not rendered as in the spec" + ); + } +} diff --git a/crates/mq-markdown/src/parser/block.rs b/crates/mq-markdown/src/parser/block.rs new file mode 100644 index 000000000..66e008bd4 --- /dev/null +++ b/crates/mq-markdown/src/parser/block.rs @@ -0,0 +1,533 @@ +//! Block structure parsing: resolves the lines into a tree of [`Block`]s. +//! +//! The constructs that can start at a line are the rules of [`rule::Rules`], tried in order, and a +//! paragraph when none starts. Containers (blockquote, list item, footnote definition) collect their +//! lines without the container prefix and parse them again, up to [`MAX_DEPTH`] levels. + +use super::code::Fence; +use super::definition; +use super::flavor::Flavor; +use super::html_flow::{self, Kind as HtmlKind}; +use super::inline; +use super::line::{Indent, Line, split_lines, tab_stops}; +use super::mdx_flow::{FlowOutcome, absorbed_until, blocks_lazy_continuation, probe_mdx_flow}; +use super::table; +mod collect; +mod footnote; +mod html; +mod leaf; +mod list; +mod rule; + +use super::tree::{Block, FootnoteBlock, InlineBlock, InlineKind, InlineSource, Item, ListBlock, QuoteBlock}; +use crate::node::{HeadingDepth, Html, ListMarker, Node, Point, Position, Toml, Yaml}; +use collect::Collector; +use footnote::{FOOTNOTE_INDENT, FootnoteMarker, footnote, footnote_marker}; +use html::{html_block, html_start}; +use list::{ItemMarker, list}; +use rule::{BlockRule, Cx, Rules, fallback}; + +/// Lines indented by this many columns or more are code, not other blocks. +pub(super) const CODE_INDENT: usize = 4; + +/// Containers nested deeper than this are parsed as plain paragraph text, which bounds recursion. +const MAX_DEPTH: usize = 128; + +/// What the first line of a container cannot start because the container interrupted a paragraph. +#[derive(Clone, Copy, Default)] +struct Interrupt { + /// Indented code cannot interrupt a paragraph. + code: bool, + /// Empty items and ordered items that do not start at 1 cannot interrupt a paragraph. + list: bool, +} + +pub(super) fn parse(src: &str, flavor: Flavor, read_frontmatter: bool) -> Vec { + let stops = tab_stops(src); + let mut lines = split_lines(src, flavor, &stops); + // A byte order mark at the start of the document is not content. + if let Some(first) = lines.first_mut().filter(|line| line.text.starts_with('\u{feff}')) { + let mark = '\u{feff}'.len_utf8(); + // Columns start after it. + *first = Line { + text: &first.text[mark..], + whole: &first.whole[mark..], + base: first.base + mark, + ..*first + }; + } + match read_frontmatter.then(|| frontmatter(&lines)).flatten() { + Some((node, next)) => { + let mut blocks = vec![Block::Node(node)]; + blocks.extend(parse_blocks(&lines[next..], 0, Interrupt::default())); + blocks + } + None => parse_blocks(&lines, 0, Interrupt::default()), + } +} + +/// Frontmatter at the very start of the document: YAML between `---` lines, or TOML between `+++` +/// lines. Without a closing line it is not frontmatter. +fn frontmatter(lines: &[Line<'_>]) -> Option<(Node, usize)> { + let is_fence = |line: &Line<'_>, marker: &str| { + line.text + .strip_prefix(marker) + .is_some_and(|rest| rest.trim_matches([' ', '\t']).is_empty()) + }; + let first = lines.first()?; + let marker = ["---", "+++"].into_iter().find(|marker| is_fence(first, marker))?; + let close = lines.iter().skip(1).position(|line| is_fence(line, marker))? + 1; + + let parts = lines[1..close] + .iter() + .map(|line| (line.text, line.eol)) + .collect::>(); + let value = join_lines(&parts); + let position = Some(Position { + start: first.point(0), + end: lines[close].end(), + }); + let node = if marker == "---" { + Node::Yaml(Yaml { value, position }) + } else { + Node::Toml(Toml { value, position }) + }; + + Some((node, close + 1)) +} + +/// Parses `lines` into blocks. `interrupting` restricts the first line when the container these +/// lines belong to interrupted an open paragraph. +fn parse_blocks(lines: &[Line<'_>], depth: usize, interrupting: Interrupt) -> Vec { + let containers = depth < MAX_DEPTH; + let mut blocks = Vec::new(); + let mut index = 0; + let mut after_paragraph = interrupting; + // Set after a container, and whether a blank line or an empty list item preceded the next block. + let mut after_container = false; + let mut blank_between = false; + + while index < lines.len() { + let interrupting = std::mem::take(&mut after_paragraph); + let line = &lines[index]; + if line.is_blank() { + blank_between = true; + index += 1; + continue; + } + let closed_container = std::mem::take(&mut after_container); + let separated = std::mem::take(&mut blank_between); + + let indent = line.indent(); + let cx = Cx { + lines, + index, + line, + indent, + start: LineStart::of(line, indent), + rest: &line.text[indent.bytes..], + depth, + containers, + interrupting, + closed_container, + separated, + }; + let step = Rules::parse(&cx, &mut blocks).unwrap_or_else(|| fallback(&cx, &mut blocks)); + index = step.next; + after_container = step.container; + after_paragraph = step.interrupt; + } + + blocks +} + +/// A block that a line starts by its first characters, before the rest of the document is known. +/// +/// The constructs are told apart by their first character, so a line starts at most one of them. Block +/// rules, lazy continuation and the end of paragraphs and tables all decide from this one result. +enum LineStart<'a> { + Fence(Fence<'a>), + /// An ATX heading of this depth. + Atx(HeadingDepth), + ThematicBreak, + Blockquote, + Item(ItemMarker), + Html(HtmlKind), + Footnote(FootnoteMarker<'a>), +} + +impl LineStart<'_> { + /// What `line` starts, when it is indented less than code. Setext underlines, tables and MDX flow + /// content depend on the lines around them, so they are not among these. + pub(super) fn of<'a>(line: &Line<'a>, indent: Indent) -> Option> { + if indent.columns >= line.code_indent() { + return None; + } + let rest = &line.text[indent.bytes..]; + match rest.as_bytes().first()? { + b'`' | b'~' | b'$' => Fence::open(rest, line.flavor).map(LineStart::Fence), + b'#' => atx_depth(rest).map(LineStart::Atx), + b'>' => Some(LineStart::Blockquote), + b'<' => html_start(line, rest).map(LineStart::Html), + b'[' => footnote_marker(line).map(LineStart::Footnote), + b'*' | b'-' | b'_' if is_thematic_break(line, indent.bytes) => Some(LineStart::ThematicBreak), + _ => ItemMarker::parse(line).map(LineStart::Item), + } + } + + /// Whether this start ends an open paragraph. A complete HTML tag, an empty item and an ordered + /// item that does not start at 1 cannot. + fn interrupts_paragraph(&self) -> bool { + match self { + LineStart::Html(kind) => *kind != HtmlKind::Complete, + LineStart::Item(marker) => marker.interrupts_paragraph(), + _ => true, + } + } +} + +/// The line without its blockquote marker and the one space or column after it, if it has a marker. +fn strip_blockquote(line: Line<'_>) -> Option> { + let indent = line.indent(); + matches!(LineStart::of(&line, indent), Some(LineStart::Blockquote)).then(|| after_blockquote_marker(line, indent)) +} + +/// The line after the blockquote marker that follows `indent`, and the one space or column after it. +fn after_blockquote_marker(line: Line<'_>, indent: Indent) -> Line<'_> { + let after = line.skip(indent.bytes + 1); + match after.text.as_bytes().first() { + Some(b' ') => after.skip(1), + // A tab is consumed by one column only. + Some(b'\t') => after.skip_columns(1), + _ => after, + } +} + +fn blockquote( + lines: &[Line<'_>], + start: usize, + depth: usize, + interrupting: Interrupt, + blocks: &mut Vec, +) -> usize { + let mut collector = Collector::new(depth, interrupting); + let mut index = start; + + while let Some(line) = lines.get(index) { + match strip_blockquote(*line) { + Some(stripped) => collector.push(stripped), + // Lazy continuation of a paragraph. + None if collector.lazy(lines, index) => {} + None => break, + } + index += 1; + } + let inner = collector.lines; + + let children = parse_blocks(&inner, depth + 1, interrupting); + let mut end = inner.last().map_or_else(|| lines[start].end(), Line::end); + // An unclosed fence at the end of the document extends past the last line. + if let Some(child_end) = children.last().and_then(last_end) + && (child_end.line, child_end.column) > (end.line, end.column) + { + end = child_end; + } + let position = Position { + start: lines[start].point(0), + end, + }; + blocks.push(Block::Quote(QuoteBlock { children, position })); + + index +} + +/// The end of the last node that `block` produces. +fn last_end(block: &Block) -> Option { + match block { + Block::Node(node) => node.position().map(|position| position.end), + Block::Fenced(fenced) => fenced.node.position().map(|position| position.end), + Block::Quote(quote) => Some(quote.position.end.clone()), + Block::List(list) => list.items.last().map(|item| item.position.end.clone()), + Block::Footnote(footnote) => Some(footnote.position.end.clone()), + Block::Inline(_) | Block::Table(_) | Block::Error(_) => None, + Block::Jsx(tag) => Some(tag.position.end.clone()), + } +} + +/// Extends the position of a trailing blockquote, or of the last item of a trailing list and of the +/// lists nested at its end, over the blank lines that follow. +fn extend_last_items(blocks: &mut [Block], end: &Point, quotes: bool) { + match blocks.last_mut() { + Some(Block::List(list)) => { + if let Some(item) = list.items.last_mut() + // An empty innermost item keeps its own end. + && !item.children.is_empty() + { + item.position.end = end.clone(); + extend_last_items(&mut item.children, end, quotes); + } + } + // A blockquote is only extended by a single whitespace-only line that ends the document. + Some(Block::Quote(quote)) if quotes => extend_quote(quote, end), + // A footnote definition also runs over the blank lines that follow. + Some(Block::Footnote(footnote)) => { + footnote.position.end = end.clone(); + extend_last_items(&mut footnote.children, end, quotes); + } + _ => {} + } +} + +/// Whether the content of `block` ends with a paragraph or a setext heading. +fn ends_in_text(block: &Block) -> bool { + match block { + Block::Inline(block) => match &block.kind { + InlineKind::Paragraph => true, + // A setext heading spans its underline line, an ATX heading does not. + InlineKind::Heading { position, .. } => position.start.line != position.end.line, + }, + Block::List(list) => list + .items + .last() + .and_then(|item| item.children.last()) + .is_some_and(ends_in_text), + Block::Quote(quote) => quote.children.last().is_some_and(ends_in_text), + Block::Footnote(footnote) => footnote.children.last().is_some_and(ends_in_text), + // A definition is a paragraph whose content was consumed. + Block::Node(node) => matches!(node, Node::Definition(_)), + Block::Fenced(_) | Block::Table(_) | Block::Jsx(_) | Block::Error(_) => false, + } +} + +/// Extends a blockquote that ends in a paragraph, and the blockquotes nested at its end. +fn extend_quote(quote: &mut QuoteBlock, end: &Point) { + if !quote.children.last().is_some_and(ends_in_text) { + return; + } + quote.position.end = end.clone(); + match quote.children.last_mut() { + Some(Block::Quote(inner)) => extend_quote(inner, end), + Some(Block::Footnote(footnote)) => footnote.position.end = end.clone(), + _ => {} + } +} + +/// Returns the heading depth when `rest` (indent already removed) is an ATX heading line. +fn atx_depth(rest: &str) -> Option { + let hashes = rest.bytes().take_while(|&b| b == b'#').count(); + let delimited = matches!(rest[hashes..].chars().next(), None | Some(' ' | '\t')); + let depth = u8::try_from(hashes).ok().and_then(HeadingDepth::new)?; + delimited.then_some(depth) +} + +fn atx_heading(line: &Line<'_>, indent: usize, depth: HeadingDepth) -> Block { + let rest = &line.text[indent + usize::from(depth.get())..]; + let content = rest.trim_matches([' ', '\t']); + // A closing sequence must be preceded by whitespace, unless it is the whole content. + let content = { + let without_closing = content.trim_end_matches('#'); + if without_closing.is_empty() || without_closing.ends_with([' ', '\t']) { + without_closing.trim_end_matches([' ', '\t']) + } else { + content + } + }; + let start = line.point(offset_in(line.text, content)); + + Block::Inline(InlineBlock { + source: InlineSource::new(std::iter::once((content, "", start))), + kind: InlineKind::Heading { + depth, + position: Position { + start: line.point(0), + end: line.end(), + }, + }, + }) +} + +/// Joins `(text, eol)` pairs, keeping each line's original terminator between lines. +pub(super) fn join_lines(parts: &[(&str, &str)]) -> String { + let mut value = String::with_capacity(parts.iter().map(|(text, eol)| text.len() + eol.len()).sum()); + for (index, (text, eol)) in parts.iter().enumerate() { + value.push_str(text); + if index + 1 < parts.len() { + value.push_str(eol); + } + } + value +} + +/// Byte offset of `child` within `parent`, where `child` is a subslice of `parent`. +fn offset_in(parent: &str, child: &str) -> usize { + child.as_ptr() as usize - parent.as_ptr() as usize +} + +/// Whether the rest of `line` after `indent` bytes is a thematic break. +fn is_thematic_break(line: &Line<'_>, indent: usize) -> bool { + let rest = &line.text[indent..]; + let marker = match rest.as_bytes().first() { + Some(b'*') => 0, + Some(b'-') => 1, + Some(b'_') => 2, + _ => return false, + }; + // Anything else after this point rules it out without looking at the rest of the line. + if line.others[marker] > line.column + indent { + return false; + } + let marker = rest.as_bytes()[0]; + rest.bytes().filter(|&byte| byte == marker).take(3).count() == 3 +} + +/// Returns the setext heading depth when `line` is a setext underline. +fn setext_depth(line: &Line<'_>) -> Option { + let Indent { columns, bytes: indent } = line.indent(); + let content = line.text[indent..].trim_end_matches([' ', '\t']); + let marker = content.chars().next()?; + if line.lazy || columns >= line.code_indent() || !content.chars().all(|c| c == marker) { + return None; + } + match marker { + '=' => Some(HeadingDepth::H1), + '-' => Some(HeadingDepth::H2), + _ => None, + } +} + +/// Whether a line without its container prefix can continue an open paragraph of that container. +/// Any list marker ends the paragraph here, even one that could not interrupt it elsewhere. +fn is_lazy_continuation(line: &Line<'_>) -> bool { + let start = LineStart::of(line, line.indent()); + !ends_paragraph(line, start.as_ref()) && !matches!(start, Some(LineStart::Item(_))) +} + +/// Whether `line` ends the paragraph that is being collected. +fn interrupts_paragraph(line: &Line<'_>) -> bool { + ends_paragraph(line, LineStart::of(line, line.indent()).as_ref()) +} + +/// Whether `line`, which starts `start`, ends an open paragraph. +fn ends_paragraph(line: &Line<'_>, start: Option<&LineStart<'_>>) -> bool { + // A lazy line was already accepted as a continuation by an enclosing container. + line.is_blank() || (!line.lazy && start.is_some_and(LineStart::interrupts_paragraph)) +} + +/// Whether `line` ends a table. Unlike a paragraph, a table is also ended by a list item that is empty +/// or that is numbered from other than one. +fn ends_table(line: &Line<'_>) -> bool { + let start = LineStart::of(line, line.indent()); + ends_paragraph(line, start.as_ref()) || (!line.lazy && matches!(start, Some(LineStart::Item(_)))) +} + +/// Collects a paragraph or a setext heading starting at `lines[start]`, with the definitions at its +/// start as blocks of their own. Returns the index after it and what it restricts on the next line. +fn paragraph(lines: &[Line<'_>], start: usize, blocks: &mut Vec) -> (usize, Interrupt) { + let mut index = start + 1; + // Lines up to this one are taken by flow content that turned out to be text. + let mut absorbed = match probe_mdx_flow(lines, start) { + Some(FlowOutcome::Nok(Some(last))) => absorbed_until(lines, start, last), + _ => start, + }; + let mut setext = None; + + while let Some(line) = lines.get(index) { + if table::starts_at(lines, index, interrupts_paragraph) { + break; + } + // MDX flow content, such as a tag or an expression alone on a line, interrupts a paragraph. + // What only looks like flow content still takes the lines it spans, whatever they start. + match probe_mdx_flow(lines, index) { + Some(FlowOutcome::Flow(_)) => break, + Some(FlowOutcome::Nok(Some(last))) => absorbed = absorbed.max(absorbed_until(lines, index, last)), + _ => {} + } + if index <= absorbed { + index += 1; + continue; + } + if let Some(depth) = setext_depth(line) { + setext = Some((depth, index)); + break; + } + if interrupts_paragraph(line) { + break; + } + index += 1; + } + + // Definitions at the start of the paragraph are blocks of their own. + let mut first = start; + if lines[start].text.trim_start_matches([' ', '\t']).starts_with('[') { + let source = paragraph_source(&lines[start..index]); + let spans = lines[start..index] + .iter() + .map(|line| Position { + start: line.point(0), + end: line.end(), + }) + .collect::>(); + let (definitions, used) = definition::extract(&source, &spans); + blocks.extend(definitions.into_iter().map(Block::Node)); + first += used; + } + if first == index { + // Nothing is left of the paragraph, so a setext underline starts a paragraph of text instead. + return match setext { + Some((_, underline)) => ( + underline, + // The underline is still a line of a paragraph, which an empty list item cannot interrupt. + Interrupt { + code: false, + list: true, + }, + ), + None => (index, Interrupt::default()), + }; + } + let source = paragraph_source(&lines[first..index]); + + match setext { + Some((depth, underline)) => { + blocks.push(Block::Inline(InlineBlock { + source, + kind: InlineKind::Heading { + depth, + position: Position { + start: lines[first].point(0), + end: lines[underline].end(), + }, + }, + })); + (underline + 1, Interrupt::default()) + } + None => { + blocks.push(Block::Inline(InlineBlock { + source, + kind: InlineKind::Paragraph, + })); + // A one-line paragraph right after a container does not restrict what follows it. + (index, Interrupt::default()) + } + } +} + +/// The raw inline content of the lines of a paragraph. Trailing spaces only matter before a line +/// ending, where they can make a hard break, so only the last line is trimmed at its end. +fn paragraph_source(lines: &[Line<'_>]) -> InlineSource { + let last = lines.len() - 1; + InlineSource::new(lines.iter().enumerate().map(|(index, line)| { + // The indentation of later lines stays in the content, where code and JSX keep it as it is. + let text = if index == 0 { + line.text.trim_start_matches([' ', '\t']) + } else { + line.text + }; + let text = if index == last { + text.trim_end_matches([' ', '\t']) + } else { + text + }; + (text, line.eol, line.point(offset_in(line.text, text))) + })) +} diff --git a/crates/mq-markdown/src/parser/block/collect.rs b/crates/mq-markdown/src/parser/block/collect.rs new file mode 100644 index 000000000..a6c0f99e1 --- /dev/null +++ b/crates/mq-markdown/src/parser/block/collect.rs @@ -0,0 +1,59 @@ +//! The lines of a container, collected for as long as they belong to it. + +use super::leaf::LeafState; +use super::{Interrupt, blocks_lazy_continuation}; +use crate::parser::line::Line; + +/// The lines of a container without its prefix, which a quote, an item or a footnote takes one by +/// one, and the blank lines that wait after them. +pub(super) struct Collector<'a> { + /// The lines that belong to the container. + pub(super) lines: Vec>, + /// The blank lines after the last one. They end the container unless a line that belongs to it + /// follows. + pub(super) blanks: Vec>, + state: LeafState<'a>, +} + +impl<'a> Collector<'a> { + /// `depth` is the depth of the container. + pub(super) fn new(depth: usize, interrupting: Interrupt) -> Self { + Self { + lines: Vec::new(), + blanks: Vec::new(), + state: LeafState::new(interrupting, depth), + } + } + + /// Takes `line`, which belongs to the container, together with the blank lines before it. + pub(super) fn push(&mut self, line: Line<'a>) { + for blank in &self.blanks { + self.state.feed(blank); + } + self.lines.append(&mut self.blanks); + self.state.feed(&line); + self.lines.push(line); + } + + /// Holds a blank line back until a line that belongs to the container follows it. + pub(super) fn blank(&mut self, line: Line<'a>) { + self.blanks.push(line); + } + + /// Takes `lines[index]` as a lazy continuation of the paragraph that is open, if it can be one. + pub(super) fn lazy(&mut self, lines: &[Line<'a>], index: usize) -> bool { + let line = &lines[index]; + if !self.blanks.is_empty() || !self.state.continues_with(line) || blocks_lazy_continuation(lines, index) { + return false; + } + self.push(Line { lazy: true, ..*line }); + true + } + + /// Keeps the blank lines that end an open fence as code, and not as the end of the container. + pub(super) fn keep_fence_blanks(&mut self) { + if self.blanks.first().is_some_and(|blank| self.state.fence_holds(blank)) { + self.lines.append(&mut self.blanks); + } + } +} diff --git a/crates/mq-markdown/src/parser/block/footnote.rs b/crates/mq-markdown/src/parser/block/footnote.rs new file mode 100644 index 000000000..d9b4a4269 --- /dev/null +++ b/crates/mq-markdown/src/parser/block/footnote.rs @@ -0,0 +1,96 @@ +//! Footnote definitions. + +use super::{Block, Collector, FootnoteBlock, Indent, Interrupt, Line, Position, inline, parse_blocks}; + +/// The indentation of the lines that continue a footnote definition. +pub(super) const FOOTNOTE_INDENT: usize = 4; + +/// The start of a footnote definition: `[^label]:`. +pub(super) struct FootnoteMarker<'a> { + label: &'a str, + /// Bytes from the start of the line to the content. + pub(super) content: usize, +} + +pub(super) fn footnote_marker<'a>(line: &Line<'a>) -> Option> { + if !line.flavor.has_gfm() { + return None; + } + let Indent { columns, bytes: indent } = line.indent(); + let rest = line.text[indent..].strip_prefix("[^")?; + if columns >= line.code_indent() { + return None; + } + // A backslash escapes the bracket that follows it, as well as another backslash. + let bytes = rest.as_bytes(); + let mut close = 0; + while close < bytes.len() && bytes[close] != b']' { + if bytes[close] == b'[' { + return None; + } + close += if bytes[close] == b'\\' && matches!(bytes.get(close + 1), Some(b'[' | b'\\' | b']')) { + 2 + } else { + 1 + }; + } + if close >= bytes.len() { + return None; + } + let label = &rest[..close]; + if label.is_empty() || label.bytes().any(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n')) { + return None; + } + let after = rest[close + 1..].strip_prefix(':')?; + let blanks = after.len() - after.trim_start_matches([' ', '\t']).len(); + Some(FootnoteMarker { + label, + content: indent + 2 + close + 2 + blanks, + }) +} + +/// Collects a footnote definition, which continues on lines indented by four columns. +pub(super) fn footnote( + lines: &[Line<'_>], + start: usize, + marker: &FootnoteMarker<'_>, + depth: usize, + interrupting: Interrupt, + blocks: &mut Vec, +) -> usize { + let mut collector = Collector::new(depth, interrupting); + collector.push(lines[start].skip(marker.content)); + let mut index = start + 1; + + while let Some(line) = lines.get(index) { + if line.is_blank() { + collector.blank(line.skip(line.text.len())); + } else if line.indent().columns >= FOOTNOTE_INDENT { + collector.push(line.skip_columns(FOOTNOTE_INDENT)); + } else if !collector.lazy(lines, index) { + break; + } + index += 1; + } + let Collector { + lines: inner, blanks, .. + } = collector; + + // Trailing blank lines belong to whatever follows, but still extend the position. + index -= blanks.len(); + let end = blanks + .last() + .or(inner.last()) + .map_or_else(|| lines[start].end(), Line::end); + let position = Position { + start: lines[start].point(0), + end, + }; + blocks.push(Block::Footnote(FootnoteBlock { + ident: inline::normalize(marker.label), + children: parse_blocks(&inner, depth + 1, interrupting), + position, + })); + + index +} diff --git a/crates/mq-markdown/src/parser/block/html.rs b/crates/mq-markdown/src/parser/block/html.rs new file mode 100644 index 000000000..27a810593 --- /dev/null +++ b/crates/mq-markdown/src/parser/block/html.rs @@ -0,0 +1,70 @@ +//! HTML blocks. + +use super::{Block, Html, HtmlKind, Line, Node, Point, Position, html_flow, join_lines}; + +/// Collects an HTML block of `kind` starting at `lines[start]`, which is indented less than four columns. +pub(super) fn html_block(lines: &[Line<'_>], start: usize, kind: HtmlKind, blocks: &mut Vec) -> usize { + let first = &lines[start]; + let mut last = start; + let mut unterminated = false; + + match kind { + HtmlKind::Basic | HtmlKind::Complete => { + // Ends before a blank line, and lines cannot continue a paragraph lazily. + for (index, line) in lines.iter().enumerate().skip(start + 1) { + if line.is_blank() || line.lazy { + break; + } + last = index; + } + } + _ => { + let indent = first.indent().bytes; + let from = indent + html_flow::first_line_offset(kind); + if !html_flow::ends_in(kind, &first.text[from..]) { + let mut ended = false; + for (index, line) in lines.iter().enumerate().skip(start + 1) { + if line.lazy { + break; + } + last = index; + if html_flow::ends_in(kind, line.text) { + ended = true; + break; + } + } + // A block that never ends runs to the end of the document, line ending included. + unterminated = !ended && lines[last].eof && !lines[last].eol.is_empty(); + } + } + } + + let parts = lines[start..=last] + .iter() + .map(|line| (line.text, line.eol)) + .collect::>(); + let mut value = join_lines(&parts); + let end = if unterminated { + value.push_str(lines[last].eol); + Point { + line: lines[last].number + 1, + column: 1, + } + } else { + lines[last].end() + }; + blocks.push(Block::Node(Node::Html(Html { + value, + position: Some(Position { + start: first.point(0), + end, + }), + }))); + + last + 1 +} + +/// The kind of HTML block that starts at `rest`, in a flavor that has HTML. +pub(super) fn html_start(line: &Line<'_>, rest: &str) -> Option { + line.flavor.has_html().then(|| html_flow::start(rest)).flatten() +} diff --git a/crates/mq-markdown/src/parser/block/leaf.rs b/crates/mq-markdown/src/parser/block/leaf.rs new file mode 100644 index 000000000..950026f67 --- /dev/null +++ b/crates/mq-markdown/src/parser/block/leaf.rs @@ -0,0 +1,325 @@ +//! Tracks the containers and the leaf block that are open in the lines a container has collected, to +//! tell whether a following line without the container prefix continues a paragraph. + +use super::{ + FOOTNOTE_INDENT, Interrupt, ItemMarker, LineStart, MAX_DEPTH, after_blockquote_marker, interrupts_paragraph, + is_lazy_continuation, setext_depth, +}; +use crate::parser::code::Fence; +use crate::parser::definition::only_definitions; +use crate::parser::html_flow::{self, Kind as HtmlKind}; +use crate::parser::line::{Indent, Line}; +use crate::parser::mdx_flow::{FlowEnd, FlowStart, flow_end, flow_start}; +use crate::parser::table; + +/// MDX flow content that spans lines is followed for this many lines, which bounds the work. +const MAX_FLOW_LINES: usize = 64; + +/// A container that is open in the collected lines, inside the one that collects them. +enum Open { + Quote, + /// A list item, whose content is indented by `width` columns. + Item { + width: usize, + /// Whether the item started empty and has no content yet: a blank line then ends it. + empty: bool, + }, + Footnote, +} + +/// MDX flow content that goes on in the next line. +struct Pending<'a> { + text: String, + /// Terminator of the last line in `text`, which separates it from the next line. + eol: &'a str, + lines: usize, +} + +pub(super) struct LeafState<'a> { + /// The containers that are open, outermost first. The leaf blocks below belong to the innermost. + open: Vec, + fence: Option>, + /// An HTML block that has not ended yet. + html: Option, + /// A table that has not ended yet. Its rows cannot be continued lazily. + table: bool, + /// The last line when it was paragraph text, which a delimiter row with as many cells can turn + /// into a header. + header: Option>, + paragraph: bool, + /// The text of the open paragraph when it starts like a definition, which a setext underline does + /// not turn into a heading when there is nothing else in it. + definitions: Option, + flow: Option>, + /// Restrictions on the next fed line, the first line of a container that interrupted a paragraph. + restricted: Interrupt, + /// How many containers can still be open before the depth limit, which no longer open any. + budget: usize, +} + +impl<'a> LeafState<'a> { + /// `depth` is the depth of the container that collects the lines. + pub(super) fn new(restricted: Interrupt, depth: usize) -> Self { + Self { + open: Vec::new(), + fence: None, + html: None, + table: false, + header: None, + paragraph: false, + definitions: None, + flow: None, + restricted, + budget: MAX_DEPTH.saturating_sub(depth + 1), + } + } + + /// Whether `line` without its container prefix continues the paragraph of the collected lines. + pub(super) fn continues_with(&self, line: &Line<'_>) -> bool { + self.paragraph && is_lazy_continuation(line) + } + + pub(super) fn feed(&mut self, line: &Line<'a>) { + self.feed_line(*line); + if !self.paragraph { + self.definitions = None; + } + } + + /// Whether the blank `line` is part of the fence that is open, which a container does not end for + /// the blank lines that come last in it. + pub(super) fn fence_holds(&self, blank: &Line<'a>) -> bool { + self.fence.is_some() && self.strip_prefixes(*blank).0 == self.open.len() + } + + /// Removes the prefixes of the open containers from `line`, and returns how many of them it has. + fn strip_prefixes(&self, mut line: Line<'a>) -> (usize, Line<'a>) { + for (index, open) in self.open.iter().enumerate() { + let width = match open { + Open::Quote => { + let indent = line.indent(); + if !matches!(LineStart::of(&line, indent), Some(LineStart::Blockquote)) { + return (index, line); + } + line = after_blockquote_marker(line, indent); + continue; + } + // Another blank line ends an item that started empty. + Open::Item { empty: true, .. } if line.is_blank() => return (index, line), + Open::Item { width, .. } => *width, + Open::Footnote => FOOTNOTE_INDENT, + }; + // A blank line has the prefix of an item, whatever its indentation. + if !line.is_blank() && line.indent().columns < width { + return (index, line); + } + line = line.skip_columns(width); + } + (self.open.len(), line) + } + + /// Ends the containers from the `keep`th on, and with them what they hold. + fn close_from(&mut self, keep: usize) { + self.open.truncate(keep); + self.fence = None; + self.html = None; + self.table = false; + self.header = None; + self.paragraph = false; + self.flow = None; + } + + fn feed_line(&mut self, line: Line<'a>) { + let restricted = std::mem::take(&mut self.restricted); + let mut line = line; + // A lazy line was already accepted by the container that collects the lines. + if !line.lazy { + let (matched, rest) = self.strip_prefixes(line); + if matched < self.open.len() { + // Containers that do not match end, unless the line goes on with their paragraph. + if self.paragraph && flow_start(&rest).is_none() && is_lazy_continuation(&rest) { + line = Line { lazy: true, ..rest }; + } else { + self.close_from(matched); + line = rest; + } + } else { + line = rest; + } + if !line.is_blank() { + for open in &mut self.open { + if let Open::Item { empty, .. } = open { + *empty = false; + } + } + } + } + // A paragraph that this line is in the container of, which markers have to interrupt. + let in_paragraph = self.paragraph && !line.lazy; + + loop { + let line_indent = line.indent(); + let Indent { columns, bytes: indent } = line_indent; + let rest = &line.text[indent..]; + + if let Some(fence) = &self.fence { + if columns < line.code_indent() && fence.is_closed_by(rest) { + self.fence = None; + } + return; + } + + if let Some(kind) = self.html { + let ends = match kind { + HtmlKind::Basic | HtmlKind::Complete => line.is_blank(), + _ => html_flow::ends_in(kind, line.text), + }; + if ends { + self.html = None; + } + self.paragraph = false; + return; + } + + if let Some(pending) = &mut self.flow { + pending.text.push_str(pending.eol); + pending.text.push_str(line.text); + pending.eol = line.eol; + pending.lines += 1; + let end = if pending.lines < MAX_FLOW_LINES { + flow_end(&pending.text) + } else { + FlowEnd::Done + }; + if matches!(end, FlowEnd::Open) { + return; + } + self.flow = None; + self.paragraph = matches!(end, FlowEnd::Text); + return; + } + + if line.is_blank() { + self.paragraph = false; + self.table = false; + self.header = None; + return; + } + let header = std::mem::take(&mut self.header); + if self.table { + if interrupts_paragraph(&line) { + self.table = false; + } else { + self.paragraph = false; + return; + } + } else if self.paragraph + && !line.lazy + && let Some(cells) = table::delimiter_cells(&line) + && header.as_ref().and_then(table::header_width) == Some(cells) + && !interrupts_paragraph(&line) + { + self.table = true; + self.paragraph = false; + return; + } + if columns >= line.code_indent() { + // Continues an open paragraph, otherwise it is indented code (or text when the + // container interrupted a paragraph). + if !self.paragraph { + self.paragraph = restricted.code; + } else if let Some(text) = &mut self.definitions { + text.push('\n'); + text.push_str(rest); + } + return; + } + let start = LineStart::of(&line, line_indent); + if let Some(LineStart::Fence(fence)) = start { + self.fence = Some(fence); + self.paragraph = false; + return; + } + if self.paragraph && setext_depth(&line).is_some() { + // Under nothing but definitions an underline is text, unless it is a rule. + if self.definitions.as_deref().is_some_and(only_definitions) { + self.definitions = None; + self.paragraph = !matches!(start, Some(LineStart::ThematicBreak)); + } else { + self.paragraph = false; + } + return; + } + let can_open = self.open.len() < self.budget; + match start { + Some(LineStart::Atx(_) | LineStart::ThematicBreak) => { + self.paragraph = false; + return; + } + Some(LineStart::Html(kind)) if kind != HtmlKind::Complete || !self.paragraph => { + let closed = match kind { + HtmlKind::Basic | HtmlKind::Complete => false, + _ => html_flow::ends_in(kind, &rest[html_flow::first_line_offset(kind)..]), + }; + self.html = (!closed).then_some(kind); + self.paragraph = false; + return; + } + Some(LineStart::Blockquote) if can_open => { + line = after_blockquote_marker(line, line_indent); + self.open.push(Open::Quote); + self.paragraph = false; + continue; + } + Some(LineStart::Footnote(marker)) if can_open => { + line = line.skip(marker.content); + self.open.push(Open::Footnote); + self.paragraph = false; + continue; + } + // A marker in the container of the open paragraph has to be able to interrupt it. + Some(LineStart::Item(marker)) + if can_open && (!(restricted.list || in_paragraph) || marker.interrupts_paragraph()) => + { + self.open.push(item(&marker)); + line = marker.content(line); + // The rest of the line starts a new item. + self.paragraph = false; + continue; + } + _ => {} + } + + // MDX flow content is not part of a paragraph. + if let Some(start) = flow_start(&line) { + self.paragraph = false; + if matches!(start, FlowStart::Pending) { + self.flow = Some(Pending { + text: line.text.to_string(), + eol: line.eol, + lines: 1, + }); + } + return; + } + self.header = Some(line); + if self.paragraph { + if let Some(text) = &mut self.definitions { + text.push('\n'); + text.push_str(rest); + } + } else { + self.definitions = rest.starts_with('[').then(|| rest.to_string()); + } + self.paragraph = true; + return; + } + } +} + +fn item(marker: &ItemMarker) -> Open { + Open::Item { + width: marker.width, + empty: marker.empty, + } +} diff --git a/crates/mq-markdown/src/parser/block/list.rs b/crates/mq-markdown/src/parser/block/list.rs new file mode 100644 index 000000000..e5dfd866a --- /dev/null +++ b/crates/mq-markdown/src/parser/block/list.rs @@ -0,0 +1,291 @@ +//! Lists and their items. + +use super::{ + Block, Collector, Indent, InlineBlock, InlineKind, Interrupt, Item, Line, LineStart, ListBlock, ListMarker, + Position, extend_last_items, interrupts_paragraph, is_thematic_break, parse_blocks, setext_depth, table, +}; + +#[derive(Clone)] +pub(super) struct ItemMarker { + ordered: bool, + kind: ListMarker, + start: u32, + /// Bytes from the start of the line to the end of the marker. + after_marker: usize, + /// Columns of whitespace between the marker and the content that belong to the marker. + gap: usize, + /// Columns that continuation lines must be indented by to belong to the item. + pub(super) width: usize, + /// Whether the first line has no content after the marker. + pub(super) empty: bool, +} + +impl ItemMarker { + pub(super) fn parse(line: &Line<'_>) -> Option { + let Indent { columns, bytes: indent } = line.indent(); + if columns >= line.code_indent() { + return None; + } + let rest = &line.text[indent..]; + let bytes = rest.as_bytes(); + if is_thematic_break(line, indent) { + return None; + } + + let (ordered, start, marker_len) = match *bytes.first()? { + b'-' | b'+' | b'*' => (false, 0, 1), + b'0'..=b'9' => { + let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count(); + if digits > 9 || !matches!(bytes.get(digits), Some(b'.' | b')')) { + return None; + } + (true, rest[..digits].parse().ok()?, digits + 1) + } + _ => return None, + }; + let kind = match bytes[marker_len - 1] { + b'-' => ListMarker::Dash, + b'+' => ListMarker::Plus, + b'*' => ListMarker::Star, + b'.' => ListMarker::Period, + _ => ListMarker::Paren, + }; + + let after = &rest[marker_len..]; + let Indent { + columns: spaces, + bytes: blanks, + } = line.skip(indent + marker_len).indent(); + if blanks == 0 && !after.is_empty() { + return None; + } + + let empty = blanks == after.len(); + // Five or more columns means the content is indented code, so only one belongs to the marker. + let gap = if empty || spaces > line.code_indent() { + 1 + } else { + spaces + }; + + Some(Self { + ordered, + kind, + start, + after_marker: indent + marker_len, + gap, + width: columns + marker_len + gap, + empty, + }) + } + + /// The line without the marker and the whitespace that belongs to it. + pub(super) fn content<'a>(&self, line: Line<'a>) -> Line<'a> { + if self.empty { + line.skip(line.text.len()) + } else { + line.skip(self.after_marker).skip_columns(self.gap) + } + } + + fn same_list(&self, other: &Self) -> bool { + self.ordered == other.ordered && self.kind == other.kind + } + + /// Whether this marker may interrupt a paragraph. + pub(super) fn interrupts_paragraph(&self) -> bool { + !self.empty && (!self.ordered || self.start == 1) + } +} + +pub(super) fn list( + lines: &[Line<'_>], + start: usize, + first: &ItemMarker, + depth: usize, + interrupting: Interrupt, + blocks: &mut Vec, +) -> usize { + let mut items = Vec::new(); + let mut spread = false; + let mut index = start; + let mut marker = first.clone(); + + loop { + let (item, end) = list_item( + lines, + index, + &marker, + depth, + if items.is_empty() { + interrupting + } else { + Interrupt::default() + }, + ); + items.push(item); + + // Blank lines between items make the list loose. + let next = (end..lines.len()).find(|&i| !lines[i].is_blank()).and_then(|i| { + ItemMarker::parse(&lines[i]) + .filter(|m| m.same_list(first)) + .map(|m| (i, m)) + }); + match next { + Some((next, next_marker)) => { + spread |= next > end; + index = next; + marker = next_marker; + } + None => { + index = end; + break; + } + } + } + + blocks.push(Block::List(ListBlock { + ordered: first.ordered, + start: first.ordered.then_some(first.start), + marker: first.kind, + spread, + items, + })); + + index +} + +/// Collects one item starting at `start`, returning it and the index of the first line after it. +pub(super) fn list_item( + lines: &[Line<'_>], + start: usize, + marker: &ItemMarker, + depth: usize, + interrupting: Interrupt, +) -> (Item, usize) { + let first = marker.content(lines[start]); + let mut collector = Collector::new(depth, interrupting); + collector.push(first); + let mut last_blank_has_whitespace = false; + let mut index = start + 1; + + while let Some(line) = lines.get(index) { + if line.is_blank() { + // An item that starts with a blank line cannot continue after another one. + if first.is_blank() && collector.lines.len() == 1 { + break; + } + last_blank_has_whitespace = !line.text.is_empty(); + collector.blank(line.skip_columns(marker.width)); + } else if line.lazy { + collector.push(*line); + } else if line.indent().columns >= marker.width { + collector.push(line.skip_columns(marker.width)); + } else if !collector.lazy(lines, index) { + break; + } + index += 1; + } + collector.keep_fence_blanks(); + let Collector { + lines: mut inner, + blanks, + .. + } = collector; + + let checkbox = inner + .iter() + .position(|line| !line.is_blank()) + .and_then(|index| task_checkbox(&inner[index..])); + + // Trailing blank lines belong to whatever follows the item, but still extend its position. + let reaches_end = index == lines.len(); + index -= blanks.len(); + + // An unclosed fence in an item includes its line terminator when the next line is blank or starts + // another container, but not when a plain paragraph or thematic break follows. + let next = lines.get(index); + let item_end = next.is_none_or(|line| { + line.is_blank() + || matches!( + LineStart::of(line, line.indent()), + Some(LineStart::Blockquote | LineStart::Item(_)) + ) + }); + if let Some(last) = inner.last_mut() { + last.item_end = item_end; + } + + let content_end = inner.last().map_or_else(|| lines[start].end(), Line::end); + let position = Position { + start: lines[start].point(0), + end: blanks.last().map_or_else(|| content_end.clone(), Line::end), + }; + let mut children = parse_blocks(&inner, depth + 1, interrupting); + // The checkbox is the start of the text of the first paragraph. + let checked = checkbox.filter(|_| remove_task_marker(&mut children)); + if let Some(blank) = blanks.last() { + extend_last_items( + &mut children, + &blank.end(), + last_blank_has_whitespace && reaches_end && blanks.len() == 1 && blank.eol.is_empty(), + ); + } + let item = Item { + checked, + children, + position, + content_end, + }; + + (item, index) +} + +/// Recognises a GFM task marker (`[ ] ` or `[x] `) at the start of an item's content, returning +/// the checked state and the bytes to skip. +fn task_checkbox(lines: &[Line<'_>]) -> Option { + let line = &lines[0]; + if !line.flavor.has_gfm() { + return None; + } + let bytes = line.text.as_bytes(); + let checked = match bytes.get(..3)? { + [b'[', b' ', b']'] => false, + [b'[', b'x' | b'X', b']'] => true, + _ => return None, + }; + // Whitespace follows the marker, which a line ending is when the paragraph goes on in the next line. + match bytes.get(3) { + Some(b' ' | b'\t') => Some(checked), + None if continues_paragraph(lines) => Some(checked), + _ => None, + } +} + +/// Whether the paragraph that starts at `lines[0]` goes on in `lines[1]`, as opposed to a blank line, +/// another block, a table or a setext underline. +fn continues_paragraph(lines: &[Line<'_>]) -> bool { + lines.get(1).is_some_and(|next| { + !interrupts_paragraph(next) && setext_depth(next).is_none() && !table::starts_at(lines, 0, interrupts_paragraph) + }) +} + +/// Removes the checkbox from the text of the first paragraph of an item, and the paragraph when nothing +/// else is in it. Returns whether the item starts with one. +fn remove_task_marker(children: &mut Vec) -> bool { + let Some(Block::Inline(InlineBlock { + source, + kind: InlineKind::Paragraph, + })) = children.first_mut() + else { + return false; + }; + if !matches!(source.text.as_bytes().get(..3), Some([b'[', b' ' | b'x' | b'X', b']'])) { + return false; + } + source.remove_prefix_and_one(3); + if source.text.is_empty() { + children.remove(0); + } + true +} diff --git a/crates/mq-markdown/src/parser/block/rule.rs b/crates/mq-markdown/src/parser/block/rule.rs new file mode 100644 index 000000000..902345ce0 --- /dev/null +++ b/crates/mq-markdown/src/parser/block/rule.rs @@ -0,0 +1,328 @@ +//! Block constructs as rules that [`parse_blocks`](super::parse_blocks) tries in order. +//! +//! A rule starts at the current line, pushes its blocks and tells where parsing resumes. The rules are +//! types chained in [`Rules`], so the dispatch is static and the order is the precedence of the +//! constructs. The paragraph is not a rule: it is what is left when no rule starts. + +use super::{Interrupt, LineStart, atx_heading, blockquote, footnote, html_block, join_lines, list, paragraph}; +use crate::node::{HorizontalRule, HorizontalRuleMarker, MdxJsEsm, Node, Position}; +use crate::parser::code::{fenced_code, indented_code}; +use crate::parser::line::{Indent, Line}; +use crate::parser::mdx_flow::{FlowOutcome, may_start_flow, mdx_flow}; +use crate::parser::table; +use crate::parser::tree::Block; + +/// The line a rule starts at and what is known about the lines before it. +pub(super) struct Cx<'a, 'l> { + pub(super) lines: &'l [Line<'a>], + pub(super) index: usize, + pub(super) line: &'l Line<'a>, + /// The indentation of `line`, what the line starts, and the text after the indentation. + pub(super) indent: Indent, + pub(super) start: Option>, + pub(super) rest: &'a str, + pub(super) depth: usize, + /// Whether containers can still be nested. + pub(super) containers: bool, + pub(super) interrupting: Interrupt, + /// Whether the previous block is a container. + pub(super) closed_container: bool, + /// Whether a blank line or an empty list item preceded `line`. + pub(super) separated: bool, +} + +/// Where parsing resumes after a rule and what the block restricts on the next line. +pub(super) struct Step { + pub(super) next: usize, + pub(super) container: bool, + pub(super) interrupt: Interrupt, +} + +impl Step { + fn to(next: usize) -> Self { + Self { + next, + container: false, + interrupt: Interrupt::default(), + } + } + + fn container(next: usize) -> Self { + Self { + container: true, + ..Self::to(next) + } + } + + fn interrupt((next, interrupt): (usize, Interrupt)) -> Self { + Self { + interrupt, + ..Self::to(next) + } + } +} + +/// A block construct. `parse` pushes to `blocks` only when it returns a step. +pub(super) trait BlockRule { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option; +} + +impl BlockRule for (A, B) { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + A::parse(cx, blocks).or_else(|| B::parse(cx, blocks)) + } +} + +/// Lines a paragraph is made of when no rule starts. +pub(super) fn fallback(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Step { + Step::interrupt(paragraph(cx.lines, cx.index, blocks)) +} + +macro_rules! chain { + ($rule:ty) => { $rule }; + ($rule:ty, $($rest:ty),+) => { ($rule, chain!($($rest),+)) }; +} + +/// The rules in the order they are tried. +pub(super) type Rules = chain!( + IndentedCode, + FencedCode, + AtxHeading, + ThematicBreak, + Blockquote, + List, + Html, + Footnote, + Table, + Esm, + MdxFlow +); + +/// Indented code, which cannot interrupt a paragraph and is part of it then. +pub(super) struct IndentedCode; + +impl BlockRule for IndentedCode { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + if cx.indent.columns < cx.line.code_indent() { + return None; + } + Some(if cx.interrupting.code { + fallback(cx, blocks) + } else { + Step::to(indented_code(cx.lines, cx.index, blocks)) + }) + } +} + +pub(super) struct FencedCode; + +impl BlockRule for FencedCode { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let Some(LineStart::Fence(fence)) = &cx.start else { + return None; + }; + // The end of an unclosed fence without content is quirky right after a container. + let own_end = cx.closed_container + && !(cx.separated + && matches!(blocks.last(), Some(Block::List(l)) if l.items.last().is_some_and(|i| i.children.is_empty()))); + Some(Step::to(fenced_code( + cx.lines, + cx.index, + cx.indent.bytes, + fence, + own_end, + blocks, + ))) + } +} + +pub(super) struct AtxHeading; + +impl BlockRule for AtxHeading { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let Some(LineStart::Atx(depth)) = cx.start else { + return None; + }; + blocks.push(atx_heading(cx.line, cx.indent.bytes, depth)); + Some(Step::to(cx.index + 1)) + } +} + +pub(super) struct ThematicBreak; + +impl BlockRule for ThematicBreak { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + if !matches!(cx.start, Some(LineStart::ThematicBreak)) { + return None; + } + blocks.push(Block::Node(Node::HorizontalRule(HorizontalRule { + marker: cx.rest.chars().next().and_then(HorizontalRuleMarker::from_char), + position: Some(Position { + start: cx.line.point(0), + end: cx.line.end(), + }), + }))); + Some(Step::to(cx.index + 1)) + } +} + +pub(super) struct Blockquote; + +impl BlockRule for Blockquote { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + if !cx.containers || !matches!(cx.start, Some(LineStart::Blockquote)) { + return None; + } + Some(Step::container(blockquote( + cx.lines, + cx.index, + cx.depth, + cx.interrupting, + blocks, + ))) + } +} + +pub(super) struct List; + +impl BlockRule for List { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let Some(LineStart::Item(marker)) = &cx.start else { + return None; + }; + if !(cx.containers && (!cx.interrupting.list || marker.interrupts_paragraph())) { + return None; + } + Some(Step::container(list( + cx.lines, + cx.index, + marker, + cx.depth, + cx.interrupting, + blocks, + ))) + } +} + +pub(super) struct Html; + +impl BlockRule for Html { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let Some(LineStart::Html(kind)) = cx.start else { + return None; + }; + Some(Step::to(html_block(cx.lines, cx.index, kind, blocks))) + } +} + +pub(super) struct Footnote; + +impl BlockRule for Footnote { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let Some(LineStart::Footnote(marker)) = &cx.start else { + return None; + }; + if !cx.containers { + return None; + } + Some(Step::container(footnote( + cx.lines, + cx.index, + marker, + cx.depth, + cx.interrupting, + blocks, + ))) + } +} + +pub(super) struct Table; + +impl BlockRule for Table { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let (items, next) = table::parse(cx.lines, cx.index, super::interrupts_paragraph, super::ends_table)?; + blocks.push(Block::Table(items)); + Some(Step::to(next)) + } +} + +/// How many brackets are open after `text`, given the `depth` before it. Strings and a comment to the end +/// of the line are skipped. +fn open_brackets(text: &str, mut depth: i32) -> i32 { + let mut chars = text.chars().peekable(); + while let Some(char) = chars.next() { + match char { + '{' | '(' | '[' => depth += 1, + '}' | ')' | ']' => depth -= 1, + '\'' | '"' | '`' => { + while let Some(next) = chars.next() { + if next == '\\' { + chars.next(); + } else if next == char { + break; + } + } + } + '/' if chars.peek() == Some(&'/') => break, + _ => {} + } + } + depth +} + +/// MDX `import` and `export`, which go on to the next blank line. +pub(super) struct Esm; + +impl BlockRule for Esm { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + let line = cx.line; + if !(line.flavor.has_jsx() + && cx.depth == 0 + && cx.indent.bytes == 0 + && (line.text.starts_with("import ") || line.text.starts_with("export "))) + { + return None; + } + // Without a JavaScript parser the module goes on to the next blank line, or past it when a bracket + // is still open. + let mut depth = 0i32; + let mut end = cx.lines.len(); + for (offset, line) in cx.lines[cx.index..].iter().enumerate() { + if line.is_blank() && depth <= 0 { + end = cx.index + offset; + break; + } + depth = open_brackets(line.text, depth); + } + let parts = cx.lines[cx.index..end] + .iter() + .map(|line| (line.text, line.eol)) + .collect::>(); + blocks.push(Block::Node(Node::MdxJsEsm(MdxJsEsm { + value: join_lines(&parts).into(), + position: Some(Position { + start: line.point(0), + end: cx.lines[end - 1].end(), + }), + }))); + Some(Step::to(end)) + } +} + +pub(super) struct MdxFlow; + +impl BlockRule for MdxFlow { + fn parse(cx: &Cx<'_, '_>, blocks: &mut Vec) -> Option { + if !may_start_flow(cx.line) { + return None; + } + let FlowOutcome::Flow(next) = mdx_flow(cx.lines, cx.index, blocks) else { + return None; + }; + // Flow content that interrupted a paragraph keeps the restrictions that came with it. + Some(Step { + interrupt: cx.interrupting, + ..Step::to(next) + }) + } +} diff --git a/crates/mq-markdown/src/parser/callout.rs b/crates/mq-markdown/src/parser/callout.rs new file mode 100644 index 000000000..449756ca2 --- /dev/null +++ b/crates/mq-markdown/src/parser/callout.rs @@ -0,0 +1,63 @@ +//! Obsidian-style callouts: a block quote whose first line is `[!TYPE]`, an optional fold marker and an +//! optional title. + +use super::tree::{Block, InlineBlock, InlineKind}; + +/// The header line of a callout. +pub(super) struct Header { + pub(super) kind: String, + pub(super) fold: Option, + pub(super) title: Option, +} + +/// Splits `[!TYPE]` and the fold marker off the start of `text`, returning the type, the marker and +/// what follows. `TYPE` is letters, digits, `-` and `_`, and a `+` or `-` right after it is the marker. +fn split(text: &str) -> Option<(&str, Option, &str)> { + let rest = text.strip_prefix("[!")?; + let end = rest.find(']')?; + let kind = &rest[..end]; + if kind.is_empty() || !kind.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_')) { + return None; + } + let after = &rest[end + 1..]; + let fold = after.chars().next().filter(|c| matches!(c, '+' | '-')); + Some((kind, fold, &after[fold.map_or(0, char::len_utf8)..])) +} + +/// Takes the header line off the first paragraph of a quote, if it is one. The title is the rest of that +/// line as written, so markup in it never mixes with the body, which starts on the next line. +pub(super) fn take_header(children: &mut Vec) -> Option
{ + let Some(Block::Inline(InlineBlock { + source, + kind: InlineKind::Paragraph, + })) = children.first_mut() + else { + return None; + }; + split(&source.text)?; + + let line = source.take_first_line(); + let (kind, fold, title) = split(&line)?; + let title = title.trim(); + let header = Header { + kind: kind.to_string(), + fold, + title: (!title.is_empty()).then(|| title.to_string()), + }; + if source.text.is_empty() { + children.remove(0); + } + Some(header) +} + +/// The header of a callout at the start of `children`, and what remains of the first paragraph after it. +/// Unlike [`take_header`] this leaves `children` as it is. +pub(super) fn peek_header(children: &[Block]) -> Option<(Header, Vec)> { + let Block::Inline(inline) = children.first()? else { + return None; + }; + split(&inline.source.text)?; + let mut head = vec![Block::Inline(inline.clone())]; + let header = take_header(&mut head)?; + Some((header, head)) +} diff --git a/crates/mq-markdown/src/parser/code.rs b/crates/mq-markdown/src/parser/code.rs new file mode 100644 index 000000000..016e978b5 --- /dev/null +++ b/crates/mq-markdown/src/parser/code.rs @@ -0,0 +1,183 @@ +//! Fenced and indented code, and math fences. + +use super::block::CODE_INDENT; +use super::flavor::Flavor; +use super::inline; +use super::line::{Indent, Line}; +use super::tree::{Block, FencedBlock}; +use crate::node::{Code, Math, Node, Point, Position}; + +/// An opening code fence, or math fence. +pub(super) struct Fence<'a> { + marker: u8, + length: usize, + /// Info string after the fence, trimmed. + info: &'a str, +} + +impl<'a> Fence<'a> { + /// Parses `rest` (indent already removed) as an opening fence, of math too when `flavor` has it. + pub(super) fn open(rest: &'a str, flavor: Flavor) -> Option { + let math = flavor.has_math(); + let marker = *rest + .as_bytes() + .first() + .filter(|b| matches!(b, b'`' | b'~') || (math && **b == b'$'))?; + let length = rest.bytes().take_while(|&b| b == marker).count(); + let info = rest[length..].trim_matches([' ', '\t']); + // Math fences are two dollars or more, and the info string cannot hold the marker of math or + // backtick fences. + let long_enough = length >= if marker == b'$' { 2 } else { 3 }; + let info_ok = !(matches!(marker, b'`' | b'$') && info.as_bytes().contains(&marker)); + (long_enough && info_ok).then_some(Self { marker, length, info }) + } + + /// Whether `rest` (indent already removed) closes this fence. + pub(super) fn is_closed_by(&self, rest: &str) -> bool { + let length = rest.bytes().take_while(|&b| b == self.marker).count(); + length >= self.length && rest[length..].trim_matches([' ', '\t']).is_empty() + } +} + +pub(super) fn fenced_code( + lines: &[Line<'_>], + start: usize, + indent: usize, + fence: &Fence<'_>, + own_end: bool, + blocks: &mut Vec, +) -> usize { + let (lang, meta) = match fence.info.split_once([' ', '\t']) { + Some((lang, meta)) => (Some(lang), Some(meta.trim_start_matches([' ', '\t']))), + None => (Some(fence.info).filter(|s| !s.is_empty()), None), + }; + + let mut body = Vec::new(); + let mut index = start + 1; + let mut end = None; + + while let Some(line) = lines.get(index) { + let Indent { + columns, + bytes: line_indent, + } = line.indent(); + if columns < line.code_indent() && fence.is_closed_by(&line.text[line_indent..]) { + end = Some(line.end()); + index += 1; + break; + } + // Remove up to the opening fence's indentation from each content line. What is left of a tab + // that is only consumed in part stays as spaces. + let content = line.skip_columns(lines[start].pad + indent); + body.push((content.pad, content.text, line.eol)); + index += 1; + } + + // An unclosed fence runs to the end of its container. When that is the end of the document the + // final line terminator is part of it, so the end is the start of the following line. + // Right after a container, a fence without content ends with its own line instead. + let closed = end.is_some(); + let end = end.unwrap_or_else(|| match lines.last() { + Some(last) if !last.eol.is_empty() && reaches_line_end(last, body.is_empty(), own_end) => Point { + line: last.number + 1, + column: 1, + }, + Some(last) => last.end(), + None => lines[start].end(), + }); + + let position = Some(Position { + start: lines[start].point(indent), + end, + }); + let mut value = String::with_capacity(body.iter().map(|(pad, text, eol)| pad + text.len() + eol.len()).sum()); + for (index, (pad, text, eol)) in body.iter().enumerate() { + value.extend(std::iter::repeat_n(' ', *pad)); + value.push_str(text); + if index + 1 < body.len() { + value.push_str(eol); + } + } + let node = if fence.marker == b'$' { + Node::Math(Math { value, position }) + } else { + Node::Code(Code { + value, + lang: lang.map(inline::unescape), + meta: meta.filter(|m| !m.is_empty()).map(inline::unescape), + fence: true, + position, + }) + }; + blocks.push(Block::Fenced(FencedBlock { + node, + closed, + lines: body.len(), + })); + + index +} + +/// Whether an unclosed fence whose last line is `last` includes that line's terminator. +fn reaches_line_end(last: &Line<'_>, empty: bool, own_end: bool) -> bool { + if empty { + last.eof && !own_end + } else { + last.eof || last.item_end + } +} + +pub(super) fn indented_code(lines: &[Line<'_>], start: usize, blocks: &mut Vec) -> usize { + let mut last_code = start; + let mut index = start; + + while let Some(line) = lines.get(index) { + // Whitespace-only lines are code when indented enough, otherwise they may just separate chunks. + if line.indent().columns >= CODE_INDENT { + last_code = index; + } else if !line.is_blank() { + break; + } + index += 1; + } + + let mut parts = lines[start..=last_code] + .iter() + .map(|line| { + let content = line.skip_columns(CODE_INDENT); + (content.pad, content.text, line.eol) + }) + .collect::>(); + // Trailing lines of whitespace only extend the position, not the value. + while parts.len() > 1 + && parts + .last() + .is_some_and(|(_, text, _)| text.trim_matches([' ', '\t']).is_empty()) + { + parts.pop(); + } + // What is left of a tab that the indent consumes in part is code, as spaces. + let mut value = String::with_capacity(parts.iter().map(|(pad, text, eol)| pad + text.len() + eol.len()).sum()); + for (index, (pad, text, eol)) in parts.iter().enumerate() { + value.extend(std::iter::repeat_n(' ', *pad)); + value.push_str(text); + if index + 1 < parts.len() { + value.push_str(eol); + } + } + + let position = Position { + start: lines[start].point(0), + end: lines[last_code].end(), + }; + + blocks.push(Block::Node(Node::Code(Code { + value, + lang: None, + meta: None, + fence: false, + position: Some(position), + }))); + + last_code + 1 +} diff --git a/crates/mq-markdown/src/parser/definition.rs b/crates/mq-markdown/src/parser/definition.rs new file mode 100644 index 000000000..d409c9a9f --- /dev/null +++ b/crates/mq-markdown/src/parser/definition.rs @@ -0,0 +1,132 @@ +//! Link reference definitions: `[label]: destination "title"` at the start of a paragraph. + +use super::inline::{destination, normalize, remove_line_indent, title_at, unescape}; +use super::scan::{eol_len, skip_blanks, skip_blanks_and_eol}; +use super::tree::InlineSource; +use crate::node::{Definition, Node, Position, Title, Url}; + +/// Parses the label at `pos` (a `[`) followed by `:`, returning the raw label and the offset after. +fn label(text: &str, pos: usize) -> Option<(&str, usize)> { + let bytes = text.as_bytes(); + let mut index = pos + 1; + while index < bytes.len() && index - pos <= 1000 { + match bytes[index] { + b'\\' => index += 1, + b'[' => return None, + b']' => { + let label = &text[pos + 1..index]; + let has_content = label.bytes().any(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r')); + return (has_content && bytes.get(index + 1) == Some(&b':')).then_some((label, index + 2)); + } + _ => {} + } + index += 1; + } + None +} + +/// A definition and where it ends. +struct Parsed { + label: String, + ident: String, + url: String, + title: Option, + /// End of the last token of the definition. + end: usize, + /// Offset of the next line, after the line ending. + next: usize, +} + +fn parse_one(text: &str, pos: usize) -> Option { + let bytes = text.as_bytes(); + let (raw_label, after_colon) = label(text, pos)?; + + let dest_start = skip_blanks_and_eol(bytes, after_colon); + let (url, after_dest) = destination(text, dest_start)?; + // A bare destination cannot be empty. + if after_dest == dest_start { + return None; + } + + // A title on the same line or on the next one, with nothing after it on its line. + let title = title_start(bytes, after_dest).and_then(|start| { + let (title, after_title) = title_at(text, start)?; + let after_blanks = skip_blanks(bytes, after_title); + let eol = eol_len(bytes, after_blanks); + (eol > 0 || after_blanks == bytes.len()).then_some((title, after_title, after_blanks + eol)) + }); + + let (title, end, next) = match title { + Some((title, end, next)) => (Some(title), end, next), + None => { + let after_blanks = skip_blanks(bytes, after_dest); + let eol = eol_len(bytes, after_blanks); + if eol == 0 && after_blanks != bytes.len() { + return None; + } + (None, after_dest, after_blanks + eol) + } + }; + + Some(Parsed { + label: unescape(&remove_line_indent(raw_label)), + ident: normalize(raw_label), + url, + title, + end, + next, + }) +} + +/// The start of a title after the destination: whitespace is required before it. +fn title_start(bytes: &[u8], after_dest: usize) -> Option { + let start = skip_blanks_and_eol(bytes, after_dest); + (start > after_dest).then_some(start) +} + +/// Parses the definitions at the start of a paragraph. `lines` holds the span of each line, from the +/// start of its indentation. Returns the definitions and how many lines they use. +pub(super) fn extract(source: &InlineSource, lines: &[Position]) -> (Vec, usize) { + let text = source.text.as_str(); + let mut nodes = Vec::new(); + let mut pos = 0; + let mut next_line = 0; + + while pos < text.len() && text.as_bytes()[pos] == b'[' { + let Some(parsed) = parse_one(text, pos) else { + break; + }; + let first = source.line_index(pos); + // The definition ends with its last line, trailing whitespace included. + let last = source.line_index(parsed.end); + nodes.push(Node::Definition(Definition { + position: Some(Position { + start: lines[first].start.clone(), + end: lines[last].end.clone(), + }), + url: Url(parsed.url), + title: parsed.title.map(Title), + ident: parsed.ident, + label: Some(parsed.label), + })); + next_line = parsed.next; + pos = skip_blanks(text.as_bytes(), parsed.next); + } + + // Every line that starts before the end of the definitions is used. + let used = source.lines.partition_point(|line| line.offset < next_line); + (nodes, used) +} + +/// Whether `text`, the lines of a paragraph, is nothing but definitions. +pub(super) fn only_definitions(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut pos = skip_blanks_and_eol(bytes, 0); + while pos < text.len() && bytes[pos] == b'[' { + let Some(parsed) = parse_one(text, pos) else { + return false; + }; + pos = skip_blanks_and_eol(bytes, parsed.next); + } + pos >= text.len() +} diff --git a/crates/mq-markdown/src/parser/error.rs b/crates/mq-markdown/src/parser/error.rs new file mode 100644 index 000000000..5525291e9 --- /dev/null +++ b/crates/mq-markdown/src/parser/error.rs @@ -0,0 +1,286 @@ +//! Errors in MDX, the only content that can be invalid. + +use super::tree::InlineSource; +use crate::node::Point; +use std::fmt; + +/// What a JSX tag has where something else must be. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MdxFound { + Char(char), + EndOfFile, +} + +impl fmt::Display for MdxFound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MdxFound::Char(char) => write!(f, "character `{char}` (U+{:04X})", *char as u32), + MdxFound::EndOfFile => write!(f, "end of file"), + } + } +} + +/// The part of a JSX tag where an unexpected character is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MdxPlace { + BeforeName, + AfterName, + BeforeMemberName, + BeforeLocalName, + BeforeAttributeName, + InAttributeName, + BeforeLocalAttributeName, + InLocalAttributeName, + AfterAttributeName, + BeforeAttributeValue, + AfterSelfClosingSlash, +} + +impl fmt::Display for MdxPlace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + MdxPlace::BeforeName => "before name", + MdxPlace::AfterName => "after name", + MdxPlace::BeforeMemberName => "before member name", + MdxPlace::BeforeLocalName => "before local name", + MdxPlace::BeforeAttributeName => "before attribute name", + MdxPlace::InAttributeName => "in attribute name", + MdxPlace::BeforeLocalAttributeName => "before local attribute name", + MdxPlace::InLocalAttributeName => "in local attribute name", + MdxPlace::AfterAttributeName => "after attribute name", + MdxPlace::BeforeAttributeValue => "before attribute value", + MdxPlace::AfterSelfClosingSlash => "after self-closing slash", + }) + } +} + +/// What is wrong with invalid MDX. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MdxErrorKind { + /// A character, or the end of the input, where a JSX tag cannot have it. + Unexpected { found: MdxFound, place: MdxPlace }, + /// An expression without the `}` that closes its `{`. + UnclosedExpression, + /// An attribute value without its closing quote. + UnclosedAttributeValue { quote: char }, + /// A closing tag that ends with `/>`. + SelfClosingSlashInClosingTag, + /// A closing tag with attributes. + AttributeInClosingTag, + /// A line of an expression or a tag inside a container that lacks the prefix of the container. + LazyLine, + /// Elements nested deeper than the parser follows. + TooDeep { limit: usize }, + /// A closing tag without an element open. + UnopenedClosingTag, + /// A closing tag whose name is not that of the open element. Elements on lines of their own say + /// where the open one starts. + MismatchedClosingTag { + closing: Option, + opening: Option, + opened_at: Option, + }, + /// An element on lines of its own that is not closed in its container. + UnclosedFlowElement { name: Option, opened_at: Point }, + /// An element in text that is not closed in its paragraph. + UnclosedTextElement { name: Option }, +} + +impl fmt::Display for MdxErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = |name: &Option| name.clone().unwrap_or_default(); + match self { + MdxErrorKind::Unexpected { found, place } => write!(f, "Unexpected {found} {place}"), + MdxErrorKind::UnclosedExpression => { + write!( + f, + "Unexpected end of file in expression, expected a corresponding closing brace for `{{`" + ) + } + MdxErrorKind::UnclosedAttributeValue { quote } => write!( + f, + "Unexpected end of file in attribute value, expected a corresponding closing quote `{quote}`" + ), + MdxErrorKind::SelfClosingSlashInClosingTag => write!( + f, + "Unexpected self-closing slash `/` in closing tag, expected the end of the tag" + ), + MdxErrorKind::AttributeInClosingTag => { + write!(f, "Unexpected attribute in closing tag, expected the end of the tag") + } + MdxErrorKind::LazyLine => write!( + f, + "Unexpected lazy line in expression in container, expected line to be prefixed with `>` when in a block quote, whitespace when in a list, etc" + ), + MdxErrorKind::TooDeep { limit } => write!(f, "Elements are nested deeper than {limit} levels"), + MdxErrorKind::UnopenedClosingTag => { + write!(f, "Unexpected closing slash `/` in tag, expected an open tag first") + } + MdxErrorKind::MismatchedClosingTag { + closing, + opening, + opened_at, + } => { + write!( + f, + "Unexpected closing tag ``, expected corresponding closing tag for `<{}>`", + name(closing), + name(opening) + )?; + match opened_at { + Some(at) => write!(f, " ({}:{})", at.line, at.column), + None => Ok(()), + } + } + MdxErrorKind::UnclosedFlowElement { + name: element, + opened_at, + } => write!( + f, + "Expected a closing tag for `<{}>` ({}:{}) before the end of its container", + name(element), + opened_at.line, + opened_at.column + ), + MdxErrorKind::UnclosedTextElement { name: element } => write!( + f, + "Expected a closing tag for `<{}>` before the end of the content", + name(element) + ), + } + } +} + +/// An error at an offset of the text that is being parsed, before the offset is known as a position. +#[derive(Clone, Debug)] +pub(super) struct Located { + pub(super) kind: MdxErrorKind, + pub(super) offset: usize, +} + +impl Located { + /// The error at its position in the document. + pub(super) fn in_source(self, source: &InlineSource) -> MdxError { + MdxError { + position: Some(source.point(self.offset)), + kind: self.kind, + } + } +} + +/// Invalid MDX, with where it is in the document when that is known. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MdxError { + kind: MdxErrorKind, + position: Option, +} + +impl MdxError { + pub(super) fn new(kind: MdxErrorKind, position: Option) -> Self { + Self { kind, position } + } + + pub fn kind(&self) -> &MdxErrorKind { + &self.kind + } + + /// Where the error is, as a line and a column that count from 1. + pub fn position(&self) -> Option<&Point> { + self.position.as_ref() + } + + /// A diagnostic that points at the error in `content`, the document it was found in. + pub(super) fn into_diagnostic(self, content: &str) -> MdxDiagnostic { + let span = self + .position + .as_ref() + .and_then(|point| offset_of(content, point)) + .map(|offset| miette::SourceSpan::from((offset, 0))); + MdxDiagnostic { + error: self, + source_code: content.to_string(), + span, + } + } +} + +impl fmt::Display for MdxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind.fmt(f) + } +} + +impl std::error::Error for MdxError {} + +/// An [`MdxError`] with the document, to show where in it the error is. +#[derive(Debug, miette::Diagnostic)] +#[diagnostic(code(mq_markdown::mdx))] +pub struct MdxDiagnostic { + error: MdxError, + #[source_code] + source_code: String, + #[label("here")] + span: Option, +} + +impl MdxDiagnostic { + pub fn error(&self) -> &MdxError { + &self.error + } +} + +impl fmt::Display for MdxDiagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.error.fmt(f) + } +} + +impl std::error::Error for MdxDiagnostic {} + +/// The byte offset of `point` in `content`, where columns count UTF-16 code units, as the positions of +/// nodes do. +fn offset_of(content: &str, point: &Point) -> Option { + let mut start = 0; + for _ in 1..point.line { + let rest = &content[start..]; + let eol = rest.find(['\n', '\r'])?; + start += eol + if rest[eol..].starts_with("\r\n") { 2 } else { 1 }; + } + let line = &content[start..]; + let line = &line[..line.find(['\n', '\r']).unwrap_or(line.len())]; + let mut column = 0; + for (index, char) in line.char_indices() { + if column + 1 >= point.column { + return Some(start + index); + } + column += char.len_utf16(); + } + Some(start + line.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::first("abc", 1, 2, Some(1))] + #[case::second_line("a\nbc", 2, 2, Some(3))] + #[case::crlf("a\r\nbc", 2, 1, Some(3))] + #[case::tab("\tx", 1, 2, Some(1))] + #[case::multibyte("éx", 1, 2, Some(2))] + #[case::astral("😀x", 1, 3, Some(4))] + #[case::end_of_line("ab\ncd", 1, 3, Some(2))] + #[case::missing_line("ab", 3, 1, None)] + fn offset_of_point( + #[case] content: &str, + #[case] line: usize, + #[case] column: usize, + #[case] expected: Option, + ) { + assert_eq!(offset_of(content, &Point { line, column }), expected); + } +} diff --git a/crates/mq-markdown/src/parser/flavor.rs b/crates/mq-markdown/src/parser/flavor.rs new file mode 100644 index 000000000..14525eddc --- /dev/null +++ b/crates/mq-markdown/src/parser/flavor.rs @@ -0,0 +1,50 @@ +//! The dialect a document is read in, and the constructs each has. + +/// The dialect a document is read in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Flavor { + /// `CommonMark` with GFM, math, frontmatter and the Obsidian syntax of the enabled features. + #[default] + Markdown, + /// MDX: `CommonMark` without indented code, HTML and autolinks, but with JSX, expressions and ESM. + Mdx, +} + +impl Flavor { + /// Indented code blocks. + pub(super) fn has_indented_code(self) -> bool { + self == Self::Markdown + } + + /// HTML blocks, raw inline HTML and autolinks in angle brackets. + pub(super) fn has_html(self) -> bool { + self == Self::Markdown + } + + /// GFM: tables, task list items, footnotes, strikethrough and autolink literals. + pub(super) fn has_gfm(self) -> bool { + self == Self::Markdown + } + + /// Math blocks and inline math. + pub(super) fn has_math(self) -> bool { + self == Self::Markdown + } + + /// Wikilinks and embeds, when their features are enabled. + #[cfg(any(feature = "wikilink", feature = "embed"))] + pub(super) fn has_obsidian(self) -> bool { + self == Self::Markdown + } + + /// JSX tags, expressions and `import`/`export` statements. + pub(super) fn has_jsx(self) -> bool { + self == Self::Mdx + } + + /// Whether code and math spans drop the indentation of their continuation lines, as the lines of a + /// paragraph do. In MDX the content of a span is kept as written. + pub(super) fn strips_span_indent(self) -> bool { + self == Self::Markdown + } +} diff --git a/crates/mq-markdown/src/parser/html_flow.rs b/crates/mq-markdown/src/parser/html_flow.rs new file mode 100644 index 000000000..524ac5dc3 --- /dev/null +++ b/crates/mq-markdown/src/parser/html_flow.rs @@ -0,0 +1,276 @@ +//! HTML blocks, with the start and end conditions of the seven kinds that `CommonMark` defines. + +use super::scan::skip_blanks; + +/// The kind of an HTML block, which decides how it ends. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Kind { + /// ``. + Comment, + /// ``. + Instruction, + /// ``. + Declaration, + /// ``. + Cdata, + /// A known block tag name: ends at a blank line. + Basic, + /// A complete tag alone on its line: ends at a blank line. It cannot interrupt a paragraph. + Complete, +} + +const RAW_NAMES: [&str; 4] = ["pre", "script", "style", "textarea"]; + +const BLOCK_NAMES: [&str; 62] = [ + "address", + "article", + "aside", + "base", + "basefont", + "blockquote", + "body", + "caption", + "center", + "col", + "colgroup", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "frame", + "frameset", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "head", + "header", + "hr", + "html", + "iframe", + "legend", + "li", + "link", + "main", + "menu", + "menuitem", + "nav", + "noframes", + "ol", + "optgroup", + "option", + "p", + "param", + "search", + "section", + "summary", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "title", + "tr", + "track", + "ul", +]; + +/// Recognizes the start of an HTML block in `rest`, the line without its indentation. +pub(super) fn start(rest: &str) -> Option { + let after = rest.strip_prefix('<')?; + if after.starts_with("!--") { + Some(Kind::Comment) + } else if after.starts_with("![CDATA[") { + Some(Kind::Cdata) + } else if after.starts_with('?') { + Some(Kind::Instruction) + } else if after + .strip_prefix('!') + .is_some_and(|name| name.starts_with(|c: char| c.is_ascii_alphabetic())) + { + Some(Kind::Declaration) + } else { + match after.strip_prefix('/') { + Some(name) => tag(name, true), + None => tag(after, false), + } + } +} + +/// Classifies the tag whose name starts `text`, which is what follows `<` or ` Option { + let bytes = text.as_bytes(); + if !bytes.first().is_some_and(u8::is_ascii_alphabetic) { + return None; + } + let name_end = bytes + .iter() + .position(|b| !(b.is_ascii_alphanumeric() || *b == b'-')) + .unwrap_or(bytes.len()); + let after = &bytes[name_end..]; + let self_closing = after.starts_with(b"/"); + if !matches!(after.first(), None | Some(b'\t' | b' ' | b'/' | b'>')) { + return None; + } + let name = text[..name_end].to_ascii_lowercase(); + + if !closing && !self_closing && RAW_NAMES.contains(&name.as_str()) { + Some(Kind::Raw) + } else if BLOCK_NAMES.contains(&name.as_str()) { + // A slash has to be the start of `/>`. + (!self_closing || after.starts_with(b"/>")).then_some(Kind::Basic) + } else { + is_complete_tag(after, closing).then_some(Kind::Complete) + } +} + +/// Whether `rest`, what follows the name of a tag, closes the tag with nothing after it on the line. +fn is_complete_tag(rest: &[u8], closing: bool) -> bool { + let mut cursor = Cursor { bytes: rest, index: 0 }; + cursor.skip_blanks(); + if !closing { + loop { + match cursor.attribute() { + Attribute::Read => cursor.skip_blanks(), + Attribute::Absent => break, + Attribute::Invalid => return false, + } + } + cursor.eat(b'/'); + } + cursor.eat(b'>') && { + cursor.skip_blanks(); + cursor.peek().is_none() + } +} + +enum Attribute { + Read, + /// There is no attribute here. + Absent, + /// An attribute whose value is malformed. + Invalid, +} + +struct Cursor<'a> { + bytes: &'a [u8], + index: usize, +} + +impl Cursor<'_> { + fn peek(&self) -> Option { + self.bytes.get(self.index).copied() + } + + fn skip_blanks(&mut self) { + self.index = skip_blanks(self.bytes, self.index); + } + + fn eat(&mut self, byte: u8) -> bool { + let found = self.peek() == Some(byte); + self.index += usize::from(found); + found + } + + fn skip_while(&mut self, accept: impl Fn(u8) -> bool) { + while self.peek().is_some_and(&accept) { + self.index += 1; + } + } + + /// Reads an attribute name and its optional value. + fn attribute(&mut self) -> Attribute { + if !self + .peek() + .is_some_and(|b| b.is_ascii_alphanumeric() || b == b':' || b == b'_') + { + return Attribute::Absent; + } + self.skip_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b':' | b'_')); + // Blanks before an `=` belong to the value, otherwise they are left to the caller. + let name_end = self.index; + self.skip_blanks(); + if self.eat(b'=') { + self.skip_blanks(); + return if self.value() { + Attribute::Read + } else { + Attribute::Invalid + }; + } + self.index = name_end; + Attribute::Read + } + + fn value(&mut self) -> bool { + match self.peek() { + None | Some(b'<' | b'=' | b'>' | b'`') => false, + Some(quote @ (b'"' | b'\'')) => { + self.index += 1; + self.skip_while(|b| b != quote); + // The closing quote has to be followed by blanks, `/` or `>`. + self.eat(quote) && matches!(self.peek(), Some(b'\t' | b' ' | b'/' | b'>')) + } + Some(_) => { + self.skip_while(|b| !matches!(b, b'\t' | b' ' | b'"' | b'\'' | b'/' | b'<' | b'=' | b'>' | b'`')); + true + } + } + } +} + +/// Where in the first line the search for the end of a block starts, so that the opening marker is +/// only reused where it can close the block (`` and `` are complete). +pub(super) fn first_line_offset(kind: Kind) -> usize { + match kind { + Kind::Comment => 2, + Kind::Instruction => 1, + Kind::Declaration => 3, + Kind::Cdata => 9, + Kind::Raw | Kind::Basic | Kind::Complete => 1, + } +} + +/// Whether `text` holds what ends a block of `kind`. Blocks of the last two kinds end at blank lines. +pub(super) fn ends_in(kind: Kind, text: &str) -> bool { + match kind { + Kind::Comment => text.contains("-->"), + Kind::Instruction => text.contains("?>"), + Kind::Declaration => text.contains('>'), + Kind::Cdata => text.contains("]]>"), + Kind::Raw => has_raw_end(text), + Kind::Basic | Kind::Complete => false, + } +} + +/// Whether `text` has the closing tag of any raw element, such as `` or ``. +fn has_raw_end(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0; + while let Some(found) = text[index..].find("') + && RAW_NAMES + .iter() + .any(|name| text[from..from + letters].eq_ignore_ascii_case(name)) + { + return true; + } + index = from; + } + false +} diff --git a/crates/mq-markdown/src/parser/inline.rs b/crates/mq-markdown/src/parser/inline.rs new file mode 100644 index 000000000..217e0c7c6 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline.rs @@ -0,0 +1,808 @@ +//! Inline parsing of paragraph, heading and table cell content. +//! +//! The content is scanned once, left to right, into a flat list of items: literal text, finished +//! nodes (code spans, autolinks, breaks), delimiter runs and bracket openers. Links close over the +//! items after their opener, and emphasis is resolved with the delimiter stack algorithm of the +//! CommonMark specification, so nesting never recurses over the source. + +mod emphasis; +mod entities; +mod entity; +mod html; +mod link; +mod literal; +mod punctuation; +mod tail; + +pub(crate) use entity::unescape; +pub(super) use entity::{decode_references, remove_line_indent}; +pub(crate) use html::is_autolink_email; +pub(crate) use link::normalize; +pub(super) use tail::{destination, title_at}; + +use super::error::{Located, MdxError, MdxErrorKind}; +use super::flavor::Flavor; +use super::mdx::{self, Fallback, Parsed, TagKind}; +use super::resolve::References; +use super::scan::{Span, eol_len}; +use super::tree::InlineSource; +use crate::node::{ + Break, CodeInline, MathInline, MdxAttributeContent, MdxJsxTextElement, MdxTextExpression, Node, Position, Text, +}; +use rustc_hash::FxHashMap; +use smol_str::SmolStr; +use std::borrow::Cow; +use std::cell::{OnceCell, RefCell}; + +/// The value of a text item: a slice of the source, or a decoded string. +pub(super) enum Value { + Slice(Span), + Owned(String), +} + +/// A run of `*`, `_` or `~` that may open or close emphasis. +pub(super) struct Delim { + pub(super) ch: u8, + /// Offset of the first remaining character. + pub(super) start: usize, + pub(super) count: usize, + /// Length of the whole run, which `count` shrinks from as the run is used. + pub(super) original: usize, + pub(super) can_open: bool, + pub(super) can_close: bool, +} + +/// A `[` or `![` that may open a link or an image. +pub(super) struct Opener { + pub(super) image: bool, + /// Offset of the `[`, or of the `!` of an image. + pub(super) start: usize, +} + +pub(super) enum Item { + Text { + start: usize, + end: usize, + value: Value, + }, + /// A finished node and how deeply it nests: a leaf is 1. It is boxed to keep the items small, which + /// are moved around while delimiters are matched. + Node { + node: Box, + depth: usize, + }, + Delim(Delim), + Open(Opener), + /// An email address in plain text. It is not linked inside link text. + Email { + start: usize, + end: usize, + prefixed: bool, + }, + /// A JSX tag in MDX. Tags are paired when the items become nodes. + Jsx(TextTag), +} + +/// A JSX tag in text, with its offsets in the source. +pub(super) struct TextTag { + name: Option, + attributes: Vec, + kind: TagKind, + start: usize, + end: usize, +} + +/// Emphasis and links nested deeper than this are text, and JSX elements are an error. This bounds the +/// recursion of everything that walks the nodes. +pub(super) const MAX_NESTING: usize = 128; + +/// How deeply the item nests: nodes carry it, and the rest are text. +pub(super) fn item_depth(item: &Item) -> usize { + match item { + Item::Node { depth, .. } => *depth, + _ => 0, + } +} + +/// What inline parsing needs to know about the whole document. +#[derive(Clone, Copy)] +pub(super) struct Document<'a> { + pub(super) references: &'a References, + pub(super) flavor: Flavor, +} + +/// What the scanner and its helpers share. +#[derive(Clone, Copy)] +pub(super) struct Context<'a> { + pub(super) source: &'a InlineSource, + pub(super) references: &'a References, + pub(super) flavor: Flavor, + /// Whether email addresses in plain text are linked: not inside link text, and not when there is no `@` + /// in the text, which is looked for once. + pub(super) emails: bool, + /// The first error in the content, which only MDX has. + error: &'a RefCell>, +} + +impl Context<'_> { + /// Records an error at an offset of the source, keeping the first one. + fn fail(&self, error: Located) { + self.error + .borrow_mut() + .get_or_insert_with(|| error.in_source(self.source)); + } + + pub(super) fn src(&self) -> &str { + &self.source.text + } + + pub(super) fn position(&self, start: usize, end: usize) -> Position { + Position { + start: self.source.point(start), + end: self.source.end_point(end), + } + } +} + +/// Parses raw inline content into nodes. +pub(super) fn parse(source: &InlineSource, doc: Document<'_>) -> Result, MdxError> { + if source.text.is_empty() { + return Ok(Vec::new()); + } + + let error = RefCell::new(None); + let context = Context { + source, + references: doc.references, + flavor: doc.flavor, + emails: doc.flavor.has_gfm() && source.text.contains('@'), + error: &error, + }; + let mut scanner = Scanner { + context: &context, + items: Vec::with_capacity(source.text.len() / 12 + 4), + openers: Vec::new(), + depths: Vec::new(), + inactive_below: 0, + pos: 0, + run: 0, + misses: html::Misses::default(), + runs: SpanRuns::default(), + braces: mdx::Braces::default(), + }; + scanner.scan(); + scanner.flush(); + + let Scanner { mut items, .. } = scanner; + emphasis::process(&mut items, &context); + let nodes = to_nodes(items, &context); + + match error.into_inner() { + Some(error) => Err(error), + None => Ok(nodes), + } +} + +/// Converts items to nodes, merging adjacent text. Unused delimiters and openers become text. +pub(super) fn to_nodes(items: Vec, context: &Context<'_>) -> Vec { + let src = context.src(); + let items = if context.emails { + literal::link_emails(src, items) + } else { + items + }; + let mut nodes = Vec::with_capacity(items.len()); + // Elements that are open, with the nodes that came before them. + let mut open: Vec<(TextTag, Vec)> = Vec::new(); + let mut current: Option<(usize, usize, String)> = None; + + let finish = |current: &mut Option<(usize, usize, String)>, nodes: &mut Vec| { + if let Some((start, end, value)) = current.take() { + nodes.push(Node::Text(Text { + value, + position: Some(context.position(start, end)), + })); + } + }; + + for item in items { + let (start, end, text): (usize, usize, Cow<'_, str>) = match item { + Item::Text { start, end, value } => match value { + Value::Slice(span) => (start, end, Cow::Borrowed(span.of(src))), + Value::Owned(value) => (start, end, Cow::Owned(value)), + }, + Item::Delim(delim) => { + let end = delim.start + delim.count; + (delim.start, end, Cow::Borrowed(&src[delim.start..end])) + } + Item::Open(opener) => { + let end = opener.start + if opener.image { 2 } else { 1 }; + (opener.start, end, Cow::Borrowed(&src[opener.start..end])) + } + Item::Node { node, .. } => { + finish(&mut current, &mut nodes); + nodes.push(*node); + continue; + } + Item::Jsx(tag) => { + finish(&mut current, &mut nodes); + match tag.kind { + TagKind::Open if open.len() >= MAX_NESTING => { + context.fail(Located { + kind: MdxErrorKind::TooDeep { limit: MAX_NESTING }, + offset: tag.start, + }); + } + TagKind::Open => open.push((tag, std::mem::take(&mut nodes))), + TagKind::SelfClosing => { + let end = tag.end; + nodes.push(text_element(context, tag, Vec::new(), end)); + } + TagKind::Close => match open.pop() { + None => context.fail(Located { + kind: MdxErrorKind::UnopenedClosingTag, + offset: tag.start, + }), + Some((opening, before)) => { + if opening.name != tag.name { + context.fail(Located { + kind: MdxErrorKind::MismatchedClosingTag { + closing: tag.name.clone(), + opening: opening.name.clone(), + opened_at: None, + }, + offset: tag.start, + }); + } + let children = std::mem::replace(&mut nodes, before); + nodes.push(text_element(context, opening, children, tag.end)); + } + }, + } + continue; + } + Item::Email { start, end, prefixed } => { + finish(&mut current, &mut nodes); + let text = &src[start..end]; + let position = Some(context.position(start, end)); + nodes.push(Node::Link(crate::node::Link { + url: crate::node::Url(if prefixed { + format!("mailto:{text}") + } else { + text.to_string() + }), + title: None, + values: vec![Node::Text(Text { + value: text.to_string(), + position: position.clone(), + })], + position, + })); + continue; + } + }; + match &mut current { + Some((_, current_end, value)) => { + value.push_str(&text); + *current_end = end; + } + None => current = Some((start, end, text.into_owned())), + } + } + finish(&mut current, &mut nodes); + + // Elements that never close are an error, and are closed where the content ends. + while let Some((opening, before)) = open.pop() { + context.fail(Located { + kind: MdxErrorKind::UnclosedTextElement { + name: opening.name.clone(), + }, + offset: opening.start, + }); + let end = opening.end; + let children = std::mem::replace(&mut nodes, before); + nodes.push(text_element(context, opening, children, end)); + } + + nodes +} + +fn text_element(context: &Context<'_>, tag: TextTag, children: Vec, end: usize) -> Node { + Node::MdxJsxTextElement(MdxJsxTextElement { + children, + position: Some(context.position(tag.start, end)), + name: tag.name.map(SmolStr::new), + attributes: tag.attributes, + }) +} + +/// Where the runs of backticks and of dollar signs start, by length, found in one pass the first time a +/// span asks, so that openers without a closer do not each scan to the end. +#[derive(Default)] +struct SpanRuns { + backticks: OnceCell>>, + dollars: OnceCell>>, +} + +impl SpanRuns { + /// The start of the first run of exactly `size` bytes `ch` that starts at or after `from`. + fn next_of_length(&self, src: &str, ch: u8, size: usize, from: usize) -> Option { + let runs = if ch == b'`' { &self.backticks } else { &self.dollars }; + let starts = runs + .get_or_init(|| { + let bytes = src.as_bytes(); + let mut runs: FxHashMap> = FxHashMap::default(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == ch { + let length = bytes[index..].iter().take_while(|&&b| b == ch).count(); + runs.entry(length).or_default().push(index); + index += length; + } else { + index += 1; + } + } + runs + }) + .get(&size)?; + starts.get(starts.partition_point(|&start| start < from)).copied() + } +} + +struct Scanner<'a> { + context: &'a Context<'a>, + items: Vec, + /// Indexes of the `Item::Open` items that are still waiting for a `]`. + openers: Vec, + /// For each opener, the deepest node pushed inside it so far. + depths: Vec, + /// Links cannot contain links, so the `[` openers below this many are deactivated. + inactive_below: usize, + pos: usize, + /// Start of the plain text that has not been pushed as an item yet. + run: usize, + /// Raw HTML terminators already known to be missing. + misses: html::Misses, + runs: SpanRuns, + /// Where the braces of the content close, for MDX. + braces: mdx::Braces, +} + +/// Bytes where `Scanner::scan` may start a construct. Every other byte is plain text. +static SPECIAL: [bool; 256] = { + let mut table = [false; 256]; + let special = b"\\&`$*_~[!]<{\n\rhHwW"; + let mut index = 0; + while index < special.len() { + table[special[index] as usize] = true; + index += 1; + } + table +}; + +impl Scanner<'_> { + fn src(&self) -> &str { + self.context.src() + } + + fn scan(&mut self) { + let len = self.src().len(); + while self.pos < len { + let bytes = self.src().as_bytes(); + let skip = bytes[self.pos..].iter().take_while(|&&b| !SPECIAL[b as usize]).count(); + let pos = self.pos + skip; + if pos == len { + self.pos = len; + break; + } + let (byte, image) = (bytes[pos], bytes.get(pos + 1) == Some(&b'[')); + self.pos = pos; + let link = if matches!(byte, b'[' | b'!') { + self.obsidian_link() + } else { + None + }; + match byte { + _ if link.is_some() => { + if let Some((node, end)) = link { + self.push_node(node, end); + } + } + b'\\' => self.escape(), + b'&' => self.entity(), + b'`' => self.span(b'`'), + b'$' if self.context.flavor.has_math() => self.span(b'$'), + b'*' | b'_' => self.delimiter(), + b'~' if self.context.flavor.has_gfm() => self.delimiter(), + b'[' => self.open(false), + b'!' if image => self.open(true), + b']' => self.close(), + b'<' if self.context.flavor.has_html() => self.angle(), + b'<' => self.jsx(), + b'{' if self.context.flavor.has_jsx() => self.expression(), + b'\n' | b'\r' => self.line_ending(), + b'h' | b'H' | b'w' | b'W' if self.context.flavor.has_gfm() => self.literal_url(), + _ => self.pos += 1, + } + } + } + + /// The wikilink `[[target|text]]` or the embed `![[target|display]]` at the current position, and the + /// offset where it ends. The target is not empty, and neither part holds a bracket or a line ending. + #[cfg(any(feature = "wikilink", feature = "embed"))] + fn obsidian_link(&self) -> Option<(Node, usize)> { + if !self.context.flavor.has_obsidian() { + return None; + } + let src = self.src(); + let start = self.pos; + let rest = &src[start..]; + let (embed, open) = match rest.as_bytes() { + [b'!', b'[', b'[', ..] if cfg!(feature = "embed") => (true, start + 3), + [b'[', b'[', ..] if cfg!(feature = "wikilink") => (false, start + 2), + _ => return None, + }; + // The content holds no bracket or line ending, so the first one found must start the `]]`. + let close = open + src[open..].find(['[', ']', '\n', '\r'])?; + if !src[close..].starts_with("]]") { + return None; + } + let content = &src[open..close]; + // In a table cell `\|` stands for the pipe, as in a code span. + let unescaped; + let content = if self.context.source.table && content.contains("\\|") { + unescaped = content.replace("\\|", "|"); + unescaped.as_str() + } else { + content + }; + let (target, label) = match content.split_once('|') { + Some((target, label)) => (target.trim(), Some(label.trim().to_string())), + None => (content.trim(), None), + }; + if target.is_empty() { + return None; + } + let end = close + 2; + let position = Some(self.context.position(start, end)); + let target = target.to_string(); + let node = if embed { + #[cfg(feature = "embed")] + { + Node::Embed(crate::node::Embed { + target, + display: label, + position, + }) + } + #[cfg(not(feature = "embed"))] + unreachable!("embeds are off") + } else { + #[cfg(feature = "wikilink")] + { + Node::WikiLink(crate::node::WikiLink { + target, + text: label, + position, + }) + } + #[cfg(not(feature = "wikilink"))] + unreachable!("wikilinks are off") + }; + Some((node, end)) + } + + #[cfg(not(any(feature = "wikilink", feature = "embed")))] + fn obsidian_link(&self) -> Option<(Node, usize)> { + None + } + + /// Pushes the pending plain text up to the current position as an item. + fn flush(&mut self) { + if self.run < self.pos { + self.items.push(Item::Text { + start: self.run, + end: self.pos, + value: Value::Slice(Span::new(self.run, self.pos)), + }); + } + self.run = self.pos; + } + + /// Pushes `item` after the pending text and continues scanning at `end`. + fn push(&mut self, item: Item, end: usize) { + self.flush(); + if let Item::Node { depth, .. } = &item { + self.note_depth(*depth); + } + self.items.push(item); + self.pos = end; + self.run = end; + } + + /// Records that a node of `depth` is inside the innermost open bracket. + fn note_depth(&mut self, depth: usize) { + if let Some(innermost) = self.depths.last_mut() { + *innermost = (*innermost).max(depth); + } + } + + fn push_node(&mut self, node: Node, end: usize) { + self.push( + Item::Node { + node: Box::new(node), + depth: 1, + }, + end, + ); + } + + fn escape(&mut self) { + let src = self.src(); + let bytes = src.as_bytes(); + let pos = self.pos; + match bytes.get(pos + 1) { + Some(next) if next.is_ascii_punctuation() => { + let item = Item::Text { + start: pos, + end: pos + 2, + value: Value::Slice(Span::new(pos + 1, pos + 2)), + }; + self.push(item, pos + 2); + } + Some(b'\n' | b'\r') => { + let end = pos + 1 + eol_len(bytes, pos + 1); + let node = Node::Break(Break { + position: Some(self.context.position(pos, end)), + }); + self.push_node(node, end); + // The indentation of the next line is not part of the text. + while matches!(self.src().as_bytes().get(self.pos), Some(b' ' | b'\t')) { + self.pos += 1; + } + self.run = self.pos; + } + _ => self.pos += 1, + } + } + + fn entity(&mut self) { + match entity::decode(self.src(), self.pos) { + Some((end, value)) => { + let item = Item::Text { + start: self.pos, + end, + value: Value::Owned(value), + }; + self.push(item, end); + } + None => self.pos += 1, + } + } + + fn line_ending(&mut self) { + let src = self.src(); + let bytes = src.as_bytes(); + let pos = self.pos; + let eol = eol_len(bytes, pos); + + // Trailing whitespace before the line ending is dropped, or makes a hard break. + let mut start = pos; + while start > self.run && matches!(bytes[start - 1], b' ' | b'\t') { + start -= 1; + } + let hard = pos - start >= 2 && bytes[start..pos].iter().all(|&b| b == b' '); + // The indentation of the next line is not part of the text. + let mut after = pos + eol; + while matches!(bytes.get(after), Some(b' ' | b'\t')) { + after += 1; + } + + self.pos = start; + self.flush(); + if hard { + let node = Node::Break(Break { + position: Some(self.context.position(start, pos + eol)), + }); + self.note_depth(1); + self.items.push(Item::Node { + node: Box::new(node), + depth: 1, + }); + } else { + self.items.push(Item::Text { + start: pos, + end: pos + eol, + value: Value::Slice(Span::new(pos, pos + eol)), + }); + } + self.pos = after; + self.run = after; + } + + /// A code span (`` ` ``) or an inline math span (`$`). + fn span(&mut self, ch: u8) { + let src = self.context.src(); + let bytes = src.as_bytes(); + let start = self.pos; + let size = bytes[start..].iter().take_while(|&&b| b == ch).count(); + + let close = self.runs.next_of_length(src, ch, size, start + size); + + let Some(close) = close else { + self.pos = start + size; + return; + }; + + let end = close + size; + // In a table cell, `\|` stands for `|` inside code as well. + let content = &src[start + size..close]; + // The lines of a paragraph lose their leading whitespace before the inline content is read. + let content = if self.context.flavor.strips_span_indent() { + remove_line_indent(content) + } else { + Cow::Borrowed(content) + }; + let content = content.as_ref(); + let value = if self.context.source.table && content.contains("\\|") { + span_value(&content.replace("\\|", "|")) + } else { + span_value(content) + }; + let position = Some(self.context.position(start, end)); + let node = if ch == b'`' { + Node::CodeInline(CodeInline { value, position }) + } else { + Node::MathInline(MathInline { value, position }) + }; + self.push_node(node, end); + } + + fn delimiter(&mut self) { + let src = self.src(); + let bytes = src.as_bytes(); + let start = self.pos; + let ch = bytes[start]; + let size = bytes[start..].iter().take_while(|&&b| b == ch).count(); + let end = start + size; + + // Strikethrough runs are one or two tildes. + if ch == b'~' && size > 2 { + self.pos = end; + return; + } + + let before = src[..start].chars().next_back(); + let after = src[end..].chars().next(); + let (can_open, can_close) = emphasis::flanking(ch, before, after, self.context.flavor.has_gfm()); + if !can_open && !can_close { + self.pos = end; + return; + } + + let delim = Delim { + ch, + start, + count: size, + original: size, + can_open, + can_close, + }; + self.push(Item::Delim(delim), end); + } + + fn open(&mut self, image: bool) { + let opener = Opener { image, start: self.pos }; + let end = self.pos + if image { 2 } else { 1 }; + self.flush(); + self.openers.push(self.items.len()); + self.depths.push(0); + self.items.push(Item::Open(opener)); + self.pos = end; + self.run = end; + } + + fn close(&mut self) { + self.flush(); + if !link::close(self) { + // Not a link: the bracket stays plain text. + self.pos += 1; + } + } + + /// `<` starts an autolink or raw inline HTML. + fn angle(&mut self) { + let context = self.context; + let src = context.src(); + let pos = self.pos; + if let Some(autolink) = html::autolink(src, pos) { + let position = |start, end| Some(self.context.position(start, end)); + let node = Node::Link(crate::node::Link { + url: crate::node::Url(autolink.url), + title: None, + values: vec![Node::Text(Text { + value: autolink.text.of(src).to_string(), + position: position(autolink.text.start, autolink.text.end), + })], + position: position(pos, autolink.end), + }); + self.push_node(node, autolink.end); + } else if let Some(end) = html::inline_html(src, pos, &mut self.misses) { + // Lines of a paragraph lose their leading whitespace, also inside a tag. + let value = src[pos..end] + .split_inclusive('\n') + .enumerate() + .map(|(index, line)| { + if index == 0 { + line + } else { + line.trim_start_matches([' ', '\t']) + } + }) + .collect::(); + let node = Node::Html(crate::node::Html { + value, + position: Some(self.context.position(pos, end)), + }); + self.push_node(node, end); + } else { + self.pos += 1; + } + } + + /// A JSX tag in MDX text. + fn jsx(&mut self) { + match mdx::tag(self.src(), self.pos, Some(&self.braces)) { + Parsed::Ok(tag) => { + let end = tag.end; + let item = Item::Jsx(TextTag { + name: tag.name, + attributes: tag.attributes, + kind: tag.kind, + start: self.pos, + end, + }); + self.push(item, end); + } + Parsed::Error(error) | Parsed::More(Fallback::Error(error)) => { + self.context.fail(error); + self.pos += 1; + } + Parsed::Nok | Parsed::More(Fallback::Nok) => self.pos += 1, + } + } + + /// An expression in MDX text. + fn expression(&mut self) { + match mdx::expression(self.src(), self.pos, Some(&self.braces)) { + Parsed::Ok((end, value)) => { + let node = Node::MdxTextExpression(MdxTextExpression { + value, + position: Some(self.context.position(self.pos, end)), + }); + self.push_node(node, end); + } + Parsed::Error(error) | Parsed::More(Fallback::Error(error)) => { + self.context.fail(error); + self.pos += 1; + } + Parsed::Nok | Parsed::More(Fallback::Nok) => self.pos += 1, + } + } + + fn literal_url(&mut self) { + literal::url(self); + } +} + +/// The value of a code or math span: the content as it is, except that one space or line ending is +/// removed from both ends when both have one and the content is not only spaces and line endings. +fn span_value(content: &str) -> SmolStr { + let is_space = |b: &u8| matches!(b, b' ' | b'\n'); + let bytes = content.as_bytes(); + if bytes.len() > 2 && is_space(&bytes[0]) && is_space(&bytes[bytes.len() - 1]) && !bytes.iter().all(is_space) { + SmolStr::new(&content[1..content.len() - 1]) + } else { + SmolStr::new(content) + } +} diff --git a/crates/mq-markdown/src/parser/inline/emphasis.rs b/crates/mq-markdown/src/parser/inline/emphasis.rs new file mode 100644 index 000000000..ba1b7e547 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/emphasis.rs @@ -0,0 +1,360 @@ +//! Emphasis, strong emphasis and strikethrough, resolved with a stack of delimiter runs. +//! +//! This follows the reference implementation of `CommonMark`: the rule of three goes by the length of +//! the whole runs. + +use super::punctuation::is_punctuation; +use super::{Context, Delim, Item, MAX_NESTING, item_depth, to_nodes}; +use crate::node::{Delete, Emphasis, Node, Strong}; +use std::num::NonZeroUsize; + +#[derive(PartialEq)] +enum Kind { + Whitespace, + Punctuation, + Other, +} + +fn kind(char: Option) -> Kind { + match char { + None => Kind::Whitespace, + Some(char) if char.is_whitespace() => Kind::Whitespace, + Some(char) if is_punctuation(char) => Kind::Punctuation, + Some(_) => Kind::Other, + } +} + +/// Whether a run of `ch` between `before` and `after` can open and can close, by the rules of +/// `CommonMark` for left- and right-flanking runs, which `~` follows too. +/// +/// With `strikethrough`, a `~` next to a run of `*` or `_` counts as what lets the run open or close, so +/// that `a*~b~*c` is emphasis around strikethrough, and `$**~~b~~` still closes. +pub(super) fn flanking(ch: u8, before: Option, after: Option, strikethrough: bool) -> (bool, bool) { + let tilde = |char: Option| strikethrough && ch != b'~' && char == Some('~'); + // For opening a `~` after the run is a letter and a `~` before it is punctuation, and for closing the + // other way round. + let (before_for_open, after_for_open) = ( + if tilde(before) { Kind::Punctuation } else { kind(before) }, + if tilde(after) { Kind::Other } else { kind(after) }, + ); + let (before_for_close, after_for_close) = ( + if tilde(before) { Kind::Other } else { kind(before) }, + if tilde(after) { Kind::Punctuation } else { kind(after) }, + ); + + let open = after_for_open == Kind::Other || (after_for_open == Kind::Punctuation && before_for_open != Kind::Other); + let close = + before_for_close == Kind::Other || (before_for_close == Kind::Punctuation && after_for_close != Kind::Other); + + if ch == b'_' { + ( + open && (before_for_open != Kind::Other || !close), + close && (after_for_close != Kind::Other || !open), + ) + } else { + (open, close) + } +} + +/// Whether `opener` and `closer` can match, by character and by the rule of three, which goes by the +/// length of the whole runs. +fn matches(opener: &Delim, closer: &Delim) -> bool { + if !opener.can_open || opener.ch != closer.ch { + return false; + } + if (opener.can_close || closer.can_open) + && !closer.original.is_multiple_of(3) + && (opener.original + closer.original).is_multiple_of(3) + { + return false; + } + // Strikethrough matches runs of the same length, one or two tildes. + !(closer.ch == b'~' && (closer.count != opener.count || closer.count > 2)) +} + +/// The place of an item in `Slots`. +/// +/// It is not zero, so that an optional one takes no more room than a number. +#[derive(Clone, Copy, PartialEq, Eq)] +struct SlotId(NonZeroUsize); + +impl SlotId { + /// The slot at `index` of the vector of the slots. + fn at(index: usize) -> Self { + Self(NonZeroUsize::MIN.saturating_add(index)) + } + + fn index(self) -> usize { + self.0.get() - 1 + } +} + +/// A node of the list the delimiters are matched in. +struct Slot { + item: Option, + prev: SlotId, + next: Option, +} + +/// The list the delimiters are matched in, which lets matched items be taken out between two delimiters +/// without moving the items after them. +/// +/// It starts with a slot that holds no item, so that every other slot has one before it. +struct Slots(Vec); + +impl Slots { + const HEAD: SlotId = SlotId(NonZeroUsize::MIN); + + /// Moves `items` into a list. + fn new(items: &mut Vec) -> Self { + let total = items.len(); + // Every pair of delimiters that match adds the slot of its node. + let delimiters = items.iter().filter(|item| matches!(item, Item::Delim(_))).count(); + let mut slots = Vec::with_capacity(total + delimiters / 2 + 1); + slots.push(Slot { + item: None, + prev: Self::HEAD, + next: (total > 0).then(|| SlotId::at(1)), + }); + for (index, item) in items.drain(..).enumerate() { + slots.push(Slot { + item: Some(item), + prev: SlotId::at(index), + next: (index + 1 < total).then(|| SlotId::at(index + 2)), + }); + } + Self(slots) + } + + fn slot(&self, id: SlotId) -> &Slot { + &self.0[id.index()] + } + + fn slot_mut(&mut self, id: SlotId) -> &mut Slot { + &mut self.0[id.index()] + } + + fn first(&self) -> Option { + self.next(Self::HEAD) + } + + fn next(&self, id: SlotId) -> Option { + self.slot(id).next + } + + fn is_delim(&self, id: SlotId) -> bool { + matches!(self.slot(id).item, Some(Item::Delim(_))) + } + + fn delim(&self, id: SlotId) -> &Delim { + match &self.slot(id).item { + Some(Item::Delim(delim)) => delim, + _ => unreachable!("the slot holds a delimiter"), + } + } + + fn delim_mut(&mut self, id: SlotId) -> &mut Delim { + match &mut self.slot_mut(id).item { + Some(Item::Delim(delim)) => delim, + _ => unreachable!("the slot holds a delimiter"), + } + } + + /// The slots after `from` and before `to`, which comes later in the list. + fn between(&self, from: SlotId, to: SlotId) -> impl Iterator + '_ { + std::iter::successors(self.next(from), |&id| self.next(id)).take_while(move |&id| id != to) + } + + /// Whether the node for the items between `opener` and `closer` stays within the nesting limit. + fn fits(&self, opener: SlotId, closer: SlotId) -> bool { + let depth = self + .between(opener, closer) + .filter_map(|id| self.slot(id).item.as_ref()) + .map(item_depth) + .max() + .unwrap_or(0); + depth < MAX_NESTING + } + + /// Takes the items between `opener` and `closer` out, and returns them with the depth of the deepest. + fn take_between(&mut self, opener: SlotId, closer: SlotId) -> (Vec, usize) { + let mut children = Vec::new(); + let mut depth = 0; + let mut cursor = self.next(opener); + while let Some(id) = cursor.filter(|&id| id != closer) { + if let Some(item) = self.slot_mut(id).item.take() { + depth = depth.max(item_depth(&item)); + children.push(item); + } + cursor = self.next(id); + } + (children, depth) + } + + /// Puts `item` between `opener` and `closer`, which are next to each other once the items between + /// them are taken out. + fn insert_between(&mut self, opener: SlotId, closer: SlotId, item: Item) { + let id = SlotId::at(self.0.len()); + self.0.push(Slot { + item: Some(item), + prev: opener, + next: Some(closer), + }); + self.slot_mut(opener).next = Some(id); + self.slot_mut(closer).prev = id; + } + + /// Takes the slot out of the list. + fn unlink(&mut self, id: SlotId) { + let (prev, next) = (self.slot(id).prev, self.slot(id).next); + self.slot_mut(prev).next = next; + if let Some(next) = next { + self.slot_mut(next).prev = prev; + } + self.slot_mut(id).item = None; + } + + /// Moves the items that are left, in order, to the end of `items`. + fn move_into(mut self, items: &mut Vec) { + let mut cursor = self.first(); + while let Some(id) = cursor { + items.extend(self.slot_mut(id).item.take()); + cursor = self.next(id); + } + } +} + +/// What decides whether a closer can match an opener: the openers a closer with the same key +/// rejected once are rejected for every later closer with it. +#[derive(Clone, Copy)] +struct Key(usize); + +impl Key { + /// The number of different keys: the character, whether it can open, the rest of the length of the + /// run by three, and for strikethrough the length of the run. + const COUNT: usize = 3 * 2 * 3 * 3; + + fn of(closer: &Delim) -> Self { + let ch = match closer.ch { + b'*' => 0, + b'_' => 1, + _ => 2, + }; + // Strikethrough also goes by the length of the run. + let count = if ch == 2 { closer.count.min(2) } else { 0 }; + Self(((ch * 2 + usize::from(closer.can_open)) * 3 + closer.original % 3) * 3 + count) + } +} + +/// For each key, the offset that the openers a closer with it rejected start at or after. +struct Bottoms([usize; Key::COUNT]); + +impl Bottoms { + fn get(&self, key: Key) -> usize { + self.0[key.0] + } + + fn set(&mut self, key: Key, start: usize) { + self.0[key.0] = start; + } +} + +/// The place in `stack` of the nearest opener that `closer` can match, not looking at openers that start +/// before `bottom`. +fn find_opener(slots: &Slots, stack: &[SlotId], closer: &Delim, bottom: usize) -> Option { + for (at, &id) in stack.iter().enumerate().rev() { + let candidate = slots.delim(id); + if candidate.start < bottom { + break; + } + if matches(candidate, closer) { + return Some(at); + } + } + None +} + +/// Matches the delimiters in `items`, replacing each pair and what is between them by a node. +/// +/// The openers that can still match are kept on a stack, and the openers a closer cannot match are +/// remembered by the offset they start at, so that the time is linear in the number of items. +pub(super) fn process(items: &mut Vec, context: &Context<'_>) { + if !items.iter().any(|item| matches!(item, Item::Delim(_))) { + return; + } + + let mut slots = Slots::new(items); + let mut stack: Vec = Vec::new(); + let mut bottoms = Bottoms([0; Key::COUNT]); + let mut cursor = slots.first(); + + while let Some(closer) = cursor { + if !slots.is_delim(closer) { + cursor = slots.next(closer); + continue; + } + let current = slots.delim(closer); + let (can_open, can_close, key) = (current.can_open, current.can_close, Key::of(current)); + + let found = can_close + .then(|| find_opener(&slots, &stack, current, bottoms.get(key))) + .flatten() + .filter(|&at| slots.fits(stack[at], closer)); + + let Some(at) = found else { + if can_close { + bottoms.set(key, slots.delim(closer).start); + } + if can_open { + stack.push(closer); + } + cursor = slots.next(closer); + continue; + }; + + let opener = stack[at]; + let (open, close) = (slots.delim(opener), slots.delim(closer)); + let used = if open.count > 1 && close.count > 1 { 2 } else { 1 }; + let ch = close.ch; + let start = open.start + open.count - used; + let end = close.start + used; + + let (children, depth) = slots.take_between(opener, closer); + let values = to_nodes(children, context); + let position = Some(context.position(start, end)); + let node = match (ch, used) { + (b'~', _) => Node::Delete(Delete { values, position }), + (_, 2) => Node::Strong(Strong { values, position }), + _ => Node::Emphasis(Emphasis { values, position }), + }; + slots.insert_between( + opener, + closer, + Item::Node { + node: Box::new(node), + depth: depth + 1, + }, + ); + + slots.delim_mut(opener).count -= used; + let close = slots.delim_mut(closer); + close.start += used; + close.count -= used; + + // Delimiters inside the new node cannot open anything else. + stack.truncate(at + 1); + if slots.delim(opener).count == 0 { + slots.unlink(opener); + stack.pop(); + } + cursor = if slots.delim(closer).count == 0 { + let next = slots.next(closer); + slots.unlink(closer); + next + } else { + Some(closer) + }; + } + + slots.move_into(items); +} diff --git a/crates/mq-markdown/src/parser/inline/entities.rs b/crates/mq-markdown/src/parser/inline/entities.rs new file mode 100644 index 000000000..9ec791374 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/entities.rs @@ -0,0 +1,2133 @@ +//! HTML character reference names and their values. +//! +//! The names that end with `;` in the table of the +//! [HTML standard](https://html.spec.whatwg.org/multipage/named-characters.html), without the `;`. +//! Sorted by name. + +pub(super) static ENTITIES: [(&str, &str); 2125] = [ + ("AElig", "Æ"), + ("AMP", "&"), + ("Aacute", "Á"), + ("Abreve", "Ă"), + ("Acirc", "Â"), + ("Acy", "А"), + ("Afr", "𝔄"), + ("Agrave", "À"), + ("Alpha", "Α"), + ("Amacr", "Ā"), + ("And", "⩓"), + ("Aogon", "Ą"), + ("Aopf", "𝔸"), + ("ApplyFunction", "⁡"), + ("Aring", "Å"), + ("Ascr", "𝒜"), + ("Assign", "≔"), + ("Atilde", "Ã"), + ("Auml", "Ä"), + ("Backslash", "∖"), + ("Barv", "⫧"), + ("Barwed", "⌆"), + ("Bcy", "Б"), + ("Because", "∵"), + ("Bernoullis", "ℬ"), + ("Beta", "Β"), + ("Bfr", "𝔅"), + ("Bopf", "𝔹"), + ("Breve", "˘"), + ("Bscr", "ℬ"), + ("Bumpeq", "≎"), + ("CHcy", "Ч"), + ("COPY", "©"), + ("Cacute", "Ć"), + ("Cap", "⋒"), + ("CapitalDifferentialD", "ⅅ"), + ("Cayleys", "ℭ"), + ("Ccaron", "Č"), + ("Ccedil", "Ç"), + ("Ccirc", "Ĉ"), + ("Cconint", "∰"), + ("Cdot", "Ċ"), + ("Cedilla", "¸"), + ("CenterDot", "·"), + ("Cfr", "ℭ"), + ("Chi", "Χ"), + ("CircleDot", "⊙"), + ("CircleMinus", "⊖"), + ("CirclePlus", "⊕"), + ("CircleTimes", "⊗"), + ("ClockwiseContourIntegral", "∲"), + ("CloseCurlyDoubleQuote", "”"), + ("CloseCurlyQuote", "’"), + ("Colon", "∷"), + ("Colone", "⩴"), + ("Congruent", "≡"), + ("Conint", "∯"), + ("ContourIntegral", "∮"), + ("Copf", "ℂ"), + ("Coproduct", "∐"), + ("CounterClockwiseContourIntegral", "∳"), + ("Cross", "⨯"), + ("Cscr", "𝒞"), + ("Cup", "⋓"), + ("CupCap", "≍"), + ("DD", "ⅅ"), + ("DDotrahd", "⤑"), + ("DJcy", "Ђ"), + ("DScy", "Ѕ"), + ("DZcy", "Џ"), + ("Dagger", "‡"), + ("Darr", "↡"), + ("Dashv", "⫤"), + ("Dcaron", "Ď"), + ("Dcy", "Д"), + ("Del", "∇"), + ("Delta", "Δ"), + ("Dfr", "𝔇"), + ("DiacriticalAcute", "´"), + ("DiacriticalDot", "˙"), + ("DiacriticalDoubleAcute", "˝"), + ("DiacriticalGrave", "`"), + ("DiacriticalTilde", "˜"), + ("Diamond", "⋄"), + ("DifferentialD", "ⅆ"), + ("Dopf", "𝔻"), + ("Dot", "¨"), + ("DotDot", "⃜"), + ("DotEqual", "≐"), + ("DoubleContourIntegral", "∯"), + ("DoubleDot", "¨"), + ("DoubleDownArrow", "⇓"), + ("DoubleLeftArrow", "⇐"), + ("DoubleLeftRightArrow", "⇔"), + ("DoubleLeftTee", "⫤"), + ("DoubleLongLeftArrow", "⟸"), + ("DoubleLongLeftRightArrow", "⟺"), + ("DoubleLongRightArrow", "⟹"), + ("DoubleRightArrow", "⇒"), + ("DoubleRightTee", "⊨"), + ("DoubleUpArrow", "⇑"), + ("DoubleUpDownArrow", "⇕"), + ("DoubleVerticalBar", "∥"), + ("DownArrow", "↓"), + ("DownArrowBar", "⤓"), + ("DownArrowUpArrow", "⇵"), + ("DownBreve", "̑"), + ("DownLeftRightVector", "⥐"), + ("DownLeftTeeVector", "⥞"), + ("DownLeftVector", "↽"), + ("DownLeftVectorBar", "⥖"), + ("DownRightTeeVector", "⥟"), + ("DownRightVector", "⇁"), + ("DownRightVectorBar", "⥗"), + ("DownTee", "⊤"), + ("DownTeeArrow", "↧"), + ("Downarrow", "⇓"), + ("Dscr", "𝒟"), + ("Dstrok", "Đ"), + ("ENG", "Ŋ"), + ("ETH", "Ð"), + ("Eacute", "É"), + ("Ecaron", "Ě"), + ("Ecirc", "Ê"), + ("Ecy", "Э"), + ("Edot", "Ė"), + ("Efr", "𝔈"), + ("Egrave", "È"), + ("Element", "∈"), + ("Emacr", "Ē"), + ("EmptySmallSquare", "◻"), + ("EmptyVerySmallSquare", "▫"), + ("Eogon", "Ę"), + ("Eopf", "𝔼"), + ("Epsilon", "Ε"), + ("Equal", "⩵"), + ("EqualTilde", "≂"), + ("Equilibrium", "⇌"), + ("Escr", "ℰ"), + ("Esim", "⩳"), + ("Eta", "Η"), + ("Euml", "Ë"), + ("Exists", "∃"), + ("ExponentialE", "ⅇ"), + ("Fcy", "Ф"), + ("Ffr", "𝔉"), + ("FilledSmallSquare", "◼"), + ("FilledVerySmallSquare", "▪"), + ("Fopf", "𝔽"), + ("ForAll", "∀"), + ("Fouriertrf", "ℱ"), + ("Fscr", "ℱ"), + ("GJcy", "Ѓ"), + ("GT", ">"), + ("Gamma", "Γ"), + ("Gammad", "Ϝ"), + ("Gbreve", "Ğ"), + ("Gcedil", "Ģ"), + ("Gcirc", "Ĝ"), + ("Gcy", "Г"), + ("Gdot", "Ġ"), + ("Gfr", "𝔊"), + ("Gg", "⋙"), + ("Gopf", "𝔾"), + ("GreaterEqual", "≥"), + ("GreaterEqualLess", "⋛"), + ("GreaterFullEqual", "≧"), + ("GreaterGreater", "⪢"), + ("GreaterLess", "≷"), + ("GreaterSlantEqual", "⩾"), + ("GreaterTilde", "≳"), + ("Gscr", "𝒢"), + ("Gt", "≫"), + ("HARDcy", "Ъ"), + ("Hacek", "ˇ"), + ("Hat", "^"), + ("Hcirc", "Ĥ"), + ("Hfr", "ℌ"), + ("HilbertSpace", "ℋ"), + ("Hopf", "ℍ"), + ("HorizontalLine", "─"), + ("Hscr", "ℋ"), + ("Hstrok", "Ħ"), + ("HumpDownHump", "≎"), + ("HumpEqual", "≏"), + ("IEcy", "Е"), + ("IJlig", "IJ"), + ("IOcy", "Ё"), + ("Iacute", "Í"), + ("Icirc", "Î"), + ("Icy", "И"), + ("Idot", "İ"), + ("Ifr", "ℑ"), + ("Igrave", "Ì"), + ("Im", "ℑ"), + ("Imacr", "Ī"), + ("ImaginaryI", "ⅈ"), + ("Implies", "⇒"), + ("Int", "∬"), + ("Integral", "∫"), + ("Intersection", "⋂"), + ("InvisibleComma", "⁣"), + ("InvisibleTimes", "⁢"), + ("Iogon", "Į"), + ("Iopf", "𝕀"), + ("Iota", "Ι"), + ("Iscr", "ℐ"), + ("Itilde", "Ĩ"), + ("Iukcy", "І"), + ("Iuml", "Ï"), + ("Jcirc", "Ĵ"), + ("Jcy", "Й"), + ("Jfr", "𝔍"), + ("Jopf", "𝕁"), + ("Jscr", "𝒥"), + ("Jsercy", "Ј"), + ("Jukcy", "Є"), + ("KHcy", "Х"), + ("KJcy", "Ќ"), + ("Kappa", "Κ"), + ("Kcedil", "Ķ"), + ("Kcy", "К"), + ("Kfr", "𝔎"), + ("Kopf", "𝕂"), + ("Kscr", "𝒦"), + ("LJcy", "Љ"), + ("LT", "<"), + ("Lacute", "Ĺ"), + ("Lambda", "Λ"), + ("Lang", "⟪"), + ("Laplacetrf", "ℒ"), + ("Larr", "↞"), + ("Lcaron", "Ľ"), + ("Lcedil", "Ļ"), + ("Lcy", "Л"), + ("LeftAngleBracket", "⟨"), + ("LeftArrow", "←"), + ("LeftArrowBar", "⇤"), + ("LeftArrowRightArrow", "⇆"), + ("LeftCeiling", "⌈"), + ("LeftDoubleBracket", "⟦"), + ("LeftDownTeeVector", "⥡"), + ("LeftDownVector", "⇃"), + ("LeftDownVectorBar", "⥙"), + ("LeftFloor", "⌊"), + ("LeftRightArrow", "↔"), + ("LeftRightVector", "⥎"), + ("LeftTee", "⊣"), + ("LeftTeeArrow", "↤"), + ("LeftTeeVector", "⥚"), + ("LeftTriangle", "⊲"), + ("LeftTriangleBar", "⧏"), + ("LeftTriangleEqual", "⊴"), + ("LeftUpDownVector", "⥑"), + ("LeftUpTeeVector", "⥠"), + ("LeftUpVector", "↿"), + ("LeftUpVectorBar", "⥘"), + ("LeftVector", "↼"), + ("LeftVectorBar", "⥒"), + ("Leftarrow", "⇐"), + ("Leftrightarrow", "⇔"), + ("LessEqualGreater", "⋚"), + ("LessFullEqual", "≦"), + ("LessGreater", "≶"), + ("LessLess", "⪡"), + ("LessSlantEqual", "⩽"), + ("LessTilde", "≲"), + ("Lfr", "𝔏"), + ("Ll", "⋘"), + ("Lleftarrow", "⇚"), + ("Lmidot", "Ŀ"), + ("LongLeftArrow", "⟵"), + ("LongLeftRightArrow", "⟷"), + ("LongRightArrow", "⟶"), + ("Longleftarrow", "⟸"), + ("Longleftrightarrow", "⟺"), + ("Longrightarrow", "⟹"), + ("Lopf", "𝕃"), + ("LowerLeftArrow", "↙"), + ("LowerRightArrow", "↘"), + ("Lscr", "ℒ"), + ("Lsh", "↰"), + ("Lstrok", "Ł"), + ("Lt", "≪"), + ("Map", "⤅"), + ("Mcy", "М"), + ("MediumSpace", " "), + ("Mellintrf", "ℳ"), + ("Mfr", "𝔐"), + ("MinusPlus", "∓"), + ("Mopf", "𝕄"), + ("Mscr", "ℳ"), + ("Mu", "Μ"), + ("NJcy", "Њ"), + ("Nacute", "Ń"), + ("Ncaron", "Ň"), + ("Ncedil", "Ņ"), + ("Ncy", "Н"), + ("NegativeMediumSpace", "\u{200B}"), + ("NegativeThickSpace", "\u{200B}"), + ("NegativeThinSpace", "\u{200B}"), + ("NegativeVeryThinSpace", "\u{200B}"), + ("NestedGreaterGreater", "≫"), + ("NestedLessLess", "≪"), + ("NewLine", "\n"), + ("Nfr", "𝔑"), + ("NoBreak", "\u{2060}"), + ("NonBreakingSpace", " "), + ("Nopf", "ℕ"), + ("Not", "⫬"), + ("NotCongruent", "≢"), + ("NotCupCap", "≭"), + ("NotDoubleVerticalBar", "∦"), + ("NotElement", "∉"), + ("NotEqual", "≠"), + ("NotEqualTilde", "≂̸"), + ("NotExists", "∄"), + ("NotGreater", "≯"), + ("NotGreaterEqual", "≱"), + ("NotGreaterFullEqual", "≧̸"), + ("NotGreaterGreater", "≫̸"), + ("NotGreaterLess", "≹"), + ("NotGreaterSlantEqual", "⩾̸"), + ("NotGreaterTilde", "≵"), + ("NotHumpDownHump", "≎̸"), + ("NotHumpEqual", "≏̸"), + ("NotLeftTriangle", "⋪"), + ("NotLeftTriangleBar", "⧏̸"), + ("NotLeftTriangleEqual", "⋬"), + ("NotLess", "≮"), + ("NotLessEqual", "≰"), + ("NotLessGreater", "≸"), + ("NotLessLess", "≪̸"), + ("NotLessSlantEqual", "⩽̸"), + ("NotLessTilde", "≴"), + ("NotNestedGreaterGreater", "⪢̸"), + ("NotNestedLessLess", "⪡̸"), + ("NotPrecedes", "⊀"), + ("NotPrecedesEqual", "⪯̸"), + ("NotPrecedesSlantEqual", "⋠"), + ("NotReverseElement", "∌"), + ("NotRightTriangle", "⋫"), + ("NotRightTriangleBar", "⧐̸"), + ("NotRightTriangleEqual", "⋭"), + ("NotSquareSubset", "⊏̸"), + ("NotSquareSubsetEqual", "⋢"), + ("NotSquareSuperset", "⊐̸"), + ("NotSquareSupersetEqual", "⋣"), + ("NotSubset", "⊂⃒"), + ("NotSubsetEqual", "⊈"), + ("NotSucceeds", "⊁"), + ("NotSucceedsEqual", "⪰̸"), + ("NotSucceedsSlantEqual", "⋡"), + ("NotSucceedsTilde", "≿̸"), + ("NotSuperset", "⊃⃒"), + ("NotSupersetEqual", "⊉"), + ("NotTilde", "≁"), + ("NotTildeEqual", "≄"), + ("NotTildeFullEqual", "≇"), + ("NotTildeTilde", "≉"), + ("NotVerticalBar", "∤"), + ("Nscr", "𝒩"), + ("Ntilde", "Ñ"), + ("Nu", "Ν"), + ("OElig", "Œ"), + ("Oacute", "Ó"), + ("Ocirc", "Ô"), + ("Ocy", "О"), + ("Odblac", "Ő"), + ("Ofr", "𝔒"), + ("Ograve", "Ò"), + ("Omacr", "Ō"), + ("Omega", "Ω"), + ("Omicron", "Ο"), + ("Oopf", "𝕆"), + ("OpenCurlyDoubleQuote", "“"), + ("OpenCurlyQuote", "‘"), + ("Or", "⩔"), + ("Oscr", "𝒪"), + ("Oslash", "Ø"), + ("Otilde", "Õ"), + ("Otimes", "⨷"), + ("Ouml", "Ö"), + ("OverBar", "‾"), + ("OverBrace", "⏞"), + ("OverBracket", "⎴"), + ("OverParenthesis", "⏜"), + ("PartialD", "∂"), + ("Pcy", "П"), + ("Pfr", "𝔓"), + ("Phi", "Φ"), + ("Pi", "Π"), + ("PlusMinus", "±"), + ("Poincareplane", "ℌ"), + ("Popf", "ℙ"), + ("Pr", "⪻"), + ("Precedes", "≺"), + ("PrecedesEqual", "⪯"), + ("PrecedesSlantEqual", "≼"), + ("PrecedesTilde", "≾"), + ("Prime", "″"), + ("Product", "∏"), + ("Proportion", "∷"), + ("Proportional", "∝"), + ("Pscr", "𝒫"), + ("Psi", "Ψ"), + ("QUOT", "\""), + ("Qfr", "𝔔"), + ("Qopf", "ℚ"), + ("Qscr", "𝒬"), + ("RBarr", "⤐"), + ("REG", "®"), + ("Racute", "Ŕ"), + ("Rang", "⟫"), + ("Rarr", "↠"), + ("Rarrtl", "⤖"), + ("Rcaron", "Ř"), + ("Rcedil", "Ŗ"), + ("Rcy", "Р"), + ("Re", "ℜ"), + ("ReverseElement", "∋"), + ("ReverseEquilibrium", "⇋"), + ("ReverseUpEquilibrium", "⥯"), + ("Rfr", "ℜ"), + ("Rho", "Ρ"), + ("RightAngleBracket", "⟩"), + ("RightArrow", "→"), + ("RightArrowBar", "⇥"), + ("RightArrowLeftArrow", "⇄"), + ("RightCeiling", "⌉"), + ("RightDoubleBracket", "⟧"), + ("RightDownTeeVector", "⥝"), + ("RightDownVector", "⇂"), + ("RightDownVectorBar", "⥕"), + ("RightFloor", "⌋"), + ("RightTee", "⊢"), + ("RightTeeArrow", "↦"), + ("RightTeeVector", "⥛"), + ("RightTriangle", "⊳"), + ("RightTriangleBar", "⧐"), + ("RightTriangleEqual", "⊵"), + ("RightUpDownVector", "⥏"), + ("RightUpTeeVector", "⥜"), + ("RightUpVector", "↾"), + ("RightUpVectorBar", "⥔"), + ("RightVector", "⇀"), + ("RightVectorBar", "⥓"), + ("Rightarrow", "⇒"), + ("Ropf", "ℝ"), + ("RoundImplies", "⥰"), + ("Rrightarrow", "⇛"), + ("Rscr", "ℛ"), + ("Rsh", "↱"), + ("RuleDelayed", "⧴"), + ("SHCHcy", "Щ"), + ("SHcy", "Ш"), + ("SOFTcy", "Ь"), + ("Sacute", "Ś"), + ("Sc", "⪼"), + ("Scaron", "Š"), + ("Scedil", "Ş"), + ("Scirc", "Ŝ"), + ("Scy", "С"), + ("Sfr", "𝔖"), + ("ShortDownArrow", "↓"), + ("ShortLeftArrow", "←"), + ("ShortRightArrow", "→"), + ("ShortUpArrow", "↑"), + ("Sigma", "Σ"), + ("SmallCircle", "∘"), + ("Sopf", "𝕊"), + ("Sqrt", "√"), + ("Square", "□"), + ("SquareIntersection", "⊓"), + ("SquareSubset", "⊏"), + ("SquareSubsetEqual", "⊑"), + ("SquareSuperset", "⊐"), + ("SquareSupersetEqual", "⊒"), + ("SquareUnion", "⊔"), + ("Sscr", "𝒮"), + ("Star", "⋆"), + ("Sub", "⋐"), + ("Subset", "⋐"), + ("SubsetEqual", "⊆"), + ("Succeeds", "≻"), + ("SucceedsEqual", "⪰"), + ("SucceedsSlantEqual", "≽"), + ("SucceedsTilde", "≿"), + ("SuchThat", "∋"), + ("Sum", "∑"), + ("Sup", "⋑"), + ("Superset", "⊃"), + ("SupersetEqual", "⊇"), + ("Supset", "⋑"), + ("THORN", "Þ"), + ("TRADE", "™"), + ("TSHcy", "Ћ"), + ("TScy", "Ц"), + ("Tab", "\t"), + ("Tau", "Τ"), + ("Tcaron", "Ť"), + ("Tcedil", "Ţ"), + ("Tcy", "Т"), + ("Tfr", "𝔗"), + ("Therefore", "∴"), + ("Theta", "Θ"), + ("ThickSpace", "  "), + ("ThinSpace", " "), + ("Tilde", "∼"), + ("TildeEqual", "≃"), + ("TildeFullEqual", "≅"), + ("TildeTilde", "≈"), + ("Topf", "𝕋"), + ("TripleDot", "⃛"), + ("Tscr", "𝒯"), + ("Tstrok", "Ŧ"), + ("Uacute", "Ú"), + ("Uarr", "↟"), + ("Uarrocir", "⥉"), + ("Ubrcy", "Ў"), + ("Ubreve", "Ŭ"), + ("Ucirc", "Û"), + ("Ucy", "У"), + ("Udblac", "Ű"), + ("Ufr", "𝔘"), + ("Ugrave", "Ù"), + ("Umacr", "Ū"), + ("UnderBar", "_"), + ("UnderBrace", "⏟"), + ("UnderBracket", "⎵"), + ("UnderParenthesis", "⏝"), + ("Union", "⋃"), + ("UnionPlus", "⊎"), + ("Uogon", "Ų"), + ("Uopf", "𝕌"), + ("UpArrow", "↑"), + ("UpArrowBar", "⤒"), + ("UpArrowDownArrow", "⇅"), + ("UpDownArrow", "↕"), + ("UpEquilibrium", "⥮"), + ("UpTee", "⊥"), + ("UpTeeArrow", "↥"), + ("Uparrow", "⇑"), + ("Updownarrow", "⇕"), + ("UpperLeftArrow", "↖"), + ("UpperRightArrow", "↗"), + ("Upsi", "ϒ"), + ("Upsilon", "Υ"), + ("Uring", "Ů"), + ("Uscr", "𝒰"), + ("Utilde", "Ũ"), + ("Uuml", "Ü"), + ("VDash", "⊫"), + ("Vbar", "⫫"), + ("Vcy", "В"), + ("Vdash", "⊩"), + ("Vdashl", "⫦"), + ("Vee", "⋁"), + ("Verbar", "‖"), + ("Vert", "‖"), + ("VerticalBar", "∣"), + ("VerticalLine", "|"), + ("VerticalSeparator", "❘"), + ("VerticalTilde", "≀"), + ("VeryThinSpace", " "), + ("Vfr", "𝔙"), + ("Vopf", "𝕍"), + ("Vscr", "𝒱"), + ("Vvdash", "⊪"), + ("Wcirc", "Ŵ"), + ("Wedge", "⋀"), + ("Wfr", "𝔚"), + ("Wopf", "𝕎"), + ("Wscr", "𝒲"), + ("Xfr", "𝔛"), + ("Xi", "Ξ"), + ("Xopf", "𝕏"), + ("Xscr", "𝒳"), + ("YAcy", "Я"), + ("YIcy", "Ї"), + ("YUcy", "Ю"), + ("Yacute", "Ý"), + ("Ycirc", "Ŷ"), + ("Ycy", "Ы"), + ("Yfr", "𝔜"), + ("Yopf", "𝕐"), + ("Yscr", "𝒴"), + ("Yuml", "Ÿ"), + ("ZHcy", "Ж"), + ("Zacute", "Ź"), + ("Zcaron", "Ž"), + ("Zcy", "З"), + ("Zdot", "Ż"), + ("ZeroWidthSpace", "\u{200B}"), + ("Zeta", "Ζ"), + ("Zfr", "ℨ"), + ("Zopf", "ℤ"), + ("Zscr", "𝒵"), + ("aacute", "á"), + ("abreve", "ă"), + ("ac", "∾"), + ("acE", "∾̳"), + ("acd", "∿"), + ("acirc", "â"), + ("acute", "´"), + ("acy", "а"), + ("aelig", "æ"), + ("af", "⁡"), + ("afr", "𝔞"), + ("agrave", "à"), + ("alefsym", "ℵ"), + ("aleph", "ℵ"), + ("alpha", "α"), + ("amacr", "ā"), + ("amalg", "⨿"), + ("amp", "&"), + ("and", "∧"), + ("andand", "⩕"), + ("andd", "⩜"), + ("andslope", "⩘"), + ("andv", "⩚"), + ("ang", "∠"), + ("ange", "⦤"), + ("angle", "∠"), + ("angmsd", "∡"), + ("angmsdaa", "⦨"), + ("angmsdab", "⦩"), + ("angmsdac", "⦪"), + ("angmsdad", "⦫"), + ("angmsdae", "⦬"), + ("angmsdaf", "⦭"), + ("angmsdag", "⦮"), + ("angmsdah", "⦯"), + ("angrt", "∟"), + ("angrtvb", "⊾"), + ("angrtvbd", "⦝"), + ("angsph", "∢"), + ("angst", "Å"), + ("angzarr", "⍼"), + ("aogon", "ą"), + ("aopf", "𝕒"), + ("ap", "≈"), + ("apE", "⩰"), + ("apacir", "⩯"), + ("ape", "≊"), + ("apid", "≋"), + ("apos", "'"), + ("approx", "≈"), + ("approxeq", "≊"), + ("aring", "å"), + ("ascr", "𝒶"), + ("ast", "*"), + ("asymp", "≈"), + ("asympeq", "≍"), + ("atilde", "ã"), + ("auml", "ä"), + ("awconint", "∳"), + ("awint", "⨑"), + ("bNot", "⫭"), + ("backcong", "≌"), + ("backepsilon", "϶"), + ("backprime", "‵"), + ("backsim", "∽"), + ("backsimeq", "⋍"), + ("barvee", "⊽"), + ("barwed", "⌅"), + ("barwedge", "⌅"), + ("bbrk", "⎵"), + ("bbrktbrk", "⎶"), + ("bcong", "≌"), + ("bcy", "б"), + ("bdquo", "„"), + ("becaus", "∵"), + ("because", "∵"), + ("bemptyv", "⦰"), + ("bepsi", "϶"), + ("bernou", "ℬ"), + ("beta", "β"), + ("beth", "ℶ"), + ("between", "≬"), + ("bfr", "𝔟"), + ("bigcap", "⋂"), + ("bigcirc", "◯"), + ("bigcup", "⋃"), + ("bigodot", "⨀"), + ("bigoplus", "⨁"), + ("bigotimes", "⨂"), + ("bigsqcup", "⨆"), + ("bigstar", "★"), + ("bigtriangledown", "▽"), + ("bigtriangleup", "△"), + ("biguplus", "⨄"), + ("bigvee", "⋁"), + ("bigwedge", "⋀"), + ("bkarow", "⤍"), + ("blacklozenge", "⧫"), + ("blacksquare", "▪"), + ("blacktriangle", "▴"), + ("blacktriangledown", "▾"), + ("blacktriangleleft", "◂"), + ("blacktriangleright", "▸"), + ("blank", "␣"), + ("blk12", "▒"), + ("blk14", "░"), + ("blk34", "▓"), + ("block", "█"), + ("bne", "=⃥"), + ("bnequiv", "≡⃥"), + ("bnot", "⌐"), + ("bopf", "𝕓"), + ("bot", "⊥"), + ("bottom", "⊥"), + ("bowtie", "⋈"), + ("boxDL", "╗"), + ("boxDR", "╔"), + ("boxDl", "╖"), + ("boxDr", "╓"), + ("boxH", "═"), + ("boxHD", "╦"), + ("boxHU", "╩"), + ("boxHd", "╤"), + ("boxHu", "╧"), + ("boxUL", "╝"), + ("boxUR", "╚"), + ("boxUl", "╜"), + ("boxUr", "╙"), + ("boxV", "║"), + ("boxVH", "╬"), + ("boxVL", "╣"), + ("boxVR", "╠"), + ("boxVh", "╫"), + ("boxVl", "╢"), + ("boxVr", "╟"), + ("boxbox", "⧉"), + ("boxdL", "╕"), + ("boxdR", "╒"), + ("boxdl", "┐"), + ("boxdr", "┌"), + ("boxh", "─"), + ("boxhD", "╥"), + ("boxhU", "╨"), + ("boxhd", "┬"), + ("boxhu", "┴"), + ("boxminus", "⊟"), + ("boxplus", "⊞"), + ("boxtimes", "⊠"), + ("boxuL", "╛"), + ("boxuR", "╘"), + ("boxul", "┘"), + ("boxur", "└"), + ("boxv", "│"), + ("boxvH", "╪"), + ("boxvL", "╡"), + ("boxvR", "╞"), + ("boxvh", "┼"), + ("boxvl", "┤"), + ("boxvr", "├"), + ("bprime", "‵"), + ("breve", "˘"), + ("brvbar", "¦"), + ("bscr", "𝒷"), + ("bsemi", "⁏"), + ("bsim", "∽"), + ("bsime", "⋍"), + ("bsol", "\\"), + ("bsolb", "⧅"), + ("bsolhsub", "⟈"), + ("bull", "•"), + ("bullet", "•"), + ("bump", "≎"), + ("bumpE", "⪮"), + ("bumpe", "≏"), + ("bumpeq", "≏"), + ("cacute", "ć"), + ("cap", "∩"), + ("capand", "⩄"), + ("capbrcup", "⩉"), + ("capcap", "⩋"), + ("capcup", "⩇"), + ("capdot", "⩀"), + ("caps", "∩︀"), + ("caret", "⁁"), + ("caron", "ˇ"), + ("ccaps", "⩍"), + ("ccaron", "č"), + ("ccedil", "ç"), + ("ccirc", "ĉ"), + ("ccups", "⩌"), + ("ccupssm", "⩐"), + ("cdot", "ċ"), + ("cedil", "¸"), + ("cemptyv", "⦲"), + ("cent", "¢"), + ("centerdot", "·"), + ("cfr", "𝔠"), + ("chcy", "ч"), + ("check", "✓"), + ("checkmark", "✓"), + ("chi", "χ"), + ("cir", "○"), + ("cirE", "⧃"), + ("circ", "ˆ"), + ("circeq", "≗"), + ("circlearrowleft", "↺"), + ("circlearrowright", "↻"), + ("circledR", "®"), + ("circledS", "Ⓢ"), + ("circledast", "⊛"), + ("circledcirc", "⊚"), + ("circleddash", "⊝"), + ("cire", "≗"), + ("cirfnint", "⨐"), + ("cirmid", "⫯"), + ("cirscir", "⧂"), + ("clubs", "♣"), + ("clubsuit", "♣"), + ("colon", ":"), + ("colone", "≔"), + ("coloneq", "≔"), + ("comma", ","), + ("commat", "@"), + ("comp", "∁"), + ("compfn", "∘"), + ("complement", "∁"), + ("complexes", "ℂ"), + ("cong", "≅"), + ("congdot", "⩭"), + ("conint", "∮"), + ("copf", "𝕔"), + ("coprod", "∐"), + ("copy", "©"), + ("copysr", "℗"), + ("crarr", "↵"), + ("cross", "✗"), + ("cscr", "𝒸"), + ("csub", "⫏"), + ("csube", "⫑"), + ("csup", "⫐"), + ("csupe", "⫒"), + ("ctdot", "⋯"), + ("cudarrl", "⤸"), + ("cudarrr", "⤵"), + ("cuepr", "⋞"), + ("cuesc", "⋟"), + ("cularr", "↶"), + ("cularrp", "⤽"), + ("cup", "∪"), + ("cupbrcap", "⩈"), + ("cupcap", "⩆"), + ("cupcup", "⩊"), + ("cupdot", "⊍"), + ("cupor", "⩅"), + ("cups", "∪︀"), + ("curarr", "↷"), + ("curarrm", "⤼"), + ("curlyeqprec", "⋞"), + ("curlyeqsucc", "⋟"), + ("curlyvee", "⋎"), + ("curlywedge", "⋏"), + ("curren", "¤"), + ("curvearrowleft", "↶"), + ("curvearrowright", "↷"), + ("cuvee", "⋎"), + ("cuwed", "⋏"), + ("cwconint", "∲"), + ("cwint", "∱"), + ("cylcty", "⌭"), + ("dArr", "⇓"), + ("dHar", "⥥"), + ("dagger", "†"), + ("daleth", "ℸ"), + ("darr", "↓"), + ("dash", "‐"), + ("dashv", "⊣"), + ("dbkarow", "⤏"), + ("dblac", "˝"), + ("dcaron", "ď"), + ("dcy", "д"), + ("dd", "ⅆ"), + ("ddagger", "‡"), + ("ddarr", "⇊"), + ("ddotseq", "⩷"), + ("deg", "°"), + ("delta", "δ"), + ("demptyv", "⦱"), + ("dfisht", "⥿"), + ("dfr", "𝔡"), + ("dharl", "⇃"), + ("dharr", "⇂"), + ("diam", "⋄"), + ("diamond", "⋄"), + ("diamondsuit", "♦"), + ("diams", "♦"), + ("die", "¨"), + ("digamma", "ϝ"), + ("disin", "⋲"), + ("div", "÷"), + ("divide", "÷"), + ("divideontimes", "⋇"), + ("divonx", "⋇"), + ("djcy", "ђ"), + ("dlcorn", "⌞"), + ("dlcrop", "⌍"), + ("dollar", "$"), + ("dopf", "𝕕"), + ("dot", "˙"), + ("doteq", "≐"), + ("doteqdot", "≑"), + ("dotminus", "∸"), + ("dotplus", "∔"), + ("dotsquare", "⊡"), + ("doublebarwedge", "⌆"), + ("downarrow", "↓"), + ("downdownarrows", "⇊"), + ("downharpoonleft", "⇃"), + ("downharpoonright", "⇂"), + ("drbkarow", "⤐"), + ("drcorn", "⌟"), + ("drcrop", "⌌"), + ("dscr", "𝒹"), + ("dscy", "ѕ"), + ("dsol", "⧶"), + ("dstrok", "đ"), + ("dtdot", "⋱"), + ("dtri", "▿"), + ("dtrif", "▾"), + ("duarr", "⇵"), + ("duhar", "⥯"), + ("dwangle", "⦦"), + ("dzcy", "џ"), + ("dzigrarr", "⟿"), + ("eDDot", "⩷"), + ("eDot", "≑"), + ("eacute", "é"), + ("easter", "⩮"), + ("ecaron", "ě"), + ("ecir", "≖"), + ("ecirc", "ê"), + ("ecolon", "≕"), + ("ecy", "э"), + ("edot", "ė"), + ("ee", "ⅇ"), + ("efDot", "≒"), + ("efr", "𝔢"), + ("eg", "⪚"), + ("egrave", "è"), + ("egs", "⪖"), + ("egsdot", "⪘"), + ("el", "⪙"), + ("elinters", "⏧"), + ("ell", "ℓ"), + ("els", "⪕"), + ("elsdot", "⪗"), + ("emacr", "ē"), + ("empty", "∅"), + ("emptyset", "∅"), + ("emptyv", "∅"), + ("emsp", " "), + ("emsp13", " "), + ("emsp14", " "), + ("eng", "ŋ"), + ("ensp", " "), + ("eogon", "ę"), + ("eopf", "𝕖"), + ("epar", "⋕"), + ("eparsl", "⧣"), + ("eplus", "⩱"), + ("epsi", "ε"), + ("epsilon", "ε"), + ("epsiv", "ϵ"), + ("eqcirc", "≖"), + ("eqcolon", "≕"), + ("eqsim", "≂"), + ("eqslantgtr", "⪖"), + ("eqslantless", "⪕"), + ("equals", "="), + ("equest", "≟"), + ("equiv", "≡"), + ("equivDD", "⩸"), + ("eqvparsl", "⧥"), + ("erDot", "≓"), + ("erarr", "⥱"), + ("escr", "ℯ"), + ("esdot", "≐"), + ("esim", "≂"), + ("eta", "η"), + ("eth", "ð"), + ("euml", "ë"), + ("euro", "€"), + ("excl", "!"), + ("exist", "∃"), + ("expectation", "ℰ"), + ("exponentiale", "ⅇ"), + ("fallingdotseq", "≒"), + ("fcy", "ф"), + ("female", "♀"), + ("ffilig", "ffi"), + ("fflig", "ff"), + ("ffllig", "ffl"), + ("ffr", "𝔣"), + ("filig", "fi"), + ("fjlig", "fj"), + ("flat", "♭"), + ("fllig", "fl"), + ("fltns", "▱"), + ("fnof", "ƒ"), + ("fopf", "𝕗"), + ("forall", "∀"), + ("fork", "⋔"), + ("forkv", "⫙"), + ("fpartint", "⨍"), + ("frac12", "½"), + ("frac13", "⅓"), + ("frac14", "¼"), + ("frac15", "⅕"), + ("frac16", "⅙"), + ("frac18", "⅛"), + ("frac23", "⅔"), + ("frac25", "⅖"), + ("frac34", "¾"), + ("frac35", "⅗"), + ("frac38", "⅜"), + ("frac45", "⅘"), + ("frac56", "⅚"), + ("frac58", "⅝"), + ("frac78", "⅞"), + ("frasl", "⁄"), + ("frown", "⌢"), + ("fscr", "𝒻"), + ("gE", "≧"), + ("gEl", "⪌"), + ("gacute", "ǵ"), + ("gamma", "γ"), + ("gammad", "ϝ"), + ("gap", "⪆"), + ("gbreve", "ğ"), + ("gcirc", "ĝ"), + ("gcy", "г"), + ("gdot", "ġ"), + ("ge", "≥"), + ("gel", "⋛"), + ("geq", "≥"), + ("geqq", "≧"), + ("geqslant", "⩾"), + ("ges", "⩾"), + ("gescc", "⪩"), + ("gesdot", "⪀"), + ("gesdoto", "⪂"), + ("gesdotol", "⪄"), + ("gesl", "⋛︀"), + ("gesles", "⪔"), + ("gfr", "𝔤"), + ("gg", "≫"), + ("ggg", "⋙"), + ("gimel", "ℷ"), + ("gjcy", "ѓ"), + ("gl", "≷"), + ("glE", "⪒"), + ("gla", "⪥"), + ("glj", "⪤"), + ("gnE", "≩"), + ("gnap", "⪊"), + ("gnapprox", "⪊"), + ("gne", "⪈"), + ("gneq", "⪈"), + ("gneqq", "≩"), + ("gnsim", "⋧"), + ("gopf", "𝕘"), + ("grave", "`"), + ("gscr", "ℊ"), + ("gsim", "≳"), + ("gsime", "⪎"), + ("gsiml", "⪐"), + ("gt", ">"), + ("gtcc", "⪧"), + ("gtcir", "⩺"), + ("gtdot", "⋗"), + ("gtlPar", "⦕"), + ("gtquest", "⩼"), + ("gtrapprox", "⪆"), + ("gtrarr", "⥸"), + ("gtrdot", "⋗"), + ("gtreqless", "⋛"), + ("gtreqqless", "⪌"), + ("gtrless", "≷"), + ("gtrsim", "≳"), + ("gvertneqq", "≩︀"), + ("gvnE", "≩︀"), + ("hArr", "⇔"), + ("hairsp", " "), + ("half", "½"), + ("hamilt", "ℋ"), + ("hardcy", "ъ"), + ("harr", "↔"), + ("harrcir", "⥈"), + ("harrw", "↭"), + ("hbar", "ℏ"), + ("hcirc", "ĥ"), + ("hearts", "♥"), + ("heartsuit", "♥"), + ("hellip", "…"), + ("hercon", "⊹"), + ("hfr", "𝔥"), + ("hksearow", "⤥"), + ("hkswarow", "⤦"), + ("hoarr", "⇿"), + ("homtht", "∻"), + ("hookleftarrow", "↩"), + ("hookrightarrow", "↪"), + ("hopf", "𝕙"), + ("horbar", "―"), + ("hscr", "𝒽"), + ("hslash", "ℏ"), + ("hstrok", "ħ"), + ("hybull", "⁃"), + ("hyphen", "‐"), + ("iacute", "í"), + ("ic", "⁣"), + ("icirc", "î"), + ("icy", "и"), + ("iecy", "е"), + ("iexcl", "¡"), + ("iff", "⇔"), + ("ifr", "𝔦"), + ("igrave", "ì"), + ("ii", "ⅈ"), + ("iiiint", "⨌"), + ("iiint", "∭"), + ("iinfin", "⧜"), + ("iiota", "℩"), + ("ijlig", "ij"), + ("imacr", "ī"), + ("image", "ℑ"), + ("imagline", "ℐ"), + ("imagpart", "ℑ"), + ("imath", "ı"), + ("imof", "⊷"), + ("imped", "Ƶ"), + ("in", "∈"), + ("incare", "℅"), + ("infin", "∞"), + ("infintie", "⧝"), + ("inodot", "ı"), + ("int", "∫"), + ("intcal", "⊺"), + ("integers", "ℤ"), + ("intercal", "⊺"), + ("intlarhk", "⨗"), + ("intprod", "⨼"), + ("iocy", "ё"), + ("iogon", "į"), + ("iopf", "𝕚"), + ("iota", "ι"), + ("iprod", "⨼"), + ("iquest", "¿"), + ("iscr", "𝒾"), + ("isin", "∈"), + ("isinE", "⋹"), + ("isindot", "⋵"), + ("isins", "⋴"), + ("isinsv", "⋳"), + ("isinv", "∈"), + ("it", "⁢"), + ("itilde", "ĩ"), + ("iukcy", "і"), + ("iuml", "ï"), + ("jcirc", "ĵ"), + ("jcy", "й"), + ("jfr", "𝔧"), + ("jmath", "ȷ"), + ("jopf", "𝕛"), + ("jscr", "𝒿"), + ("jsercy", "ј"), + ("jukcy", "є"), + ("kappa", "κ"), + ("kappav", "ϰ"), + ("kcedil", "ķ"), + ("kcy", "к"), + ("kfr", "𝔨"), + ("kgreen", "ĸ"), + ("khcy", "х"), + ("kjcy", "ќ"), + ("kopf", "𝕜"), + ("kscr", "𝓀"), + ("lAarr", "⇚"), + ("lArr", "⇐"), + ("lAtail", "⤛"), + ("lBarr", "⤎"), + ("lE", "≦"), + ("lEg", "⪋"), + ("lHar", "⥢"), + ("lacute", "ĺ"), + ("laemptyv", "⦴"), + ("lagran", "ℒ"), + ("lambda", "λ"), + ("lang", "⟨"), + ("langd", "⦑"), + ("langle", "⟨"), + ("lap", "⪅"), + ("laquo", "«"), + ("larr", "←"), + ("larrb", "⇤"), + ("larrbfs", "⤟"), + ("larrfs", "⤝"), + ("larrhk", "↩"), + ("larrlp", "↫"), + ("larrpl", "⤹"), + ("larrsim", "⥳"), + ("larrtl", "↢"), + ("lat", "⪫"), + ("latail", "⤙"), + ("late", "⪭"), + ("lates", "⪭︀"), + ("lbarr", "⤌"), + ("lbbrk", "❲"), + ("lbrace", "{"), + ("lbrack", "["), + ("lbrke", "⦋"), + ("lbrksld", "⦏"), + ("lbrkslu", "⦍"), + ("lcaron", "ľ"), + ("lcedil", "ļ"), + ("lceil", "⌈"), + ("lcub", "{"), + ("lcy", "л"), + ("ldca", "⤶"), + ("ldquo", "“"), + ("ldquor", "„"), + ("ldrdhar", "⥧"), + ("ldrushar", "⥋"), + ("ldsh", "↲"), + ("le", "≤"), + ("leftarrow", "←"), + ("leftarrowtail", "↢"), + ("leftharpoondown", "↽"), + ("leftharpoonup", "↼"), + ("leftleftarrows", "⇇"), + ("leftrightarrow", "↔"), + ("leftrightarrows", "⇆"), + ("leftrightharpoons", "⇋"), + ("leftrightsquigarrow", "↭"), + ("leftthreetimes", "⋋"), + ("leg", "⋚"), + ("leq", "≤"), + ("leqq", "≦"), + ("leqslant", "⩽"), + ("les", "⩽"), + ("lescc", "⪨"), + ("lesdot", "⩿"), + ("lesdoto", "⪁"), + ("lesdotor", "⪃"), + ("lesg", "⋚︀"), + ("lesges", "⪓"), + ("lessapprox", "⪅"), + ("lessdot", "⋖"), + ("lesseqgtr", "⋚"), + ("lesseqqgtr", "⪋"), + ("lessgtr", "≶"), + ("lesssim", "≲"), + ("lfisht", "⥼"), + ("lfloor", "⌊"), + ("lfr", "𝔩"), + ("lg", "≶"), + ("lgE", "⪑"), + ("lhard", "↽"), + ("lharu", "↼"), + ("lharul", "⥪"), + ("lhblk", "▄"), + ("ljcy", "љ"), + ("ll", "≪"), + ("llarr", "⇇"), + ("llcorner", "⌞"), + ("llhard", "⥫"), + ("lltri", "◺"), + ("lmidot", "ŀ"), + ("lmoust", "⎰"), + ("lmoustache", "⎰"), + ("lnE", "≨"), + ("lnap", "⪉"), + ("lnapprox", "⪉"), + ("lne", "⪇"), + ("lneq", "⪇"), + ("lneqq", "≨"), + ("lnsim", "⋦"), + ("loang", "⟬"), + ("loarr", "⇽"), + ("lobrk", "⟦"), + ("longleftarrow", "⟵"), + ("longleftrightarrow", "⟷"), + ("longmapsto", "⟼"), + ("longrightarrow", "⟶"), + ("looparrowleft", "↫"), + ("looparrowright", "↬"), + ("lopar", "⦅"), + ("lopf", "𝕝"), + ("loplus", "⨭"), + ("lotimes", "⨴"), + ("lowast", "∗"), + ("lowbar", "_"), + ("loz", "◊"), + ("lozenge", "◊"), + ("lozf", "⧫"), + ("lpar", "("), + ("lparlt", "⦓"), + ("lrarr", "⇆"), + ("lrcorner", "⌟"), + ("lrhar", "⇋"), + ("lrhard", "⥭"), + ("lrm", "‎"), + ("lrtri", "⊿"), + ("lsaquo", "‹"), + ("lscr", "𝓁"), + ("lsh", "↰"), + ("lsim", "≲"), + ("lsime", "⪍"), + ("lsimg", "⪏"), + ("lsqb", "["), + ("lsquo", "‘"), + ("lsquor", "‚"), + ("lstrok", "ł"), + ("lt", "<"), + ("ltcc", "⪦"), + ("ltcir", "⩹"), + ("ltdot", "⋖"), + ("lthree", "⋋"), + ("ltimes", "⋉"), + ("ltlarr", "⥶"), + ("ltquest", "⩻"), + ("ltrPar", "⦖"), + ("ltri", "◃"), + ("ltrie", "⊴"), + ("ltrif", "◂"), + ("lurdshar", "⥊"), + ("luruhar", "⥦"), + ("lvertneqq", "≨︀"), + ("lvnE", "≨︀"), + ("mDDot", "∺"), + ("macr", "¯"), + ("male", "♂"), + ("malt", "✠"), + ("maltese", "✠"), + ("map", "↦"), + ("mapsto", "↦"), + ("mapstodown", "↧"), + ("mapstoleft", "↤"), + ("mapstoup", "↥"), + ("marker", "▮"), + ("mcomma", "⨩"), + ("mcy", "м"), + ("mdash", "—"), + ("measuredangle", "∡"), + ("mfr", "𝔪"), + ("mho", "℧"), + ("micro", "µ"), + ("mid", "∣"), + ("midast", "*"), + ("midcir", "⫰"), + ("middot", "·"), + ("minus", "−"), + ("minusb", "⊟"), + ("minusd", "∸"), + ("minusdu", "⨪"), + ("mlcp", "⫛"), + ("mldr", "…"), + ("mnplus", "∓"), + ("models", "⊧"), + ("mopf", "𝕞"), + ("mp", "∓"), + ("mscr", "𝓂"), + ("mstpos", "∾"), + ("mu", "μ"), + ("multimap", "⊸"), + ("mumap", "⊸"), + ("nGg", "⋙̸"), + ("nGt", "≫⃒"), + ("nGtv", "≫̸"), + ("nLeftarrow", "⇍"), + ("nLeftrightarrow", "⇎"), + ("nLl", "⋘̸"), + ("nLt", "≪⃒"), + ("nLtv", "≪̸"), + ("nRightarrow", "⇏"), + ("nVDash", "⊯"), + ("nVdash", "⊮"), + ("nabla", "∇"), + ("nacute", "ń"), + ("nang", "∠⃒"), + ("nap", "≉"), + ("napE", "⩰̸"), + ("napid", "≋̸"), + ("napos", "ʼn"), + ("napprox", "≉"), + ("natur", "♮"), + ("natural", "♮"), + ("naturals", "ℕ"), + ("nbsp", " "), + ("nbump", "≎̸"), + ("nbumpe", "≏̸"), + ("ncap", "⩃"), + ("ncaron", "ň"), + ("ncedil", "ņ"), + ("ncong", "≇"), + ("ncongdot", "⩭̸"), + ("ncup", "⩂"), + ("ncy", "н"), + ("ndash", "–"), + ("ne", "≠"), + ("neArr", "⇗"), + ("nearhk", "⤤"), + ("nearr", "↗"), + ("nearrow", "↗"), + ("nedot", "≐̸"), + ("nequiv", "≢"), + ("nesear", "⤨"), + ("nesim", "≂̸"), + ("nexist", "∄"), + ("nexists", "∄"), + ("nfr", "𝔫"), + ("ngE", "≧̸"), + ("nge", "≱"), + ("ngeq", "≱"), + ("ngeqq", "≧̸"), + ("ngeqslant", "⩾̸"), + ("nges", "⩾̸"), + ("ngsim", "≵"), + ("ngt", "≯"), + ("ngtr", "≯"), + ("nhArr", "⇎"), + ("nharr", "↮"), + ("nhpar", "⫲"), + ("ni", "∋"), + ("nis", "⋼"), + ("nisd", "⋺"), + ("niv", "∋"), + ("njcy", "њ"), + ("nlArr", "⇍"), + ("nlE", "≦̸"), + ("nlarr", "↚"), + ("nldr", "‥"), + ("nle", "≰"), + ("nleftarrow", "↚"), + ("nleftrightarrow", "↮"), + ("nleq", "≰"), + ("nleqq", "≦̸"), + ("nleqslant", "⩽̸"), + ("nles", "⩽̸"), + ("nless", "≮"), + ("nlsim", "≴"), + ("nlt", "≮"), + ("nltri", "⋪"), + ("nltrie", "⋬"), + ("nmid", "∤"), + ("nopf", "𝕟"), + ("not", "¬"), + ("notin", "∉"), + ("notinE", "⋹̸"), + ("notindot", "⋵̸"), + ("notinva", "∉"), + ("notinvb", "⋷"), + ("notinvc", "⋶"), + ("notni", "∌"), + ("notniva", "∌"), + ("notnivb", "⋾"), + ("notnivc", "⋽"), + ("npar", "∦"), + ("nparallel", "∦"), + ("nparsl", "⫽⃥"), + ("npart", "∂̸"), + ("npolint", "⨔"), + ("npr", "⊀"), + ("nprcue", "⋠"), + ("npre", "⪯̸"), + ("nprec", "⊀"), + ("npreceq", "⪯̸"), + ("nrArr", "⇏"), + ("nrarr", "↛"), + ("nrarrc", "⤳̸"), + ("nrarrw", "↝̸"), + ("nrightarrow", "↛"), + ("nrtri", "⋫"), + ("nrtrie", "⋭"), + ("nsc", "⊁"), + ("nsccue", "⋡"), + ("nsce", "⪰̸"), + ("nscr", "𝓃"), + ("nshortmid", "∤"), + ("nshortparallel", "∦"), + ("nsim", "≁"), + ("nsime", "≄"), + ("nsimeq", "≄"), + ("nsmid", "∤"), + ("nspar", "∦"), + ("nsqsube", "⋢"), + ("nsqsupe", "⋣"), + ("nsub", "⊄"), + ("nsubE", "⫅̸"), + ("nsube", "⊈"), + ("nsubset", "⊂⃒"), + ("nsubseteq", "⊈"), + ("nsubseteqq", "⫅̸"), + ("nsucc", "⊁"), + ("nsucceq", "⪰̸"), + ("nsup", "⊅"), + ("nsupE", "⫆̸"), + ("nsupe", "⊉"), + ("nsupset", "⊃⃒"), + ("nsupseteq", "⊉"), + ("nsupseteqq", "⫆̸"), + ("ntgl", "≹"), + ("ntilde", "ñ"), + ("ntlg", "≸"), + ("ntriangleleft", "⋪"), + ("ntrianglelefteq", "⋬"), + ("ntriangleright", "⋫"), + ("ntrianglerighteq", "⋭"), + ("nu", "ν"), + ("num", "#"), + ("numero", "№"), + ("numsp", " "), + ("nvDash", "⊭"), + ("nvHarr", "⤄"), + ("nvap", "≍⃒"), + ("nvdash", "⊬"), + ("nvge", "≥⃒"), + ("nvgt", ">⃒"), + ("nvinfin", "⧞"), + ("nvlArr", "⤂"), + ("nvle", "≤⃒"), + ("nvlt", "<⃒"), + ("nvltrie", "⊴⃒"), + ("nvrArr", "⤃"), + ("nvrtrie", "⊵⃒"), + ("nvsim", "∼⃒"), + ("nwArr", "⇖"), + ("nwarhk", "⤣"), + ("nwarr", "↖"), + ("nwarrow", "↖"), + ("nwnear", "⤧"), + ("oS", "Ⓢ"), + ("oacute", "ó"), + ("oast", "⊛"), + ("ocir", "⊚"), + ("ocirc", "ô"), + ("ocy", "о"), + ("odash", "⊝"), + ("odblac", "ő"), + ("odiv", "⨸"), + ("odot", "⊙"), + ("odsold", "⦼"), + ("oelig", "œ"), + ("ofcir", "⦿"), + ("ofr", "𝔬"), + ("ogon", "˛"), + ("ograve", "ò"), + ("ogt", "⧁"), + ("ohbar", "⦵"), + ("ohm", "Ω"), + ("oint", "∮"), + ("olarr", "↺"), + ("olcir", "⦾"), + ("olcross", "⦻"), + ("oline", "‾"), + ("olt", "⧀"), + ("omacr", "ō"), + ("omega", "ω"), + ("omicron", "ο"), + ("omid", "⦶"), + ("ominus", "⊖"), + ("oopf", "𝕠"), + ("opar", "⦷"), + ("operp", "⦹"), + ("oplus", "⊕"), + ("or", "∨"), + ("orarr", "↻"), + ("ord", "⩝"), + ("order", "ℴ"), + ("orderof", "ℴ"), + ("ordf", "ª"), + ("ordm", "º"), + ("origof", "⊶"), + ("oror", "⩖"), + ("orslope", "⩗"), + ("orv", "⩛"), + ("oscr", "ℴ"), + ("oslash", "ø"), + ("osol", "⊘"), + ("otilde", "õ"), + ("otimes", "⊗"), + ("otimesas", "⨶"), + ("ouml", "ö"), + ("ovbar", "⌽"), + ("par", "∥"), + ("para", "¶"), + ("parallel", "∥"), + ("parsim", "⫳"), + ("parsl", "⫽"), + ("part", "∂"), + ("pcy", "п"), + ("percnt", "%"), + ("period", "."), + ("permil", "‰"), + ("perp", "⊥"), + ("pertenk", "‱"), + ("pfr", "𝔭"), + ("phi", "φ"), + ("phiv", "ϕ"), + ("phmmat", "ℳ"), + ("phone", "☎"), + ("pi", "π"), + ("pitchfork", "⋔"), + ("piv", "ϖ"), + ("planck", "ℏ"), + ("planckh", "ℎ"), + ("plankv", "ℏ"), + ("plus", "+"), + ("plusacir", "⨣"), + ("plusb", "⊞"), + ("pluscir", "⨢"), + ("plusdo", "∔"), + ("plusdu", "⨥"), + ("pluse", "⩲"), + ("plusmn", "±"), + ("plussim", "⨦"), + ("plustwo", "⨧"), + ("pm", "±"), + ("pointint", "⨕"), + ("popf", "𝕡"), + ("pound", "£"), + ("pr", "≺"), + ("prE", "⪳"), + ("prap", "⪷"), + ("prcue", "≼"), + ("pre", "⪯"), + ("prec", "≺"), + ("precapprox", "⪷"), + ("preccurlyeq", "≼"), + ("preceq", "⪯"), + ("precnapprox", "⪹"), + ("precneqq", "⪵"), + ("precnsim", "⋨"), + ("precsim", "≾"), + ("prime", "′"), + ("primes", "ℙ"), + ("prnE", "⪵"), + ("prnap", "⪹"), + ("prnsim", "⋨"), + ("prod", "∏"), + ("profalar", "⌮"), + ("profline", "⌒"), + ("profsurf", "⌓"), + ("prop", "∝"), + ("propto", "∝"), + ("prsim", "≾"), + ("prurel", "⊰"), + ("pscr", "𝓅"), + ("psi", "ψ"), + ("puncsp", " "), + ("qfr", "𝔮"), + ("qint", "⨌"), + ("qopf", "𝕢"), + ("qprime", "⁗"), + ("qscr", "𝓆"), + ("quaternions", "ℍ"), + ("quatint", "⨖"), + ("quest", "?"), + ("questeq", "≟"), + ("quot", "\""), + ("rAarr", "⇛"), + ("rArr", "⇒"), + ("rAtail", "⤜"), + ("rBarr", "⤏"), + ("rHar", "⥤"), + ("race", "∽̱"), + ("racute", "ŕ"), + ("radic", "√"), + ("raemptyv", "⦳"), + ("rang", "⟩"), + ("rangd", "⦒"), + ("range", "⦥"), + ("rangle", "⟩"), + ("raquo", "»"), + ("rarr", "→"), + ("rarrap", "⥵"), + ("rarrb", "⇥"), + ("rarrbfs", "⤠"), + ("rarrc", "⤳"), + ("rarrfs", "⤞"), + ("rarrhk", "↪"), + ("rarrlp", "↬"), + ("rarrpl", "⥅"), + ("rarrsim", "⥴"), + ("rarrtl", "↣"), + ("rarrw", "↝"), + ("ratail", "⤚"), + ("ratio", "∶"), + ("rationals", "ℚ"), + ("rbarr", "⤍"), + ("rbbrk", "❳"), + ("rbrace", "}"), + ("rbrack", "]"), + ("rbrke", "⦌"), + ("rbrksld", "⦎"), + ("rbrkslu", "⦐"), + ("rcaron", "ř"), + ("rcedil", "ŗ"), + ("rceil", "⌉"), + ("rcub", "}"), + ("rcy", "р"), + ("rdca", "⤷"), + ("rdldhar", "⥩"), + ("rdquo", "”"), + ("rdquor", "”"), + ("rdsh", "↳"), + ("real", "ℜ"), + ("realine", "ℛ"), + ("realpart", "ℜ"), + ("reals", "ℝ"), + ("rect", "▭"), + ("reg", "®"), + ("rfisht", "⥽"), + ("rfloor", "⌋"), + ("rfr", "𝔯"), + ("rhard", "⇁"), + ("rharu", "⇀"), + ("rharul", "⥬"), + ("rho", "ρ"), + ("rhov", "ϱ"), + ("rightarrow", "→"), + ("rightarrowtail", "↣"), + ("rightharpoondown", "⇁"), + ("rightharpoonup", "⇀"), + ("rightleftarrows", "⇄"), + ("rightleftharpoons", "⇌"), + ("rightrightarrows", "⇉"), + ("rightsquigarrow", "↝"), + ("rightthreetimes", "⋌"), + ("ring", "˚"), + ("risingdotseq", "≓"), + ("rlarr", "⇄"), + ("rlhar", "⇌"), + ("rlm", "‏"), + ("rmoust", "⎱"), + ("rmoustache", "⎱"), + ("rnmid", "⫮"), + ("roang", "⟭"), + ("roarr", "⇾"), + ("robrk", "⟧"), + ("ropar", "⦆"), + ("ropf", "𝕣"), + ("roplus", "⨮"), + ("rotimes", "⨵"), + ("rpar", ")"), + ("rpargt", "⦔"), + ("rppolint", "⨒"), + ("rrarr", "⇉"), + ("rsaquo", "›"), + ("rscr", "𝓇"), + ("rsh", "↱"), + ("rsqb", "]"), + ("rsquo", "’"), + ("rsquor", "’"), + ("rthree", "⋌"), + ("rtimes", "⋊"), + ("rtri", "▹"), + ("rtrie", "⊵"), + ("rtrif", "▸"), + ("rtriltri", "⧎"), + ("ruluhar", "⥨"), + ("rx", "℞"), + ("sacute", "ś"), + ("sbquo", "‚"), + ("sc", "≻"), + ("scE", "⪴"), + ("scap", "⪸"), + ("scaron", "š"), + ("sccue", "≽"), + ("sce", "⪰"), + ("scedil", "ş"), + ("scirc", "ŝ"), + ("scnE", "⪶"), + ("scnap", "⪺"), + ("scnsim", "⋩"), + ("scpolint", "⨓"), + ("scsim", "≿"), + ("scy", "с"), + ("sdot", "⋅"), + ("sdotb", "⊡"), + ("sdote", "⩦"), + ("seArr", "⇘"), + ("searhk", "⤥"), + ("searr", "↘"), + ("searrow", "↘"), + ("sect", "§"), + ("semi", ";"), + ("seswar", "⤩"), + ("setminus", "∖"), + ("setmn", "∖"), + ("sext", "✶"), + ("sfr", "𝔰"), + ("sfrown", "⌢"), + ("sharp", "♯"), + ("shchcy", "щ"), + ("shcy", "ш"), + ("shortmid", "∣"), + ("shortparallel", "∥"), + ("shy", "\u{AD}"), + ("sigma", "σ"), + ("sigmaf", "ς"), + ("sigmav", "ς"), + ("sim", "∼"), + ("simdot", "⩪"), + ("sime", "≃"), + ("simeq", "≃"), + ("simg", "⪞"), + ("simgE", "⪠"), + ("siml", "⪝"), + ("simlE", "⪟"), + ("simne", "≆"), + ("simplus", "⨤"), + ("simrarr", "⥲"), + ("slarr", "←"), + ("smallsetminus", "∖"), + ("smashp", "⨳"), + ("smeparsl", "⧤"), + ("smid", "∣"), + ("smile", "⌣"), + ("smt", "⪪"), + ("smte", "⪬"), + ("smtes", "⪬︀"), + ("softcy", "ь"), + ("sol", "/"), + ("solb", "⧄"), + ("solbar", "⌿"), + ("sopf", "𝕤"), + ("spades", "♠"), + ("spadesuit", "♠"), + ("spar", "∥"), + ("sqcap", "⊓"), + ("sqcaps", "⊓︀"), + ("sqcup", "⊔"), + ("sqcups", "⊔︀"), + ("sqsub", "⊏"), + ("sqsube", "⊑"), + ("sqsubset", "⊏"), + ("sqsubseteq", "⊑"), + ("sqsup", "⊐"), + ("sqsupe", "⊒"), + ("sqsupset", "⊐"), + ("sqsupseteq", "⊒"), + ("squ", "□"), + ("square", "□"), + ("squarf", "▪"), + ("squf", "▪"), + ("srarr", "→"), + ("sscr", "𝓈"), + ("ssetmn", "∖"), + ("ssmile", "⌣"), + ("sstarf", "⋆"), + ("star", "☆"), + ("starf", "★"), + ("straightepsilon", "ϵ"), + ("straightphi", "ϕ"), + ("strns", "¯"), + ("sub", "⊂"), + ("subE", "⫅"), + ("subdot", "⪽"), + ("sube", "⊆"), + ("subedot", "⫃"), + ("submult", "⫁"), + ("subnE", "⫋"), + ("subne", "⊊"), + ("subplus", "⪿"), + ("subrarr", "⥹"), + ("subset", "⊂"), + ("subseteq", "⊆"), + ("subseteqq", "⫅"), + ("subsetneq", "⊊"), + ("subsetneqq", "⫋"), + ("subsim", "⫇"), + ("subsub", "⫕"), + ("subsup", "⫓"), + ("succ", "≻"), + ("succapprox", "⪸"), + ("succcurlyeq", "≽"), + ("succeq", "⪰"), + ("succnapprox", "⪺"), + ("succneqq", "⪶"), + ("succnsim", "⋩"), + ("succsim", "≿"), + ("sum", "∑"), + ("sung", "♪"), + ("sup", "⊃"), + ("sup1", "¹"), + ("sup2", "²"), + ("sup3", "³"), + ("supE", "⫆"), + ("supdot", "⪾"), + ("supdsub", "⫘"), + ("supe", "⊇"), + ("supedot", "⫄"), + ("suphsol", "⟉"), + ("suphsub", "⫗"), + ("suplarr", "⥻"), + ("supmult", "⫂"), + ("supnE", "⫌"), + ("supne", "⊋"), + ("supplus", "⫀"), + ("supset", "⊃"), + ("supseteq", "⊇"), + ("supseteqq", "⫆"), + ("supsetneq", "⊋"), + ("supsetneqq", "⫌"), + ("supsim", "⫈"), + ("supsub", "⫔"), + ("supsup", "⫖"), + ("swArr", "⇙"), + ("swarhk", "⤦"), + ("swarr", "↙"), + ("swarrow", "↙"), + ("swnwar", "⤪"), + ("szlig", "ß"), + ("target", "⌖"), + ("tau", "τ"), + ("tbrk", "⎴"), + ("tcaron", "ť"), + ("tcedil", "ţ"), + ("tcy", "т"), + ("tdot", "⃛"), + ("telrec", "⌕"), + ("tfr", "𝔱"), + ("there4", "∴"), + ("therefore", "∴"), + ("theta", "θ"), + ("thetasym", "ϑ"), + ("thetav", "ϑ"), + ("thickapprox", "≈"), + ("thicksim", "∼"), + ("thinsp", " "), + ("thkap", "≈"), + ("thksim", "∼"), + ("thorn", "þ"), + ("tilde", "˜"), + ("times", "×"), + ("timesb", "⊠"), + ("timesbar", "⨱"), + ("timesd", "⨰"), + ("tint", "∭"), + ("toea", "⤨"), + ("top", "⊤"), + ("topbot", "⌶"), + ("topcir", "⫱"), + ("topf", "𝕥"), + ("topfork", "⫚"), + ("tosa", "⤩"), + ("tprime", "‴"), + ("trade", "™"), + ("triangle", "▵"), + ("triangledown", "▿"), + ("triangleleft", "◃"), + ("trianglelefteq", "⊴"), + ("triangleq", "≜"), + ("triangleright", "▹"), + ("trianglerighteq", "⊵"), + ("tridot", "◬"), + ("trie", "≜"), + ("triminus", "⨺"), + ("triplus", "⨹"), + ("trisb", "⧍"), + ("tritime", "⨻"), + ("trpezium", "⏢"), + ("tscr", "𝓉"), + ("tscy", "ц"), + ("tshcy", "ћ"), + ("tstrok", "ŧ"), + ("twixt", "≬"), + ("twoheadleftarrow", "↞"), + ("twoheadrightarrow", "↠"), + ("uArr", "⇑"), + ("uHar", "⥣"), + ("uacute", "ú"), + ("uarr", "↑"), + ("ubrcy", "ў"), + ("ubreve", "ŭ"), + ("ucirc", "û"), + ("ucy", "у"), + ("udarr", "⇅"), + ("udblac", "ű"), + ("udhar", "⥮"), + ("ufisht", "⥾"), + ("ufr", "𝔲"), + ("ugrave", "ù"), + ("uharl", "↿"), + ("uharr", "↾"), + ("uhblk", "▀"), + ("ulcorn", "⌜"), + ("ulcorner", "⌜"), + ("ulcrop", "⌏"), + ("ultri", "◸"), + ("umacr", "ū"), + ("uml", "¨"), + ("uogon", "ų"), + ("uopf", "𝕦"), + ("uparrow", "↑"), + ("updownarrow", "↕"), + ("upharpoonleft", "↿"), + ("upharpoonright", "↾"), + ("uplus", "⊎"), + ("upsi", "υ"), + ("upsih", "ϒ"), + ("upsilon", "υ"), + ("upuparrows", "⇈"), + ("urcorn", "⌝"), + ("urcorner", "⌝"), + ("urcrop", "⌎"), + ("uring", "ů"), + ("urtri", "◹"), + ("uscr", "𝓊"), + ("utdot", "⋰"), + ("utilde", "ũ"), + ("utri", "▵"), + ("utrif", "▴"), + ("uuarr", "⇈"), + ("uuml", "ü"), + ("uwangle", "⦧"), + ("vArr", "⇕"), + ("vBar", "⫨"), + ("vBarv", "⫩"), + ("vDash", "⊨"), + ("vangrt", "⦜"), + ("varepsilon", "ϵ"), + ("varkappa", "ϰ"), + ("varnothing", "∅"), + ("varphi", "ϕ"), + ("varpi", "ϖ"), + ("varpropto", "∝"), + ("varr", "↕"), + ("varrho", "ϱ"), + ("varsigma", "ς"), + ("varsubsetneq", "⊊︀"), + ("varsubsetneqq", "⫋︀"), + ("varsupsetneq", "⊋︀"), + ("varsupsetneqq", "⫌︀"), + ("vartheta", "ϑ"), + ("vartriangleleft", "⊲"), + ("vartriangleright", "⊳"), + ("vcy", "в"), + ("vdash", "⊢"), + ("vee", "∨"), + ("veebar", "⊻"), + ("veeeq", "≚"), + ("vellip", "⋮"), + ("verbar", "|"), + ("vert", "|"), + ("vfr", "𝔳"), + ("vltri", "⊲"), + ("vnsub", "⊂⃒"), + ("vnsup", "⊃⃒"), + ("vopf", "𝕧"), + ("vprop", "∝"), + ("vrtri", "⊳"), + ("vscr", "𝓋"), + ("vsubnE", "⫋︀"), + ("vsubne", "⊊︀"), + ("vsupnE", "⫌︀"), + ("vsupne", "⊋︀"), + ("vzigzag", "⦚"), + ("wcirc", "ŵ"), + ("wedbar", "⩟"), + ("wedge", "∧"), + ("wedgeq", "≙"), + ("weierp", "℘"), + ("wfr", "𝔴"), + ("wopf", "𝕨"), + ("wp", "℘"), + ("wr", "≀"), + ("wreath", "≀"), + ("wscr", "𝓌"), + ("xcap", "⋂"), + ("xcirc", "◯"), + ("xcup", "⋃"), + ("xdtri", "▽"), + ("xfr", "𝔵"), + ("xhArr", "⟺"), + ("xharr", "⟷"), + ("xi", "ξ"), + ("xlArr", "⟸"), + ("xlarr", "⟵"), + ("xmap", "⟼"), + ("xnis", "⋻"), + ("xodot", "⨀"), + ("xopf", "𝕩"), + ("xoplus", "⨁"), + ("xotime", "⨂"), + ("xrArr", "⟹"), + ("xrarr", "⟶"), + ("xscr", "𝓍"), + ("xsqcup", "⨆"), + ("xuplus", "⨄"), + ("xutri", "△"), + ("xvee", "⋁"), + ("xwedge", "⋀"), + ("yacute", "ý"), + ("yacy", "я"), + ("ycirc", "ŷ"), + ("ycy", "ы"), + ("yen", "¥"), + ("yfr", "𝔶"), + ("yicy", "ї"), + ("yopf", "𝕪"), + ("yscr", "𝓎"), + ("yucy", "ю"), + ("yuml", "ÿ"), + ("zacute", "ź"), + ("zcaron", "ž"), + ("zcy", "з"), + ("zdot", "ż"), + ("zeetrf", "ℨ"), + ("zeta", "ζ"), + ("zfr", "𝔷"), + ("zhcy", "ж"), + ("zigrarr", "⇝"), + ("zopf", "𝕫"), + ("zscr", "𝓏"), + ("zwj", "‍"), + ("zwnj", "‌"), +]; diff --git a/crates/mq-markdown/src/parser/inline/entity.rs b/crates/mq-markdown/src/parser/inline/entity.rs new file mode 100644 index 000000000..4b5552f43 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/entity.rs @@ -0,0 +1,127 @@ +//! Character references (`&`, `#`, `#`) and backslash escapes. + +use super::entities::ENTITIES; + +/// Decodes the character reference that starts at `pos`, returning the offset after it and its value. +pub(super) fn decode(src: &str, pos: usize) -> Option<(usize, String)> { + decode_with(src, pos, true) +} + +/// `decode`, where a numeric reference of any length is one when `limited` is not set, and is +/// replaced with U+FFFD when it is out of range. +fn decode_with(src: &str, pos: usize, limited: bool) -> Option<(usize, String)> { + let bytes = src.as_bytes(); + debug_assert_eq!(bytes[pos], b'&'); + let rest = &bytes[pos + 1..]; + + if rest.first() == Some(&b'#') { + let (radix, digits_from, max) = match rest.get(1) { + Some(b'x' | b'X') => (16, 2, 6), + _ => (10, 1, 7), + }; + let max = if limited { max } else { usize::MAX }; + let digits = rest[digits_from..] + .iter() + .take_while(|b| (radix == 16 && b.is_ascii_hexdigit()) || b.is_ascii_digit()) + .count(); + if digits == 0 || digits > max || rest.get(digits_from + digits) != Some(&b';') { + return None; + } + let number = + u32::from_str_radix(&src[pos + 1 + digits_from..pos + 1 + digits_from + digits], radix).unwrap_or(u32::MAX); + // Control characters other than whitespace are not allowed, nor are surrogates and + // out of range numbers. + let char = match char::from_u32(number) { + Some('\0'..='\u{8}' | '\u{b}' | '\u{e}'..='\u{1f}' | '\u{7f}'..='\u{9f}') | None => { + char::REPLACEMENT_CHARACTER + } + Some(char) => char, + }; + return Some((pos + 1 + digits_from + digits + 1, char.to_string())); + } + + let name = rest.iter().take_while(|b| b.is_ascii_alphanumeric()).count(); + if name == 0 || name > 31 || rest.get(name) != Some(&b';') { + return None; + } + let name = &src[pos + 1..pos + 1 + name]; + let index = ENTITIES + .binary_search_by(|(key, _)| key.as_bytes().cmp(name.as_bytes())) + .ok()?; + Some((pos + 1 + name.len() + 1, ENTITIES[index].1.to_string())) +} + +/// Decodes backslash escapes and character references, as in link destinations and titles. +pub(crate) fn unescape(value: &str) -> String { + let bytes = value.as_bytes(); + let mut result = String::with_capacity(value.len()); + let mut from = 0; + let mut index = 0; + + while index < bytes.len() { + match bytes[index] { + b'\\' if bytes.get(index + 1).is_some_and(u8::is_ascii_punctuation) => { + result.push_str(&value[from..index]); + result.push(bytes[index + 1] as char); + index += 2; + from = index; + } + b'&' => match decode(value, index) { + Some((end, decoded)) => { + result.push_str(&value[from..index]); + result.push_str(&decoded); + index = end; + from = index; + } + None => index += 1, + }, + _ => index += 1, + } + } + result.push_str(&value[from..]); + + result +} + +/// Decodes character references only, as in the string values of JSX attributes. +pub(in crate::parser) fn decode_references(value: &str) -> String { + let bytes = value.as_bytes(); + let mut result = String::with_capacity(value.len()); + let mut from = 0; + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'&' + && let Some((end, decoded)) = decode_with(value, index, false) + { + result.push_str(&value[from..index]); + result.push_str(&decoded); + index = end; + from = index; + } else { + index += 1; + } + } + result.push_str(&value[from..]); + + result +} + +/// Removes the indentation that follows line endings, as labels of definitions and references show it. +pub(in crate::parser) fn remove_line_indent(value: &str) -> std::borrow::Cow<'_, str> { + if !value.contains(['\n', '\r']) { + return std::borrow::Cow::Borrowed(value); + } + let mut result = String::with_capacity(value.len()); + let mut after_line_ending = false; + for char in value.chars() { + match char { + ' ' | '\t' if after_line_ending => {} + _ => { + after_line_ending = matches!(char, '\n' | '\r'); + result.push(char); + } + } + } + std::borrow::Cow::Owned(result) +} diff --git a/crates/mq-markdown/src/parser/inline/html.rs b/crates/mq-markdown/src/parser/inline/html.rs new file mode 100644 index 000000000..2266f3f2d --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/html.rs @@ -0,0 +1,216 @@ +//! Autolinks (``) and raw inline HTML. + +use crate::parser::scan::{Span, is_whitespace, skip_whitespace}; + +/// An autolink found at `<`. +pub(super) struct Autolink { + pub(super) url: String, + /// The link text, inside the angle brackets. + pub(super) text: Span, + /// Offset after the closing `>`. + pub(super) end: usize, +} + +/// Parses an autolink at `pos` (a `<`): an absolute URI or an email address. +pub(super) fn autolink(src: &str, pos: usize) -> Option { + let bytes = src.as_bytes(); + let inner = pos + 1; + + let scheme_char = |b: &u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'); + let scheme = bytes + .get(inner) + .is_some_and(u8::is_ascii_alphabetic) + .then(|| { + 1 + bytes[inner + 1..] + .iter() + .take(31) + .take_while(|b| scheme_char(b)) + .count() + }) + .filter(|&length| length >= 2 && bytes.get(inner + length) == Some(&b':')); + + if let Some(length) = scheme { + // Everything up to the closing `>` is the URL. + let from = inner + length + 1; + let close = from + + bytes[from..] + .iter() + .position(|&b| b == b'>' || b <= 0x1F || matches!(b, b' ' | b'<' | 0x7F))?; + return (bytes[close] == b'>').then(|| Autolink { + url: src[inner..close].to_string(), + text: Span::new(inner, close), + end: close + 1, + }); + } + + let end = email(bytes, inner)?; + Some(Autolink { + url: format!("mailto:{}", &src[inner..end]), + text: Span::new(inner, end), + end: end + 1, + }) +} + +/// Whether `text` is an email address that an autolink (``) can hold. +pub(crate) fn is_autolink_email(text: &str) -> bool { + let closed = format!("{text}>"); + email(closed.as_bytes(), 0) == Some(text.len()) +} + +/// Parses an email address for an autolink at `start`, returning the offset of the closing `>`. +/// The characters allowed are those of the mdast email autolink, which differ slightly from `CommonMark`. +fn email(bytes: &[u8], start: usize) -> Option { + let atext = |b: u8| matches!(b, b'#'..=b'\'' | b'*' | b'+' | b'-'..=b'9' | b'=' | b'?' | b'A'..=b'Z' | b'^'..=b'~'); + let mut index = start; + while bytes.get(index).copied().is_some_and(atext) { + index += 1; + } + if index == start || bytes.get(index) != Some(&b'@') { + return None; + } + index += 1; + + // Labels of at most 63 letters, digits and hyphens, starting and ending with a letter or digit. + loop { + let label = index; + if !bytes.get(index).is_some_and(u8::is_ascii_alphanumeric) { + return None; + } + while bytes + .get(index) + .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'-') + { + index += 1; + } + if index - label > 63 || bytes[index - 1] == b'-' { + return None; + } + match bytes.get(index) { + Some(b'.') => index += 1, + Some(b'>') => return Some(index), + _ => return None, + } + } +} + +/// Terminators that a search reached the end of the input without finding, so that later searches +/// for the same one fail without scanning again. Each is the first offset a search started from. +#[derive(Default)] +pub(super) struct Misses { + comment: Option, + instruction: Option, + cdata: Option, + declaration: Option, +} + +/// Finds `terminator` in `src` from `from`, recording a failure in `miss`. +fn find_from(src: &str, from: usize, terminator: &str, miss: &mut Option) -> Option { + if miss.is_some_and(|missed| from >= missed) { + return None; + } + let found = src[from..].find(terminator).map(|index| from + index); + if found.is_none() { + *miss = Some(from); + } + found +} + +/// Parses raw inline HTML at `pos` (a `<`), returning the offset after it. +pub(super) fn inline_html(src: &str, pos: usize, misses: &mut Misses) -> Option { + let rest = &src[pos..]; + let bytes = rest.as_bytes(); + + if rest.starts_with("") { + return Some(pos + 5); + } + if rest.starts_with("") { + return Some(pos + 6); + } + return find_from(src, pos + 4, "-->", &mut misses.comment).map(|index| index + 3); + } + if rest.starts_with("", &mut misses.instruction).map(|index| index + 2); + } + if rest.starts_with("", &mut misses.cdata).map(|index| index + 3); + } + if rest.starts_with("", &mut misses.declaration).map(|index| index + 1); + } + if rest.starts_with(" usize { + if !bytes.first().is_some_and(u8::is_ascii_alphabetic) { + return 0; + } + bytes + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || **b == b'-') + .count() +} + +/// The length of a closing tag such as `` at the start of `bytes`. +fn closing_tag(bytes: &[u8]) -> Option { + let name = tag_name(&bytes[2..]); + if name == 0 { + return None; + } + let index = skip_whitespace(bytes, 2 + name); + (bytes.get(index) == Some(&b'>')).then_some(index + 1) +} + +/// The length of an open tag such as `` at the start of `bytes`. +fn open_tag(bytes: &[u8]) -> Option { + let name = tag_name(&bytes[1..]); + if name == 0 { + return None; + } + let mut index = 1 + name; + + loop { + let after_space = skip_whitespace(bytes, index); + let has_space = after_space > index; + match bytes.get(after_space)? { + b'>' => return Some(after_space + 1), + b'/' => return (bytes.get(after_space + 1) == Some(&b'>')).then_some(after_space + 2), + byte if has_space && (byte.is_ascii_alphabetic() || matches!(byte, b'_' | b':')) => { + index = attribute(bytes, after_space)?; + } + _ => return None, + } + } +} + +/// The offset after the attribute (name and optional value) that starts at `start`. +fn attribute(bytes: &[u8], start: usize) -> Option { + let name = bytes[start..] + .iter() + .take_while(|b| b.is_ascii_alphanumeric() || matches!(**b, b'_' | b'.' | b':' | b'-')) + .count(); + let end = start + name; + + let after_space = skip_whitespace(bytes, end); + if bytes.get(after_space) != Some(&b'=') { + return Some(end); + } + let value = skip_whitespace(bytes, after_space + 1); + match bytes.get(value)? { + quote @ (b'"' | b'\'') => { + let close = bytes[value + 1..].iter().position(|b| b == quote)?; + Some(value + 1 + close + 1) + } + _ => { + let length = bytes[value..] + .iter() + .take_while(|&&b| !is_whitespace(b) && !matches!(b, b'"' | b'\'' | b'=' | b'<' | b'>' | b'`')) + .count(); + (length > 0).then_some(value + length) + } + } +} diff --git a/crates/mq-markdown/src/parser/inline/link.rs b/crates/mq-markdown/src/parser/inline/link.rs new file mode 100644 index 000000000..9aa491634 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/link.rs @@ -0,0 +1,268 @@ +//! Links, images, references and footnote references, resolved when a `]` is found. + +use super::entity::{remove_line_indent, unescape}; +use super::tail::{Tail, inline_tail, reference}; +use super::{Context, Item, MAX_NESTING, Scanner, Value, emphasis, item_depth, to_nodes}; +#[cfg(feature = "wikilink")] +use crate::node::RenderOptions; +use crate::node::{FootnoteRef, Image, ImageRef, Link, LinkRef, Node, Position, Text, Title, Url}; +use crate::parser::scan::Span; + +/// Normalizes a reference label: whitespace runs become one space, the ends are trimmed, and the case +/// is folded. +pub(crate) fn normalize(label: &str) -> String { + let bytes = label.as_bytes(); + let mut result = String::with_capacity(label.len()); + let mut in_whitespace = true; + let mut start = 0; + + for (index, byte) in bytes.iter().enumerate() { + if matches!(byte, b'\t' | b'\n' | b'\r' | b' ') { + if !in_whitespace { + result.push_str(&label[start..index]); + in_whitespace = true; + } + } else if in_whitespace { + if !result.is_empty() { + result.push(' '); + } + start = index; + in_whitespace = false; + } + } + if !in_whitespace { + result.push_str(&label[start..]); + } + + result.to_lowercase().to_uppercase().to_lowercase() +} + +/// What a closing bracket resolves to. +enum Kind { + Inline(Tail), + /// `derived` tells that the label is the text of the reference itself. + Reference { + label: String, + end: usize, + derived: bool, + }, + Footnote { + label: String, + }, +} + +/// Handles a `]` at the scanner position. Returns whether it closed a link or an image. +pub(super) fn close(scanner: &mut Scanner<'_>) -> bool { + let Some(&opener_index) = scanner.openers.last() else { + return false; + }; + let Item::Open(opener) = &scanner.items[opener_index] else { + return false; + }; + let (image, opener_start) = (opener.image, opener.start); + let active = image || scanner.openers.len() > scanner.inactive_below; + scanner.openers.pop(); + scanner.inactive_below = scanner.inactive_below.min(scanner.openers.len()); + let inner_depth = scanner.depths.pop().unwrap_or(0); + // The brackets stay text when they do not close, and what is inside them is still inside the outer ones. + scanner.note_depth(inner_depth); + if !active || inner_depth >= MAX_NESTING { + return false; + } + + let context = scanner.context; + let src = context.src(); + let pos = scanner.pos; + let label_start = opener_start + if image { 2 } else { 1 }; + let label = &src[label_start..pos]; + let after = pos + 1; + + // After a `!`, a footnote reference is the reference and the `!` is text, unless a destination follows. + let is_footnote = footnote_label(label).is_some_and(|name| context.references.footnotes.contains(&normalize(name))) + && (!image || inline_tail(src, after).is_none()); + let bang = image && is_footnote; + let (image, opener_start) = if bang { + (false, opener_start + 1) + } else { + (image, opener_start) + }; + let kind = if is_footnote { + Some(Kind::Footnote { + label: label[1..].to_string(), + }) + } else if let Some(tail) = inline_tail(src, after) { + Some(Kind::Inline(tail)) + } else { + reference(context, label, after).map(|(label, end, derived)| Kind::Reference { label, end, derived }) + }; + let Some(kind) = kind else { + return false; + }; + + // Everything after the opener becomes the content. + let mut content = scanner.items.split_off(opener_index + 1); + scanner.items.pop(); + if bang { + scanner.items.push(Item::Text { + start: opener_start - 1, + end: opener_start, + value: Value::Slice(Span::new(opener_start - 1, opener_start)), + }); + } + // Email addresses are not linked inside link text. + let inner = Context { + emails: false, + ..*context + }; + emphasis::process(&mut content, &inner); + let depth = content.iter().map(item_depth).max().unwrap_or(0) + 1; + let values = to_nodes(content, &inner); + + let (node, end) = build(kind, image, values, after, |end| context.position(opener_start, end)); + + if !image { + // Links cannot contain links: earlier `[` openers can no longer become links. + scanner.inactive_below = scanner.openers.len(); + } + + scanner.note_depth(depth); + scanner.items.push(Item::Node { + node: Box::new(node), + depth, + }); + scanner.pos = end; + scanner.run = end; + true +} + +/// Builds the node for a resolved bracket pair, returning it and the offset where scanning goes on. +/// `position` gives the position from the opener to the given end offset. +fn build( + kind: Kind, + image: bool, + values: Vec, + after: usize, + position: impl Fn(usize) -> Position, +) -> (Node, usize) { + match kind { + Kind::Footnote { label } => { + let node = Node::FootnoteRef(FootnoteRef { + ident: normalize(&label), + label: Some(unescape(&remove_line_indent(&label))), + position: Some(position(after)), + }); + (node, after) + } + Kind::Inline(tail) => { + let position = Some(position(tail.end)); + let node = if image { + Node::Image(Image { + alt: plain_text(&values), + url: tail.url, + title: tail.title, + position, + }) + } else { + make_link(tail.url, tail.title, values, position) + }; + (node, tail.end) + } + Kind::Reference { label, end, derived } => { + let position = Some(position(end)); + // A label taken from the text has its markup removed. + let shown = if derived { + plain_text(&values) + } else { + unescape(&remove_line_indent(&label)) + }; + let node = if image { + Node::ImageRef(ImageRef { + alt: plain_text(&values), + ident: normalize(&label), + label: Some(shown), + position, + }) + } else { + Node::LinkRef(LinkRef { + ident: normalize(&label), + label: Some(shown), + values: unwrap_links(values), + position, + }) + }; + (node, end) + } + } +} + +/// The label of a footnote reference: `^` followed by text without whitespace. +fn footnote_label(label: &str) -> Option<&str> { + let name = label.strip_prefix('^')?; + (!name.is_empty() && !name.bytes().any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))).then_some(name) +} + +/// Replaces links in link text by their content: a link cannot contain a link, so these come from +/// autolink literals. A wikilink there is text. +fn unwrap_links(values: Vec) -> Vec { + values + .into_iter() + .flat_map(|child| match child { + Node::Link(Link { values, position, .. }) => values + .into_iter() + .map(|value| match value { + // An autolink literal spans exactly its text, which is not decoded as it was linked. In + // link text it is ordinary text, so escapes and references in it apply. + Node::Text(Text { + value, + position: text_position, + }) if text_position == position => Node::Text(Text { + value: unescape(&value), + position: text_position, + }), + other => other, + }) + .collect(), + #[cfg(feature = "wikilink")] + Node::WikiLink(link) => vec![Node::Text(Text { + value: Node::WikiLink(link).to_string_with(&RenderOptions::default()), + position: None, + })], + other => vec![other], + }) + .collect() +} + +/// Builds a link, unwrapping the links in its text. +pub(super) fn make_link(url: String, title: Option, values: Vec, position: Option) -> Node { + let values = unwrap_links(values); + + Node::Link(Link { + url: Url(url), + title: title.map(Title), + values, + position, + }) +} + +/// The plain text of inline nodes, as used for the alternative text of images. +fn plain_text(nodes: &[Node]) -> String { + let mut text = String::new(); + for node in nodes { + match node { + Node::Text(node) => text.push_str(&node.value), + Node::CodeInline(node) => text.push_str(&node.value), + Node::MathInline(node) => text.push_str(&node.value), + Node::Html(node) => text.push_str(&node.value), + Node::Emphasis(node) => text.push_str(&plain_text(&node.values)), + Node::Strong(node) => text.push_str(&plain_text(&node.values)), + Node::Delete(node) => text.push_str(&plain_text(&node.values)), + Node::Link(node) => text.push_str(&plain_text(&node.values)), + Node::LinkRef(node) => text.push_str(&plain_text(&node.values)), + Node::Break(_) => text.push('\n'), + Node::Image(node) => text.push_str(&node.alt), + Node::ImageRef(node) => text.push_str(&node.alt), + _ => {} + } + } + text +} diff --git a/crates/mq-markdown/src/parser/inline/literal.rs b/crates/mq-markdown/src/parser/inline/literal.rs new file mode 100644 index 000000000..f30102293 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/literal.rs @@ -0,0 +1,285 @@ +//! GFM autolink literals: `www.example.com`, `https://example.com` and `user@example.com`. +//! +//! A link runs to the first whitespace or `<`, without the punctuation, character references and +//! unbalanced `)` at its end. + +use super::punctuation::is_punctuation; +use super::{Item, Scanner, Value}; +use crate::node::{Link, Node, Text, Url}; +use crate::parser::scan::Span; + +/// Whether `char` is a symbol of the emoji blocks, which a domain may contain. +fn is_emoji(char: char) -> bool { + matches!(char, '\u{2600}'..='\u{27BF}' | '\u{1F300}'..='\u{1FAFF}') +} + +/// Whether `char` can be in a domain besides the separators `.`, `_` and `-`. +fn is_domain_char(char: char) -> bool { + is_emoji(char) || !(char.is_whitespace() || is_punctuation(char)) +} + +/// Where a `]` ends the link: at the end of the text, before whitespace, or before a `(` or `[` +/// that continues a Markdown link. +fn closes_label(bytes: &[u8], index: usize) -> bool { + bytes[index] == b']' && matches!(bytes.get(index + 1), None | Some(b'\t' | b'\n' | b' ' | b'(' | b'[')) +} + +/// The end of the autolink whose domain starts at `start`, with the trailing punctuation left out. +fn autolink_end(src: &str, start: usize) -> usize { + let bytes = src.as_bytes(); + let mut end = start; + for (offset, char) in src[start..].char_indices() { + if char.is_whitespace() || char == '<' || closes_label(bytes, start + offset) { + break; + } + end = start + offset + char.len_utf8(); + } + + // The trailing punctuation starts where only punctuation is left up to the end. + let mut trailing = end; + while trailing > start { + match bytes[trailing - 1] { + b'!' | b'"' | b'\'' | b')' | b'*' | b',' | b'.' | b':' | b'?' | b']' | b'_' | b'~' => trailing -= 1, + b';' => { + // A character reference such as `&` goes as a whole. + let letters = bytes[start..trailing - 1] + .iter() + .rev() + .take_while(|b| b.is_ascii_alphabetic()) + .count(); + let reference = trailing - 1 - letters; + trailing = if letters > 0 && reference > start && bytes[reference - 1] == b'&' { + reference - 1 + } else { + trailing - 1 + }; + } + _ => break, + } + } + + // A `)` that closes a `(` of the link is part of it. + let open = bytes[start..trailing].iter().filter(|b| **b == b'(').count(); + let mut closed = bytes[start..trailing].iter().filter(|b| **b == b')').count(); + while trailing < end && bytes[trailing] == b')' && closed < open { + closed += 1; + trailing += 1; + } + trailing +} + +/// Whether the domain at the start of `text` is valid: it has a character besides the separators, +/// and no `_` in its last two segments. +fn has_valid_domain(text: &str) -> bool { + let length = text + .find(|char: char| !(matches!(char, '.' | '_' | '-') || is_domain_char(char))) + .unwrap_or(text.len()); + let domain = &text[..length]; + let underscore_free = domain.rsplit('.').take(2).all(|segment| !segment.contains('_')); + underscore_free && domain.chars().any(is_domain_char) +} + +/// Matches `http://` or `https://` followed by a domain and a path. +fn protocol(src: &str, pos: usize) -> Option { + let bytes = src.as_bytes(); + if pos > 0 && bytes[pos - 1].is_ascii_alphabetic() { + return None; + } + let letters = bytes[pos..] + .iter() + .take(5) + .take_while(|b| b.is_ascii_alphabetic()) + .count(); + let name = src[pos..pos + letters].to_ascii_lowercase(); + if !matches!(name.as_str(), "http" | "https") || !src[pos + letters..].starts_with("://") { + return None; + } + let start = pos + letters + 3; + let end = autolink_end(src, start); + has_valid_domain(&src[start..end]).then_some(end) +} + +/// Matches `www.` followed by a domain and a path. +fn www(src: &str, pos: usize) -> Option { + let bytes = src.as_bytes(); + if pos > 0 + && !matches!( + bytes[pos - 1], + b'\t' | b'\n' | b' ' | b'(' | b'*' | b'_' | b'[' | b']' | b'~' + ) + { + return None; + } + let prefix = bytes.get(pos..pos + 4)?; + if !prefix[..3].eq_ignore_ascii_case(b"www") || prefix[3] != b'.' || pos + 4 >= bytes.len() { + return None; + } + let end = autolink_end(src, pos); + has_valid_domain(&src[pos..end]).then_some(end) +} + +/// Handles `h` or `w` at the scanner position: a protocol or `www.` autolink. +pub(super) fn url(scanner: &mut Scanner<'_>) { + let context = scanner.context; + let src = context.src(); + let pos = scanner.pos; + let is_www = matches!(src.as_bytes()[pos], b'w' | b'W'); + let end = if is_www { www(src, pos) } else { protocol(src, pos) }; + + let Some(end) = end.filter(|&end| end > pos) else { + scanner.pos += 1; + return; + }; + let text = &src[pos..end]; + let position = Some(context.position(pos, end)); + let node = Node::Link(Link { + url: Url(if is_www { + format!("http://{text}") + } else { + text.to_string() + }), + title: None, + values: vec![Node::Text(Text { + value: text.to_string(), + position: position.clone(), + })], + position, + }); + scanner.push_node(node, end); +} + +/// The start of the local part of an email address that ends at `at`, not before `min`. +fn local_start(bytes: &[u8], min: usize, at: usize) -> Option { + let mut index = at; + while index > min && matches!(bytes[index - 1], b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'_' | b'a'..=b'z') + { + index -= 1; + } + (index != at).then_some(index) +} + +/// The end of the domain of an email address that starts at `start`. +fn email_domain(bytes: &[u8], start: usize, xmpp: bool) -> Option { + let mut index = start; + let mut dot = false; + + while index < bytes.len() { + match bytes[index] { + b'-' | b'0'..=b'9' | b'A'..=b'Z' | b'_' | b'a'..=b'z' => {} + b'/' if xmpp => {} + b'.' if bytes.get(index + 1).is_some_and(u8::is_ascii_alphanumeric) => dot = true, + _ => break, + } + index += 1; + } + + (index > start && dot && matches!(bytes[index - 1], b'.' | b'A'..=b'Z' | b'a'..=b'z')).then_some(index) +} + +/// Appends the plain text `start..end` to `out`, turning email addresses in it into links. +fn split_emails(src: &str, start: usize, end: usize, out: &mut Vec) { + let bytes = src.as_bytes(); + let mut emitted = start; + let mut min = start; + let mut index = start; + + while index < end { + if bytes[index] == b'@' + && let Some(mut from) = local_start(bytes, min, index) + { + // `mailto:` and `xmpp:` are part of the link and are not prefixed again. + let (mut prefixed, mut xmpp) = (true, false); + if from > min && bytes[from - 1] == b':' { + let mut word = from - 1; + while word > min && bytes[word - 1].is_ascii_alphanumeric() { + word -= 1; + } + let name = src[word..from - 1].to_ascii_lowercase(); + if name == "xmpp" || name == "mailto" { + (from, prefixed, xmpp) = (word, false, name == "xmpp"); + } + } + if let Some(to) = email_domain(&bytes[..end], index + 1, xmpp).filter(|&to| bytes.get(to) != Some(&b'@')) { + if emitted < from { + out.push(Item::Text { + start: emitted, + end: from, + value: Value::Slice(Span::new(emitted, from)), + }); + } + out.push(Item::Email { + start: from, + end: to, + prefixed, + }); + emitted = to; + min = to; + index = to; + continue; + } + } + index += 1; + } + + if emitted < end { + out.push(Item::Text { + start: emitted, + end, + value: Value::Slice(Span::new(emitted, end)), + }); + } +} + +/// The source range of an item that is plain data: text that is not decoded, and delimiters and +/// brackets that were not used. +fn data_range(item: &Item) -> Option { + match item { + Item::Text { + start, + end, + value: Value::Slice(span), + } if span.start == *start && span.end == *end => Some(*span), + Item::Delim(delim) => Some(Span::new(delim.start, delim.start + delim.count)), + Item::Open(opener) => Some(Span::new(opener.start, opener.start + if opener.image { 2 } else { 1 })), + _ => None, + } +} + +/// Links the email addresses found in runs of plain data. A line ending ends a run. +pub(super) fn link_emails(src: &str, items: Vec) -> Vec { + let mut out = Vec::with_capacity(items.len()); + let mut group: Vec = Vec::new(); + let mut range = Span::new(0, 0); + + let finish = |group: &mut Vec, range: Span, out: &mut Vec| { + if group.is_empty() { + return; + } + if range.of(src).contains('@') { + group.clear(); + split_emails(src, range.start, range.end, out); + } else { + out.append(group); + } + }; + + for item in items { + let data = data_range(&item).filter(|span| !matches!(src.as_bytes()[span.start], b'\n' | b'\r')); + match data { + Some(span) => { + if group.is_empty() { + range.start = span.start; + } + range.end = span.end; + group.push(item); + } + None => { + finish(&mut group, range, &mut out); + out.push(item); + } + } + } + finish(&mut group, range, &mut out); + + out +} diff --git a/crates/mq-markdown/src/parser/inline/punctuation.rs b/crates/mq-markdown/src/parser/inline/punctuation.rs new file mode 100644 index 000000000..4fdfc8383 --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/punctuation.rs @@ -0,0 +1,375 @@ +//! Unicode punctuation for emphasis flanking rules. +//! +//! The Unicode general categories P and S (Unicode 16.0.0), as `CommonMark` defines punctuation. +//! Sorted ranges, ASCII included. + +const RANGES: [(u32, u32); 349] = [ + (0x0021, 0x002F), + (0x003A, 0x0040), + (0x005B, 0x0060), + (0x007B, 0x007E), + (0x00A1, 0x00A9), + (0x00AB, 0x00AC), + (0x00AE, 0x00B1), + (0x00B4, 0x00B4), + (0x00B6, 0x00B8), + (0x00BB, 0x00BB), + (0x00BF, 0x00BF), + (0x00D7, 0x00D7), + (0x00F7, 0x00F7), + (0x02C2, 0x02C5), + (0x02D2, 0x02DF), + (0x02E5, 0x02EB), + (0x02ED, 0x02ED), + (0x02EF, 0x02FF), + (0x0375, 0x0375), + (0x037E, 0x037E), + (0x0384, 0x0385), + (0x0387, 0x0387), + (0x03F6, 0x03F6), + (0x0482, 0x0482), + (0x055A, 0x055F), + (0x0589, 0x058A), + (0x058D, 0x058F), + (0x05BE, 0x05BE), + (0x05C0, 0x05C0), + (0x05C3, 0x05C3), + (0x05C6, 0x05C6), + (0x05F3, 0x05F4), + (0x0606, 0x060F), + (0x061B, 0x061B), + (0x061D, 0x061F), + (0x066A, 0x066D), + (0x06D4, 0x06D4), + (0x06DE, 0x06DE), + (0x06E9, 0x06E9), + (0x06FD, 0x06FE), + (0x0700, 0x070D), + (0x07F6, 0x07F9), + (0x07FE, 0x07FF), + (0x0830, 0x083E), + (0x085E, 0x085E), + (0x0888, 0x0888), + (0x0964, 0x0965), + (0x0970, 0x0970), + (0x09F2, 0x09F3), + (0x09FA, 0x09FB), + (0x09FD, 0x09FD), + (0x0A76, 0x0A76), + (0x0AF0, 0x0AF1), + (0x0B70, 0x0B70), + (0x0BF3, 0x0BFA), + (0x0C77, 0x0C77), + (0x0C7F, 0x0C7F), + (0x0C84, 0x0C84), + (0x0D4F, 0x0D4F), + (0x0D79, 0x0D79), + (0x0DF4, 0x0DF4), + (0x0E3F, 0x0E3F), + (0x0E4F, 0x0E4F), + (0x0E5A, 0x0E5B), + (0x0F01, 0x0F17), + (0x0F1A, 0x0F1F), + (0x0F34, 0x0F34), + (0x0F36, 0x0F36), + (0x0F38, 0x0F38), + (0x0F3A, 0x0F3D), + (0x0F85, 0x0F85), + (0x0FBE, 0x0FC5), + (0x0FC7, 0x0FCC), + (0x0FCE, 0x0FDA), + (0x104A, 0x104F), + (0x109E, 0x109F), + (0x10FB, 0x10FB), + (0x1360, 0x1368), + (0x1390, 0x1399), + (0x1400, 0x1400), + (0x166D, 0x166E), + (0x169B, 0x169C), + (0x16EB, 0x16ED), + (0x1735, 0x1736), + (0x17D4, 0x17D6), + (0x17D8, 0x17DB), + (0x1800, 0x180A), + (0x1940, 0x1940), + (0x1944, 0x1945), + (0x19DE, 0x19FF), + (0x1A1E, 0x1A1F), + (0x1AA0, 0x1AA6), + (0x1AA8, 0x1AAD), + (0x1B4E, 0x1B4F), + (0x1B5A, 0x1B6A), + (0x1B74, 0x1B7F), + (0x1BFC, 0x1BFF), + (0x1C3B, 0x1C3F), + (0x1C7E, 0x1C7F), + (0x1CC0, 0x1CC7), + (0x1CD3, 0x1CD3), + (0x1FBD, 0x1FBD), + (0x1FBF, 0x1FC1), + (0x1FCD, 0x1FCF), + (0x1FDD, 0x1FDF), + (0x1FED, 0x1FEF), + (0x1FFD, 0x1FFE), + (0x2010, 0x2027), + (0x2030, 0x205E), + (0x207A, 0x207E), + (0x208A, 0x208E), + (0x20A0, 0x20C0), + (0x2100, 0x2101), + (0x2103, 0x2106), + (0x2108, 0x2109), + (0x2114, 0x2114), + (0x2116, 0x2118), + (0x211E, 0x2123), + (0x2125, 0x2125), + (0x2127, 0x2127), + (0x2129, 0x2129), + (0x212E, 0x212E), + (0x213A, 0x213B), + (0x2140, 0x2144), + (0x214A, 0x214D), + (0x214F, 0x214F), + (0x218A, 0x218B), + (0x2190, 0x2429), + (0x2440, 0x244A), + (0x249C, 0x24E9), + (0x2500, 0x2775), + (0x2794, 0x2B73), + (0x2B76, 0x2B95), + (0x2B97, 0x2BFF), + (0x2CE5, 0x2CEA), + (0x2CF9, 0x2CFC), + (0x2CFE, 0x2CFF), + (0x2D70, 0x2D70), + (0x2E00, 0x2E2E), + (0x2E30, 0x2E5D), + (0x2E80, 0x2E99), + (0x2E9B, 0x2EF3), + (0x2F00, 0x2FD5), + (0x2FF0, 0x2FFF), + (0x3001, 0x3004), + (0x3008, 0x3020), + (0x3030, 0x3030), + (0x3036, 0x3037), + (0x303D, 0x303F), + (0x309B, 0x309C), + (0x30A0, 0x30A0), + (0x30FB, 0x30FB), + (0x3190, 0x3191), + (0x3196, 0x319F), + (0x31C0, 0x31E5), + (0x31EF, 0x31EF), + (0x3200, 0x321E), + (0x322A, 0x3247), + (0x3250, 0x3250), + (0x3260, 0x327F), + (0x328A, 0x32B0), + (0x32C0, 0x33FF), + (0x4DC0, 0x4DFF), + (0xA490, 0xA4C6), + (0xA4FE, 0xA4FF), + (0xA60D, 0xA60F), + (0xA673, 0xA673), + (0xA67E, 0xA67E), + (0xA6F2, 0xA6F7), + (0xA700, 0xA716), + (0xA720, 0xA721), + (0xA789, 0xA78A), + (0xA828, 0xA82B), + (0xA836, 0xA839), + (0xA874, 0xA877), + (0xA8CE, 0xA8CF), + (0xA8F8, 0xA8FA), + (0xA8FC, 0xA8FC), + (0xA92E, 0xA92F), + (0xA95F, 0xA95F), + (0xA9C1, 0xA9CD), + (0xA9DE, 0xA9DF), + (0xAA5C, 0xAA5F), + (0xAA77, 0xAA79), + (0xAADE, 0xAADF), + (0xAAF0, 0xAAF1), + (0xAB5B, 0xAB5B), + (0xAB6A, 0xAB6B), + (0xABEB, 0xABEB), + (0xFB29, 0xFB29), + (0xFBB2, 0xFBC2), + (0xFD3E, 0xFD4F), + (0xFDCF, 0xFDCF), + (0xFDFC, 0xFDFF), + (0xFE10, 0xFE19), + (0xFE30, 0xFE52), + (0xFE54, 0xFE66), + (0xFE68, 0xFE6B), + (0xFF01, 0xFF0F), + (0xFF1A, 0xFF20), + (0xFF3B, 0xFF40), + (0xFF5B, 0xFF65), + (0xFFE0, 0xFFE6), + (0xFFE8, 0xFFEE), + (0xFFFC, 0xFFFD), + (0x10100, 0x10102), + (0x10137, 0x1013F), + (0x10179, 0x10189), + (0x1018C, 0x1018E), + (0x10190, 0x1019C), + (0x101A0, 0x101A0), + (0x101D0, 0x101FC), + (0x1039F, 0x1039F), + (0x103D0, 0x103D0), + (0x1056F, 0x1056F), + (0x10857, 0x10857), + (0x10877, 0x10878), + (0x1091F, 0x1091F), + (0x1093F, 0x1093F), + (0x10A50, 0x10A58), + (0x10A7F, 0x10A7F), + (0x10AC8, 0x10AC8), + (0x10AF0, 0x10AF6), + (0x10B39, 0x10B3F), + (0x10B99, 0x10B9C), + (0x10D6E, 0x10D6E), + (0x10D8E, 0x10D8F), + (0x10EAD, 0x10EAD), + (0x10F55, 0x10F59), + (0x10F86, 0x10F89), + (0x11047, 0x1104D), + (0x110BB, 0x110BC), + (0x110BE, 0x110C1), + (0x11140, 0x11143), + (0x11174, 0x11175), + (0x111C5, 0x111C8), + (0x111CD, 0x111CD), + (0x111DB, 0x111DB), + (0x111DD, 0x111DF), + (0x11238, 0x1123D), + (0x112A9, 0x112A9), + (0x113D4, 0x113D5), + (0x113D7, 0x113D8), + (0x1144B, 0x1144F), + (0x1145A, 0x1145B), + (0x1145D, 0x1145D), + (0x114C6, 0x114C6), + (0x115C1, 0x115D7), + (0x11641, 0x11643), + (0x11660, 0x1166C), + (0x116B9, 0x116B9), + (0x1173C, 0x1173F), + (0x1183B, 0x1183B), + (0x11944, 0x11946), + (0x119E2, 0x119E2), + (0x11A3F, 0x11A46), + (0x11A9A, 0x11A9C), + (0x11A9E, 0x11AA2), + (0x11B00, 0x11B09), + (0x11BE1, 0x11BE1), + (0x11C41, 0x11C45), + (0x11C70, 0x11C71), + (0x11EF7, 0x11EF8), + (0x11F43, 0x11F4F), + (0x11FD5, 0x11FF1), + (0x11FFF, 0x11FFF), + (0x12470, 0x12474), + (0x12FF1, 0x12FF2), + (0x16A6E, 0x16A6F), + (0x16AF5, 0x16AF5), + (0x16B37, 0x16B3F), + (0x16B44, 0x16B45), + (0x16D6D, 0x16D6F), + (0x16E97, 0x16E9A), + (0x16FE2, 0x16FE2), + (0x1BC9C, 0x1BC9C), + (0x1BC9F, 0x1BC9F), + (0x1CC00, 0x1CCEF), + (0x1CD00, 0x1CEB3), + (0x1CF50, 0x1CFC3), + (0x1D000, 0x1D0F5), + (0x1D100, 0x1D126), + (0x1D129, 0x1D164), + (0x1D16A, 0x1D16C), + (0x1D183, 0x1D184), + (0x1D18C, 0x1D1A9), + (0x1D1AE, 0x1D1EA), + (0x1D200, 0x1D241), + (0x1D245, 0x1D245), + (0x1D300, 0x1D356), + (0x1D6C1, 0x1D6C1), + (0x1D6DB, 0x1D6DB), + (0x1D6FB, 0x1D6FB), + (0x1D715, 0x1D715), + (0x1D735, 0x1D735), + (0x1D74F, 0x1D74F), + (0x1D76F, 0x1D76F), + (0x1D789, 0x1D789), + (0x1D7A9, 0x1D7A9), + (0x1D7C3, 0x1D7C3), + (0x1D800, 0x1D9FF), + (0x1DA37, 0x1DA3A), + (0x1DA6D, 0x1DA74), + (0x1DA76, 0x1DA83), + (0x1DA85, 0x1DA8B), + (0x1E14F, 0x1E14F), + (0x1E2FF, 0x1E2FF), + (0x1E5FF, 0x1E5FF), + (0x1E95E, 0x1E95F), + (0x1ECAC, 0x1ECAC), + (0x1ECB0, 0x1ECB0), + (0x1ED2E, 0x1ED2E), + (0x1EEF0, 0x1EEF1), + (0x1F000, 0x1F02B), + (0x1F030, 0x1F093), + (0x1F0A0, 0x1F0AE), + (0x1F0B1, 0x1F0BF), + (0x1F0C1, 0x1F0CF), + (0x1F0D1, 0x1F0F5), + (0x1F10D, 0x1F1AD), + (0x1F1E6, 0x1F202), + (0x1F210, 0x1F23B), + (0x1F240, 0x1F248), + (0x1F250, 0x1F251), + (0x1F260, 0x1F265), + (0x1F300, 0x1F6D7), + (0x1F6DC, 0x1F6EC), + (0x1F6F0, 0x1F6FC), + (0x1F700, 0x1F776), + (0x1F77B, 0x1F7D9), + (0x1F7E0, 0x1F7EB), + (0x1F7F0, 0x1F7F0), + (0x1F800, 0x1F80B), + (0x1F810, 0x1F847), + (0x1F850, 0x1F859), + (0x1F860, 0x1F887), + (0x1F890, 0x1F8AD), + (0x1F8B0, 0x1F8BB), + (0x1F8C0, 0x1F8C1), + (0x1F900, 0x1FA53), + (0x1FA60, 0x1FA6D), + (0x1FA70, 0x1FA7C), + (0x1FA80, 0x1FA89), + (0x1FA8F, 0x1FAC6), + (0x1FACE, 0x1FADC), + (0x1FADF, 0x1FAE9), + (0x1FAF0, 0x1FAF8), + (0x1FB00, 0x1FB92), + (0x1FB94, 0x1FBEF), +]; + +/// Whether `char` is a Unicode punctuation character. +pub(super) fn is_punctuation(char: char) -> bool { + if char.is_ascii() { + return char.is_ascii_punctuation(); + } + let code = char as u32; + RANGES + .binary_search_by(|&(start, end)| { + if code < start { + std::cmp::Ordering::Greater + } else if code > end { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} diff --git a/crates/mq-markdown/src/parser/inline/tail.rs b/crates/mq-markdown/src/parser/inline/tail.rs new file mode 100644 index 000000000..cba364d9a --- /dev/null +++ b/crates/mq-markdown/src/parser/inline/tail.rs @@ -0,0 +1,179 @@ +//! The parts that follow the text of a link: destinations, titles and reference labels. + +use super::Context; +use super::entity; +use super::link::normalize; +use crate::parser::scan::{eol_len, skip_blanks_and_eol}; + +/// The destination and title of an inline link, and the offset after its closing parenthesis. +pub(super) struct Tail { + pub(super) url: String, + pub(super) title: Option, + pub(super) end: usize, +} + +/// Resolves a reference whose text is `text` and whose closing bracket is followed by `after`. +/// Returns the label, the offset after the reference, and whether the label is the text itself. +pub(super) fn reference(context: &Context<'_>, text: &str, after: usize) -> Option<(String, usize, bool)> { + let src = context.src(); + // A label has at most 999 characters, which also keeps the normalizing of long text away. + let defined = + |label: &str| label.chars().nth(999).is_none() && context.references.definitions.contains(&normalize(label)); + + if src[after..].starts_with('[') { + match parse_label(src, after) { + // A full reference: `[text][label]`. + Some((end, label)) if !label.is_empty() => { + return defined(label).then(|| (label.to_string(), end, false)); + } + // A collapsed reference: `[text][]`. + Some((end, _)) => { + return (has_content(text) && defined(text)).then(|| (text.to_string(), end, true)); + } + None => {} + } + } + + // A shortcut reference: `[text]`. + (has_content(text) && defined(text)).then(|| (text.to_string(), after, true)) +} + +fn has_content(label: &str) -> bool { + label.bytes().any(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r')) +} + +/// Parses a link label at `pos` (the `[`), returning the offset after the `]` and the raw label. +/// An empty label is returned for `[]`. +fn parse_label(src: &str, pos: usize) -> Option<(usize, &str)> { + let bytes = src.as_bytes(); + let mut index = pos + 1; + while index < bytes.len() && index - pos <= 1000 { + match bytes[index] { + b'\\' => index += 1, + b'[' => return None, + b']' => { + let label = &src[pos + 1..index]; + return (label.is_empty() || has_content(label)).then_some((index + 1, label)); + } + _ => {} + } + index += 1; + } + None +} + +/// Parses `(destination "title")` at `pos`. +pub(super) fn inline_tail(src: &str, pos: usize) -> Option { + let bytes = src.as_bytes(); + if bytes.get(pos) != Some(&b'(') { + return None; + } + + let index = skip_blanks_and_eol(bytes, pos + 1); + let (url, after_url) = destination(src, index)?; + + let after_space = skip_blanks_and_eol(bytes, after_url); + if bytes.get(after_space) == Some(&b')') { + return Some(Tail { + url, + title: None, + end: after_space + 1, + }); + } + // A title needs whitespace before it. + if after_space == after_url { + return None; + } + let (title, after_title) = title_at(src, after_space)?; + + let closing = skip_blanks_and_eol(bytes, after_title); + (bytes.get(closing) == Some(&b')')).then_some(Tail { + url, + title: Some(title), + end: closing + 1, + }) +} + +/// Parses a link destination at `pos`, returning it decoded and the offset after it. +pub(in crate::parser) fn destination(src: &str, pos: usize) -> Option<(String, usize)> { + let bytes = src.as_bytes(); + + if bytes.get(pos) == Some(&b'<') { + let mut index = pos + 1; + while let Some(&byte) = bytes.get(index) { + match byte { + b'>' => return Some((entity::unescape(&src[pos + 1..index]), index + 1)), + b'<' | b'\n' | b'\r' => return None, + b'\\' => index += 1, + _ => {} + } + index += 1; + } + return None; + } + + let mut index = pos; + let mut depth = 0usize; + while let Some(&byte) = bytes.get(index) { + match byte { + b'\\' if bytes.get(index + 1).is_some_and(u8::is_ascii_punctuation) => index += 1, + b'(' => { + depth += 1; + if depth > 32 { + return None; + } + } + b')' => { + if depth == 0 { + break; + } + depth -= 1; + } + b' ' | b'\t' | b'\n' | b'\r' => break, + // A NUL is let through, though it is a control character. + byte if byte.is_ascii_control() && byte != 0 => break, + _ => {} + } + index += 1; + } + (depth == 0).then(|| (entity::unescape(&src[pos..index]), index)) +} + +/// Parses a link title at `pos`, returning it decoded and the offset after its closing delimiter. +pub(in crate::parser) fn title_at(src: &str, pos: usize) -> Option<(String, usize)> { + let bytes = src.as_bytes(); + let close = match bytes.get(pos)? { + b'"' => b'"', + b'\'' => b'\'', + b'(' => b')', + _ => return None, + }; + + let mut index = pos + 1; + while let Some(&byte) = bytes.get(index) { + match byte { + byte if byte == close => { + // The lines of a paragraph lose their leading whitespace, also inside a title. + let title = entity::remove_line_indent(&src[pos + 1..index]); + return Some((entity::unescape(&title), index + 1)); + } + b'(' if close == b')' => return None, + b'\\' => index += 1, + b'\n' | b'\r' => { + // A title cannot contain a blank line. + let length = eol_len(bytes, index); + let mut next = index + length; + while matches!(bytes.get(next), Some(b' ' | b'\t')) { + next += 1; + } + if matches!(bytes.get(next), Some(b'\n' | b'\r')) { + return None; + } + index += length - 1; + } + _ => {} + } + index += 1; + } + None +} diff --git a/crates/mq-markdown/src/parser/line.rs b/crates/mq-markdown/src/parser/line.rs new file mode 100644 index 000000000..92058f0c1 --- /dev/null +++ b/crates/mq-markdown/src/parser/line.rs @@ -0,0 +1,281 @@ +//! Source lines and their positions. + +use super::block::CODE_INDENT; +use super::flavor::Flavor; +use crate::node::Point; + +/// Leading whitespace: how many columns it spans, where tabs go to the next multiple of four, and how +/// many bytes it takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Indent { + pub(super) columns: usize, + pub(super) bytes: usize, +} + +#[derive(Clone, Copy)] +pub(super) struct Line<'a> { + pub(super) number: usize, + /// Byte offset of `text` within the original line, non-zero inside containers. + pub(super) column: usize, + pub(super) text: &'a str, + /// Line terminator that follows `text`; empty on the last line without one. + pub(super) eol: &'a str, + /// Whether this is the last line of the document. + pub(super) eof: bool, + /// Whether this is the last line of a list item. + pub(super) item_end: bool, + /// Whether the line continues a paragraph without its container prefix. + pub(super) lazy: bool, + /// The tabs of the line, to compute columns without scanning the line again. + pub(super) stops: &'a [TabStop], + /// The whole line without its terminator, whatever a container removed from `text`, to turn byte + /// offsets into columns. + pub(super) whole: &'a str, + /// Byte offset of the line in the document, to compare with `stops`. + pub(super) base: usize, + /// Columns left over from a tab that a container consumed only in part. They count as leading + /// whitespace of `text`. + pub(super) pad: usize, + /// The dialect of the document. + pub(super) flavor: Flavor, + /// For `*`, `-` and `_`: one past the offset in the line of the last byte that is neither that + /// character, nor a space or tab, or 0 if there is none. A thematic break is a run of one of them + /// and whitespace to the end of the line, so this tells at once, from any offset, that it is not. + pub(super) others: [usize; 3], +} + +impl<'a> Line<'a> { + /// The indentation from which a line is code. Without indented code, indentation never matters. + pub(super) fn code_indent(&self) -> usize { + if self.flavor.has_indented_code() { + CODE_INDENT + } else { + usize::MAX + } + } + + /// The zero-based visual column where `text` starts in the document. + fn start_column(&self) -> usize { + self.visual_column(self.column) + } + + /// The zero-based visual column of the byte at `offset` in the line. + fn visual_column(&self, offset: usize) -> usize { + let at = self.base + offset; + let passed = self.stops.partition_point(|stop| stop.at < at); + match passed.checked_sub(1).map(|index| self.stops[index]) { + Some(stop) => stop.after + (at - stop.at - 1), + None => offset, + } + } + + /// The leading whitespace. + pub(super) fn indent(&self) -> Indent { + let mut columns = self.pad; + let mut absolute = self.start_column(); + for (index, byte) in self.text.bytes().enumerate() { + match byte { + b' ' => { + columns += 1; + absolute += 1; + } + b'\t' => { + let width = (absolute / 4 + 1) * 4 - absolute; + columns += width; + absolute += width; + } + _ => return Indent { columns, bytes: index }, + } + } + Indent { + columns, + bytes: self.text.len(), + } + } + + pub(super) fn is_blank(&self) -> bool { + self.text.bytes().all(|b| matches!(b, b' ' | b'\t')) + } + + /// The position of the byte at `byte` in `text`. Columns are in UTF-16 code units, and a tab is one. + pub(super) fn point(&self, byte: usize) -> Point { + let offset = self.column + byte; + Point { + line: self.number, + column: offset - extra_bytes(&self.whole.as_bytes()[..offset.min(self.whole.len())]) + 1, + } + } + + /// The same line with the first `bytes` bytes removed. + pub(super) fn skip(self, bytes: usize) -> Line<'a> { + Line { + column: self.column + bytes, + text: &self.text[bytes..], + pad: 0, + ..self + } + } + + /// The same line with `columns` columns of leading whitespace removed. What is left of a tab that + /// is only consumed in part stays as padding. + pub(super) fn skip_columns(self, columns: usize) -> Line<'a> { + let mut remaining = columns; + let consumed = remaining.min(self.pad); + let mut pad = self.pad - consumed; + remaining -= consumed; + + let mut absolute = self.start_column(); + let mut bytes = 0; + for byte in self.text.bytes() { + if remaining == 0 { + break; + } + match byte { + b' ' => { + remaining -= 1; + absolute += 1; + } + b'\t' => { + let width = (absolute / 4 + 1) * 4 - absolute; + absolute += width; + if width > remaining { + pad = width - remaining; + remaining = 0; + } else { + remaining -= width; + } + } + _ => break, + } + bytes += 1; + } + + Line { + column: self.column + bytes, + text: &self.text[bytes..], + pad, + ..self + } + } + + pub(super) fn end(&self) -> Point { + self.point(self.text.len()) + } +} + +/// A tab in the document and the zero-based column right after it. +#[derive(Clone, Copy)] +pub(super) struct TabStop { + at: usize, + after: usize, +} + +/// Every tab in `src` in order. +pub(super) fn tab_stops(src: &str) -> Vec { + let mut stops = Vec::new(); + // The byte offset and the column right after the last tab or line start. + let (mut anchor, mut anchor_column) = (0, 0); + for (index, byte) in src.bytes().enumerate() { + match byte { + b'\n' | b'\r' => (anchor, anchor_column) = (index + 1, 0), + b'\t' => { + let column = anchor_column + (index - anchor); + let after = (column / 4 + 1) * 4; + stops.push(TabStop { at: index, after }); + (anchor, anchor_column) = (index + 1, after); + } + _ => {} + } + } + stops +} + +/// How many of `bytes`, which are whole characters, are more than UTF-16 code units: each continuation +/// byte is one, except that a character of four bytes is two code units. +pub(super) fn extra_bytes(bytes: &[u8]) -> usize { + let (mut continuation, mut astral) = (0, 0); + for &byte in bytes { + continuation += usize::from(byte & 0xC0 == 0x80); + astral += usize::from(byte >= 0xF0); + } + continuation - astral +} + +/// The `Line::others` of `text`. +fn last_others(text: &str) -> [usize; 3] { + let mut last = [0; 3]; + for (index, byte) in text.bytes().enumerate() { + match byte { + b' ' | b'\t' => {} + b'*' => (last[1], last[2]) = (index + 1, index + 1), + b'-' => (last[0], last[2]) = (index + 1, index + 1), + b'_' => (last[0], last[1]) = (index + 1, index + 1), + _ => last = [index + 1; 3], + } + } + last +} + +/// Splits `src` into lines on `\n`, `\r\n` and `\r`. A trailing terminator does not add a line. +pub(super) fn split_lines<'a>(src: &'a str, flavor: Flavor, stops: &'a [TabStop]) -> Vec> { + let bytes = src.as_bytes(); + let mut lines = Vec::new(); + let (mut start, mut index) = (0, 0); + let mut next_stop = 0; + // The tabs between `start` and `end`. + let mut take_stops = |end: usize| { + let first = next_stop; + while stops.get(next_stop).is_some_and(|stop| stop.at < end) { + next_stop += 1; + } + &stops[first..next_stop] + }; + + while index < bytes.len() { + let end = index; + match bytes[index] { + b'\n' => index += 1, + b'\r' => index += if bytes.get(index + 1) == Some(&b'\n') { 2 } else { 1 }, + _ => { + index += 1; + continue; + } + } + lines.push(Line { + number: lines.len() + 1, + column: 0, + text: &src[start..end], + eol: &src[end..index], + eof: index == bytes.len(), + item_end: false, + lazy: false, + stops: take_stops(end), + whole: &src[start..end], + base: start, + pad: 0, + flavor, + others: last_others(&src[start..end]), + }); + start = index; + } + + if start < src.len() { + lines.push(Line { + number: lines.len() + 1, + column: 0, + text: &src[start..], + eol: "", + eof: true, + item_end: false, + lazy: false, + stops: take_stops(src.len()), + whole: &src[start..], + base: start, + pad: 0, + flavor, + others: last_others(&src[start..]), + }); + } + + lines +} diff --git a/crates/mq-markdown/src/parser/mdx.rs b/crates/mq-markdown/src/parser/mdx.rs new file mode 100644 index 000000000..cf9c8ed9c --- /dev/null +++ b/crates/mq-markdown/src/parser/mdx.rs @@ -0,0 +1,493 @@ +//! MDX: JSX tags and expressions, as the MDX syntax extensions to CommonMark define them, without a JavaScript parser, +//! so expressions are only checked for balanced braces, outside of strings, template literals and comments. + +use super::error::{Located, MdxErrorKind, MdxFound, MdxPlace}; +use super::scan::Span; +use crate::node::{MdxAttributeContent, MdxAttributeValue, MdxJsxAttribute}; +use rustc_hash::FxHashMap; +use smol_str::SmolStr; +use std::cell::OnceCell; + +/// What the end of the input means for a construct it ends inside of. +pub(super) enum Fallback { + Nok, + Error(Located), +} + +/// The outcome of parsing at a position. +pub(super) enum Parsed { + Ok(T), + /// Not this construct, so the text is something else. + Nok, + /// The input ended inside the construct. More input may complete it, and if there is none this + /// is what the end of the input means for it. + More(Fallback), + /// The construct is invalid. + Error(Located), +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum TagKind { + Open, + Close, + SelfClosing, +} + +/// A JSX tag: ``, `` or ``. A fragment has no name. +pub(super) struct Tag { + pub(super) name: Option, + pub(super) attributes: Vec, + pub(super) kind: TagKind, + /// Offset after the closing `>`. + pub(super) end: usize, +} + +/// Whether `char` can start a JavaScript identifier. +fn id_start(char: char) -> bool { + unicode_id::UnicodeID::is_id_start(char) || matches!(char, '$' | '_') +} + +/// Whether `char` can continue a JSX identifier, which may contain dashes. +fn id_continue(char: char) -> bool { + unicode_id::UnicodeID::is_id_continue(char) || matches!(char, '\u{200c}' | '\u{200d}' | '-') +} + +/// What a tag parser stops with: a failure of the construct, or of the input. +enum Stop { + Nok, + More(Fallback), + Error(Located), +} + +type Step = Result; + +/// Where each `{` of a source closes, found in one pass the first time one is asked for, so that +/// many unclosed braces do not each scan to the end of the input. +#[derive(Default)] +pub(super) struct Braces(OnceCell>); + +impl Braces { + /// The offset of the `}` that closes the `{` at `open`, if there is one. + fn close(&self, src: &str, open: usize) -> Option { + self.0.get_or_init(|| match_braces(src, 0, false)).get(&open).copied() + } +} + +enum Frame { + /// A `{`, or a `${` of a template literal when it has no offset. + Brace(Option), + Template, +} + +/// Matches the braces of the expressions in `src` from `from`, which are not checked as JavaScript but +/// skip over strings, template literals and comments. Text outside braces is not looked at. With +/// `single`, `from` is a `{` and the search ends once it is closed. +fn match_braces(src: &str, from: usize, single: bool) -> FxHashMap { + let bytes = src.as_bytes(); + let mut closes = FxHashMap::default(); + let mut stack = Vec::new(); + let mut index = from; + + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + match stack.last() { + None => { + if byte == b'{' { + stack.push(Frame::Brace(Some(index - 1))); + } + } + Some(Frame::Template) => match byte { + b'\\' => index += 1, + b'`' => { + stack.pop(); + } + b'$' if bytes.get(index) == Some(&b'{') => { + stack.push(Frame::Brace(None)); + index += 1; + } + _ => {} + }, + Some(Frame::Brace(_)) => match byte { + b'{' => stack.push(Frame::Brace(Some(index - 1))), + b'}' => { + if let Some(Frame::Brace(Some(open))) = stack.pop() { + closes.insert(open, index - 1); + if single && open == from { + break; + } + } + } + b'`' => stack.push(Frame::Template), + b'\'' | b'"' => index = skip_string(bytes, index, byte).unwrap_or(index), + b'/' => match bytes.get(index) { + Some(b'/') => { + index += bytes[index..].iter().take_while(|&&b| b != b'\n').count(); + } + Some(b'*') => { + if let Some(end) = src[index + 1..].find("*/") { + index += 1 + end + 2; + } + } + _ => {} + }, + _ => {} + }, + } + } + closes +} + +/// The offset after the string that starts before `from` with the `quote`. A string that does not end on +/// its line is not one. +fn skip_string(bytes: &[u8], from: usize, quote: u8) -> Option { + let mut index = from; + while let Some(&byte) = bytes.get(index) { + match byte { + b'\\' => index += 1, + b'\n' | b'\r' => return None, + _ if byte == quote => return Some(index + 1), + _ => {} + } + index += 1; + } + None +} + +struct Cursor<'a> { + src: &'a str, + index: usize, + /// Set when the same source is searched for many expressions. Without it each one is scanned. + braces: Option<&'a Braces>, +} + +impl Cursor<'_> { + fn peek(&self) -> Option { + self.src[self.index..].chars().next() + } + + fn bump(&mut self) { + if let Some(char) = self.peek() { + self.index += char.len_utf8(); + } + } + + /// The input ends inside a construct: more input may follow, otherwise it is not one. + fn end_of_input(&self) -> Step { + Err(Stop::More(Fallback::Nok)) + } + + /// The construct is invalid at the current position. + fn crash(&self, kind: MdxErrorKind) -> Step { + Err(Stop::Error(Located { + kind, + offset: self.index, + })) + } + + /// The current character, or the end of the input, cannot be at `place`. + fn unexpected(&self, place: MdxPlace) -> Step { + let found = self.peek().map_or(MdxFound::EndOfFile, MdxFound::Char); + self.crash(MdxErrorKind::Unexpected { found, place }) + } + + /// Skips whitespace, including line endings. Ends the tag at the end of the input. + fn skip_whitespace(&mut self) -> Step<()> { + while self.peek().is_some_and(char::is_whitespace) { + self.bump(); + } + if self.peek().is_none() { + return self.end_of_input(); + } + Ok(()) + } + + fn name_part(&mut self) -> Step<&str> { + let start = self.index; + self.bump(); + while self.peek().is_some_and(id_continue) { + self.bump(); + } + Ok(&self.src[start..self.index]) + } + + /// The name of a tag, with its member (`.`) or namespace (`:`) parts, cleaned of whitespace. + fn tag_name(&mut self) -> Step { + let mut name = self.name_part()?.to_string(); + self.skip_whitespace()?; + match self.peek() { + Some('.') => { + while self.peek() == Some('.') { + self.bump(); + self.skip_whitespace()?; + if !self.peek().is_some_and(id_start) { + return self.unexpected(MdxPlace::BeforeMemberName); + } + name.push('.'); + name.push_str(self.name_part()?); + self.skip_whitespace()?; + } + } + Some(':') => { + self.bump(); + self.skip_whitespace()?; + if !self.peek().is_some_and(id_start) { + return self.unexpected(MdxPlace::BeforeLocalName); + } + name.push(':'); + name.push_str(self.name_part()?); + self.skip_whitespace()?; + } + _ => {} + } + Ok(name) + } + + /// A `{...}` expression with balanced braces, returning the range of its content. + fn expression(&mut self) -> Step { + let open = self.index; + self.bump(); + let start = self.index; + let end = match self.braces { + Some(braces) => braces.close(self.src, open), + None => match_braces(self.src, open, true).get(&open).copied(), + }; + let Some(end) = end else { + self.index = self.src.len(); + return Err(Stop::More(Fallback::Error(Located { + kind: MdxErrorKind::UnclosedExpression, + offset: open, + }))); + }; + self.index = end + 1; + Ok(Span::new(start, end)) + } +} + +impl Cursor<'_> { + /// The value of an expression: its content, without up to two columns of whitespace at the start + /// of each line after the first. + fn expression_value(&self, content: Span) -> SmolStr { + let content = content.of(self.src); + if !content.contains(['\n', '\r']) { + return SmolStr::new(content); + } + let mut value = String::with_capacity(content.len()); + // How many more columns of whitespace may be dropped at the start of the current line, and the + // column that the line is at. + let mut droppable = 0usize; + let mut column = 0usize; + for char in content.chars() { + match char { + '\n' | '\r' => { + droppable = 2; + column = 0; + value.push(char); + } + ' ' if droppable > 0 => { + droppable -= 1; + column += 1; + } + // What is left of a tab that is dropped in part stays as spaces. + '\t' if droppable > 0 => { + let width = 4 - column % 4; + value.extend(std::iter::repeat_n(' ', width.saturating_sub(droppable))); + droppable = droppable.saturating_sub(width); + column += width; + } + _ => { + droppable = 0; + value.push(char); + } + } + } + SmolStr::new(value) + } + + /// Checks the character after a name: whitespace or one of `allowed`. + fn end_of_name(&self, allowed: &[char], place: MdxPlace) -> Step<()> { + match self.peek() { + None => self.end_of_input(), + Some(char) if char.is_whitespace() || allowed.contains(&char) => Ok(()), + Some(_) => self.unexpected(place), + } + } + + fn attribute(&mut self) -> Step { + let mut name = self.name_part()?.to_string(); + self.end_of_name(&['/', ':', '=', '>', '{'], MdxPlace::InAttributeName)?; + self.skip_whitespace()?; + + if self.peek() == Some(':') { + self.bump(); + self.skip_whitespace()?; + if !self.peek().is_some_and(id_start) { + return self.unexpected(MdxPlace::BeforeLocalAttributeName); + } + name.push(':'); + name.push_str(self.name_part()?); + self.end_of_name(&['/', '=', '>', '{'], MdxPlace::InLocalAttributeName)?; + self.skip_whitespace()?; + } + + let name = SmolStr::new(name); + if self.peek() != Some('=') { + if !matches!(self.peek(), Some('/' | '>' | '{')) && !self.peek().is_some_and(id_start) { + return self.unexpected(MdxPlace::AfterAttributeName); + } + return Ok(MdxAttributeContent::Property(MdxJsxAttribute { name, value: None })); + } + + self.bump(); + self.skip_whitespace()?; + let value = match self.peek() { + Some(quote @ ('"' | '\'')) => { + self.bump(); + let start = self.index; + let Some(length) = self.src[start..].find(quote) else { + return Err(Stop::More(Fallback::Error(Located { + kind: MdxErrorKind::UnclosedAttributeValue { quote }, + offset: start - 1, + }))); + }; + self.index = start + length + 1; + MdxAttributeValue::Literal(SmolStr::new(super::inline::decode_references( + &self.src[start..start + length], + ))) + } + Some('{') => { + let content = self.expression()?; + MdxAttributeValue::Expression(self.expression_value(content)) + } + _ => { + return self.unexpected(MdxPlace::BeforeAttributeValue); + } + }; + Ok(MdxAttributeContent::Property(MdxJsxAttribute { + name, + value: Some(value), + })) + } + + fn tag(&mut self) -> Step { + // `<` is followed by a name, not by whitespace. + match self.peek() { + Some(' ' | '\t' | '\n' | '\r') => return Err(Stop::Nok), + None => return self.end_of_input(), + Some(_) => {} + } + self.skip_whitespace()?; + + let mut kind = TagKind::Open; + if self.peek() == Some('/') { + self.bump(); + self.skip_whitespace()?; + kind = TagKind::Close; + } + + let name = match self.peek() { + Some('>') => None, + Some(char) if id_start(char) => Some(self.tag_name()?), + _ => return self.unexpected(MdxPlace::BeforeName), + }; + if name.is_some() { + self.end_of_tag_name()?; + } + + let mut attributes = Vec::new(); + // Errors about a closing tag are reported once the tag is known to end. + let mut misplaced = None; + loop { + match self.peek() { + Some('/') => { + self.bump(); + self.skip_whitespace()?; + if self.peek() != Some('>') { + return self.unexpected(MdxPlace::AfterSelfClosingSlash); + } + if kind == TagKind::Close { + return self.crash(MdxErrorKind::SelfClosingSlashInClosingTag); + } + kind = TagKind::SelfClosing; + } + Some('>') => { + self.bump(); + break; + } + Some(char) if char == '{' || id_start(char) => { + if kind == TagKind::Close && misplaced.is_none() { + misplaced = Some(Located { + kind: MdxErrorKind::AttributeInClosingTag, + offset: self.index, + }); + } + let attribute = if char == '{' { + let content = self.expression()?; + MdxAttributeContent::Expression(self.expression_value(content)) + } else { + self.attribute()? + }; + attributes.push(attribute); + self.skip_whitespace()?; + } + None => return self.end_of_input(), + Some(_) => return self.unexpected(MdxPlace::BeforeAttributeName), + } + } + + if let Some(error) = misplaced { + return Err(Stop::Error(error)); + } + + Ok(Tag { + name, + attributes, + kind, + end: self.index, + }) + } + + /// After the name of a tag comes a slash, the end, or an attribute. + fn end_of_tag_name(&self) -> Step<()> { + match self.peek() { + None => self.end_of_input(), + Some('/' | '>' | '{') => Ok(()), + // An attribute needs whitespace before it. + Some(char) if id_start(char) && self.src[..self.index].ends_with(char::is_whitespace) => Ok(()), + Some(_) => self.unexpected(MdxPlace::AfterName), + } + } +} + +fn parsed(step: Step) -> Parsed { + match step { + Ok(value) => Parsed::Ok(value), + Err(Stop::Nok) => Parsed::Nok, + Err(Stop::More(fallback)) => Parsed::More(fallback), + Err(Stop::Error(error)) => Parsed::Error(error), + } +} + +/// Parses the JSX tag that starts at `pos`, a `<`. +pub(super) fn tag(src: &str, pos: usize, braces: Option<&Braces>) -> Parsed { + let mut cursor = Cursor { + src, + index: pos + 1, + braces, + }; + parsed(cursor.tag()) +} + +/// Parses the expression that starts at `pos`, a `{`, returning the offset after it and its value. +pub(super) fn expression(src: &str, pos: usize, braces: Option<&Braces>) -> Parsed<(usize, SmolStr)> { + let mut cursor = Cursor { + src, + index: pos, + braces, + }; + parsed( + cursor + .expression() + .map(|content| (content.end + 1, cursor.expression_value(content))), + ) +} diff --git a/crates/mq-markdown/src/parser/mdx_flow.rs b/crates/mq-markdown/src/parser/mdx_flow.rs new file mode 100644 index 000000000..f516c94e9 --- /dev/null +++ b/crates/mq-markdown/src/parser/mdx_flow.rs @@ -0,0 +1,234 @@ +//! MDX flow content: JSX tags and expressions that make up whole lines. + +use super::error::{Located, MdxError, MdxErrorKind}; +use super::line::Line; +use super::mdx::{self, Fallback, Parsed}; +use super::tree::{Block, InlineSource, JsxTag}; +use crate::node::{MdxFlowExpression, Node, Point, Position}; + +/// What the items of a line of MDX flow content add up to. +pub(super) enum Flow { + /// Not flow content. When some items were parsed, the offset where the last one ends. + Nok(Option), + More(Fallback), + Error(Located), + /// The items, and the offset where the last one ends. + Done(Vec, usize), +} + +pub(super) enum FlowItem { + Tag(mdx::Tag, usize), + Expression(smol_str::SmolStr, usize, usize), +} + +/// Parses JSX tags and expressions that make up whole lines: they may only be separated by spaces. +fn flow_items(source: &InlineSource) -> Flow { + let text = source.text.as_str(); + let mut index = text.len() - text.trim_start_matches([' ', '\t']).len(); + let mut items = Vec::new(); + + loop { + let start = index; + let after_expression = match text[index..].chars().next() { + Some('<') => match mdx::tag(text, index, None) { + Parsed::Ok(tag) => { + index = tag.end; + items.push(FlowItem::Tag(tag, start)); + false + } + Parsed::Nok => return Flow::Nok(items.last().map(item_end)), + Parsed::More(fallback) => return Flow::More(fallback), + Parsed::Error(error) => return Flow::Error(error), + }, + Some('{') => match mdx::expression(text, index, None) { + Parsed::Ok((end, value)) => { + index = end; + items.push(FlowItem::Expression(value, start, end)); + true + } + Parsed::Nok => return Flow::Nok(items.last().map(item_end)), + Parsed::More(fallback) => return Flow::More(fallback), + Parsed::Error(error) => return Flow::Error(error), + }, + _ => return Flow::Nok(items.last().map(item_end)), + }; + + let end = index; + index += text[index..].len() - text[index..].trim_start_matches([' ', '\t']).len(); + match text[index..].chars().next() { + None | Some('\n' | '\r') => return Flow::Done(items, end), + Some('<') => {} + Some('{') if !after_expression => {} + Some(_) => return Flow::Nok(Some(end)), + } + } +} + +fn item_end(item: &FlowItem) -> usize { + match item { + FlowItem::Tag(tag, _) => tag.end, + FlowItem::Expression(_, _, end) => *end, + } +} + +/// What a line of MDX makes of flow content. +pub(super) enum FlowOutcome { + /// Flow content that uses the lines up to the given index. + Flow(usize), + /// Not flow content. Lines that its items span up to the given index still belong together. + Nok(Option), + /// The document ends inside a construct that could have been flow content. It is not, but a + /// paragraph is not continued lazily with such a line. + Pending, +} + +/// Parses MDX flow content that starts at `lines[start]` and pushes its blocks. +pub(super) fn mdx_flow(lines: &[Line<'_>], start: usize, blocks: &mut Vec) -> FlowOutcome { + let mut count = 1; + loop { + let end = (start + count).min(lines.len()); + let mut source = InlineSource::new( + lines[start..end] + .iter() + .map(|line| (line.text, line.eol, line.point(0))), + ); + // The last line has its line ending, unless the document ends without one. + source.text.push_str(lines[end - 1].eol); + // The container ends but the document goes on after it. + let container_end = end == lines.len() && lines.last().is_some_and(|line| !line.eof); + match flow_items(&source) { + // The construct goes on: read more lines, unless the container has none left. + Flow::More(_) if end < lines.len() => count *= 2, + Flow::More(_) if container_end => { + blocks.push(Block::Error(MdxError::new( + MdxErrorKind::LazyLine, + Some(lines[start].point(0)), + ))); + return FlowOutcome::Flow(lines.len()); + } + Flow::More(Fallback::Nok) => return FlowOutcome::Pending, + Flow::More(Fallback::Error(error)) => { + blocks.push(Block::Error(error.in_source(&source))); + return FlowOutcome::Flow(lines.len()); + } + Flow::Nok(end) => { + let last_line = + end.map(|end| start + source.lines.partition_point(|line| line.offset < end).saturating_sub(1)); + return FlowOutcome::Nok(last_line); + } + Flow::Error(error) => { + blocks.push(Block::Error(error.in_source(&source))); + return FlowOutcome::Flow(lines.len()); + } + Flow::Done(items, last) => { + let last_line = start + + source + .lines + .partition_point(|line| line.offset < last) + .saturating_sub(1); + if let Some(lazy) = lines[start + 1..=last_line].iter().find(|line| line.lazy) { + blocks.push(Block::Error(MdxError::new(MdxErrorKind::LazyLine, Some(lazy.point(0))))); + return FlowOutcome::Flow(lines.len()); + } + for item in items { + blocks.push(match item { + FlowItem::Tag(tag, from) => Block::Jsx(JsxTag { + name: tag.name, + attributes: tag.attributes, + kind: tag.kind, + position: Position { + start: source.point(from), + end: source.point(tag.end), + }, + }), + FlowItem::Expression(value, from, to) => { + Block::Node(Node::MdxFlowExpression(MdxFlowExpression { + value, + position: Some(Position { + start: source.point(from), + end: source.point(to), + }), + })) + } + }); + } + return FlowOutcome::Flow(last_line + 1); + } + } + } +} + +/// Whether `lines[index]` cannot continue a paragraph lazily because MDX flow content starts there, +/// or could have started there if the document had gone on. +pub(super) fn blocks_lazy_continuation(lines: &[Line<'_>], index: usize) -> bool { + matches!( + probe_mdx_flow(lines, index), + Some(FlowOutcome::Flow(_) | FlowOutcome::Pending) + ) +} + +/// Whether `line` can start flow content: it is MDX and begins with a tag or an expression. The rule +/// that parses flow content and the checks of what a paragraph can continue all start from this. +pub(super) fn may_start_flow(line: &Line<'_>) -> bool { + line.flavor.has_jsx() + && matches!( + line.text.trim_start_matches([' ', '\t']).as_bytes().first(), + Some(b'<' | b'{') + ) +} + +pub(super) fn probe_mdx_flow(lines: &[Line<'_>], index: usize) -> Option { + let line = &lines[index]; + (may_start_flow(line) && !line.lazy).then(|| mdx_flow(lines, index, &mut Vec::new())) +} + +/// What a single MDX line makes of flow content, for tracking what a paragraph can continue. +pub(super) enum FlowStart { + /// The line is flow content, or an error in it. + Complete, + /// The construct goes on in the next line. + Pending, +} + +/// Whether a single MDX line looks like flow content. +pub(super) fn flow_start(line: &Line<'_>) -> Option { + if !may_start_flow(line) { + return None; + } + // The line ending counts, unless the document ends here. + let mut source = InlineSource::new(std::iter::once((line.text, "", line.point(0)))); + source.text.push_str(line.eol); + match flow_items(&source) { + Flow::Nok(_) => None, + Flow::More(_) => Some(FlowStart::Pending), + _ => Some(FlowStart::Complete), + } +} + +/// Where flow content made of the lines in `text` stands after its last line. +pub(super) enum FlowEnd { + /// It goes on in the next line. + Open, + /// It is complete, or invalid. + Done, + /// It is not flow content: the lines are text. + Text, +} + +pub(super) fn flow_end(text: &str) -> FlowEnd { + let mut source = InlineSource::new(std::iter::once((text, "\n", Point { line: 1, column: 1 }))); + source.text.push('\n'); + match flow_items(&source) { + Flow::More(_) => FlowEnd::Open, + Flow::Nok(_) => FlowEnd::Text, + _ => FlowEnd::Done, + } +} + +/// The last line of `first..=last` that a paragraph takes when flow content spans them: it stops +/// before a blank line, which ends the paragraph whatever a construct that spans it is. +pub(super) fn absorbed_until(lines: &[Line<'_>], first: usize, last: usize) -> usize { + (first + 1..=last) + .find(|&index| lines[index].is_blank()) + .map_or(last, |blank| blank - 1) +} diff --git a/crates/mq-markdown/src/parser/render_html.rs b/crates/mq-markdown/src/parser/render_html.rs new file mode 100644 index 000000000..d37db8b35 --- /dev/null +++ b/crates/mq-markdown/src/parser/render_html.rs @@ -0,0 +1,768 @@ +//! Renders the block tree as HTML, with CommonMark, GFM, math and frontmatter, and with raw HTML +//! allowed. +//! +//! The output is the HTML of the CommonMark and GFM specs, line endings included: blocks start on a +//! line of their own, and the line ending that the source has after the last block is kept. + +use super::flavor::Flavor; +use super::inline::{self, Document}; +use super::resolve::{self, References}; +use super::tree::{Block, FencedBlock, InlineBlock, InlineKind, InlineSource, ListBlock, TableItem}; +use crate::node::{Code, Node, TableAlignKind}; +use rustc_hash::FxHashMap; + +#[cfg(any(feature = "wikilink", feature = "embed", feature = "callout"))] +mod obsidian; + +/// Protocols that a link may have, others are dropped. +const SAFE_PROTOCOL_HREF: [&str; 6] = ["http", "https", "irc", "ircs", "mailto", "xmpp"]; +/// Protocols that an image source may have. +const SAFE_PROTOCOL_SRC: [&str; 2] = ["http", "https"]; + +/// Renders `content` as HTML. +pub(super) fn render(content: &str) -> String { + let blocks = super::block::parse(content, Flavor::Markdown, true); + let mut references = References::default(); + resolve::collect(&blocks, &mut references); + + let mut html = Html { + out: String::new(), + eol: first_line_ending(content), + references, + targets: FxHashMap::default(), + tight: Vec::new(), + calls: Vec::new(), + notes: Vec::new(), + check: None, + in_link: false, + }; + html.collect_targets(&blocks); + html.blocks(&blocks); + + if !html.calls.is_empty() { + html.footnote_section(); + } else if has_line_ending_after(content, last_rendered(&blocks)) && !list_then_hidden(content, &blocks) { + html.line_ending_if_needed(); + } + html.out +} + +/// The line ending of the first line that has one, as the document uses it throughout. +fn first_line_ending(content: &str) -> &'static str { + match content.find(['\n', '\r']) { + Some(index) if content[index..].starts_with("\r\n") => "\r\n", + Some(index) if content[index..].starts_with('\r') => "\r", + _ => "\n", + } +} + +/// Whether the source has a line ending after the line on which `end` ends. A document that has no +/// block to render has none. +fn has_line_ending_after(content: &str, end: Option) -> bool { + let Some(end) = end else { return false }; + let mut lines = 1; + let mut bytes = content.bytes().peekable(); + while let Some(byte) = bytes.next() { + match byte { + b'\n' => lines += 1, + b'\r' => { + if bytes.peek() == Some(&b'\n') { + bytes.next(); + } + lines += 1; + } + _ => {} + } + } + end < lines +} + +/// Whether a list is followed by nothing but blocks without output, such as definitions, and no blank +/// line at the end: its last line ending is then part of the list, where it is dropped. +fn list_then_hidden(content: &str, blocks: &[Block]) -> bool { + let hidden = |block: &Block| { + matches!( + block, + Block::Node(Node::Definition(_) | Node::Yaml(_) | Node::Toml(_)) | Block::Footnote(_) + ) + }; + let trailing = blocks.iter().rev().take_while(|block| hidden(block)).count(); + let blank_end = { + let trimmed = content.trim_end_matches([' ', '\t']); + ["\n\n", "\r\n\r\n", "\n\r\n", "\r\n\n", "\r\r"] + .iter() + .any(|end| trimmed.ends_with(end)) + }; + if trailing == 0 || blank_end || blocks.len() == trailing { + return false; + } + let list = &blocks[blocks.len() - trailing - 1]; + // Without a blank line in between, what follows would belong to the list. + matches!(list, Block::List(_)) + && matches!((end_line(list), start_line(&blocks[blocks.len() - trailing])), (Some(end), Some(start)) if start > end + 1) +} + +/// The line on which the last block that produces output ends. +fn last_rendered(blocks: &[Block]) -> Option { + blocks.iter().rev().find_map(|block| match block { + Block::Node(Node::Definition(_) | Node::Yaml(_) | Node::Toml(_)) | Block::Footnote(_) => None, + _ => end_line(block), + }) +} + +fn end_line(block: &Block) -> Option { + match block { + Block::Node(node) => node.position().map(|position| position.end.line), + Block::Fenced(fenced) => fenced.node.position().map(|position| position.end.line), + Block::Inline(InlineBlock { + kind: InlineKind::Heading { position, .. }, + .. + }) => Some(position.end.line), + Block::Inline(block) => block.source.lines.last().map(|line| line.point.line), + Block::Quote(quote) => Some(quote.position.end.line), + // The position of an item includes the blank lines that follow it. + Block::List(list) => list.items.last().and_then(|item| { + item.children + .iter() + .rev() + .find_map(end_line) + .or(Some(item.position.start.line)) + }), + Block::Footnote(footnote) => Some(footnote.position.end.line), + Block::Table(items) => items.last().map(|item| match item { + TableItem::Cell { position, .. } | TableItem::Align { position, .. } => position.end.line, + }), + Block::Jsx(_) | Block::Error(_) => None, + } +} + +fn start_line(block: &Block) -> Option { + match block { + Block::Node(node) => node.position().map(|position| position.start.line), + Block::Fenced(fenced) => fenced.node.position().map(|position| position.start.line), + Block::Inline(InlineBlock { + kind: InlineKind::Heading { position, .. }, + .. + }) => Some(position.start.line), + Block::Inline(block) => block.source.lines.first().map(|line| line.point.line), + Block::Quote(quote) => Some(quote.position.start.line), + Block::List(list) => list.items.first().map(|item| item.position.start.line), + Block::Footnote(footnote) => Some(footnote.position.start.line), + Block::Table(items) => items.first().map(|item| match item { + TableItem::Cell { position, .. } | TableItem::Align { position, .. } => position.start.line, + }), + Block::Jsx(_) | Block::Error(_) => None, + } +} + +/// Whether the list is loose: blank lines between its items, or between the blocks of an item. +fn is_loose(list: &ListBlock) -> bool { + list.spread + || list.items.iter().any(|item| { + item.children.windows(2).any(|pair| { + matches!((end_line(&pair[0]), start_line(&pair[1])), (Some(end), Some(start)) if start > end + 1) + }) + }) +} + +struct Html { + out: String, + eol: &'static str, + references: References, + /// The destination and title of each definition, by normalized label. The first one wins. + targets: FxHashMap)>, + /// Whether the innermost container is a tight list, which has paragraphs without `

`. + tight: Vec, + /// Footnotes in the order they are first referenced, with the number of references. + calls: Vec<(String, usize)>, + /// The rendered bodies of footnote definitions. + notes: Vec<(String, String)>, + /// The task list check that starts the next paragraph. + check: Option, + /// Whether the node is inside a link, which cannot contain a link. + in_link: bool, +} + +impl Html { + /// The document that inline content is parsed in: the definitions of this one, as Markdown. + fn document(&self) -> Document<'_> { + Document { + references: &self.references, + flavor: Flavor::Markdown, + } + } + + fn line_ending(&mut self) { + self.out.push_str(self.eol); + } + + fn line_ending_if_needed(&mut self) { + if !matches!(self.out.as_bytes().last(), None | Some(b'\n' | b'\r')) { + self.line_ending(); + } + } + + fn collect_targets(&mut self, blocks: &[Block]) { + for block in blocks { + match block { + Block::Node(Node::Definition(definition)) => { + self.targets.entry(definition.ident.clone()).or_insert_with(|| { + ( + definition.url.0.clone(), + definition.title.as_ref().map(|title| title.0.clone()), + ) + }); + } + Block::Quote(quote) => self.collect_targets("e.children), + Block::Footnote(footnote) => self.collect_targets(&footnote.children), + Block::List(list) => list.items.iter().for_each(|item| self.collect_targets(&item.children)), + _ => {} + } + } + } + + fn blocks(&mut self, blocks: &[Block]) { + for block in blocks { + self.block(block); + } + } + + fn block(&mut self, block: &Block) { + match block { + Block::Node(node) => self.leaf(node), + Block::Fenced(fenced) => self.fenced(fenced), + Block::Inline(block) => self.inline_block(block), + Block::Quote(quote) => { + #[cfg(feature = "callout")] + if let Some((header, head)) = super::callout::peek_header("e.children) { + self.callout(&header, &head, "e.children[1..]); + return; + } + self.tight.push(false); + self.line_ending_if_needed(); + self.out.push_str("

"); + self.blocks("e.children); + self.tight.pop(); + self.line_ending_if_needed(); + self.out.push_str("
"); + } + Block::List(list) => self.list(list), + Block::Footnote(footnote) => { + self.tight.push(false); + let outer = std::mem::take(&mut self.out); + self.blocks(&footnote.children); + let body = std::mem::replace(&mut self.out, outer); + self.tight.pop(); + self.notes.push((footnote.ident.clone(), body)); + } + Block::Table(items) => self.table(items), + Block::Jsx(_) | Block::Error(_) => {} + } + } + + fn leaf(&mut self, node: &Node) { + match node { + Node::HorizontalRule(_) => { + self.line_ending_if_needed(); + self.out.push_str("
"); + } + Node::Html(html) => { + self.line_ending_if_needed(); + push_html(&mut self.out, &html.value); + } + Node::Code(code) => self.code(code, usize::from(!code.value.is_empty()), true), + _ => {} + } + } + + fn fenced(&mut self, fenced: &FencedBlock) { + match &fenced.node { + Node::Code(code) => self.code(code, fenced.lines, fenced.closed), + Node::Math(math) => { + self.line_ending_if_needed(); + self.out.push_str("
");
+                self.raw_flow(&math.value, fenced.lines, fenced.closed);
+            }
+            _ => {}
+        }
+    }
+
+    fn code(&mut self, code: &Code, lines: usize, closed: bool) {
+        self.line_ending_if_needed();
+        self.out.push_str("
');
+        self.raw_flow(&code.value, lines, closed);
+    }
+
+    /// The content of a code or math block: every line ends with a line ending. A fence that is not
+    /// closed ends with one as well.
+    fn raw_flow(&mut self, value: &str, lines: usize, closed: bool) {
+        encode(&mut self.out, value);
+        if lines > 0 {
+            self.line_ending();
+        }
+        self.out.push_str("
"); + if !closed { + self.line_ending_if_needed(); + } + } + + fn inline_block(&mut self, block: &InlineBlock) { + let nodes = inline::parse(&block.source, self.document()).unwrap_or_default(); + match &block.kind { + InlineKind::Paragraph => { + let tight = self.tight.last().copied().unwrap_or(false); + if !tight { + self.line_ending_if_needed(); + self.out.push_str("

"); + } else if !self.out.ends_with("

  • ") { + // What comes before a tight paragraph is followed by the line ending of the source. + self.line_ending_if_needed(); + } + self.task_check(); + self.inlines(&nodes); + if !tight { + self.out.push_str("

    "); + } + } + InlineKind::Heading { depth, .. } => { + self.line_ending_if_needed(); + self.out.push_str(&format!("")); + self.inlines(&nodes); + self.out.push_str(&format!("")); + } + } + } + + fn task_check(&mut self) { + if let Some(checked) = self.check.take() { + self.out.push_str(" "); + } + } + + fn list(&mut self, list: &ListBlock) { + let tight = !is_loose(list); + self.tight.push(tight); + self.line_ending_if_needed(); + self.out.push_str(if list.ordered { "'); + + for item in &list.items { + self.line_ending_if_needed(); + self.out.push_str("
  • "); + self.check = item.checked; + let empty = !item.children.iter().any(is_content); + self.blocks(&item.children); + // An item without content has the checkbox alone. + self.task_check(); + + let tight_paragraph = + tight + && item.children.iter().rfind(|block| is_content(block)).is_some_and( + |block| matches!(block, Block::Inline(b) if matches!(b.kind, InlineKind::Paragraph)), + ); + if !tight_paragraph && !empty { + self.line_ending_if_needed(); + } + self.out.push_str("
  • "); + } + + self.tight.pop(); + self.line_ending(); + self.out.push_str(if list.ordered { "" } else { "" }); + } + + fn table(&mut self, items: &[TableItem]) { + let aligns = items + .iter() + .find_map(|item| match item { + TableItem::Align { align, .. } => Some(align.as_slice()), + TableItem::Cell { .. } => None, + }) + .unwrap_or_default(); + + let mut rows: Vec>> = Vec::new(); + for item in items { + if let TableItem::Cell { row, source, .. } = item { + if rows.len() <= *row { + rows.resize_with(row + 1, Vec::new); + } + rows[*row].push(source); + } + } + + self.line_ending_if_needed(); + self.out.push_str("
    "); + for (index, row) in rows.iter().enumerate() { + if index == 0 { + self.line_ending_if_needed(); + self.out.push_str(""); + } else if index == 1 { + self.line_ending_if_needed(); + self.out.push_str(""); + } + self.line_ending_if_needed(); + self.out.push_str(""); + let tag = if index == 0 { "th" } else { "td" }; + for (column, align) in aligns.iter().enumerate() { + self.line_ending_if_needed(); + self.out.push_str(&format!("<{tag}")); + match align { + TableAlignKind::Left => self.out.push_str(" align=\"left\""), + TableAlignKind::Right => self.out.push_str(" align=\"right\""), + TableAlignKind::Center => self.out.push_str(" align=\"center\""), + TableAlignKind::None => {} + } + self.out.push('>'); + if let Some(Some(source)) = row.get(column) { + let nodes = inline::parse(source, self.document()).unwrap_or_default(); + self.inlines(&nodes); + } + self.out.push_str(&format!("")); + } + self.line_ending_if_needed(); + self.out.push_str(""); + if index == 0 { + self.line_ending_if_needed(); + self.out.push_str(""); + } + } + if rows.len() > 1 { + self.line_ending_if_needed(); + self.out.push_str(""); + } + self.line_ending_if_needed(); + self.out.push_str("
    "); + } + + fn inlines(&mut self, nodes: &[Node]) { + for node in nodes { + self.inline_node(node); + } + } + + fn inline_node(&mut self, node: &Node) { + match node { + Node::Text(text) => encode(&mut self.out, &text.value), + Node::Emphasis(node) => self.wrapped("em", &node.values), + Node::Strong(node) => self.wrapped("strong", &node.values), + Node::Delete(node) => self.wrapped("del", &node.values), + Node::CodeInline(code) => { + self.out.push_str(""); + self.code_span(&code.value); + } + Node::MathInline(math) => { + self.out.push_str(""); + self.code_span(&math.value); + } + Node::Break(_) => { + self.out.push_str("
    "); + self.line_ending(); + } + Node::Html(html) => push_html(&mut self.out, &html.value), + Node::Link(link) => { + let title = link.title.as_ref().map(|title| title.0.as_str()); + self.link(&link.url.0, title, &link.values); + } + Node::LinkRef(link) => { + let (url, title) = self.targets.get(&link.ident).cloned().unwrap_or_default(); + self.link(&url, title.as_deref(), &link.values); + } + Node::Image(image) => { + self.image(&image.url, &image.alt, image.title.as_deref()); + } + Node::ImageRef(image) => { + let (url, title) = self.targets.get(&image.ident).cloned().unwrap_or_default(); + self.image(&url, &image.alt, title.as_deref()); + } + Node::FootnoteRef(reference) => self.footnote_call(&reference.ident), + #[cfg(feature = "wikilink")] + Node::WikiLink(link) => self.wikilink(&link.target, link.text.as_deref()), + #[cfg(feature = "embed")] + Node::Embed(embed) => self.embed(&embed.target, embed.display.as_deref()), + _ => {} + } + } + + /// The content of a code span: line endings are spaces, and one space is dropped on both sides + /// unless there is nothing but spaces. + fn code_span(&mut self, value: &str) { + let mut text = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(char) = chars.next() { + match char { + '\r' if chars.peek() == Some(&'\n') => {} + '\n' | '\r' => text.push(' '), + _ => text.push(char), + } + } + let bytes = text.as_bytes(); + // Spaces at the edges were already dropped in the value, but line endings were not. + let edge = value.starts_with(['\n', '\r']) || value.ends_with(['\n', '\r']); + let trim = edge + && bytes.len() > 2 + && bytes[0] == b' ' + && bytes[bytes.len() - 1] == b' ' + && bytes.iter().any(|&b| b != b' '); + encode(&mut self.out, if trim { &text[1..text.len() - 1] } else { &text }); + self.out.push_str("
    "); + } + + fn wrapped(&mut self, tag: &str, nodes: &[Node]) { + self.out.push_str(&format!("<{tag}>")); + self.inlines(nodes); + self.out.push_str(&format!("")); + } + + fn link(&mut self, url: &str, title: Option<&str>, values: &[Node]) { + if self.in_link { + self.inlines(values); + return; + } + self.out.push_str("
    '); + self.in_link = true; + self.inlines(values); + self.in_link = false; + self.out.push_str(""); + } + + fn image(&mut self, url: &str, alt: &str, title: Option<&str>) { + self.out.push_str("\"");"); + } + + fn title(&mut self, title: Option<&str>) { + if let Some(title) = title { + self.out.push_str(" title=\""); + encode(&mut self.out, title); + self.out.push('"'); + } + } + + fn footnote_call(&mut self, ident: &str) { + let index = match self.calls.iter().position(|(id, _)| id == ident) { + Some(index) => index, + None => { + self.calls.push((ident.to_string(), 0)); + self.calls.len() - 1 + } + }; + self.calls[index].1 += 1; + let count = self.calls[index].1; + + let id = sanitize(ident); + self.out.push_str(&format!( + " 1 { + self.out.push_str(&format!("-{count}")); + } + self.out.push_str(&format!( + "\" data-footnote-ref=\"\" aria-describedby=\"footnote-label\">{}", + index + 1 + )); + } + + /// The list of the footnotes that were referenced, after everything else. + fn footnote_section(&mut self) { + self.line_ending_if_needed(); + self.out + .push_str("

    Footnotes

    "); + self.line_ending(); + self.out.push_str("
      "); + + for (ident, count) in std::mem::take(&mut self.calls) { + let id = sanitize(&ident); + self.line_ending(); + self.out.push_str(&format!("
    1. ")); + self.line_ending(); + + let mut backreferences = String::new(); + for number in 1..=count { + if number > 1 { + backreferences.push(' '); + } + backreferences.push_str(&format!(" 1 { + backreferences.push_str(&format!("-{number}")); + } + backreferences.push_str( + "\" data-footnote-backref=\"\" aria-label=\"Back to content\" class=\"data-footnote-backref\">\u{21a9}", + ); + if number > 1 { + backreferences.push_str(&format!("{number}")); + } + backreferences.push_str(""); + } + + let body = self + .notes + .iter() + .find(|(note, _)| *note == ident) + .map(|(_, body)| body.as_str()) + .unwrap_or_default(); + let trimmed = body.trim_end_matches(['\n', '\r']); + if let Some(before) = trimmed.strip_suffix("

      ") { + self.out.push_str(before); + self.out.push(' '); + self.out.push_str(&backreferences); + self.out.push_str("

      "); + self.out.push_str(&body[trimmed.len()..]); + } else { + self.out.push_str(body); + self.line_ending_if_needed(); + self.out.push_str(&backreferences); + } + self.line_ending_if_needed(); + self.out.push_str("
    2. "); + } + + self.line_ending(); + self.out.push_str("
    "); + self.line_ending(); + self.out.push_str("
    "); + self.line_ending(); + } +} + +/// Whether a block shows up in the output of a list item. +fn is_content(block: &Block) -> bool { + !matches!(block, Block::Node(Node::Definition(_)) | Block::Footnote(_)) +} + +/// Writes `value` with the characters that are special in HTML escaped, and NUL replaced. +fn encode(out: &mut String, value: &str) { + let mut start = 0; + for (index, byte) in value.bytes().enumerate() { + let replacement = match byte { + b'\0' => "\u{fffd}", + b'&' => "&", + b'"' => """, + b'<' => "<", + b'>' => ">", + _ => continue, + }; + out.push_str(&value[start..index]); + out.push_str(replacement); + start = index + 1; + } + out.push_str(&value[start..]); +} + +/// Percent-encodes what is not allowed in a URL, keeps what already is encoded, and escapes it for HTML. +fn sanitize(value: &str) -> String { + // NUL is replaced before the URL is encoded. + let chars = value + .chars() + .map(|char| if char == '\0' { '\u{fffd}' } else { char }) + .collect::>(); + let mut normalized = String::with_capacity(value.len()); + let mut index = 0; + let mut start = 0; + let mut buffer = [0; 4]; + + while index < chars.len() { + let char = chars[index]; + + // A correct percent encoded value. + if char == '%' + && index + 2 < chars.len() + && chars[index + 1].is_ascii_alphanumeric() + && chars[index + 2].is_ascii_alphanumeric() + { + index += 3; + continue; + } + + if char >= '\u{80}' || !matches!(char, '!' | '#' | '$' | '&'..=';' | '=' | '?'..='Z' | '_' | 'a'..='z' | '~') { + normalized.extend(&chars[start..index]); + char.encode_utf8(&mut buffer); + for byte in &buffer[..char.len_utf8()] { + normalized.push_str(&format!("%{byte:02X}")); + } + start = index + 1; + } + index += 1; + } + normalized.extend(&chars[start..]); + + let mut result = String::with_capacity(normalized.len()); + encode(&mut result, &normalized); + result +} + +/// Like [`sanitize`], and empty when the URL has a protocol that is not in `protocols`. +fn sanitize_with_protocols(value: &str, protocols: &[&str]) -> String { + let value = sanitize(value); + let end = value.find(['?', '#', '/']); + let mut colon = value.find(':'); + + // A colon after `?`, `#` or `/` is not the end of a protocol. + if let (Some(end), Some(index)) = (end, colon) + && index > end + { + colon = None; + } + if let Some(colon) = colon + && !protocols.contains(&value[..colon].to_lowercase().as_str()) + { + return String::new(); + } + value +} + +/// The tags that GFM does not let through as raw HTML: their `<` is written as `<`. +const DISALLOWED_TAGS: [&str; 9] = [ + "title", + "textarea", + "style", + "xmp", + "iframe", + "noembed", + "noframes", + "script", + "plaintext", +]; + +/// Writes raw HTML, with the start and end tags of [`DISALLOWED_TAGS`] made text. +fn push_html(out: &mut String, html: &str) { + let mut rest = html; + while let Some(at) = rest.find('<') { + let (before, tag) = rest.split_at(at); + out.push_str(before); + let name = tag[1..].strip_prefix('/').unwrap_or(&tag[1..]); + let end = name.find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(name.len()); + let boundary = matches!( + name[end..].chars().next(), + None | Some(' ' | '\t' | '\n' | '\r' | '/' | '>') + ); + if boundary && DISALLOWED_TAGS.iter().any(|tag| name[..end].eq_ignore_ascii_case(tag)) { + out.push_str("<"); + } else { + out.push('<'); + } + rest = &tag[1..]; + } + out.push_str(rest); +} diff --git a/crates/mq-markdown/src/parser/render_html/obsidian.rs b/crates/mq-markdown/src/parser/render_html/obsidian.rs new file mode 100644 index 000000000..5d534386e --- /dev/null +++ b/crates/mq-markdown/src/parser/render_html/obsidian.rs @@ -0,0 +1,186 @@ +//! HTML for the Obsidian syntax: wikilinks, embeds and callouts. +//! +//! The classes and attributes are the ones Obsidian writes, so styles made for it apply. A link +//! target is used as the URL as written, because the files of a vault are not known here. + +#[cfg(feature = "callout")] +use super::super::callout::Header; +#[cfg(feature = "callout")] +use super::super::tree::Block; +#[cfg(feature = "embed")] +use super::SAFE_PROTOCOL_SRC; +use super::{Html, encode}; +#[cfg(any(feature = "wikilink", feature = "embed"))] +use super::{SAFE_PROTOCOL_HREF, sanitize_with_protocols}; + +/// What Obsidian shows for a link without text: `note#Heading` is `note > Heading`. +#[cfg(any(feature = "wikilink", feature = "embed"))] +fn shown_target(target: &str) -> String { + match target.split_once('#') { + Some(("", heading)) => heading.to_string(), + Some((page, heading)) => format!("{page} > {heading}"), + None => target.to_string(), + } +} + +/// How an embedded file is shown, by its extension. +#[cfg(feature = "embed")] +enum Media { + Image, + Audio, + Video, + Pdf, + Note, +} + +#[cfg(feature = "embed")] +fn media(target: &str) -> Media { + let path = target.split(['#', '?']).next().unwrap_or_default(); + let Some((_, extension)) = path.rsplit_once('.') else { + return Media::Note; + }; + match extension.to_ascii_lowercase().as_str() { + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" | "avif" => Media::Image, + "mp3" | "wav" | "m4a" | "ogg" | "3gp" | "flac" => Media::Audio, + "mp4" | "webm" | "ogv" | "mov" | "mkv" => Media::Video, + "pdf" => Media::Pdf, + _ => Media::Note, + } +} + +/// The width and the height from a display like `100` or `100x200`. +#[cfg(feature = "embed")] +fn size(display: &str) -> Option<(&str, Option<&str>)> { + let digits = |value: &str| !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()); + match display.split_once('x') { + Some((width, height)) if digits(width) && digits(height) => Some((width, Some(height))), + None if digits(display) => Some((display, None)), + _ => None, + } +} + +/// The alt text and the size from a display like `alt`, `100`, or `alt|100`. +#[cfg(feature = "embed")] +fn alt_and_size(display: &str) -> (Option<&str>, Option<(&str, Option<&str>)>) { + match display.rsplit_once('|') { + Some((alt, last)) => match size(last) { + Some(dimensions) => (Some(alt), Some(dimensions)), + None => (Some(display), None), + }, + None => match size(display) { + Some(dimensions) => (None, Some(dimensions)), + None => (Some(display), None), + }, + } +} + +impl Html { + /// `` to the target, which is plain text inside another link. + #[cfg(any(feature = "wikilink", feature = "embed"))] + fn internal_link(&mut self, class: &str, target: &str, shown: &str) { + if self.in_link { + encode(&mut self.out, shown); + return; + } + self.out.push_str(&format!( + ""); + encode(&mut self.out, shown); + self.out.push_str(""); + } + + /// `[[target|text]]`. + #[cfg(feature = "wikilink")] + pub(super) fn wikilink(&mut self, target: &str, text: Option<&str>) { + let shown = text.map_or_else(|| shown_target(target), str::to_string); + self.internal_link("internal-link", target, &shown); + } + + /// `![[target|display]]`: an image, audio, video or PDF where the browser can show the file, and a + /// link where it cannot, which is a note. + #[cfg(feature = "embed")] + pub(super) fn embed(&mut self, target: &str, display: Option<&str>) { + let kind = media(target); + if matches!(kind, Media::Note) { + let shown = display.map_or_else(|| shown_target(target), str::to_string); + return self.internal_link("internal-link embed", target, &shown); + } + + let src = sanitize_with_protocols(target, &SAFE_PROTOCOL_SRC); + let (alt, dimensions) = display.map_or((None, None), alt_and_size); + let mut attributes = String::new(); + if let Some((width, height)) = dimensions { + attributes.push_str(&format!(" width=\"{width}\"")); + if let Some(height) = height { + attributes.push_str(&format!(" height=\"{height}\"")); + } + } + + match kind { + Media::Image => { + self.out.push_str(&format!("\""));")); + } + Media::Audio => self.out.push_str(&format!("")), + Media::Video => self + .out + .push_str(&format!("")), + Media::Pdf => self + .out + .push_str(&format!("")), + Media::Note => {} + } + } + + /// A callout: `
    ` when it can be folded, and a `
    ` otherwise. `head` is what remains of + /// the first paragraph after the header line, and `rest` the other blocks of the quote. + #[cfg(feature = "callout")] + pub(super) fn callout(&mut self, header: &Header, head: &[Block], rest: &[Block]) { + let kind = header.kind.to_lowercase(); + let title = header.title.clone().unwrap_or_else(|| { + let mut chars = kind.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect::() + }) + }); + + self.line_ending_if_needed(); + let mut data = String::new(); + encode(&mut data, &kind); + let closing = if header.fold.is_some() { + let open = if header.fold == Some('+') { " open" } else { "" }; + self.out + .push_str(&format!("
    ")); + self.line_ending(); + self.out.push_str(""); + encode(&mut self.out, &title); + self.out.push_str(""); + "
    " + } else { + self.out + .push_str(&format!("
    ")); + self.line_ending(); + self.out.push_str("
    "); + encode(&mut self.out, &title); + self.out.push_str("
    "); + "
    " + }; + + if !head.is_empty() || !rest.is_empty() { + self.line_ending(); + self.out.push_str("
    "); + self.tight.push(false); + self.blocks(head); + self.blocks(rest); + self.tight.pop(); + self.line_ending_if_needed(); + self.out.push_str("
    "); + } + self.line_ending(); + self.out.push_str(closing); + } +} diff --git a/crates/mq-markdown/src/parser/resolve.rs b/crates/mq-markdown/src/parser/resolve.rs new file mode 100644 index 000000000..f460e58d4 --- /dev/null +++ b/crates/mq-markdown/src/parser/resolve.rs @@ -0,0 +1,279 @@ +//! Turns the block tree into nodes: parses the inline content, pairs JSX tags and flattens lists and +//! tables. + +use super::error::{MdxError, MdxErrorKind}; +use super::flavor::Flavor; +use super::inline::{self, Document, MAX_NESTING}; +use super::mdx::TagKind; +use super::tree::{Block, InlineBlock, InlineKind, JsxTag, ListBlock, QuoteBlock, TableItem}; +use crate::node::{ + Blockquote, Footnote, Heading, Level, List, MdxJsxFlowElement, Node, Position, TableAlign, TableCell, +}; +use rustc_hash::FxHashSet; + +/// The identifiers that references can resolve to. +#[derive(Default)] +pub(super) struct References { + /// Normalized labels of link reference definitions. + pub(super) definitions: FxHashSet, + /// Normalized labels of footnote definitions. + pub(super) footnotes: FxHashSet, +} + +pub(super) fn resolve(blocks: Vec, flavor: Flavor) -> Result, MdxError> { + let mut references = References::default(); + collect(&blocks, &mut references); + + let mut nodes = Vec::new(); + flatten( + blocks, + Document { + references: &references, + flavor, + }, + &mut nodes, + )?; + Ok(nodes) +} + +pub(super) fn collect(blocks: &[Block], references: &mut References) { + for block in blocks { + match block { + Block::Node(Node::Definition(definition)) => { + references.definitions.insert(definition.ident.clone()); + } + Block::Quote(quote) => collect("e.children, references), + Block::Footnote(footnote) => { + references.footnotes.insert(footnote.ident.clone()); + collect(&footnote.children, references); + } + Block::List(list) => list.items.iter().for_each(|item| collect(&item.children, references)), + _ => {} + } + } +} + +/// The JSX elements that are open while blocks are turned into nodes, and the nodes inside the +/// innermost one. +struct Frames { + open: Vec<(JsxTag, Vec)>, + root: Vec, +} + +impl Frames { + fn push(&mut self, node: Node) { + match self.open.last_mut() { + Some((_, children)) => children.push(node), + None => self.root.push(node), + } + } + + fn extend(&mut self, nodes: Vec) { + match self.open.last_mut() { + Some((_, children)) => children.extend(nodes), + None => self.root.extend(nodes), + } + } + + fn tag(&mut self, tag: JsxTag) -> Result<(), MdxError> { + match tag.kind { + TagKind::Open if self.open.len() >= MAX_NESTING => { + return Err(MdxError::new( + MdxErrorKind::TooDeep { limit: MAX_NESTING }, + Some(tag.position.start), + )); + } + TagKind::Open => self.open.push((tag, Vec::new())), + TagKind::SelfClosing => self.push(element(tag, Vec::new(), None)), + TagKind::Close => { + let Some((open, children)) = self.open.pop() else { + return Err(MdxError::new( + MdxErrorKind::UnopenedClosingTag, + Some(tag.position.start), + )); + }; + if open.name != tag.name { + return Err(MdxError::new( + MdxErrorKind::MismatchedClosingTag { + closing: tag.name, + opening: open.name, + opened_at: Some(open.position.start), + }, + Some(tag.position.start), + )); + } + let end = tag.position.end; + self.push(element(open, children, Some(end))); + } + } + Ok(()) + } +} + +fn element(tag: JsxTag, children: Vec, end: Option) -> Node { + Node::MdxJsxFlowElement(MdxJsxFlowElement { + children, + position: Some(Position { + start: tag.position.start, + end: end.unwrap_or(tag.position.end), + }), + name: tag.name, + attributes: tag.attributes, + }) +} + +fn flatten(blocks: Vec, doc: Document<'_>, nodes: &mut Vec) -> Result<(), MdxError> { + let mut frames = Frames { + open: Vec::new(), + root: Vec::new(), + }; + + for block in blocks { + match block { + Block::Node(node) => frames.push(node), + Block::Fenced(fenced) => frames.push(fenced.node), + Block::Inline(block) => { + let mut out = Vec::new(); + inline_block(block, doc, &mut out)?; + frames.extend(out); + } + Block::Quote(quote) => frames.push(quote_node(quote, doc)?), + Block::List(list) => { + let mut out = Vec::new(); + list_nodes(list, 0, doc, &mut out)?; + frames.extend(out); + } + Block::Footnote(footnote) => { + let mut values = Vec::new(); + flatten(footnote.children, doc, &mut values)?; + frames.push(Node::Footnote(Footnote { + ident: footnote.ident, + values, + position: Some(footnote.position), + })); + } + Block::Table(items) => { + for item in items { + let node = match item { + TableItem::Cell { + row, + column, + position, + source, + } => Node::TableCell(TableCell { + values: match source { + Some(source) => inline::parse(&source, doc)?, + None => Vec::new(), + }, + column, + row, + position: Some(position), + }), + TableItem::Align { align, position } => Node::TableAlign(TableAlign { + align, + position: Some(position), + }), + }; + frames.push(node); + } + } + Block::Jsx(tag) => frames.tag(tag)?, + Block::Error(error) => return Err(error), + } + } + + if let Some((open, _)) = frames.open.pop() { + let opened_at = open.position.start; + return Err(MdxError::new( + MdxErrorKind::UnclosedFlowElement { + name: open.name, + opened_at: opened_at.clone(), + }, + Some(opened_at), + )); + } + nodes.append(&mut frames.root); + + Ok(()) +} + +/// A block quote, or a callout when the `callout` feature is enabled and its first line is a callout header. +fn quote_node(quote: QuoteBlock, doc: Document<'_>) -> Result { + #[cfg(feature = "callout")] + let (header, children) = { + let mut children = quote.children; + (super::callout::take_header(&mut children), children) + }; + #[cfg(not(feature = "callout"))] + let children = quote.children; + + let mut values = Vec::new(); + flatten(children, doc, &mut values)?; + let position = Some(quote.position); + + #[cfg(feature = "callout")] + if let Some(header) = header { + return Ok(Node::Callout(crate::node::Callout { + kind: header.kind, + fold: header.fold, + title: header.title, + values, + position, + })); + } + Ok(Node::Blockquote(Blockquote { values, position })) +} + +fn inline_block(block: InlineBlock, doc: Document<'_>, nodes: &mut Vec) -> Result<(), MdxError> { + let values = inline::parse(&block.source, doc)?; + match block.kind { + InlineKind::Paragraph => nodes.extend(values), + InlineKind::Heading { depth, position } => nodes.push(Node::Heading(Heading { + depth, + values, + position: Some(position), + })), + } + Ok(()) +} + +/// Emits one flat `Node::List` per item, followed by the items of its nested lists one level deeper. +fn list_nodes(list: ListBlock, level: Level, doc: Document<'_>, nodes: &mut Vec) -> Result<(), MdxError> { + for (index, item) in list.items.into_iter().enumerate() { + let (nested, others): (Vec<_>, Vec<_>) = item.children.into_iter().partition(|b| matches!(b, Block::List(_))); + let mut values = Vec::new(); + flatten(others, doc, &mut values)?; + let position = match ( + values.first().and_then(Node::position), + values.last().and_then(Node::position), + ) { + (Some(first), Some(last)) => Position { + start: first.start.clone(), + end: last.end.clone(), + }, + _ => Position { + start: item.position.start, + end: item.content_end, + }, + }; + + nodes.push(Node::List(List { + values, + index, + level, + ordered: list.ordered, + checked: item.checked, + spread: list.spread, + start: list.start, + marker: Some(list.marker), + position: Some(position), + })); + + for block in nested { + if let Block::List(sub_list) = block { + list_nodes(sub_list, level + 1, doc, nodes)?; + } + } + } + Ok(()) +} diff --git a/crates/mq-markdown/src/parser/scan.rs b/crates/mq-markdown/src/parser/scan.rs new file mode 100644 index 000000000..32ba2bda9 --- /dev/null +++ b/crates/mq-markdown/src/parser/scan.rs @@ -0,0 +1,89 @@ +//! Byte-level helpers shared by the block and inline parsers. + +/// A byte range `start..end` of a source text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Span { + pub(super) start: usize, + pub(super) end: usize, +} + +impl Span { + pub(super) fn new(start: usize, end: usize) -> Self { + Self { start, end } + } + + /// The text of the range in `src`. + pub(super) fn of(self, src: &str) -> &str { + &src[self.start..self.end] + } +} + +/// The length of the line ending at `index`, or 0 when there is none. +pub(super) fn eol_len(bytes: &[u8], index: usize) -> usize { + match bytes.get(index..) { + Some([b'\r', b'\n', ..]) => 2, + Some([b'\r' | b'\n', ..]) => 1, + _ => 0, + } +} + +/// Whether `byte` is a space, a tab or a line ending. +pub(super) fn is_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r') +} + +/// The offset after the spaces and tabs at `index`. +pub(super) fn skip_blanks(bytes: &[u8], mut index: usize) -> usize { + while matches!(bytes.get(index), Some(b' ' | b'\t')) { + index += 1; + } + index +} + +/// The offset after the spaces, tabs and line endings at `index`. +pub(super) fn skip_whitespace(bytes: &[u8], mut index: usize) -> usize { + while bytes.get(index).copied().is_some_and(is_whitespace) { + index += 1; + } + index +} + +/// The offset after the spaces and tabs at `index`, including at most one line ending among them. +pub(super) fn skip_blanks_and_eol(bytes: &[u8], index: usize) -> usize { + let index = skip_blanks(bytes, index); + match eol_len(bytes, index) { + 0 => index, + eol => skip_blanks(bytes, index + eol), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::none("a", 0, 0)] + #[case::lf("a\nb", 1, 1)] + #[case::cr("a\rb", 1, 1)] + #[case::crlf("a\r\nb", 1, 2)] + #[case::past_end("a", 5, 0)] + fn eol_len_at(#[case] text: &str, #[case] index: usize, #[case] expected: usize) { + assert_eq!(eol_len(text.as_bytes(), index), expected); + } + + #[rstest] + #[case::blanks(" \t a", 0, 3)] + #[case::one_eol(" \n a", 0, 3)] + #[case::crlf(" \r\n\ta", 0, 4)] + #[case::two_eols(" \n\n a", 0, 2)] + #[case::nothing("a", 0, 0)] + fn skip_blanks_and_eol_at(#[case] text: &str, #[case] index: usize, #[case] expected: usize) { + assert_eq!(skip_blanks_and_eol(text.as_bytes(), index), expected); + } + + #[test] + fn skip_whitespace_crosses_line_endings() { + assert_eq!(skip_whitespace(b" \n\r\n\ta", 0), 5); + } +} diff --git a/crates/mq-markdown/src/parser/table.rs b/crates/mq-markdown/src/parser/table.rs new file mode 100644 index 000000000..f4661f2e9 --- /dev/null +++ b/crates/mq-markdown/src/parser/table.rs @@ -0,0 +1,224 @@ +//! GFM tables. + +use super::line::Line; +use super::tree::{InlineSource, TableItem}; +use crate::node::{Point, Position, TableAlignKind}; + +/// One cell of a row, as byte offsets within the line. +struct Cell<'a> { + /// Start of the cell, at its leading pipe when it has one. + start: usize, + end: usize, + content: &'a str, + content_offset: usize, +} + +/// Splits a row into cells on unescaped pipes. Leading and trailing pipes do not make cells. +fn split_row<'a>(line: &Line<'a>) -> Vec> { + let text = line.text; + let indent = text.len() - text.trim_start_matches([' ', '\t']).len(); + let end = text.trim_end_matches([' ', '\t']).len().max(indent); + let bytes = text.as_bytes(); + + let mut pipes = Vec::new(); + let mut index = indent; + while index < end { + match bytes[index] { + b'\\' => index += 1, + b'|' => pipes.push(index), + _ => {} + } + index += 1; + } + + let leading = pipes.first() == Some(&indent); + let trailing = pipes.last().is_some_and(|&pipe| pipe + 1 == end); + + // Segments between pipes: (start of the leading pipe or content, content start, content end). + let mut segments = Vec::with_capacity(pipes.len() + 1); + let mut from = indent; + for &pipe in &pipes { + segments.push((from, pipe)); + from = pipe + 1; + } + segments.push((from, end)); + + let mut first = 0; + let mut last = segments.len(); + if leading { + first = 1; + } + if trailing && last - first > 1 { + last -= 1; + } + + (first..last) + .map(|index| { + let (from, to) = segments[index]; + let raw = &text[from..to]; + let content = raw.trim_matches([' ', '\t']); + let content_offset = from + (raw.len() - raw.trim_start_matches([' ', '\t']).len()); + // The first cell starts at the start of the line, later ones at their leading pipe. + let start = if index == first { 0 } else { pipes[index - 1] }; + // The last cell takes the whitespace at the end of the line along. + let end = if index + 1 == last { text.len() } else { pipes[index] }; + Cell { + start, + end, + content, + content_offset, + } + }) + .collect() +} + +/// Parses a delimiter cell such as `:-:` into its alignment. +fn align(content: &str) -> Option { + let left = content.starts_with(':'); + let right = content.len() > 1 && content.ends_with(':'); + let dashes = &content[usize::from(left)..content.len() - usize::from(right)]; + if dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') { + return None; + } + Some(match (left, right) { + (true, true) => TableAlignKind::Center, + (true, false) => TableAlignKind::Left, + (false, true) => TableAlignKind::Right, + (false, false) => TableAlignKind::None, + }) +} + +/// The cells of `line` as the header row of a table. +fn header_cells<'a>(line: &Line<'a>) -> Option>> { + // A lone pipe is both the leading and the trailing one of a row, which then has no cells. + (line.text.trim_matches([' ', '\t']) != "|").then(|| split_row(line)) +} + +/// The number of cells of `line` when a delimiter row with as many cells can make it a header. +pub(super) fn header_width(line: &Line<'_>) -> Option { + if !line.flavor.has_gfm() { + return None; + } + header_cells(line).map(|cells| cells.len()) +} + +/// The alignments of a table that starts with `lines[start]` as its header, if it is one. +fn header<'a>( + lines: &[Line<'a>], + start: usize, + starts_block: fn(&Line<'_>) -> bool, +) -> Option<(Vec>, Vec)> { + let head = &lines[start]; + if !head.flavor.has_gfm() { + return None; + } + let delimiter = lines.get(start + 1)?; + // The delimiter row cannot start another block, such as a list item. + if head.indent().columns >= head.code_indent() + || delimiter.indent().columns >= delimiter.code_indent() + || delimiter.lazy + || starts_block(delimiter) + { + return None; + } + let text = delimiter.text.trim_matches([' ', '\t']); + // Without a pipe, a colon tells the delimiter row from the underline of a heading. + if !text.contains(['|', ':']) + || !text.contains('-') + || !text.bytes().all(|b| matches!(b, b'|' | b':' | b'-' | b' ' | b'\t')) + { + return None; + } + + let cells = header_cells(head)?; + let aligns = split_row(delimiter) + .iter() + .map(|cell| align(cell.content)) + .collect::>>()?; + (cells.len() == aligns.len()).then_some((cells, aligns)) +} + +/// Whether a table starts at `lines[start]`. +pub(super) fn starts_at(lines: &[Line<'_>], start: usize, starts_block: fn(&Line<'_>) -> bool) -> bool { + header(lines, start, starts_block).is_some() +} + +fn cell_item(line: &Line<'_>, row: usize, column: usize, cell: &Cell<'_>) -> TableItem { + let source = (!cell.content.is_empty()).then(|| { + let mut source = InlineSource::new(std::iter::once((cell.content, "", line.point(cell.content_offset)))); + source.table = true; + source + }); + TableItem::Cell { + row, + column, + position: Position { + start: line.point(cell.start), + end: line.point(cell.end), + }, + source, + } +} + +/// Parses a table starting at `lines[start]`, returning its nodes and the index after it. +/// `starts_block` tells which lines cannot be the delimiter row, `ends_row` which lines end the table +/// besides blank lines. +pub(super) fn parse( + lines: &[Line<'_>], + start: usize, + starts_block: fn(&Line<'_>) -> bool, + ends_row: fn(&Line<'_>) -> bool, +) -> Option<(Vec, usize)> { + let (cells, aligns) = header(lines, start, starts_block)?; + + let mut items = Vec::new(); + for (column, cell) in cells.iter().enumerate() { + items.push(cell_item(&lines[start], 0, column, cell)); + } + let delimiter_line = lines[start + 1].number; + items.push(TableItem::Align { + align: aligns, + position: Position { + start: Point { + line: delimiter_line, + column: 1, + }, + end: Point { + line: delimiter_line, + column: 1, + }, + }, + }); + + let mut index = start + 2; + let mut row = 1; + while let Some(line) = lines.get(index) { + if line.is_blank() || line.indent().columns >= line.code_indent() || ends_row(line) { + break; + } + for (column, cell) in split_row(line).iter().enumerate() { + items.push(cell_item(line, row, column, cell)); + } + row += 1; + index += 1; + } + + Some((items, index)) +} + +/// The number of cells of `line` when it is a delimiter row such as `|:-|-:|`. +pub(super) fn delimiter_cells(line: &Line<'_>) -> Option { + let text = line.text.trim_matches([' ', '\t']); + // Without a pipe, a colon tells the delimiter row from the underline of a heading. + if !text.contains(['|', ':']) + || !text.contains('-') + || !text.bytes().all(|b| matches!(b, b'|' | b':' | b'-' | b' ' | b'\t')) + { + return None; + } + let cells = split_row(line); + cells + .iter() + .all(|cell| align(cell.content).is_some()) + .then_some(cells.len()) +} diff --git a/crates/mq-markdown/src/parser/tree.rs b/crates/mq-markdown/src/parser/tree.rs new file mode 100644 index 000000000..af0935468 --- /dev/null +++ b/crates/mq-markdown/src/parser/tree.rs @@ -0,0 +1,253 @@ +//! The block tree produced by block parsing, before inline content is parsed. +//! +//! Inline parsing needs to know every definition of the document, including those that come later, so +//! it runs in [`super::resolve`] once all blocks are known. + +use super::error::MdxError; +use super::line::extra_bytes; +use super::mdx::TagKind; +use super::scan::eol_len; +use crate::node::{HeadingDepth, ListMarker, MdxAttributeContent, Node, Point, Position, TableAlignKind}; + +/// Where a line of an [`InlineSource`] starts: its offset in the text and its position in the document. +#[derive(Clone)] +pub(super) struct LineStart { + pub(super) offset: usize, + pub(super) point: Point, +} + +/// Raw inline content of a paragraph, heading or table cell, with the source position of each line. +#[derive(Clone)] +pub(super) struct InlineSource { + pub(super) text: String, + /// Where each line starts, in `text` and in the document. + pub(super) lines: Vec, + /// Whether this is the content of a table cell, where `\|` in code stands for `|`. + pub(super) table: bool, + /// For each line, how many bytes of `text` before it are more than code units. Empty for ASCII. + line_extra: Vec, + /// An offset in `text` and how many bytes before it are more than code units: positions are mostly + /// asked for in order, or near each other. + cursor: std::cell::Cell<(usize, usize)>, +} + +impl InlineSource { + /// Joins `(text, eol, start)` lines, keeping the terminators between them. + pub(super) fn new<'a>(lines: impl ExactSizeIterator) -> Self { + let count = lines.len(); + let mut text = String::new(); + let mut starts = Vec::with_capacity(count); + for (index, (line, eol, start)) in lines.enumerate() { + starts.push(LineStart { + offset: text.len(), + point: start, + }); + text.push_str(line); + if index + 1 < count { + text.push_str(eol); + } + } + let mut source = Self { + text, + lines: starts, + table: false, + line_extra: Vec::new(), + cursor: std::cell::Cell::new((0, 0)), + }; + source.index_lines(); + source + } + + /// Finds how far each line is in code units from its offset in bytes. + fn index_lines(&mut self) { + self.line_extra.clear(); + self.cursor.set((0, 0)); + if self.text.is_ascii() { + return; + } + let mut extra = 0; + let mut from = 0; + for line in &self.lines { + extra += extra_bytes(&self.text.as_bytes()[from..line.offset]); + from = line.offset; + self.line_extra.push(extra); + } + } + + /// How many bytes of `text` before `offset` are more than UTF-16 code units. + fn extra_before(&self, offset: usize) -> usize { + let bytes = self.text.as_bytes(); + let (at, extra) = self.cursor.get(); + let extra = if offset >= at { + extra + extra_bytes(&bytes[at..offset]) + } else { + extra - extra_bytes(&bytes[offset..at]) + }; + self.cursor.set((offset, extra)); + extra + } + + /// Removes the first `marker` bytes and the one space, tab or line ending that follows them, and the + /// line ending after a space or a tab. + pub(super) fn remove_prefix_and_one(&mut self, marker: usize) { + let bytes = self.text.as_bytes(); + let one = if matches!(bytes.get(marker), Some(b' ' | b'\t')) { + 1 + eol_len(bytes, marker + 1) + } else { + eol_len(bytes, marker) + }; + self.drain_to(marker + one); + } + + /// Removes the first line with its line ending and returns it without the line ending. + #[cfg(feature = "callout")] + pub(super) fn take_first_line(&mut self) -> String { + let Some(eol) = self.text.find(['\n', '\r']) else { + self.lines.clear(); + return std::mem::take(&mut self.text); + }; + let line = self.text[..eol].to_string(); + let end = eol + eol_len(self.text.as_bytes(), eol); + self.drain_to(end); + line + } + + /// Removes the first `end` bytes, keeping the position of every line that is left. + fn drain_to(&mut self, end: usize) { + let new_point = self.point(end); + self.text.replace_range(..end, ""); + + let index = self.line_index(end); + let first = LineStart { + offset: 0, + point: Point { + line: self.lines[index].point.line, + column: new_point.column, + }, + }; + let rest = self.lines[index + 1..].iter().map(|line| LineStart { + offset: line.offset - end, + point: line.point.clone(), + }); + self.lines = std::iter::once(first).chain(rest).collect(); + self.index_lines(); + } + + /// The index of the line that `offset` is on. + pub(super) fn line_index(&self, offset: usize) -> usize { + self.lines + .partition_point(|line| line.offset <= offset) + .saturating_sub(1) + } + + /// The position where a node ends at `offset`. After a line ending that is the start of the next + /// line in the document, not the start of its content inside containers. + pub(super) fn end_point(&self, offset: usize) -> Point { + let line = &self.lines[self.line_index(offset)]; + if line.offset == offset && offset > 0 { + return Point { + line: line.point.line, + column: 1, + }; + } + self.point(offset) + } + + /// The position of the byte at `offset` in `text`. + pub(super) fn point(&self, offset: usize) -> Point { + let index = self.line_index(offset); + let LineStart { offset: start, point } = &self.lines[index]; + let extra = match self.line_extra.get(index) { + Some(before) => self.extra_before(offset) - before, + None => 0, + }; + Point { + line: point.line, + column: point.column + (offset - start) - extra, + } + } +} + +pub(super) enum Block { + /// A finished leaf such as a code block or a thematic break. + Node(Node), + /// A fenced code or math block. + Fenced(FencedBlock), + Inline(InlineBlock), + Quote(QuoteBlock), + List(ListBlock), + Footnote(FootnoteBlock), + Table(Vec), + /// A JSX tag on a line of its own in MDX. Tags are paired when the blocks become nodes. + Jsx(JsxTag), + /// Invalid MDX, reported when the blocks become nodes. + Error(MdxError), +} + +pub(super) struct FencedBlock { + pub(super) node: Node, + /// Whether it has a closing fence. + pub(super) closed: bool, + /// The number of lines of content, blank ones included. + pub(super) lines: usize, +} + +#[derive(Clone)] +pub(super) struct InlineBlock { + pub(super) source: InlineSource, + pub(super) kind: InlineKind, +} + +#[derive(Clone)] +pub(super) enum InlineKind { + Paragraph, + Heading { depth: HeadingDepth, position: Position }, +} + +pub(super) struct QuoteBlock { + pub(super) children: Vec, + pub(super) position: Position, +} + +pub(super) struct FootnoteBlock { + /// The normalized label. + pub(super) ident: String, + pub(super) children: Vec, + pub(super) position: Position, +} + +pub(super) struct ListBlock { + pub(super) ordered: bool, + pub(super) start: Option, + pub(super) marker: ListMarker, + pub(super) spread: bool, + pub(super) items: Vec, +} + +pub(super) struct Item { + pub(super) checked: Option, + pub(super) children: Vec, + pub(super) position: Position, + /// Where the last line of the item ends, before the blank lines that `position` runs over. + pub(super) content_end: Point, +} + +pub(super) enum TableItem { + Cell { + row: usize, + column: usize, + position: Position, + source: Option, + }, + Align { + align: Vec, + position: Position, + }, +} + +pub(super) struct JsxTag { + pub(super) name: Option, + pub(super) attributes: Vec, + pub(super) kind: TagKind, + pub(super) position: Position, +} diff --git a/crates/mq-markdown/tests/gfm_roundtrip_fidelity.rs b/crates/mq-markdown/tests/gfm_roundtrip_fidelity.rs index 8b027ddda..f47d290cc 100644 --- a/crates/mq-markdown/tests/gfm_roundtrip_fidelity.rs +++ b/crates/mq-markdown/tests/gfm_roundtrip_fidelity.rs @@ -69,19 +69,7 @@ fn parse_spec_examples(text: &str) -> Vec { /// Examples where the round trip currently changes rendered meaning, by /// number in the spec revision pinned above. Remove an entry once it's /// fixed; add one (with its section) if a new gap turns up. -const KNOWN_FAILURES: &[usize] = &[ - 5, 6, 7, // Tabs - 229, 269, // List items - 271, 272, 295, // Lists - // 300, 306, 346, 479, 480, 599: URL-shaped text disables backslash - // escapes even in link text (GFM extended autolink scanning). - 300, 306, // Backslash escapes - 346, // Code spans - 479, 480, // Emphasis and strong emphasis - 522, 534, // Links - 570, 589, // Images - 599, 602, // Autolinks -]; +const KNOWN_FAILURES: &[usize] = &[]; #[test] #[ignore = "fetches spec.txt over the network; run via `just test-gfm-spec`"] diff --git a/crates/mq-markdown/tests/html_rendering_tests.rs b/crates/mq-markdown/tests/html_rendering_tests.rs new file mode 100644 index 000000000..8a2ea10ca --- /dev/null +++ b/crates/mq-markdown/tests/html_rendering_tests.rs @@ -0,0 +1,500 @@ +//! Parameterized tests for the HTML that `to_html` renders: every construct, the line endings, the +//! escaping, and the URLs. Each case lists the input and the exact output that is expected. + +use mq_markdown::{Markdown, to_html}; +use rstest::rstest; + +#[rstest] +#[case::paragraph("a\n", "

    a

    \n")] +#[case::paragraph_without_line_ending("a", "

    a

    ")] +#[case::two_paragraphs("a\n\nb\n", "

    a

    \n

    b

    \n")] +#[case::soft_break("a\nb\n", "

    a\nb

    \n")] +#[case::hard_break_spaces("a \nb\n", "

    a
    \nb

    \n")] +#[case::hard_break_backslash("a\\\nb\n", "

    a
    \nb

    \n")] +#[case::atx_heading("# a\n", "

    a

    \n")] +#[case::atx_closing_sequence("## a ##\n", "

    a

    \n")] +#[case::setext_one("a\n===\n", "

    a

    \n")] +#[case::setext_two("a\n---\n", "

    a

    \n")] +#[case::heading_with_inline("# a *b* `c`\n", "

    a b c

    \n")] +#[case::thematic_break("---\n", "
    \n")] +#[case::thematic_break_after_paragraph("a\n\n***\n", "

    a

    \n
    \n")] +#[case::empty("", "")] +#[case::blank_lines("\n\n \n", "")] +#[case::code_keeps_rest_of_tab_in_list( + "- foo\n\n\t\tbar\n", + "
      \n
    • \n

      foo

      \n
        bar\n
      \n
    • \n
    \n" +)] +#[case::code_keeps_rest_of_tab_in_quote(">\t\tfoo\n", "
    \n
      foo\n
    \n
    \n")] +#[case::heading_keeps_a_leading_hash("# #[allow(dead_code)]\n", "

    #[allow(dead_code)]

    \n")] +#[case::heading_keeps_a_hash_and_a_space("# # a\n", "

    # a

    \n")] +#[case::code_span_over_lines_loses_indent("a `b\n c` d\n", "

    a b c d

    \n")] +#[case::lazy_line_of_backticks_after_quote("> q\n``", "
    \n

    q\n``

    \n
    ")] +#[case::quote_can_start_with_code_after_paragraph( + "text\n> code\n", + "

    text

    \n
    \n
    code\n
    \n
    \n" +)] +#[case::quote_can_start_with_ordered_item_after_paragraph( + "text\n> 2. a\n", + "

    text

    \n
    \n
      \n
    1. a
    2. \n
    \n
    \n" +)] +#[case::ordered_item_does_not_interrupt_after_quote( + "> q\n\nfoo\n2. a\n", + "
    \n

    q

    \n
    \n

    foo\n2. a

    \n" +)] +#[case::dashes_after_setext_heading_are_a_rule("a\n---\n---\nb\n", "

    a

    \n
    \n

    b

    \n")] +#[case::text_after_setext_heading_in_item_is_a_paragraph( + "- a\n ---\nb\n", + "
      \n
    • \n

      a

      \n
    • \n
    \n

    b

    \n" +)] +#[case::code_of_an_item_is_one_block( + "a\n- b\n c\n", + "

    a

    \n
      \n
    • \n
        b\n  c\n
      \n
    • \n
    \n" +)] +#[case::task_without_text("- [ ] \n", "
      \n
    • \n
    \n")] +#[case::task_without_text_keeps_the_list( + "- [x] \n- b\n", + "
      \n
    • \n
    • b
    • \n
    \n" +)] +#[case::task_text_is_not_a_list( + "- [x] - a\n", + "
      \n
    • - a
    • \n
    \n" +)] +#[case::task_text_is_not_a_quote( + "- [ ] > a\n", + "
      \n
    • > a
    • \n
    \n" +)] +#[case::task_on_the_next_line( + "- [ ] \n b\n", + "
      \n
    • b
    • \n
    \n" +)] +#[case::task_after_empty_marker( + "-\n [ ] b\n", + "
      \n
    • b
    • \n
    \n" +)] +#[case::checked_task_after_empty_marker( + "-\n [x] b\n", + "
      \n
    • b
    • \n
    \n" +)] +#[case::raw_html_ends_at_any_raw_closing_tag( + "\n", + "<script>\n</style>\n

    hello\n</script>

    \n" +)] +#[case::raw_html_ends_at_pre_in_a_script( + " b\n", "

    a </SCRIPT> b

    \n")] +#[case::tag_filter_block("\n", "<script>\nx\n</script>\n")] +#[case::tag_filter_keeps_other_tags("\n", "\n")] +#[case::autolink_email_after_a_slash( + "x@y.com/z@w.org\n", + "

    x@y.com/z@w.org

    \n" +)] +#[case::autolink_domain_with_emoji( + "http://x\u{1F344}.ga/\n", + "

    http://x\u{1F344}.ga/

    \n" +)] +#[case::autolink_email_before_an_at_sign("x@y.com@\n", "

    x@y.com@

    \n")] +#[case::emphasis_after_a_word_before_punctuation("r*_q* x\n", "

    r*_q* x

    \n")] +#[case::emphasis_next_to_other_runs("]_**é日*\n", "

    ]_*é日

    \n")] +#[case::hard_break_in_image_alt("![a\\\nb](/u)\n", "

    \"a\nb\"

    \n")] +#[case::image_in_image_alt("![foo ![bar](/url)](/url2)\n", "

    \"foo

    \n")] +fn blocks(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +#[rstest] +#[case::emphasis("*a*\n", "

    a

    \n")] +#[case::emphasis_underscore("_a_\n", "

    a

    \n")] +#[case::strong("**a**\n", "

    a

    \n")] +#[case::strong_emphasis("***a***\n", "

    a

    \n")] +#[case::strikethrough("~~a~~\n", "

    a

    \n")] +#[case::strikethrough_single_tilde("~a~\n", "

    a

    \n")] +#[case::code_span("`a`\n", "

    a

    \n")] +#[case::code_span_trim("` a `\n", "

    a

    \n")] +#[case::code_span_keeps_inner_spaces("`a b`\n", "

    a b

    \n")] +#[case::code_span_line_ending("`a\nb`\n", "

    a b

    \n")] +#[case::code_span_escapes_html("`&`\n", "

    <a>&

    \n")] +#[case::math_inline("$a$\n", "

    a

    \n")] +#[case::escape("\\*a\\*\n", "

    *a*

    \n")] +#[case::entity("© # A\n", "

    © # A

    \n")] +#[case::unknown_entity("&nope;\n", "

    &nope;

    \n")] +#[case::control_character_reference("�  \n", "

    \u{fffd} \u{fffd} \u{fffd}

    \n")] +#[case::special_characters("a < b > c & \"d\"\n", "

    a < b > c & "d"

    \n")] +#[case::nul("a\0b\n", "

    a\u{fffd}b

    \n")] +#[case::raw_html_inline("a c d\n", "

    a c d

    \n")] +#[case::raw_html_inline_multiline("x
    \n", "

    x

    \n")] +fn inline(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +#[rstest] +#[case::link("[a](b)\n", "

    a

    \n")] +#[case::link_title("[a](b \"c\")\n", "

    a

    \n")] +#[case::link_title_escaped("[a](b \"<&>\")\n", "

    a

    \n")] +#[case::link_emphasis("[*a*](b)\n", "

    a

    \n")] +#[case::link_angle_destination("[a]()\n", "

    a

    \n")] +#[case::link_parentheses("[a](b(c))\n", "

    a

    \n")] +#[case::link_non_ascii( + "[a](http://あ.jp/パス)\n", + "

    a

    \n" +)] +#[case::link_keeps_percent_encoding("[a](b%20c)\n", "

    a

    \n")] +#[case::link_encodes_quote("[a](b\"c)\n", "

    a

    \n")] +#[case::link_ampersand("[a](b?c=1&d=2)\n", "

    a

    \n")] +#[case::link_entity("[a](b&c)\n", "

    a

    \n")] +#[case::link_empty("[a]()\n", "

    a

    \n")] +#[case::link_relative("[a](../b#c)\n", "

    a

    \n")] +#[case::link_http("[a](HTTP://b)\n", "

    a

    \n")] +#[case::link_mailto("[a](mailto:b@c)\n", "

    a

    \n")] +#[case::link_javascript_dropped("[a](javascript:b)\n", "

    a

    \n")] +#[case::link_javascript_mixed_case_dropped("[a](JaVaScRiPt:b)\n", "

    a

    \n")] +#[case::link_data_dropped("[a](data:text/html,b)\n", "

    a

    \n")] +#[case::link_colon_after_path_is_relative("[a](b/c:d)\n", "

    a

    \n")] +#[case::link_colon_after_query_is_relative("[a](b?c:d)\n", "

    a

    \n")] +#[case::image("![a](b)\n", "

    \"a\"

    \n")] +#[case::image_title("![a](b \"c\")\n", "

    \"a\"

    \n")] +#[case::image_alt_is_plain_text("![a *b* `c`](d)\n", "

    \"a

    \n")] +#[case::image_alt_escaped("![a\"b](c)\n", "

    \"a"b\"

    \n")] +#[case::image_data_dropped("![a](data:b)\n", "

    \"a\"

    \n")] +#[case::image_mailto_dropped("![a](mailto:b)\n", "

    \"a\"

    \n")] +#[case::reference("[a]\n\n[a]: /u \"t\"\n", "

    a

    \n")] +#[case::reference_full("[a][b]\n\n[b]: /u\n", "

    a

    \n")] +#[case::reference_collapsed("[a][]\n\n[a]: /u\n", "

    a

    \n")] +#[case::reference_case_insensitive("[A]\n\n[a]: /u\n", "

    A

    \n")] +#[case::reference_first_definition_wins("[a]\n\n[a]: /one\n[a]: /two\n", "

    a

    \n")] +#[case::reference_before_definition("[a]: /u\n\n[a]\n", "

    a

    \n")] +#[case::reference_undefined("[a]\n", "

    [a]

    \n")] +#[case::image_reference("![a]\n\n[a]: /u\n", "

    \"a\"

    \n")] +#[case::definition_alone("[a]: /u\n", "")] +#[case::autolink("\n", "

    http://a.b

    \n")] +#[case::autolink_email("\n", "

    a@b.c

    \n")] +#[case::autolink_escapes( + "\n", + "

    http://a.b/?c=1&d=2

    \n" +)] +#[case::autolink_literal_www("www.a.com\n", "

    www.a.com

    \n")] +#[case::autolink_literal_http( + "see http://a.com/b.\n", + "

    see http://a.com/b.

    \n" +)] +#[case::autolink_literal_email("a@b.com\n", "

    a@b.com

    \n")] +#[case::autolink_literal_in_link_is_text( + "[http://a.b](http://c.d)\n", + "

    http://a.b

    \n" +)] +#[case::autolink_literal_non_ascii( + "http://あ.jp/パス\n", + "

    http://あ.jp/パス

    \n" +)] +fn links_and_images(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +#[rstest] +#[case::fenced("```\na\n```\n", "
    a\n
    \n")] +#[case::fenced_language("```rust\na\n```\n", "
    a\n
    \n")] +#[case::fenced_meta_is_dropped("```rust title=x\na\n```\n", "
    a\n
    \n")] +#[case::fenced_language_escaped("```a\"b\nc\n```\n", "
    c\n
    \n")] +#[case::fenced_empty("```\n```\n", "
    \n")] +#[case::fenced_blank_line("```\n\n```\n", "
    \n
    \n")] +#[case::fenced_blank_lines_kept("```\na\n\n\n```\n", "
    a\n\n\n
    \n")] +#[case::fenced_escapes_html("```\n&\n```\n", "
    <a>&\n
    \n")] +#[case::fenced_tilde("~~~\na\n~~~\n", "
    a\n
    \n")] +#[case::fenced_indent_removed(" ```\n a\n b\n ```\n", "
    a\n b\n
    \n")] +#[case::fenced_unclosed("```\na", "
    a\n
    \n")] +#[case::fenced_unclosed_empty("```\n", "
    \n")] +#[case::fenced_without_final_line_ending("```\na\n```", "
    a\n
    ")] +#[case::indented(" a\n", "
    a\n
    \n")] +#[case::indented_blank_line_inside(" a\n\n b\n", "
    a\n\nb\n
    \n")] +#[case::indented_trailing_blank_lines_dropped(" a\n\n\n", "
    a\n
    \n")] +#[case::math("$$\na\n$$\n", "
    a\n
    \n")] +#[case::html_block("
    \na\n
    \n", "
    \na\n
    \n")] +#[case::html_block_then_paragraph("
    \n\na\n", "
    \n

    a

    \n")] +#[case::html_comment("\n", "\n")] +#[case::frontmatter_is_dropped("---\na: b\n---\n# c\n", "

    c

    \n")] +#[case::toml_frontmatter_is_dropped("+++\na = 1\n+++\n# c\n", "

    c

    \n")] +fn code_and_html(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +#[rstest] +#[case::quote("> a\n", "
    \n

    a

    \n
    \n")] +#[case::quote_lazy("> a\nb\n", "
    \n

    a\nb

    \n
    \n")] +#[case::quote_empty(">\n", "
    \n
    \n")] +#[case::quote_nested("> > a\n", "
    \n
    \n

    a

    \n
    \n
    \n")] +#[case::quote_with_heading_and_code( + "> # a\n> ```\n> b\n> ```\n", + "
    \n

    a

    \n
    b\n
    \n
    \n" +)] +#[case::quote_then_paragraph("> a\n\nb\n", "
    \n

    a

    \n
    \n

    b

    \n")] +#[case::quote_with_list("> - a\n", "
    \n
      \n
    • a
    • \n
    \n
    \n")] +#[case::unordered("- a\n- b\n", "
      \n
    • a
    • \n
    • b
    • \n
    \n")] +#[case::ordered("1. a\n2. b\n", "
      \n
    1. a
    2. \n
    3. b
    4. \n
    \n")] +#[case::ordered_start("3. a\n4. b\n", "
      \n
    1. a
    2. \n
    3. b
    4. \n
    \n")] +#[case::ordered_start_zero("0. a\n", "
      \n
    1. a
    2. \n
    \n")] +#[case::ordered_paren("1) a\n", "
      \n
    1. a
    2. \n
    \n")] +#[case::list_loose("- a\n\n- b\n", "
      \n
    • \n

      a

      \n
    • \n
    • \n

      b

      \n
    • \n
    \n")] +#[case::list_loose_item_with_two_blocks( + "- a\n\n b\n- c\n", + "
      \n
    • \n

      a

      \n

      b

      \n
    • \n
    • \n

      c

      \n
    • \n
    \n" +)] +#[case::list_tight_with_nested("- a\n - b\n", "
      \n
    • a\n
        \n
      • b
      • \n
      \n
    • \n
    \n")] +#[case::list_tight_with_code( + "- a\n ```\n b\n ```\n", + "
      \n
    • a\n
      b\n
      \n
    • \n
    \n" +)] +#[case::list_tight_with_heading("- # a\n- b\n", "
      \n
    • \n

      a

      \n
    • \n
    • b
    • \n
    \n")] +#[case::list_empty_item("-\n- a\n", "
      \n
    • \n
    • a
    • \n
    \n")] +#[case::list_changed_marker_is_new_list("- a\n* b\n", "
      \n
    • a
    • \n
    \n
      \n
    • b
    • \n
    \n")] +#[case::list_after_paragraph("a\n- b\n", "

    a

    \n
      \n
    • b
    • \n
    \n")] +#[case::list_blank_lines_after_are_not_loose("- a\n\nb\n", "
      \n
    • a
    • \n
    \n

    b

    \n")] +#[case::list_quote_in_item("- > a\n", "
      \n
    • \n
      \n

      a

      \n
      \n
    • \n
    \n")] +#[case::task_unchecked("- [ ] a\n", "
      \n
    • a
    • \n
    \n")] +#[case::task_checked( + "- [x] a\n", + "
      \n
    • a
    • \n
    \n" +)] +#[case::task_loose( + "- [ ] a\n\n- b\n", + "
      \n
    • \n

      a

      \n
    • \n
    • \n

      b

      \n
    • \n
    \n" +)] +fn containers(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +#[rstest] +#[case::table( + "| a | b |\n|---|---|\n| c | d |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    cd
    \n" +)] +#[case::table_alignment( + "| a | b | c |\n|:--|:-:|--:|\n| d | e | f |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    abc
    def
    \n" +)] +#[case::table_without_body("| a |\n|---|\n", "\n\n\n\n\n\n
    a
    \n")] +#[case::table_short_row_is_filled( + "| a | b |\n|---|---|\n| c |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    c
    \n" +)] +#[case::table_long_row_is_cut( + "| a |\n|---|\n| b | c |\n", + "\n\n\n\n\n\n\n\n\n\n\n
    a
    b
    \n" +)] +#[case::table_without_outer_pipes( + "a | b\n-|-\nc | d\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    cd
    \n" +)] +#[case::table_inline_content( + "| *a* | `b` |\n|---|---|\n| [c](d) | ~~e~~ |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    ce
    \n" +)] +#[case::table_escaped_pipe( + "| a \\| b |\n|---|\n", + "\n\n\n\n\n\n
    a | b
    \n" +)] +#[case::table_ended_by_blank_line( + "| a |\n|---|\n| b |\n\nc\n", + "\n\n\n\n\n\n\n\n\n\n\n
    a
    b
    \n

    c

    \n" +)] +#[case::table_multibyte( + "| あ | 🎉 |\n|---|---|\n| é | ß |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    あ🎉
    éß
    \n" +)] +fn tables(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_html(input), expected); +} + +const NOTE_OPEN: &str = "

    Footnotes

    \n
      \n"; + +fn call(id: &str, fnref: &str, number: usize) -> String { + format!( + "{number}" + ) +} + +fn back(fnref: &str, sup: Option) -> String { + let sup = sup.map(|n| format!("{n}")).unwrap_or_default(); + format!( + "\u{21a9}{sup}" + ) +} + +#[test] +fn footnote() { + let expected = format!( + "

      a{}

      \n{NOTE_OPEN}
    1. \n

      b {}

      \n
    2. \n
    \n
    \n", + call("1", "1", 1), + back("1", None) + ); + assert_eq!(to_html("a[^1]\n\n[^1]: b\n"), expected); +} + +#[test] +fn footnote_referenced_twice_has_two_backreferences() { + let expected = format!( + "

    a{} b{}

    \n{NOTE_OPEN}
  • \n

    c {} {}

    \n
  • \n\n\n", + call("1", "1", 1), + call("1", "1-2", 1), + back("1", None), + back("1-2", Some(2)) + ); + assert_eq!(to_html("a[^1] b[^1]\n\n[^1]: c\n"), expected); +} + +#[test] +fn footnotes_are_numbered_in_the_order_of_their_references() { + let html = to_html("a[^b] c[^a]\n\n[^a]: x\n[^b]: y\n"); + let first = html.find("
  • ").unwrap(); + let second = html.find("
  • ").unwrap(); + assert!(first < second, "{html}"); + assert!( + html.contains(&call("b", "b", 1)) && html.contains(&call("a", "a", 2)), + "{html}" + ); +} + +#[test] +fn footnote_label_is_case_insensitive_and_its_id_is_lowercase() { + let html = to_html("a[^Ab]\n\n[^ab]: x\n"); + assert!(html.contains("href=\"#user-content-fn-ab\""), "{html}"); +} + +#[test] +fn footnote_id_is_percent_encoded() { + let html = to_html("a[^é]\n\n[^é]: x\n"); + assert!(html.contains("user-content-fn-%C3%A9"), "{html}"); +} + +#[test] +fn footnote_without_definition_is_text() { + assert_eq!(to_html("a[^1]\n"), "

    a[^1]

    \n"); +} + +#[test] +fn footnote_without_reference_is_not_rendered() { + assert_eq!(to_html("[^1]: a\n"), ""); +} + +#[test] +fn footnote_with_a_list_puts_the_backreference_on_its_own_line() { + let expected = format!( + "

    a{}

    \n{NOTE_OPEN}
  • \n
      \n
    • l
    • \n
    \n{}\n
  • \n\n\n", + call("1", "1", 1), + back("1", None) + ); + assert_eq!(to_html("a[^1]\n\n[^1]:\n - l\n"), expected); +} + +#[test] +fn footnote_with_two_paragraphs_has_the_backreference_in_the_last() { + let html = to_html("a[^1]\n\n[^1]: p\n\n q\n"); + assert!( + html.contains(&format!("

    p

    \n

    q {}

    ", back("1", None))), + "{html}" + ); +} + +#[test] +fn footnote_defined_before_its_reference_is_still_rendered() { + let html = to_html("[^1]: a\n\nb[^1]\n"); + assert!(html.starts_with("

    b"), "{html}"); + assert!(html.contains("

  • "), "{html}"); +} + +#[test] +fn footnote_reference_inside_a_footnote_is_numbered_when_it_is_met() { + let html = to_html("[^1]: x [^2]\n[^2]: y\n\nz[^1]\n"); + // `[^2]` is met first, in the definition of `[^1]`. + assert!(html.contains(&call("2", "2", 1)), "{html}"); + assert!(html.contains(&call("1", "1", 2)), "{html}"); +} + +#[rstest] +#[case::lf("a\n\nb\n")] +#[case::list("- a\n- b\n")] +#[case::quote("> a\n> b\n")] +#[case::code("```\na\nb\n```\n")] +#[case::table("| a |\n|---|\n| b |\n")] +#[case::footnote("a[^1]\n\n[^1]: b\n")] +#[case::hard_break("a \nb\n")] +fn crlf_documents_render_with_crlf(#[case] input: &str) { + let crlf = input.replace('\n', "\r\n"); + assert_eq!(to_html(&crlf), to_html(input).replace('\n', "\r\n")); +} + +#[rstest] +#[case::no_final_line_ending("a", false)] +#[case::final_line_ending("a\n", true)] +#[case::heading("# a", false)] +#[case::heading_final_line_ending("# a\n", true)] +#[case::closed_fence("```\na\n```", false)] +#[case::closed_fence_final_line_ending("```\na\n```\n", true)] +#[case::list("- a", false)] +#[case::list_final_line_ending("- a\n", true)] +#[case::table("| a |\n|---|", false)] +#[case::table_final_line_ending("| a |\n|---|\n", true)] +#[case::blank_lines_after("a\n\n\n", true)] +fn trailing_line_ending_follows_the_source(#[case] input: &str, #[case] ends_with_line_ending: bool) { + assert_eq!( + to_html(input).ends_with('\n'), + ends_with_line_ending, + "{:?}", + to_html(input) + ); +} + +#[rstest] +#[case::paragraph("a *b*\n\n- c\n")] +#[case::link("[a](b \"c\")\n")] +#[case::table("| a | b |\n|:-|-:|\n| c | d |\n")] +#[case::footnote("a[^1]\n\n[^1]: b\n")] +fn to_html_of_a_document_equals_to_html_of_its_source(#[case] input: &str) { + let document: Markdown = input.parse().unwrap(); + assert_eq!(document.to_html(), to_html(input)); +} + +#[rstest] +#[case::children_are_markdown("\ntext *em*\n\n", &["", "

    text em

    ", "
    "])] +#[case::nested_elements("\n\n*x*\n\n\n", &["", "", "

    x

    ", "
    ", "
    "])] +#[case::in_a_list_item("- \n *x*\n \n", &["", "

    x

    ", "
    "])] +fn mdx_jsx_flow_children_are_rendered_as_markdown(#[case] input: &str, #[case] expected: &[&str]) { + let html = Markdown::from_mdx_str(input).unwrap().to_html(); + for part in expected { + assert!(html.lines().any(|line| line == *part), "{part:?} in {html:?}"); + } +} + +#[rstest] +#[case::footnote_in_a_quote( + "> [^n]: f\n> ===\nlazy\n\n[^n]\n", + "

    f\n===\nlazy [r]: /u\n> ===\nlazy\n", "

    ===\nlazy

    ")] +#[case::item_content_after_a_blank_line("> - a\n>\n> b\n> ===\nlazy\n", "

    b

    ")] +#[case::line_after_a_lone_pipe_and_a_delimiter_in_a_quote("> a\n|\n> |-|\n| - |\n", "

    a\n|\n|-|\n| - |

    ")] +#[case::line_after_a_table_without_header_pipe("> a\n> -|\nlazy\n", "\n\n

    lazy

    ")] +#[case::line_after_a_table_with_delimiter_pipes("> a\n> |-|\nlazy\n", "\n\n

    lazy

    ")] +#[case::line_lazy_for_an_inner_quote("> > q\n> a\n> ===\nlazy\n", "

    q\na\n===\nlazy

    ")] +fn lazy_lines_continue_the_paragraph_of_the_innermost_container(#[case] input: &str, #[case] expected: &str) { + let html = to_html(input); + assert!(html.contains(expected), "{expected:?} in {html:?}"); +} diff --git a/crates/mq-markdown/tests/mdast_tests.rs b/crates/mq-markdown/tests/mdast_tests.rs new file mode 100644 index 000000000..a7e38fd26 --- /dev/null +++ b/crates/mq-markdown/tests/mdast_tests.rs @@ -0,0 +1,1036 @@ +//! The nodes mq reads from MDX and Markdown, flattened to tokens. The expected tokens are those of the +//! mdast that the official packages (`mdast-util-from-markdown` with `mdast-util-mdx`, `mdast-util-gfm`, +//! `mdast-util-math` and `mdast-util-frontmatter`) make of the same input, except where a comment says +//! how mq differs. The inputs come from the tests of markdown-rs and micromark and from hand-written cases. + +use mq_markdown::{Markdown, MdxAttributeContent, MdxAttributeValue, Node, TableAlignKind}; +use rstest::rstest; + +enum Token { + Text(String), + Other(String), +} + +/// Quotes `value` as `JSON.stringify` does. +fn quote(value: &str) -> String { + let mut out = String::from('"'); + for char in value.chars() { + match char { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + char if char < ' ' => out.push_str(&format!("\\u{:04x}", char as u32)), + char => out.push(char), + } + } + out.push('"'); + out +} + +fn other(token: impl Into) -> Token { + Token::Other(token.into()) +} + +fn container(head: String, inner: Vec) -> Vec { + let mut tokens = vec![other(format!("{head}("))]; + tokens.extend(inner); + tokens.push(other(")")); + tokens +} + +/// The tokens of `nodes`, with adjacent text joined when `inline`. +fn children(nodes: &[Node], inline: bool) -> Vec { + let mut tokens: Vec = Vec::new(); + for token in nodes.iter().flat_map(node_tokens) { + match (tokens.last_mut(), token) { + (Some(Token::Text(last)), Token::Text(text)) if inline => last.push_str(&text), + (_, token) => tokens.push(token), + } + } + tokens +} + +fn attributes(attributes: &[MdxAttributeContent]) -> String { + attributes + .iter() + .map(|attribute| match attribute { + MdxAttributeContent::Expression(value) => format!(" ...{{{value}}}"), + MdxAttributeContent::Property(property) => match &property.value { + None => format!(" {}", property.name), + Some(MdxAttributeValue::Literal(value)) => format!(" {}=lit:{}", property.name, quote(value)), + Some(MdxAttributeValue::Expression(value)) => format!(" {}=expr:{{{value}}}", property.name), + }, + }) + .collect() +} + +fn or_empty(value: &Option) -> String { + value.as_ref().map(ToString::to_string).unwrap_or_default() +} + +/// Paragraphs have no token, since mq has none, and list items are flat, as in mq. +fn node_tokens(node: &Node) -> Vec { + match node { + Node::Heading(heading) => container(format!("heading {}", heading.depth), children(&heading.values, true)), + Node::HorizontalRule(_) => vec![other("hr")], + Node::Code(code) => vec![other(format!( + "code {} {} {}", + or_empty(&code.lang), + or_empty(&code.meta), + quote(&code.value) + ))], + Node::Html(html) => vec![other(format!("html {}", quote(&html.value)))], + Node::Blockquote(quote) => container("blockquote".into(), children("e.values, false)), + Node::List(list) => { + let number = if list.ordered { + format!("o{}", list.start.unwrap_or(1) as usize + list.index) + } else { + "u".into() + }; + let checked = match list.checked { + Some(true) => "x", + Some(false) => "_", + None => "-", + }; + let spread = if list.spread { "s" } else { "t" }; + container( + format!("item {} {number} {checked} {spread}", list.level), + children(&list.values, false), + ) + } + Node::TableCell(cell) => container( + format!("cell {} {}", cell.row, cell.column), + children(&cell.values, true), + ), + Node::TableAlign(align) => { + let align = align + .align + .iter() + .map(|kind| match kind { + TableAlignKind::Left => "\"left\"", + TableAlignKind::Right => "\"right\"", + TableAlignKind::Center => "\"center\"", + TableAlignKind::None => "\"none\"", + }) + .collect::>(); + vec![other(format!("align [{}]", align.join(",")))] + } + Node::Definition(definition) => vec![other(format!( + "definition {} {} {}", + definition.ident, + definition.url.as_str(), + or_empty(&definition.title) + ))], + Node::Footnote(footnote) => container( + format!("footnote {}", footnote.ident), + children(&footnote.values, false), + ), + Node::FootnoteRef(reference) => vec![other(format!("footnoteref {}", reference.ident))], + Node::Math(math) => vec![other(format!("math {}", quote(&math.value)))], + Node::MathInline(math) => vec![other(format!("imath {}", quote(&math.value)))], + Node::Yaml(yaml) => vec![other(format!("yaml {}", quote(&yaml.value)))], + Node::Toml(toml) => vec![other(format!("toml {}", quote(&toml.value)))], + Node::Text(text) => vec![Token::Text(text.value.clone())], + Node::Emphasis(emphasis) => container("em".into(), children(&emphasis.values, true)), + Node::Strong(strong) => container("strong".into(), children(&strong.values, true)), + Node::Delete(delete) => container("del".into(), children(&delete.values, true)), + Node::CodeInline(code) => vec![other(format!("code_inline {}", quote(&code.value)))], + Node::Break(_) => vec![other("br")], + Node::Link(link) => container( + format!("link {} {}", link.url.as_str(), or_empty(&link.title)), + children(&link.values, true), + ), + Node::Image(image) => vec![other(format!( + "image {} {} {}", + image.url, + or_empty(&image.title), + quote(&image.alt) + ))], + Node::LinkRef(reference) => container( + format!("linkref {}", reference.ident), + children(&reference.values, true), + ), + Node::ImageRef(reference) => vec![other(format!("imageref {} {}", reference.ident, quote(&reference.alt)))], + Node::MdxJsxFlowElement(element) => container( + format!("flow <{}{}>", or_empty(&element.name), attributes(&element.attributes)), + children(&element.children, false), + ), + Node::MdxJsxTextElement(element) => container( + format!("text <{}{}>", or_empty(&element.name), attributes(&element.attributes)), + children(&element.children, true), + ), + Node::MdxFlowExpression(expression) => vec![other(format!("flowexpr {{{}}}", expression.value))], + Node::MdxTextExpression(expression) => vec![other(format!("textexpr {{{}}}", expression.value))], + Node::MdxJsEsm(esm) => vec![other(format!("esm {}", quote(&esm.value)))], + Node::Empty => Vec::new(), + node => panic!("unexpected node {}", node.name()), + } +} + +fn tokens(markdown: &Markdown) -> Vec { + children(&markdown.nodes, false) + .into_iter() + .map(|token| match token { + Token::Text(text) => format!("T{}", quote(&text)), + Token::Other(token) => token, + }) + .collect() +} + +/// Checks the tokens of `input`, and that what mq writes of it reads back as the same tokens. +fn check(input: &str, expected: &[&str], parse: fn(&str) -> miette::Result) { + let markdown = parse(input).unwrap_or_else(|error| panic!("{input:?}: {error}")); + assert_eq!(tokens(&markdown), expected, "{input:?}"); + let written = markdown.to_string(); + let reparsed = parse(&written).unwrap_or_else(|error| panic!("{input:?} written as {written:?}: {error}")); + assert_eq!(tokens(&reparsed), expected, "{input:?} written as {written:?}"); +} + +#[rstest] +// Hand-written. +// should support a self-closing element +#[case("
    ", &["flow (", ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("", &["flow (", ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("<>", &["flow <>(", ")"])] +#[case("x", &["text (", r#"T"x""#, ")"])] +#[case("\n\nx\n\n", &["flow (", r#"T"x""#, ")"])] +#[case("\n x\n", &["flow (", r#"T"x""#, ")"])] +#[case("a c d", &[r#"T"a ""#, "text (", r#"T"c""#, ")", r#"T" d""#])] +#[case("a d", &[r#"T"a ""#, "text (", ")", r#"T" d""#])] +// should support an expression +#[case("{a}", &["flowexpr {a}"])] +// Differs from the official packages: Expressions are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("{a {b} c}", &["flowexpr {a {b} c}"])] +// Differs from the official packages: Expressions are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("{a\nb}", &["flowexpr {a\nb}"])] +// Differs from the official packages: Expressions are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("{a\n b\n c}", &["flowexpr {a\nb\n c}"])] +#[case("x {a} y", &[r#"T"x ""#, "textexpr {a}", r#"T" y""#])] +#[case("{a} ", &["flowexpr {a}", "flow (", ")"])] +#[case(" {a}", &["flow (", ")", "flowexpr {a}"])] +#[case(" x", &["text (", ")", r#"T" x""#])] +#[case("{a}x", &["textexpr {a}", r#"T"x""#])] +#[case("{b}", &["flow (", "flowexpr {b}", ")"])] +#[case("a < b", &[r#"T"a < b""#])] +#[case("\n\n\n", &["flow (", "flow (", ")", ")"])] +#[case("> \n> x\n> ", &["blockquote(", "flow (", r#"T"x""#, ")", ")"])] +#[case("- \n x\n ", &["item 0 u - t(", "flow (", r#"T"x""#, ")", ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("", &["flow (", ")", "flow (", ")"])] +#[case("xy", &["text (", r#"T"x""#, ")", "text (", r#"T"y""#, ")"])] +#[case("a\n", &[r#"T"a""#, "flow (", ")"])] +#[case("a\n{b}", &[r#"T"a""#, "flowexpr {b}"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case(r#"a ."#, &[r#"T"a ""#, r#"text ("#, ")", r#"T".""#])] +#[case("c", &[r#"text ("#, r#"T"c""#, ")"])] +// should support prefixed attributes +#[case("a c.", &[r#"T"a ""#, r#"text ("#, r#"T"c""#, ")", r#"T".""#])] +// should support prefixed and normal attributes +#[case(r#"a ."#, &[r#"T"a ""#, r#"text ("#, ")", r#"T".""#])] +#[case("", &["flow (", ")"])] +#[case("\n\n# h\n\n- l\n\n", &["flow (", "heading 1(", r#"T"h""#, ")", "item 0 u - t(", r#"T"l""#, ")", ")"])] +#[case("\n\n![a](b)\n\n", &["flow (", r#"image b "a""#, ")"])] +#[case("\n \n x\n \n", &["flow (", "flow (", r#"T"x""#, ")", ")"])] +#[case("import a from \"b\"\nexport const x = 1\n\nx\n", &[r#"esm "import a from \"b\"\nexport const x = 1""#, r#"T"x""#])] +#[case("export default d\n", &[r#"esm "export default d""#])] +#[case(" import a from \"b\"\n", &[r#"T"import a from \"b\"""#])] +#[case("import{a} from \"b\"\n", &[r#"T"import""#, "textexpr {a}", r#"T" from \"b\"""#])] +#[case("importx a\n", &[r#"T"importx a""#])] +#[case("- import a from \"b\"\n", &["item 0 u - t(", r#"T"import a from \"b\"""#, ")"])] +#[case("text\nimport a from \"b\"\n", &[r#"T"text\nimport a from \"b\"""#])] +// Differs from the official packages: A tag that is not closed before the end of its text is text here and in markdown-rs, and an error in the JavaScript packages. +#[case(" e"#, &[r#"T"a ""#, r#"text ("#, ")", r#"T" e""#])] +#[case("a c", &[r#"T"a ""#, "text (", r#"T"c""#, ")"])] +#[case("", &[r#"flow ("#, ")"])] +#[case("", &[r#"flow ("#, ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("", &["flow (", ")"])] +// should crash if not a spread +// Differs from the official packages: Expressions are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("", &["flow (", ")"])] +#[case("", &["flow (", ")"])] +#[case("", &["flow (", ")"])] +#[case("{/* comment */}", &["flowexpr {/* comment */}"])] +// should support an empty expression +#[case("{}", &["flowexpr {}"])] +// should support an empty expression (1) +#[case("a {} b", &[r#"T"a ""#, "textexpr {}", r#"T" b""#])] +#[case("a {b} {c} d", &[r#"T"a ""#, "textexpr {b}", r#"T" ""#, "textexpr {c}", r#"T" d""#])] +#[case("{b}{c}", &["text (", "textexpr {b}", "textexpr {c}", ")"])] +#[case("# ", &["heading 1(", "text (", ")", ")"])] +#[case("# a {b} c", &["heading 1(", r#"T"a ""#, "textexpr {b}", r#"T" c""#, ")"])] +#[case("*b*", &["em(", "text (", r#"T"b""#, ")", ")"])] +#[case("**a {b}**", &["strong(", r#"T"a ""#, "textexpr {b}", ")"])] +#[case("[](b)", &["link b (", "text (", ")", ")"])] +#[case("``", &[r#"code_inline """#])] +#[case("`{a}`", &[r#"code_inline "{a}""#])] +#[case("\n", &["flow (", ")", "flow (", ")"])] +#[case(" ", &["flow (", ")", "flow (", ")"])] +#[case("x\n", &["text (", ")", r#"T"x""#])] +#[case("x", &[r#"T"x""#, "text (", ")"])] +#[case("\n\nb\n\n\n\n", &["flow (", r#"T"b""#, ")", "flow (", ")"])] +// should support an element w/ content +#[case("\nb\n", &["flow (", r#"T"b""#, ")"])] +#[case("\n \n", &["flow (", "flow (", ")", ")"])] +#[case("- a\n \n", &["item 0 u - t(", r#"T"a""#, "flow (", ")", ")"])] +#[case("> a\n> \n", &["blockquote(", r#"T"a""#, "flow (", ")", ")"])] +// should support an element w/ containers as content +#[case("\n- b\n", &["flow (", "item 0 u - t(", r#"T"b""#, ")", ")"])] +#[case("\n\n- b\n\n", &["flow (", "item 0 u - t(", r#"T"b""#, ")", ")"])] +#[case("\n\nf\n\n", &[r#"flow ("#, r#"T"f""#, ")"])] +#[case("", &["flow (", ")"])] +#[case("", &["flow (", ")"])] +#[case("<_a />", &["flow <_a>(", ")"])] +#[case("<$a />", &["flow <$a>(", ")"])] +#[case("< a />", &[r#"T"< a />""#])] +#[case("", &["flow (", ")"])] +#[case("", &["flow (", ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("", &[r#"flow ("#, ")"])] +#[case("", &["flow (", ")"])] +#[case("", &[r#"flow ("#, ")"])] +#[case("{a}\n{b}", &["flowexpr {a}", "flowexpr {b}"])] +#[case("{a}\n\n{b}", &["flowexpr {a}", "flowexpr {b}"])] +#[case("{a} {b}", &["textexpr {a}", r#"T" ""#, "textexpr {b}"])] +#[case("{a}{b}", &["textexpr {a}", "textexpr {b}"])] +#[case("x\n{a}\n", &[r#"T"x""#, "flowexpr {a}"])] +#[case("{\n}", &["flowexpr {\n}"])] +#[case("{a}\n", &["flowexpr {a}", "flow (", ")"])] +#[case("\n{a}", &["flow (", ")", "flowexpr {a}"])] +// should support a closed element +#[case("a c d", &[r#"T"a ""#, "text (", r#"T" c ""#, ")", r#"T" d""#])] +#[case("\n", &["flow (", ")"])] +#[case("", &["flow (", ")"])] +#[case(" ", &["flow (", ")"])] +#[case("<>a", &["text <>(", r#"T"a""#, ")"])] +#[case("<>\n\na\n\n", &["flow <>(", r#"T"a""#, ")"])] +#[case("{`}`}", &["flow (", "flowexpr {`}`}", ")"])] +#[case("{'}'}", &["flowexpr {'}'}"])] +#[case(r#"{"}"}"#, &[r#"flowexpr {"}"}"#])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("", &["flow (", ")"])] +#[case(r#"a\"#, &[r#"T"a""#])] +#[case(r#"a\{b}"#, &[r#"T"a{b}""#])] +#[case("<a/>", &[r#"T"""#])] +#[case("a {b}", &[r#"T"a {b}""#])] +#[case("https://a.b", &[r#"T"https://a.b""#])] +#[case("www.a.b", &[r#"T"www.a.b""#])] +#[case("---\ntitle: x\n---\n\na", &[r#"yaml "title: x""#, r#"T"a""#])] +#[case("a\n---\n", &["heading 2(", r#"T"a""#, ")"])] +#[case("***\n", &["hr"])] +#[case("```\n\n```\n", &[r#"code """#])] +#[case(" \n", &["flow (", ")"])] +#[case("\n x\n", &["flow (", ")", r#"T"x""#])] +#[case("\n\n x\n\n", &["flow (", r#"T"x""#, ")"])] +#[case("1. \n", &["item 0 o1 - t(", "flow (", ")", ")"])] +#[case("* \n b\n \n", &["item 0 u - t(", "flow (", r#"T"b""#, ")", ")"])] +// Differs from the official packages: A tag that is not closed before the end of its text is text here and in markdown-rs, and an error in the JavaScript packages. +#[case("x ", &["flow (", ")"])] +#[case("{a // }\n}", &["flowexpr {a // }\n}"])] +#[case(r#"{"a}" + '{'}"#, &[r#"flowexpr {"a}" + '{'}"#])] +#[case(r#""#, &[r#"flow ("#, ")"])] +#[case("{a /* { */}", &["flowexpr {a /* { */}"])] +#[case("{`a${b}c`}", &["flowexpr {`a${b}c`}"])] +#[case("{`a${`}`}c`}", &["flowexpr {`a${`}`}c`}"])] +#[case(r#"{'\'}'}"#, &[r#"flowexpr {'\'}'}"#])] +#[case("{a // {\n}", &["flowexpr {a // {\n}"])] +#[case("x {'}'} y", &[r#"T"x ""#, "textexpr {'}'}", r#"T" y""#])] +#[case("x {`}`} y", &[r#"T"x ""#, "textexpr {`}`}", r#"T" y""#])] +#[case("{/* } */}\n", &["flowexpr {/* } */}"])] +#[case("{'}'}", &["flow (", "flowexpr {'}'}", ")"])] +// Differs from the official packages: Expressions are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("{'a\nb'}", &["flowexpr {'a\nb'}"])] +#[case("{a / b}", &["flowexpr {a / b}"])] +#[case("{a /* x */ + 1}", &["flowexpr {a /* x */ + 1}"])] +#[case("---\ntitle: x\n---\n\n\n", &[r#"yaml "title: x""#, "flow (", ")"])] +#[case("+++\na = 1\n+++\n\nb\n", &[r#"toml "a = 1""#, r#"T"b""#])] +#[case("---\na\n", &["hr", r#"T"a""#])] +#[case("---\n\n---\n\nx\n", &[r#"yaml """#, r#"T"x""#])] +#[case("---\ntitle: x\n---\nimport a from \"b\"\n\n{a}\n", &[r#"yaml "title: x""#, r#"esm "import a from \"b\"""#, "flowexpr {a}"])] +// From markdown-rs, `tests/mdx_esm.rs`. +// should support an import +#[case("import a from 'b'\n\nc", &[r#"esm "import a from 'b'""#, r#"T"c""#])] +// should support an export +#[case("export default a\n\nb", &[r#"esm "export default a""#, r#"T"b""#])] +// should not support other keywords (`impossible`) +#[case("impossible", &[r#"T"impossible""#])] +// should not support other keywords (`exporting`) +#[case("exporting", &[r#"T"exporting""#])] +// should not support a non-whitespace after the keyword +#[case("import.", &[r#"T"import.""#])] +// should not support a non-whitespace after the keyword (import-as-a-function) +#[case("import('a')", &[r#"T"import('a')""#])] +// should not support an indent +#[case(" import a from 'b'\n export default c", &[r#"T"import a from 'b'\nexport default c""#])] +// should not support keywords in containers +#[case("- import a from 'b'\n> export default c", &["item 0 u - t(", r#"T"import a from 'b'""#, ")", "blockquote(", r#"T"export default c""#, ")"])] +// should support imports and exports in the same “block” +#[case("import a from 'b'\nexport default c", &[r#"esm "import a from 'b'\nexport default c""#])] +// should support imports and exports in separate “blocks” +#[case("import a from 'b'\n\nexport default c", &[r#"esm "import a from 'b'""#, r#"esm "export default c""#])] +// should support imports and exports in between other constructs +#[case("a\n\nimport a from 'b'\n\nb\n\nexport default c", &[r#"T"a""#, r#"esm "import a from 'b'""#, r#"T"b""#, r#"esm "export default c""#])] +// should not support import/exports when interrupting paragraphs +#[case("a\nimport a from 'b'\n\nb\nexport default c", &[r#"T"a\nimport a from 'b'""#, r#"T"b\nexport default c""#])] +// should crash on invalid import/exports (1) +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("import a", &[r#"esm "import a""#])] +// should crash on invalid import/exports (2) +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("import 1/1", &[r#"esm "import 1/1""#])] +// should support line endings in import/exports +#[case("export {\n a\n} from 'b'\n\nc", &[r#"esm "export {\n a\n} from 'b'""#, r#"T"c""#])] +// should support blank lines in import/exports +#[case("export {\n\n a\n\n} from 'b'\n\nc", &[r#"esm "export {\n\n a\n\n} from 'b'""#, r#"T"c""#])] +// should crash on markdown after import/export w/o blank line +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("import a from 'b'\n*md*?", &[r#"esm "import a from 'b'\n*md*?""#])] +// should support comments in “blocks” +#[case("export var a = 1\n// b\n/* c */\n\nd", &[r#"esm "export var a = 1\n// b\n/* c */""#, r#"T"d""#])] +// should crash on other statements in “blocks” +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("export var a = 1\nvar b\n\nc", &[r#"esm "export var a = 1\nvar b""#, r#"T"c""#])] +// should crash on import-as-a-function with a space `import (x)` +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("import ('a')\n\nb", &[r#"esm "import ('a')""#, r#"T"b""#])] +// should support a reexport from another import +#[case("import a from 'b'\nexport {a}\n\nc", &[r#"esm "import a from 'b'\nexport {a}""#, r#"T"c""#])] +// should support a reexport from another import w/ semicolons +#[case("import a from 'b';\nexport {a};\n\nc", &[r#"esm "import a from 'b';\nexport {a};""#, r#"T"c""#])] +// should support a reexport default from another import +#[case("import a from 'b'\nexport {a as default}\n\nc", &[r#"esm "import a from 'b'\nexport {a as default}""#, r#"T"c""#])] +// should support JSX by default +#[case("export var a = () => ", &[r#"esm "export var a = () => ""#])] +// should support EOF after EOL +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("export {a}\n", &[r#"esm "export {a}""#])] +// should support a reexport from another esm block (1) +#[case("import a from 'b'\n\nexport {a}\n\nc", &[r#"esm "import a from 'b'""#, r#"esm "export {a}""#, r#"T"c""#])] +// should support a reexport from another esm block (2) +#[case("import a from 'b'\n\nexport {a}\n\n# c", &[r#"esm "import a from 'b'""#, r#"esm "export {a}""#, "heading 1(", r#"T"c""#, ")"])] +// should support mdx esm as `MdxjsEsm`s in mdast +#[case("import a from 'b'\nexport {a}", &[r#"esm "import a from 'b'\nexport {a}""#])] +// From markdown-rs, `tests/mdx_expression_flow.rs`. +// should prefer indented code over expressions if it’s enabled +#[case(" {}", &["flowexpr {}"])] +// should support indented expressions if indented code is enabled +#[case(" {}", &["flowexpr {}"])] +// should support a line ending in an expression +#[case("{\n}\na", &["flowexpr {\n}", r#"T"a""#])] +// should support expressions followed by spaces +#[case("{ a } \t\nb", &["flowexpr { a }", r#"T"b""#])] +// should support expressions preceded by spaces +#[case(" { a }\nb", &["flowexpr { a }", r#"T"b""#])] +// should support lists after non-expressions (wooorm/markdown-rs#11) +#[case("a\n\n* b", &[r#"T"a""#, "item 0 u - t(", r#"T"b""#, ")"])] +// should not support laziness (2) +#[case("> a\n{b}", &["blockquote(", r#"T"a""#, ")", "flowexpr {b}"])] +// should not support laziness (3) +#[case("> {a}\nb", &["blockquote(", "flowexpr {a}", ")", r#"T"b""#])] +// should support mdx expressions (flow) as `MdxFlowExpression`s in mdast +#[case("{alpha +\nbravo}", &["flowexpr {alpha +\nbravo}"])] +// should support indent in `MdxFlowExpression` in mdast +#[case(" {`\n a\n `}", &["flowexpr {`\n a\n`}"])] +// should support expressions padded w/ parens +#[case("a{(b)}c", &[r#"T"a""#, "textexpr {(b)}", r#"T"c""#])] +// should support expressions padded w/ parens and comments +#[case("a{/* b */ ( (c) /* d */ + (e) )}f", &[r#"T"a""#, "textexpr {/* b */ ( (c) /* d */ + (e) )}", r#"T"f""#])] +// should use correct positional info when tabs are used (1, indent) +#[case("{`\n\t`}", &["flowexpr {`\n `}"])] +// should use correct positional info when tabs are used (2, content) +#[case("{`\nalpha\t`}", &["flowexpr {`\nalpha\t`}"])] +// should support template strings in JSX (text) in block quotes +#[case("> aaa d\n> `} /> eee", &["blockquote(", r#"T"aaa ""#, "text (", ")", r#"T" eee""#, ")"])] +// should use correct positional when there are virtual spaces due to a block quote +#[case("> ab {`\n>\t`}", &["blockquote(", r#"T"ab ""#, "textexpr {`\n`}", ")"])] +// should keep the correct number of spaces in a blockquote (flow) +#[case("> {`\n> alpha\n> bravo\n> charlie\n> delta\n> `}", &["blockquote(", "flowexpr {`\nalpha\nbravo\ncharlie\n delta\n`}", ")"])] +// should support a spread +#[case("", &["flow (", ")"])] +// should crash on an incorrect spread +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("", &["flow (", ")"])] +// should crash if not an identifier +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("", &["flow (", ")"])] +// should crash on an empty spread +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("", &["flow (", ")"])] +// should crash on a comment spread +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("", &["flow (", ")"])] +// From markdown-rs, `tests/mdx_expression_text.rs`. +// should support an empty expression (2) +#[case("a { \t\r\n} b", &[r#"T"a ""#, "textexpr { \t\r\n}", r#"T" b""#])] +// should support a multiline comment (1) +#[case("a {/**/} b", &[r#"T"a ""#, "textexpr {/**/}", r#"T" b""#])] +// should support a multiline comment (2) +#[case("a { /*\n*/\t} b", &[r#"T"a ""#, "textexpr { /*\n*/\t}", r#"T" b""#])] +// should support a multiline comment (3) +#[case("a {/*b*//*c*/} d", &[r#"T"a ""#, "textexpr {/*b*//*c*/}", r#"T" d""#])] +// should support a multiline comment (4) +#[case("a {b/*c*/} d", &[r#"T"a ""#, "textexpr {b/*c*/}", r#"T" d""#])] +// should support a multiline comment (5) +#[case("a {/*b*/c} d", &[r#"T"a ""#, "textexpr {/*b*/c}", r#"T" d""#])] +// should support a line comment followed by a line ending +#[case("a {//\n} b", &[r#"T"a ""#, "textexpr {//\n}", r#"T" b""#])] +// should support a line comment followed by a line ending and an expression +#[case("a {// b\nc} d", &[r#"T"a ""#, "textexpr {// b\nc}", r#"T" d""#])] +// should support an expression followed by a line comment and a line ending +#[case("a {b// c\n} d", &[r#"T"a ""#, "textexpr {b// c\n}", r#"T" d""#])] +// should support comments +#[case("a {/*b*/ // c\n} d", &[r#"T"a ""#, "textexpr {/*b*/ // c\n}", r#"T" d""#])] +// should support expression statements (1) +#[case("a {b.c} d", &[r#"T"a ""#, "textexpr {b.c}", r#"T" d""#])] +// should support expression statements (2) +#[case("a {1 + 1} b", &[r#"T"a ""#, "textexpr {1 + 1}", r#"T" b""#])] +// should support expression statements (3) +#[case("a {function () {}} b", &[r#"T"a ""#, "textexpr {function () {}}", r#"T" b""#])] +// should crash on non-expressions +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case(r#"a {var b = "c"} d"#, &[r#"T"a ""#, r#"textexpr {var b = "c"}"#, r#"T" d""#])] +// should support expressions in containers +#[case("> a {\n> b} c", &["blockquote(", r#"T"a ""#, "textexpr {\nb}", r#"T" c""#, ")"])] +// should crash on incorrect expressions in containers (1) +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("> a {\n> b<} c", &["blockquote(", r#"T"a ""#, "textexpr {\nb<}", r#"T" c""#, ")"])] +// should crash on incorrect expressions in containers (2) +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("> a {\n> b\n> c} d", &["blockquote(", r#"T"a ""#, "textexpr {\nb\nc}", r#"T" d""#, ")"])] +// should support an expression +#[case("a {b} c", &[r#"T"a ""#, "textexpr {b}", r#"T" c""#])] +// should support a line ending in an expression +#[case("a {\n} b", &[r#"T"a ""#, "textexpr {\n}", r#"T" b""#])] +// should support just a closing brace +#[case("a } b", &[r#"T"a } b""#])] +// should support expressions as the first thing when following by other things +#[case("{ a } b", &["textexpr { a }", r#"T" b""#])] +// should support mdx expressions (text) as `MdxTextExpression`s in mdast +#[case("a {alpha} b.", &[r#"T"a ""#, "textexpr {alpha}", r#"T" b.""#])] +// should crash on an incorrect expression +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case("a {??} b", &[r#"T"a ""#, "textexpr {??}", r#"T" b""#])] +// should support an unbalanced opening brace (if JS permits) +#[case("a { /* { */ } b", &[r#"T"a ""#, "textexpr { /* { */ }", r#"T" b""#])] +// should support an unbalanced closing brace (if JS permits) +#[case("a { /* } */ } b", &[r#"T"a ""#, "textexpr { /* } */ }", r#"T" b""#])] +// should keep the correct number of spaces in a blockquote (text) +#[case("> alpha {`\n> bravo\n> charlie\n> delta\n> echo\n> `} foxtrot.", &["blockquote(", r#"T"alpha ""#, "textexpr {`\nbravo\ncharlie\ndelta\n echo\n`}", r#"T" foxtrot.""#, ")"])] +// From markdown-rs, `tests/mdx_jsx_flow.rs`. +// should prefer indented code over jsx if it’s enabled +#[case(" ", &["flow (", ")"])] +// should support indented jsx if indented code is enabled +#[case(" ", &["flow (", ")"])] +// should support a closed element +#[case("", &["flow (", ")"])] +// should support attributes +// Differs from the official packages: Expressions and ESM are not parsed as JavaScript, so invalid JavaScript is accepted. +#[case(r#""#, &[r#"flow ("#, ")"])] +// should support a dangling `>` in a tag (not a block quote) +#[case(" \nb\n ", &["flow (", r#"T"b""#, ")"])] +// should support trailing initial and final whitespace around tags +#[case(" \nb\n ", &["flow (", r#"T"b""#, ")"])] +// should support tags after tags +#[case(" \t\nc\n ", &["flow (", "flow (", r#"T"c""#, ")", ")"])] +// should not support lazy flow (8) +#[case("> a\n", &["blockquote(", r#"T"a""#, ")", "flow (", ")"])] +// should support mdx jsx (flow) as `MdxJsxFlowElement`s in mdast +#[case("<>\n * a\n", &["flow <>(", "item 0 u - t(", r#"T"a""#, ")", ")"])] +// should support tags and expressions (unaware) +#[case("
    \n{1}\n
    ", &["flow
    (", "flowexpr {1}", ")"])] +// should support tags and expressions (aware) +#[case("
    \n{'}'}\n
    ", &["flow
    (", "flowexpr {'}'}", ")"])] +// should support tags and expressions with text before (text) +#[case("x{1}", &[r#"T"x""#, "text (", "textexpr {1}", ")"])] +// should support tags and expressions with text between, early (text) +#[case("x{1}", &["text (", r#"T"x""#, "textexpr {1}", ")"])] +// should support tags and expressions with text between, late (text) +#[case("{1}x", &["text (", "textexpr {1}", r#"T"x""#, ")"])] +// should support tags and expressions with text after (text) +#[case("{1}x", &["text (", "textexpr {1}", ")", r#"T"x""#])] +// should support a tag and then an expression (flow) +#[case("{1}", &["flow (", ")", "flowexpr {1}"])] +// should support a tag, an expression, then text (text) +#[case("{1}x", &["text (", ")", "textexpr {1}", r#"T"x""#])] +// should support text, a tag, then an expression (text) +#[case("x{1}", &[r#"T"x""#, "text (", ")", "textexpr {1}"])] +// should support an expression and then a tag (flow) +#[case("{1}", &["flowexpr {1}", "flow (", ")"])] +// should support an expression, a tag, then text (text) +#[case("{1}x", &["textexpr {1}", "text (", ")", r#"T"x""#])] +// should support text, an expression, then a tag (text) +#[case("x{1}", &[r#"T"x""#, "textexpr {1}", "text (", ")"])] +// should nicely interleaf (micromark/micromark-extension-mdx-jsx#9) +#[case("{[\n'',\n{c:''}\n]}", &["flow (", "flowexpr {[\n'',\n{c:''}\n]}", ")"])] +// should nicely interleaf (mdx-js/mdx#1945) +#[case("\n\n ", &["flow y\nz", + "<style>x</style> y\n

    z

    ", + r#" +html " y" @1:1-1:19 +text "z" @2:1-2:2 +"# +)] +#[case::html_comment( + "\nx", + "\n

    x

    ", + r#" +html "" @1:1-3:7 +text "x" @4:1-4:2 +"# +)] +#[case::html_instruction( + "\ny", + "\n

    y

    ", + r#" +html "" @1:1-2:5 +text "y" @3:1-3:2 +"# +)] +#[case::html_declaration( + "\nx", + "\n

    x

    ", + r#" +html "" @1:1-1:16 +text "x" @2:1-2:2 +"# +)] +#[case::html_cdata( + "\ny", + "\n

    y

    ", + r#" +html "" @1:1-3:5 +text "y" @4:1-4:2 +"# +)] +#[case::html_complete_tag("
    \ny", "\ny", r#"html "\ny" @1:1-2:2"#)] +#[case::html_tag_then_text( + "y", + "

    y

    ", + r#" +html "" @1:1-1:13 +text "y" @1:13-1:14 +"# +)] +#[case::html_interrupt_basic( + "text\n\nz" @1:1-2:2"#)] +#[case::html_multiline_open( + "\nz", + "\nz", + r#"html "\nz" @1:1-3:2"# +)] +#[case::html_in_quote( + ">
    \n> x", + "
    \n
    \nx\n
    ", + r#" +blockquote @1:1-2:4 + html "
    \nx" @1:3-2:4 +"# +)] +#[case::html_in_list( + "-
    \n x", + "
      \n
    • \n
      \nx\n
    • \n
    ", + r#" +list level=0 index=0 marker=- @1:3-2:4 + html "
    \nx" @1:3-2:4 +"# +)] +#[case::html_uppercase("
    \nx", "
    \nx", r#"html "
    \nx" @1:1-2:2"#)] +#[case::html_self_closing("
    \nx", "
    \nx", r#"html "
    \nx" @1:1-2:2"#)] +#[case::html_ins("\nx", "\nx", r#"html "\nx" @1:1-2:2"#)] +#[case::html_br("
    \nx", "
    \nx", r#"html "
    \nx" @1:1-2:2"#)] +#[case::html_textarea( + "", + "<textarea>\nx\n\ny</textarea>", + r#"html "" @1:1-4:13"# +)] +#[case::html_table_two_blocks( + "\n\n\n
    ", + "\n\n
    ", + r#" +html "\n" @1:1-2:5 +html "
    " @4:1-4:5 +"# +)] +#[case::html_img_self_close( + "\nz", + "\nz", + r#"html "\nz" @1:1-2:2"# +)] +#[case::html_img_then_text( + " y", + "

    y

    ", + r#" +html "" @1:1-1:16 +text " y" @1:16-1:18 +"# +)] +#[case::html_multiline_tag_inline( + "\nz", + "

    \nz

    ", + r#" +html "" @1:1-2:3 +text "\nz" @2:3-3:2 +"# +)] +#[case::html_indent4("
    ", "
    <div>\n
    ", r#"code "
    " @1:1-1:10"#)] +#[case::html_comment_then_text( + "y\nz", + "y\n

    z

    ", + r#" +html "y" @1:1-1:10 +text "z" @2:1-2:2 +"# +)] +#[case::html_then_code_line("
    \n code", "
    \n code", r#"html "
    \n code" @1:1-2:9"#)] +#[case::html_comment_short( + "\nx", + "\n

    x

    ", + r#" +html "" @1:1-1:6 +text "x" @2:1-2:2 +"# +)] +#[case::html_instruction_short( + "\nx", + "\n

    x

    ", + r#" +html "" @1:1-1:4 +text "x" @2:1-2:2 +"# +)] +#[case::html_lazy_quote( + ">
    \nx", + "
    \n
    \n
    \n

    x

    ", + r#" +blockquote @1:1-1:8 + html "
    " @1:3-1:8 +text "x" @2:1-2:2 +"# +)] +#[case::html_attr_forms( + "\nx", + "\nx", + r#"html "\nx" @1:1-2:2"# +)] +#[case::html_bad_attr("\nx", "

    <a b=>\nx

    ", r#"text "
    \nx" @1:1-2:2"#)] +#[case::fm_yaml( + "---\na: b\n---\ntext", + "

    text

    ", + r#" +yaml "a: b" @1:1-3:4 +text "text" @4:1-4:5 +"# +)] +#[case::fm_yaml_only("---\na: b\n---", "", r#"yaml "a: b" @1:1-3:4"#)] +#[case::fm_empty("---\n---", "", r#"yaml "" @1:1-2:4"#)] +#[case::fm_unclosed( + "---\na\n", + "
    \n

    a

    \n", + r#" +hr marker='-' @1:1-1:4 +text "a" @2:1-2:2 +"# +)] +#[case::fm_blank_content( + "---\n\n---\nx", + "

    x

    ", + r#" +yaml "" @1:1-3:4 +text "x" @4:1-4:2 +"# +)] +#[case::fm_toml( + "+++\na = 1\n+++\nx", + "

    x

    ", + r#" +toml "a = 1" @1:1-3:4 +text "x" @4:1-4:2 +"# +)] +#[case::fm_indented( + " ---\na\n---", + "
    \n

    a

    ", + r#" +hr marker='-' @1:1-1:5 +heading 2 @2:1-3:4 + text "a" @2:1-2:2 +"# +)] +#[case::fm_close_trailing_space( + "---\na\n--- \nx", + "

    x

    ", + r#" +yaml "a" @1:1-3:5 +text "x" @4:1-4:2 +"# +)] +#[case::fm_close_longer( + "---\na\n----\nx", + "
    \n

    a

    \n

    x

    ", + r#" +hr marker='-' @1:1-1:4 +heading 2 @2:1-3:5 + text "a" @2:1-2:2 +text "x" @4:1-4:2 +"# +)] +#[case::fm_open_trailing_space("--- \na\n---", "", r#"yaml "a" @1:1-3:4"#)] +#[case::fm_open_junk( + "---x\na\n---", + "

    ---x\na

    ", + r#" +heading 2 @1:1-3:4 + text "---x\na" @1:1-2:2 +"# +)] +#[case::fm_not_first( + "text\n---\na\n---", + "

    text

    \n

    a

    ", + r#" +heading 2 @1:1-2:4 + text "text" @1:1-1:5 +heading 2 @3:1-4:4 + text "a" @3:1-3:2 +"# +)] +#[case::fm_blank_before( + "\n---\na\n---", + "
    \n

    a

    ", + r#" +hr marker='-' @2:1-2:4 +heading 2 @3:1-4:4 + text "a" @3:1-3:2 +"# +)] +#[case::fm_multiline_content( + "---\na: 1\n\nb: 2\n---\n# h", + "

    h

    ", + r#" +yaml "a: 1\n\nb: 2" @1:1-5:4 +heading 1 @6:1-6:4 + text "h" @6:3-6:4 +"# +)] +#[case::fm_hr_after( + "---\na\n---\n\n---", + "
    ", + r#" +yaml "a" @1:1-3:4 +hr marker='-' @5:1-5:4 +"# +)] +#[case::math_basic( + "$$\na\n$$", + "
    a\n
    ", + r#"math "a" @1:1-3:3"# +)] +#[case::math_then_text( + "$$\na\n$$\nx", + "
    a\n
    \n

    x

    ", + r#" +math "a" @1:1-3:3 +text "x" @4:1-4:2 +"# +)] +#[case::math_meta( + "$$ meta\na\n$$", + "
    a\n
    ", + r#"math "a" @1:1-3:3"# +)] +#[case::math_unclosed( + "$$\na", + "
    a\n
    \n", + r#"math "a" @1:1-2:2"# +)] +#[case::math_longer_open( + "$$$\na\n$$", + "
    a\n$$\n
    \n", + r#"math "a\n$$" @1:1-3:3"# +)] +#[case::math_longer_close( + "$$\na\n$$$$", + "
    a\n
    ", + r#"math "a" @1:1-3:5"# +)] +#[case::math_indented( + " $$\n a\n b\n $$", + "
    a\n b\n
    ", + r#"math "a\n b" @1:3-4:5"# +)] +#[case::math_empty( + "$$\n$$", + "
    ", + r#"math "" @1:1-2:3"# +)] +#[case::math_inline_not_block( + "$$a$$", + "

    a

    ", + r#"math_inline "a" @1:1-1:6"# +)] +#[case::math_info_no_close( + "$$a\n$$", + "
    ", + r#"math "" @1:1-2:3"# +)] +#[case::math_in_quote( + "> $$\n> a\n> $$", + "
    \n
    a\n
    \n
    ", + r#" +blockquote @1:1-3:5 + math "a" @1:3-3:5 +"# +)] +#[case::math_in_list( + "- $$\n a\n $$", + "
      \n
    • \n
      a\n
      \n
    • \n
    ", + r#" +list level=0 index=0 marker=- @1:3-3:5 + math "a" @1:3-3:5 +"# +)] +#[case::math_close_junk( + "$$\na\n$$ x", + "
    a\n$$ x\n
    \n", + r#"math "a\n$$ x" @1:1-3:5"# +)] +#[case::math_interrupts_paragraph( + "text\n$$\na\n$$", + "

    text

    \n
    a\n
    ", + r#" +text "text" @1:1-1:5 +math "a" @2:1-4:3 +"# +)] +#[case::math_blank_inside( + "$$\n\na\n$$", + "
    \na\n
    ", + r#"math "\na" @1:1-4:3"# +)] +#[case::math_single_dollar_line( + "$ $\nx", + "

    \nx

    ", + r#" +math_inline " " @1:1-1:4 +text "\nx" @1:4-2:2 +"# +)] +#[case::crlf_frontmatter( + "---\r\na\r\n---\r\nx", + "

    x

    ", + r#" +yaml "a" @1:1-3:4 +text "x" @4:1-4:2 +"# +)] +#[case::break_end_indented( + "a \n b", + "

    a
    \nb

    ", + r#" +text "a" @1:1-1:2 +break @1:2-2:1 +text "b" @2:3-2:4 +"# +)] +#[case::text_end_indented_continuation( + "*a*\n ", + "

    a\n

    ", + r#" +emphasis @1:1-1:4 + text "a" @1:2-1:3 +text "\n" @1:4-2:1 +html "" @2:3-2:6 +"# +)] +#[case::soft_end_before_node( + "a\n `b`", + "

    a\nb

    ", + r#" +text "a\n" @1:1-2:1 +code_inline "b" @2:3-2:6 +"# +)] +#[case::break_in_quote( + "> a \n> b", + "
    \n

    a
    \nb

    \n
    ", + r#" +blockquote @1:1-2:4 + text "a" @1:3-1:4 + break @1:4-2:1 + text "b" @2:3-2:4 +"# +)] +#[case::text_eol_in_footnote( + "[^a]: x \n [a]", + "", + r#" +footnote "a" @1:1-2:6 + text "x\n[a]" @1:7-2:6 +"# +)] +#[case::math_whitespace_content( + " $$\n \n $$", + "
    \n
    ", + r#"math "" @1:3-3:5"# +)] +#[case::math_whitespace_content2( + " $$\n \n $$", + "
    \n
    ", + r#"math "" @1:3-3:5"# +)] +#[case::table_indented_no_pipe( + " a|b\n|-|:-:|", + "\n\n\n\n\n\n\n
    ab
    ", + r#" +cell 0:0 @1:1-1:4 + text "a" @1:3-1:4 +cell 0:1 @1:4-1:6 + text "b" @1:5-1:6 +align none,center @2:1-2:1 +"# +)] +#[case::tab_nested_list( + "- a\n\t- b\n\t\t- c\n", + "
      \n
    • a\n
        \n
      • b\n
          \n
        • c
        • \n
        \n
      • \n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-1:4 + text "a" @1:3-1:4 +list level=1 index=0 marker=- @2:4-2:5 + text "b" @2:4-2:5 +list level=2 index=0 marker=- @3:5-3:6 + text "c" @3:5-3:6 +"# +)] +#[case::tab_nested_ordered( + "1. a\n\t1. b\n\t\t1. c\n", + "
      \n
    1. a\n
        \n
      1. b\n
          \n
        1. c
        2. \n
        \n
      2. \n
      \n
    2. \n
    \n", + r#" +list level=0 index=0 ordered start=1 marker=. @1:4-1:5 + text "a" @1:4-1:5 +list level=1 index=0 ordered start=1 marker=. @2:5-2:6 + text "b" @2:5-2:6 +list level=2 index=0 ordered start=1 marker=. @3:6-3:7 + text "c" @3:6-3:7 +"# +)] +#[case::tab_list_code( + "- a\n\n\t\tcode\n", + "
      \n
    • \n

      a

      \n
        code\n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-3:7 + text "a" @1:3-1:4 + code " code" @3:2-3:7 +"# +)] +#[case::tab_in_text( + "a\tb\t*c*\n", + "

    a\tb\tc

    \n", + r#" +text "a\tb\t" @1:1-1:5 +emphasis @1:5-1:8 + text "c" @1:6-1:7 +"# +)] +#[case::tab_code_block( + "\tcode\n\t\tmore\n", + "
    code\n\tmore\n
    \n", + r#"code "code\n\tmore" @1:1-2:7"# +)] +#[case::tab_after_marker( + "-\ta\n", + "
      \n
    • a
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-1:4 + text "a" @1:3-1:4 +"# +)] +#[case::atx_leading_sequences( + "# # a", + "

    # a

    ", + r##" +heading 1 @1:1-1:6 + text "# a" @1:3-1:6 +"## +)] +#[case::atx_leading_sequences2( + "## # a", + "

    # a

    ", + r##" +heading 2 @1:1-1:7 + text "# a" @1:4-1:7 +"## +)] +#[case::atx_only_sequences( + "# # #", + "

    #

    ", + r##" +heading 1 @1:1-1:6 + text "#" @1:3-1:4 +"## +)] +#[case::atx_no_space_sequence( + "# #a", + "

    #a

    ", + r##" +heading 1 @1:1-1:5 + text "#a" @1:3-1:5 +"## +)] +#[case::atx_double_sequence( + "# ## a", + "

    ## a

    ", + r###" +heading 1 @1:1-1:7 + text "## a" @1:3-1:7 +"### +)] +#[case::atx_tab_sequence( + "#\t# a", + "

    # a

    ", + r##" +heading 1 @1:1-1:6 + text "# a" @1:3-1:6 +"## +)] +#[case::atx_closing_and_leading( + "# # a #", + "

    # a

    ", + r##" +heading 1 @1:1-1:8 + text "# a" @1:3-1:6 +"## +)] +#[case::atx_inner_hash( + "# a # b", + "

    a # b

    ", + r#" +heading 1 @1:1-1:8 + text "a # b" @1:3-1:8 +"# +)] +#[case::quote_footnote_trailing_space( + "- a\n > [^a]: x\n ", + "
      \n
    • a\n
      \n
      \n
    • \n
    ", + r#" +list level=0 index=0 marker=- @1:3-3:3 + text "a" @1:3-1:4 + blockquote @2:3-3:3 + footnote "a" @2:5-3:3 + text "x" @2:11-2:12 +"# +)] +#[case::indented_code_trailing_blank(" code\n\n\n", "
    code\n
    \n", r#"code "code" @1:1-1:9"#)] +#[case::indented_code_trailing_spaces( + " code\n \n", + "
    code\n
    \n", + r#"code "code" @1:1-2:9"# +)] +#[case::bom_heading( + "\u{feff}# a\n", + "

    a

    \n", + r#" +heading 1 @1:1-1:4 + text "a" @1:3-1:4 +"# +)] +#[case::bom_frontmatter("\u{feff}---\na: b\n---\n", "", r#"yaml "a: b" @1:1-3:4"#)] +#[case::bom_text("\u{feff}a\n", "

    a

    \n", r#"text "a" @1:1-1:2"#)] +#[case::www_multibyte_after_ww("wwß x", "

    wwß x

    ", r#"text "wwß x" @1:1-1:6"#)] +#[case::www_combining("ww\u{301}.a.com", "

    ww\u{301}.a.com

    ", r#"text "ww\u{301}.a.com" @1:1-1:10"#)] +#[case::www_cjk( + "www.あ.com/パス x", + "

    www.あ.com/パス x

    ", + r#" +link "http://www.あ.com/パス" @1:1-1:13 + text "www.あ.com/パス" @1:1-1:13 +text " x" @1:13-1:15 +"# +)] +#[case::http_cjk( + "http://あ.jp/パス ok", + "

    http://あ.jp/パス ok

    ", + r#" +link "http://あ.jp/パス" @1:1-1:15 + text "http://あ.jp/パス" @1:1-1:15 +text " ok" @1:15-1:18 +"# +)] +#[case::email_cjk_domain("a@あ.com", "

    a@あ.com

    ", r#"text "a@あ.com" @1:1-1:8"#)] +#[case::email_cjk_local("あ@b.com", "

    あ@b.com

    ", r#"text "あ@b.com" @1:1-1:8"#)] +#[case::html_inline_continuation( + "", + "

    ", + r#"html "" @1:1-2:12"# +)] +#[case::html_inline_continuation_tab( + "", + "

    ", + r#"html "" @1:1-2:9"# +)] +#[case::table_trailing_space( + "| a | b | \n|-|-| \n| c | d | \n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    cd
    \n", + r#" +cell 0:0 @1:1-1:5 + text "a" @1:3-1:4 +cell 0:1 @1:5-1:12 + text "b" @1:7-1:8 +align none,none @2:1-2:1 +cell 1:0 @3:1-3:5 + text "c" @3:3-3:4 +cell 1:1 @3:5-3:12 + text "d" @3:7-3:8 +"# +)] +#[case::table_no_pipes_trailing_space( + "a | b\n--|--\nd | e \n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    de
    \n", + r#" +cell 0:0 @1:1-1:3 + text "a" @1:1-1:2 +cell 0:1 @1:3-1:6 + text "b" @1:5-1:6 +align none,none @2:1-2:1 +cell 1:0 @3:1-3:3 + text "d" @3:1-3:2 +cell 1:1 @3:3-3:7 + text "e" @3:5-3:6 +"# +)] +#[case::table_trailing_tab( + "| a | b |\t\n|-|-|\n| c | d | \n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    ab
    cd
    \n", + r#" +cell 0:0 @1:1-1:5 + text "a" @1:3-1:4 +cell 0:1 @1:5-1:11 + text "b" @1:7-1:8 +align none,none @2:1-2:1 +cell 1:0 @3:1-3:5 + text "c" @3:3-3:4 +cell 1:1 @3:5-3:13 + text "d" @3:7-3:8 +"# +)] +#[case::table_cjk( + "| あ | 🎉 |\n|:-|-:|\n| é | ß |\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n
    あ🎉
    éß
    \n", + r#" +cell 0:0 @1:1-1:5 + text "あ" @1:3-1:4 +cell 0:1 @1:5-1:11 + text "🎉" @1:7-1:9 +align left,right @2:1-2:1 +cell 1:0 @3:1-3:5 + text "é" @3:3-3:4 +cell 1:1 @3:5-3:10 + text "ß" @3:7-3:8 +"# +)] +#[case::table_single_column_colon( + "a\n:-\nb\n", + "\n\n\n\n\n\n\n\n\n\n\n
    a
    b
    \n", + r#" +cell 0:0 @1:1-1:2 + text "a" @1:1-1:2 +align left @2:1-2:1 +cell 1:0 @3:1-3:2 + text "b" @3:1-3:2 +"# +)] +#[case::table_single_column_right( + "a\n---:\nb\nc\n", + "\n\n\n\n\n\n\n\n\n\n\n\n\n\n
    a
    b
    c
    \n", + r#" +cell 0:0 @1:1-1:2 + text "a" @1:1-1:2 +align right @2:1-2:1 +cell 1:0 @3:1-3:2 + text "b" @3:1-3:2 +cell 2:0 @4:1-4:2 + text "c" @4:1-4:2 +"# +)] +#[case::table_double_colon_not_delimiter("a\n::-\n", "

    a\n::-

    \n", r#"text "a\n::-" @1:1-2:4"#)] +#[case::table_body_empty_marker( + "a|b\n|-|-|\n*\n", + "\n\n\n\n\n\n\n
    ab
    \n
      \n
    • \n
    \n", + r#" +cell 0:0 @1:1-1:2 + text "a" @1:1-1:2 +cell 0:1 @1:2-1:4 + text "b" @1:3-1:4 +align none,none @2:1-2:1 +list level=0 index=0 marker=* @3:1-3:2 +"# +)] +#[case::table_body_ordered_marker( + "a|b\n|-|-|\n2. x\n", + "\n\n\n\n\n\n\n
    ab
    \n
      \n
    1. x
    2. \n
    \n", + r#" +cell 0:0 @1:1-1:2 + text "a" @1:1-1:2 +cell 0:1 @1:2-1:4 + text "b" @1:3-1:4 +align none,none @2:1-2:1 +list level=0 index=0 ordered start=2 marker=. @3:4-3:5 + text "x" @3:4-3:5 +"# +)] +#[case::table_body_empty_ordered( + "a|b\n|-|-|\n1.\n", + "\n\n\n\n\n\n\n
    ab
    \n
      \n
    1. \n
    \n", + r#" +cell 0:0 @1:1-1:2 + text "a" @1:1-1:2 +cell 0:1 @1:2-1:4 + text "b" @1:3-1:4 +align none,none @2:1-2:1 +list level=0 index=0 ordered start=1 marker=. @3:1-3:3 +"# +)] +#[case::paragraph_after_partial_tab( + "- a\n\n\t[P](h) x\n", + "
      \n
    • \n

      a

      \n

      P x

      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-3:10 + text "a" @1:3-1:4 + link "h" @3:2-3:8 + text "P" @3:3-3:4 + text " x" @3:8-3:10 +"# +)] +#[case::fence_after_partial_tab( + "- a\n\n\t```ts\n\t\tx\n\t```\n", + "
      \n
    • \n

      a

      \n
      \tx\n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-5:5 + text "a" @1:3-1:4 + code "\tx" lang="ts" fence @3:2-5:5 +"# +)] +#[case::math_after_partial_tab( + "- a\n\n\t$$\n\tm\n\t$$\n", + "
      \n
    • \n

      a

      \n
      m\n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-5:4 + text "a" @1:3-1:4 + math "m" @3:2-5:4 +"# +)] +#[case::quote_after_partial_tab( + "- a\n\n\t> q\n", + "
      \n
    • \n

      a

      \n
      \n

      q

      \n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-3:5 + text "a" @1:3-1:4 + blockquote @3:2-3:5 + text "q" @3:4-3:5 +"# +)] +#[case::heading_after_partial_tab( + "- a\n\n\t# h\n", + "
      \n
    • \n

      a

      \n

      h

      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-3:5 + text "a" @1:3-1:4 + heading 1 @3:2-3:5 + text "h" @3:4-3:5 +"# +)] +#[case::control_character_references( + "… Ÿ ���", + "

    ����\u{c}�\u{a0}���

    ", + r#"text "����\u{c}�\u{a0}���" @1:1-1:61"# +)] +#[case::footnote_escaped_bracket("[^a\\]: x", "

    [^a]: x

    ", r#"text "[^a]: x" @1:1-1:9"#)] +#[case::footnote_escaped_backslash( + "[^a\\\\]: x", + "", + r#" +footnote "a\\\\" @1:1-1:10 + text "x" @1:9-1:10 +"# +)] +#[case::footnote_escaped_open( + "[^a\\[]: x", + "", + r#" +footnote "a\\[" @1:1-1:10 + text "x" @1:9-1:10 +"# +)] +#[case::footnote_unescaped_open("[^a[]: x", "

    [^a[]: x

    ", r#"text "[^a[]: x" @1:1-1:9"#)] +#[case::footnote_empty_then_blank("[^a]: \n\n", "", r#"footnote "a" @1:1-2:1"#)] +#[case::footnote_empty_then_spaces("[^a]: \n \n", "", r#"footnote "a" @1:1-2:5"#)] +#[case::footnote_empty_then_text( + "[^a]: \n\nb", + "

    b

    ", + r#" +footnote "a" @1:1-2:1 +text "b" @3:1-3:2 +"# +)] +#[case::definition_nul_destination( + "[a]: \0\n\n[a]\n", + "

    a

    \n", + r#" +definition "a" "\0" label="a" @1:1-1:7 +link_ref "a" label="a" @3:1-3:4 + text "a" @3:2-3:3 +"# +)] +#[case::link_nul_destination( + "[a](x\0y)", + "

    a

    ", + r#" +link "x\0y" @1:1-1:9 + text "a" @1:2-1:3 +"# +)] +#[case::definition_then_empty_marker( + "[a]: b\n-", + "

    -

    ", + r#" +definition "a" "b" label="a" @1:1-1:7 +text "-" @2:1-2:2 +"# +)] +#[case::definition_then_item( + "[a]: b\n- x", + "
      \n
    • x
    • \n
    ", + r#" +definition "a" "b" label="a" @1:1-1:7 +list level=0 index=0 marker=- @2:3-2:4 + text "x" @2:3-2:4 +"# +)] +#[case::definition_then_ordered( + "[a]: b\n1.", + "

    1.

    ", + r#" +definition "a" "b" label="a" @1:1-1:7 +text "1." @2:1-2:3 +"# +)] +#[case::emphasis_cjk( + "*あ*。*い*", + "

    あ。い

    ", + r#" +emphasis @1:1-1:4 + text "あ" @1:2-1:3 +text "。" @1:4-1:5 +emphasis @1:5-1:8 + text "い" @1:6-1:7 +"# +)] +#[case::strong_cjk( + "**日本語**です", + "

    日本語です

    ", + r#" +strong @1:1-1:8 + text "日本語" @1:3-1:6 +text "です" @1:8-1:10 +"# +)] +#[case::underscore_accent( + "_é_ _ß_", + "

    é ß

    ", + r#" +emphasis @1:1-1:4 + text "é" @1:2-1:3 +text " " @1:4-1:5 +emphasis @1:5-1:8 + text "ß" @1:6-1:7 +"# +)] +#[case::fullwidth_markers("*a* _b_", "

    *a* _b_

    ", r#"text "*a* _b_" @1:1-1:8"#)] +#[case::emphasis_after_cjk_punctuation( + "。*a*、**b**", + "

    。a、b

    ", + r#" +text "。" @1:1-1:2 +emphasis @1:2-1:5 + text "a" @1:3-1:4 +text "、" @1:5-1:6 +strong @1:6-1:11 + text "b" @1:8-1:9 +"# +)] +#[case::combining_mark( + "e\u{301}*a*", + "

    e\u{301}a

    ", + r#" +text "e\u{301}" @1:1-1:3 +emphasis @1:3-1:6 + text "a" @1:4-1:5 +"# +)] +#[case::emoji_zwj( + "👨\u{200d}👩\u{200d}👧 *a*", + "

    👨\u{200d}👩\u{200d}👧 a

    ", + r#" +text "👨\u{200d}👩\u{200d}👧 " @1:1-1:10 +emphasis @1:10-1:13 + text "a" @1:11-1:12 +"# +)] +#[case::fence_cjk_info( + "```あ\n日本語\n```\n", + "
    日本語\n
    \n", + r#"code "日本語" lang="あ" fence @1:1-3:4"# +)] +#[case::fence_in_quote_emoji( + "> ```🎉\n> x\n> ```\n", + "
    \n
    x\n
    \n
    \n", + r#" +blockquote @1:1-3:6 + code "x" lang="🎉" fence @1:3-3:6 +"# +)] +#[case::fence_in_list_cjk( + "- ```あ\n 日本語\n ```\n", + "
      \n
    • \n
      日本語\n
      \n
    • \n
    \n", + r#" +list level=0 index=0 marker=- @1:3-3:6 + code "日本語" lang="あ" fence @1:3-3:6 +"# +)] +#[case::math_cjk( + "$$\nあ\n$$\n\n$い$\n", + "
    あ\n
    \n

    い

    \n", + r#" +math "あ" @1:1-3:3 +math_inline "い" @5:1-5:4 +"# +)] +#[case::heading_cjk( + "# 見出し\n\n見出し2\n===\n", + "

    見出し

    \n

    見出し2

    \n", + r#" +heading 1 @1:1-1:6 + text "見出し" @1:3-1:6 +heading 1 @3:1-4:4 + text "見出し2" @3:1-3:5 +"# +)] +#[case::link_cjk( + "[あ](http://い.jp/う \"え\")", + "

    あ

    ", + r#" +link "http://い.jp/う" title="え" @1:1-1:23 + text "あ" @1:2-1:3 +"# +)] +#[case::reference_cjk( + "[あ]\n\n[あ]: /u\n", + "

    あ

    \n", + r#" +link_ref "あ" label="あ" @1:1-1:4 + text "あ" @1:2-1:3 +definition "あ" "/u" label="あ" @3:1-3:8 +"# +)] +#[case::footnote_cjk( + "あ[^い]\n\n[^い]: う\n", + "

    あ1

    \n

    Footnotes

    \n
      \n
    1. \n

      う ↩

      \n
    2. \n
    \n
    \n", + r#" +text "あ" @1:1-1:2 +footnote_ref "い" label="い" @1:2-1:6 +footnote "い" @3:1-3:8 + text "う" @3:7-3:8 +"# +)] +#[case::ideographic_space( + "a\u{3000}b\n\u{3000}c", + "

    a\u{3000}b\n\u{3000}c

    ", + r#"text "a\u{3000}b\n\u{3000}c" @1:1-2:3"# +)] +#[case::nbsp_around_emphasis( + "a\u{a0}*b*\u{a0}c", + "

    a\u{a0}b\u{a0}c

    ", + r#" +text "a\u{a0}" @1:1-1:3 +emphasis @1:3-1:6 + text "b" @1:4-1:5 +text "\u{a0}c" @1:6-1:8 +"# +)] +#[case::entity_cjk_context( + "あ&いあ🎉", + "

    あ&いあ🎉

    ", + r#"text "あ&いあ🎉" @1:1-1:25"# +)] +#[case::autolink_cjk( + "", + "

    http://あ.jp/パス

    ", + r#" +link "http://あ.jp/パス" @1:1-1:17 + text "http://あ.jp/パス" @1:2-1:16 +"# +)] +#[case::code_span_cjk( + "`あ`と``い`う``", + "

    あとい`う

    ", + r#" +code_inline "あ" @1:1-1:4 +text "と" @1:4-1:5 +code_inline "い`う" @1:5-1:12 +"# +)] +#[case::hard_break_cjk( + "あ \nい\\\nう", + "

    あ
    \nい
    \nう

    ", + r#" +text "あ" @1:1-1:2 +break @1:2-2:1 +text "い" @2:1-2:2 +break @2:2-3:1 +text "う" @3:1-3:2 +"# +)] +#[case::nul_in_text("a\0b", "

    a�b

    ", r#"text "a\0b" @1:1-1:4"#)] +fn markdown(#[case] input: &str, #[case] html: &str, #[case] expected: &str) { + let nodes = Markdown::from_markdown_str(input).unwrap().nodes; + assert_eq!(tree(&nodes), expected.trim_matches('\n'), "{input:?}"); + assert_eq!(mq_markdown::to_html(input), html, "{input:?}"); +} + +#[rstest] +#[case::jsx_self("", "jsx_flow @1:1-1:6")] +#[case::jsx_attrs( + "", + r#"jsx_flow @1:1-1:39"# +)] +#[case::jsx_member("", "jsx_flow @1:1-1:10")] +#[case::jsx_namespace("", r#"jsx_flow @1:1-1:16"#)] +#[case::jsx_fragment("<>", "jsx_flow <> @1:1-1:6")] +#[case::jsx_text_pair( + "x", + r#" +jsx_text @1:1-1:9 + text "x" @1:4-1:5 +"# +)] +#[case::jsx_flow_pair( + "\n\nx\n\n", + r#" +jsx_flow @1:1-5:5 + text "x" @3:1-3:2 +"# +)] +#[case::jsx_flow_indented_child( + "\n x\n", + r#" +jsx_flow @1:1-3:5 + text "x" @2:3-2:4 +"# +)] +#[case::jsx_inline( + "a c d", + r#" +text "a " @1:1-1:3 +jsx_text @1:3-1:11 + text "c" @1:6-1:7 +text " d" @1:11-1:13 +"# +)] +#[case::jsx_inline_self( + "a d", + r#" +text "a " @1:1-1:3 +jsx_text @1:3-1:7 +text " d" @1:7-1:9 +"# +)] +#[case::expr_flow("{a}", r#"expression_flow "a" @1:1-1:4"#)] +#[case::expr_nested("{a {b} c}", r#"expression_flow "a {b} c" @1:1-1:10"#)] +#[case::expr_multiline("{a\nb}", r#"expression_flow "a\nb" @1:1-2:3"#)] +#[case::expr_multiline_indented("{a\n b\n c}", r#"expression_flow "a\nb\n c" @1:1-3:6"#)] +#[case::expr_text( + "x {a} y", + r#" +text "x " @1:1-1:3 +expression_text "a" @1:3-1:6 +text " y" @1:6-1:8 +"# +)] +#[case::expr_then_tag( + "{a} ", + r#" +expression_flow "a" @1:1-1:4 +jsx_flow @1:5-1:9 +"# +)] +#[case::tag_then_expr( + " {a}", + r#" +jsx_flow @1:1-1:5 +expression_flow "a" @1:6-1:9 +"# +)] +#[case::tag_then_text( + " x", + r#" +jsx_text @1:1-1:5 +text " x" @1:5-1:7 +"# +)] +#[case::expr_then_text( + "{a}x", + r#" +expression_text "a" @1:1-1:4 +text "x" @1:4-1:5 +"# +)] +#[case::jsx_in_expr_child( + "{b}", + r#" +jsx_flow @1:1-1:11 + expression_flow "b" @1:4-1:7 +"# +)] +#[case::jsx_unclosed_text("b", "error: Expected a closing tag for `` before the end of the content")] +#[case::jsx_mismatch( + "", + "error: Unexpected closing tag ``, expected corresponding closing tag for `` (1:1)" +)] +#[case::lt_space("a < b", r#"text "a < b" @1:1-1:6"#)] +#[case::lt_digit("a <3", "error: Unexpected character `3` (U+0033) before name")] +#[case::attr_no_value("", "error: Unexpected character `>` (U+003E) before attribute value")] +#[case::attr_space_after_eq("", r#"jsx_flow @1:1-1:12"#)] +#[case::attr_spaces_around_eq( + "a .", + r#" +text "a " @1:1-1:3 +jsx_text @1:3-1:15 +text "." @1:15-1:16 +"# +)] +#[case::attr_line_before_eq( + "c", + r#" +jsx_text @1:1-2:12 + text "c" @2:7-2:8 +"# +)] +#[case::attr_namespace_spaces( + "a c.", + r#" +text "a " @1:1-1:3 +jsx_text @1:3-2:24 + text "c" @2:19-2:20 +text "." @2:24-2:25 +"# +)] +#[case::attr_spaced_names( + "a .", + r#" +text "a " @1:1-1:3 +jsx_text @1:3-1:29 +text "." @1:29-1:30 +"# +)] +#[case::attr_expr_space_after_eq("", "jsx_flow @1:1-1:12")] +#[case::jsx_nested_flow( + "\n\n\n", + r#" +jsx_flow @1:1-4:5 + jsx_flow @2:1-3:5 +"# +)] +#[case::jsx_in_quote( + "> \n> x\n> ", + r#" +blockquote @1:1-3:7 + jsx_flow @1:3-3:7 + text "x" @2:3-2:4 +"# +)] +#[case::jsx_in_list( + "- \n x\n ", + r#" +list level=0 index=0 marker=- @1:3-3:7 + jsx_flow @1:3-3:7 + text "x" @2:3-2:4 +"# +)] +#[case::attr_quote_inside("", r#"jsx_flow @1:1-1:14"#)] +#[case::two_tags( + "", + r#" +jsx_flow @1:1-1:5 +jsx_flow @1:5-1:9 +"# +)] +#[case::two_text_elements( + "xy", + r#" +jsx_text @1:1-1:9 + text "x" @1:4-1:5 +jsx_text @1:9-1:17 + text "y" @1:12-1:13 +"# +)] +#[case::interrupt_paragraph_tag( + "a\n", + r#" +text "a" @1:1-1:2 +jsx_flow @2:1-2:5 +"# +)] +#[case::interrupt_paragraph_expr( + "a\n{b}", + r#" +text "a" @1:1-1:2 +expression_flow "b" @2:1-2:4 +"# +)] +#[case::two_expressions( + "{a}\n{b}", + r#" +expression_flow "a" @1:1-1:4 +expression_flow "b" @2:1-2:4 +"# +)] +#[case::expr_in_flow_element( + "\n\n{b}\n\n", + r#" +jsx_flow @1:1-5:5 + expression_flow "b" @3:1-3:4 +"# +)] +#[case::indented_tag(" ", "jsx_flow @1:4-1:8")] +#[case::very_indented_tag(" ", "jsx_flow @1:5-1:9")] +#[case::heading_jsx( + "# h b", + r#" +heading 1 @1:1-1:13 + text "h " @1:3-1:5 + jsx_text @1:5-1:13 + text "b" @1:8-1:9 +"# +)] +#[case::emphasis_crossing( + "*x*", + r#" +emphasis @1:1-1:11 + jsx_text @1:2-1:10 + text "x" @1:5-1:6 +"# +)] +#[case::emphasis_crossing_bad( + "*x*", + "error: Expected a closing tag for `` before the end of the content" +)] +#[case::link_jsx( + "[x](y)", + r#" +link "y" @1:1-1:14 + jsx_text @1:2-1:10 + text "x" @1:5-1:6 +"# +)] +#[case::code_not_jsx( + "`` {b}", + r#" +code_inline "" @1:1-1:7 +text " " @1:7-1:8 +expression_text "b" @1:8-1:11 +"# +)] +#[case::escaped("\\ \\{b}", r#"text " {b}" @1:1-1:11"#)] +#[case::attr_entities("", r#"jsx_flow @1:1-1:21"#)] +#[case::attr_multiline_literal("", r#"jsx_flow @1:1-2:6"#)] +#[case::closing_spaces( + "x", + r#" +jsx_text @1:1-1:13 + text "x" @1:6-1:7 +"# +)] +#[case::lt_space_name("< a>", r#"text "< a>" @1:1-1:5"#)] +#[case::self_closing_space("", "jsx_flow @1:1-1:7")] +#[case::attr_on_next_line("", "jsx_flow @1:1-2:5")] +#[case::dashed_name("", "jsx_flow @1:1-1:8")] +#[case::dashed_attr("", r#"jsx_flow @1:1-1:14"#)] +#[case::bad_name_char("", "error: Unexpected character `$` (U+0024) after name")] +#[case::expr_unclosed( + "{a", + "error: Unexpected end of file in expression, expected a corresponding closing brace for `{`" +)] +#[case::expr_empty("{}", r#"expression_flow "" @1:1-1:3"#)] +#[case::expr_space("{ }", r#"expression_flow " " @1:1-1:4"#)] +#[case::empty_flow_element("\n", "jsx_flow @1:1-2:5")] +#[case::text_element_unclosed_line( + "x\n", + "error: Expected a closing tag for `` before the end of the content" +)] +#[case::stray_close("", "error: Unexpected closing slash `/` in tag, expected an open tag first")] +#[case::stray_close_text("x ", "error: Unexpected closing slash `/` in tag, expected an open tag first")] +#[case::element_with_emphasis( + "*b*", + r#" +jsx_text @1:1-1:11 + emphasis @1:4-1:7 + text "b" @1:5-1:6 +"# +)] +#[case::flow_element_with_emphasis( + "\n*b*\n", + r#" +jsx_flow @1:1-3:5 + emphasis @2:1-2:4 + text "b" @2:2-2:3 +"# +)] +#[case::flow_element_with_list( + "\n- b\n", + r#" +jsx_flow @1:1-3:5 + list level=0 index=0 marker=- @2:3-2:4 + text "b" @2:3-2:4 +"# +)] +#[case::list_items_unbalanced( + "- \n- ", + "error: Expected a closing tag for `` (1:3) before the end of its container" +)] +#[case::spaced_equals("", r#"jsx_flow @1:1-1:14"#)] +#[case::heading( + "# h\n\ntext *a* [b](c) `d`\n", + r#" +heading 1 @1:1-1:4 + text "h" @1:3-1:4 +text "text " @3:1-3:6 +emphasis @3:6-3:9 + text "a" @3:7-3:8 +text " " @3:9-3:10 +link "c" @3:10-3:16 + text "b" @3:11-3:12 +text " " @3:16-3:17 +code_inline "d" @3:17-3:20 +"# +)] +#[case::indented_is_text(" not code\n", r#"text "not code" @1:5-1:13"#)] +#[case::indented_heading( + " # h\n", + r#" +heading 1 @1:1-1:9 + text "h" @1:8-1:9 +"# +)] +#[case::no_autolink("", "error: Unexpected character `/` (U+002F) before local name")] +#[case::no_gfm( + "| a |\n|-|\n\n~a~ www.a.b [^a]\n\n- [ ] a\n", + r#" +text "| a |\n|-|" @1:1-2:4 +text "~a~ www.a.b [^a]" @4:1-4:17 +list level=0 index=0 marker=- @6:3-6:8 + text "[ ] a" @6:3-6:8 +"# +)] +#[case::no_math( + "$a$\n\n$$\na\n$$\n", + r#" +text "$a$" @1:1-1:4 +text "$$\na\n$$" @3:1-5:3 +"# +)] +#[case::frontmatter("---\na\n---\n", r#"yaml "a" @1:1-3:4"#)] +#[case::frontmatter_toml( + "+++\na = 1\n+++\n\n", + r#" +toml "a = 1" @1:1-3:4 +jsx_flow @5:1-5:6 +"# +)] +#[case::frontmatter_unclosed( + "---\na\n", + r#" +hr marker='-' @1:1-1:4 +text "a" @2:1-2:2 +"# +)] +#[case::esm( + "import a from 'b'\n\nexport const c = 1\n", + r#" +esm "import a from 'b'" @1:1-1:18 +esm "export const c = 1" @3:1-3:19 +"# +)] +#[case::definition( + "[a]: /u\n\n[a]\n", + r#" +definition "a" "/u" label="a" @1:1-1:8 +link_ref "a" label="a" @3:1-3:4 + text "a" @3:2-3:3 +"# +)] +#[case::list_deep_indent( + "- a\n", + r#" +list level=0 index=0 marker=- @1:8-1:9 + text "a" @1:8-1:9 +"# +)] +#[case::fence("```rust\ncode\n```\n", r#"code "code" lang="rust" fence @1:1-3:4"#)] +#[case::lone_cr_after_lt("<\r/-x", r#"text "<\r/-x" @1:1-2:4"#)] +#[case::crlf_after_lt("<\r\n/-x", r#"text "<\r\n/-x" @1:1-2:4"#)] +#[case::expression_lone_cr("{\r }", r#"expression_flow "\r" @1:1-2:3"#)] +#[case::expression_cr_indent("{a\r b\r\n c\n d}", r#"expression_flow "a\r b\r\nc\nd" @1:1-4:5"#)] +#[case::tilde_not_delimiter("*~*a", r#"text "*~*a" @1:1-1:5"#)] +#[case::tilde_strong_not_delimiter("**~**あ", r#"text "**~**あ" @1:1-1:7"#)] +#[case::dollar_after_name("", "error: Unexpected character `$` (U+0024) after name")] +#[case::dollar_after_space("", "jsx_flow @1:1-1:8")] +#[case::cjk_tag_name("<あ b=\"c\" />", r#"jsx_flow <あ b="c"> @1:1-1:12"#)] +#[case::fullwidth_underscore_name("", "jsx_flow @1:1-1:8")] +#[case::jsx_text_cjk( + "あ い う", + r#" +text "あ " @1:1-1:3 +jsx_text @1:3-1:11 + text "い" @1:6-1:7 +text " う" @1:11-1:13 +"# +)] +#[case::expression_cjk( + "{あ} い", + r#" +expression_text "あ" @1:1-1:4 +text " い" @1:4-1:6 +"# +)] +fn mdx(#[case] input: &str, #[case] expected: &str) { + let actual = match Markdown::from_mdx_str(input) { + Ok(markdown) => tree(&markdown.nodes), + Err(error) => format!("error: {error}"), + }; + assert_eq!(actual, expected.trim_matches('\n'), "{input:?}"); +} diff --git a/crates/mq-run/src/cli.rs b/crates/mq-run/src/cli.rs index 605614fd9..2b4bae0f6 100644 --- a/crates/mq-run/src/cli.rs +++ b/crates/mq-run/src/cli.rs @@ -466,6 +466,12 @@ struct ProgramArgs { #[arg(long = "no-header", default_value_t = false)] no_header: bool, + /// Do not read frontmatter at the start of Markdown or MDX input: the lines between `---` or + /// `+++` are then text, a heading or a rule. Applies to `-I markdown` and `-I mdx`, and to + /// `.md` and `.mdx` files + #[arg(long = "no-frontmatter", default_value_t = false)] + no_frontmatter: bool, + /// Search modules from the directory #[arg(short = 'L', long = "directory")] module_directories: Option>, @@ -710,9 +716,9 @@ struct OutputArgs { #[clap(long, default_value_t = false, conflicts_with = "quiet")] unbuffered: bool, - /// Set the list style for markdown output - #[clap(long, value_enum, default_value_t = ListStyle::Dash, conflicts_with = "quiet")] - list_style: ListStyle, + /// Set the list style for markdown output (default: keep the original) + #[clap(long, value_enum, conflicts_with = "quiet")] + list_style: Option, /// Set the link title surround style for markdown output #[clap(long, value_enum, default_value_t = LinkTitleStyle::Double, conflicts_with = "quiet")] @@ -1809,6 +1815,7 @@ impl Cli { } self.validate_csv_options()?; + self.validate_frontmatter_option()?; self.check_output_permitted()?; match &self.commands { @@ -2044,6 +2051,20 @@ impl Cli { Ok(aggregate.map(|agg| format!("{} | {}", agg, query)).unwrap_or(query)) } + fn validate_frontmatter_option(&self) -> miette::Result<()> { + if self.input.program.no_frontmatter + && matches!(self.explicit_input_format(), Some(fmt) if !matches!(fmt, InputFormat::Markdown | InputFormat::Mdx)) + { + return Err(miette!("--no-frontmatter only applies to -I markdown or -I mdx")); + } + Ok(()) + } + + /// How Markdown and MDX input is read. + fn parse_options(&self) -> mq_markdown::ParseOptions { + mq_markdown::ParseOptions::default().with_frontmatter(!self.input.program.no_frontmatter) + } + fn validate_csv_options(&self) -> miette::Result<()> { if (self.input.program.csv_delimiter.is_some() || self.input.program.no_header) && matches!(self.explicit_input_format(), Some(fmt) if !matches!(fmt, InputFormat::Csv | InputFormat::Tsv | InputFormat::Psv)) @@ -2055,6 +2076,16 @@ impl Cli { Ok(()) } + /// Whether the input of `file` is read as MDX, so that what is written for it is escaped as MDX. + fn is_mdx_input(&self, file: &Option) -> bool { + match self.explicit_input_format() { + Some(format) => matches!(format, InputFormat::Mdx), + None => file + .as_ref() + .is_some_and(|file| matches!(InputFormat::from_path(file), InputFormat::Mdx)), + } + } + fn explicit_input_format(&self) -> Option { self.input .program @@ -2161,8 +2192,8 @@ impl Cli { } }) { // Native formats - InputFormat::Markdown => mq_lang::parse_markdown_input(text)?, - InputFormat::Mdx => mq_lang::parse_mdx_input(text)?, + InputFormat::Markdown => mq_lang::parse_markdown_input_with(text, self.parse_options())?, + InputFormat::Mdx => mq_lang::parse_mdx_input_with(text, self.parse_options())?, InputFormat::Html => mq_lang::parse_html_input(text)?, InputFormat::Text => mq_lang::parse_text_input(text)?, InputFormat::Null => mq_lang::null_input(), @@ -2226,7 +2257,7 @@ impl Cli { }; if self.quiet { - return self.print(runtime_values); + return self.print(runtime_values, self.is_mdx_input(file)); } if let Some(input) = grep_input { @@ -2240,7 +2271,7 @@ impl Cli { )?; grep::print_grep(runtime_values, &input, file, handle, before, after) } else { - self.print(runtime_values) + self.print(runtime_values, self.is_mdx_input(file)) } } @@ -2632,7 +2663,7 @@ impl Cli { vec![mq_lang::RuntimeValue::String(Shared::new("".to_string()))].into_iter(), ) .map_err(|e| *e)?; - self.print(separator)?; + self.print(separator, false)?; } self.emit_results(runtime_values, grep_input, file) @@ -3044,15 +3075,15 @@ impl Cli { value } - fn build_markdown(&self, runtime_values: &[mq_lang::RuntimeValue]) -> mq_markdown::Markdown { + fn build_markdown(&self, runtime_values: &[mq_lang::RuntimeValue], mdx: bool) -> mq_markdown::Markdown { let mut markdown = mq_markdown::Markdown::new(runtime_values.iter().flat_map(Self::runtime_value_to_nodes).collect()); markdown.set_options(mq_markdown::RenderOptions { - list_style: match self.output.list_style.clone() { + list_style: self.output.list_style.clone().map(|style| match style { ListStyle::Dash => mq_markdown::ListStyle::Dash, ListStyle::Plus => mq_markdown::ListStyle::Plus, ListStyle::Star => mq_markdown::ListStyle::Star, - }, + }), link_title_style: match self.output.link_title_style.clone() { LinkTitleStyle::Double => mq_markdown::TitleSurroundStyle::Double, LinkTitleStyle::Single => mq_markdown::TitleSurroundStyle::Single, @@ -3062,6 +3093,7 @@ impl Cli { LinkUrlStyle::None => mq_markdown::UrlSurroundStyle::None, LinkUrlStyle::Angle => mq_markdown::UrlSurroundStyle::Angle, }, + mdx, }); markdown } @@ -3069,7 +3101,7 @@ impl Cli { /// Renders `runtime_values` to a byte buffer without writing anywhere. /// `emit_diff` always passes `colorize: false` — it needs plain text to diff /// against the uncolored original, and colors the diff lines itself. - fn render(&self, runtime_values: &[mq_lang::RuntimeValue], colorize: bool) -> miette::Result> { + fn render(&self, runtime_values: &[mq_lang::RuntimeValue], colorize: bool, mdx: bool) -> miette::Result> { let mut buf = Vec::new(); match self.resolved_output_format() { @@ -3095,20 +3127,20 @@ impl Cli { } } OutputFormat::Html => { - let markdown = self.build_markdown(runtime_values); + let markdown = self.build_markdown(runtime_values, mdx); buf.extend_from_slice(markdown.to_html().as_bytes()); } OutputFormat::Text => { - let markdown = self.build_markdown(runtime_values); + let markdown = self.build_markdown(runtime_values, mdx); buf.extend_from_slice(markdown.to_text().as_bytes()); } OutputFormat::Markdown if colorize => { - let markdown = self.build_markdown(runtime_values); + let markdown = self.build_markdown(runtime_values, mdx); let theme = mq_markdown::ColorTheme::from_env(); buf.extend_from_slice(markdown.to_colored_string_with_theme(&theme).as_bytes()); } OutputFormat::Markdown => { - let markdown = self.build_markdown(runtime_values); + let markdown = self.build_markdown(runtime_values, mdx); buf.extend_from_slice(markdown.to_string().as_bytes()); } OutputFormat::Table => { @@ -3117,7 +3149,7 @@ impl Cli { buf.extend_from_slice(format!("{}\n", table).as_bytes()); } OutputFormat::Grep => { - let markdown = self.build_markdown(runtime_values); + let markdown = self.build_markdown(runtime_values, mdx); buf.extend_from_slice(markdown.to_string().as_bytes()); } OutputFormat::Gron => { @@ -3156,7 +3188,7 @@ impl Cli { Ok(buf) } - fn print(&self, runtime_values: mq_lang::RuntimeValues) -> miette::Result<()> { + fn print(&self, runtime_values: mq_lang::RuntimeValues, mdx: bool) -> miette::Result<()> { let stripped_values: Option> = self.output.no_position.then(|| { runtime_values .values() @@ -3185,7 +3217,7 @@ impl Cli { )?; let colorize = self.output.color_output && !Self::is_no_color(); - let buf = self.render(runtime_values, colorize)?; + let buf = self.render(runtime_values, colorize, mdx)?; Self::write_ignore_pipe(&mut handle, &buf)?; handle.finish()?; @@ -3200,7 +3232,7 @@ impl Cli { content: &ContentData, ) -> miette::Result<()> { let original = content.as_str().unwrap_or(""); - let rendered = self.render(runtime_values.values(), false)?; + let rendered = self.render(runtime_values.values(), false, self.is_mdx_input(file))?; let rendered = String::from_utf8_lossy(&rendered); if original != rendered { @@ -4107,7 +4139,7 @@ mod tests { let cli = Cli { input: InputArgs::default(), output: OutputArgs { - list_style: style.clone(), + list_style: Some(style.clone()), ..Default::default() }, commands: None, @@ -5949,6 +5981,65 @@ mod tests { assert_eq!(cli.tabular_query_prefix(&InputFormat::Json), None); } + fn frontmatter_cli(format: Option, no_frontmatter: bool) -> Cli { + Cli { + input: InputArgs { + program: ProgramArgs { + input_format: format, + no_frontmatter, + ..Default::default() + }, + ..Default::default() + }, + ..Cli::default() + } + } + + #[rstest] + #[case::markdown(InputFormat::Markdown)] + #[case::mdx(InputFormat::Mdx)] + fn test_frontmatter_is_read_unless_turned_off(#[case] format: InputFormat) { + let input = ContentData::Text("---\ntitle: x\n---\n\ntext".to_string()); + let names = |cli: &Cli| -> Vec { + cli.resolve_input(&None, &input) + .unwrap() + .iter() + .map(|value| match value { + mq_lang::RuntimeValue::Markdown(node, _) => node.name().to_string(), + other => other.to_string(), + }) + .collect() + }; + + assert_eq!(names(&frontmatter_cli(Some(format.clone()), false)), ["yaml", "text"]); + assert_eq!( + names(&frontmatter_cli(Some(format), true)), + ["Horizontal_rule", "h2", "text"] + ); + } + + #[rstest] + #[case::markdown(Some(InputFormat::Markdown), true)] + #[case::mdx(Some(InputFormat::Mdx), true)] + #[case::by_extension(None, true)] + #[case::csv(Some(InputFormat::Csv), false)] + #[case::json(Some(InputFormat::Json), false)] + fn test_no_frontmatter_applies_to_markdown_and_mdx(#[case] format: Option, #[case] valid: bool) { + assert_eq!( + frontmatter_cli(format, true).validate_frontmatter_option().is_ok(), + valid + ); + } + + #[test] + fn test_no_frontmatter_is_not_checked_when_it_is_not_given() { + assert!( + frontmatter_cli(Some(InputFormat::Csv), false) + .validate_frontmatter_option() + .is_ok() + ); + } + #[test] fn test_json_auto_parse() { let (_, temp_file_path) = create_file("auto_parse_test.json", r#"{"key": "value"}"#); diff --git a/crates/mq-run/src/output/table.rs b/crates/mq-run/src/output/table.rs index c0b1950d9..bc804457c 100644 --- a/crates/mq-run/src/output/table.rs +++ b/crates/mq-run/src/output/table.rs @@ -410,7 +410,7 @@ mod tests { #[test] fn test_table_all_markdown_nodes() { let node = mq_markdown::Node::Heading(mq_markdown::Heading { - depth: 1, + depth: mq_markdown::HeadingDepth::H1, values: vec![mq_markdown::Node::Text(mq_markdown::Text { value: "Title".to_string(), position: None, diff --git a/crates/mq-run/tests/cookbook_tests.rs b/crates/mq-run/tests/cookbook_tests.rs index 01c8234a0..155839baf 100644 --- a/crates/mq-run/tests/cookbook_tests.rs +++ b/crates/mq-run/tests/cookbook_tests.rs @@ -332,7 +332,7 @@ fn cookbook_extract_mdx_components() { let out = run(&["-I", "mdx", "select(is_mdx())", path.to_str().unwrap()]); assert_eq!( out.trim(), - "\nContent" + "\n\n Content\n" ); } diff --git a/crates/mq-run/tests/integration_tests.rs b/crates/mq-run/tests/integration_tests.rs index e57fc3da7..a58da0958 100644 --- a/crates/mq-run/tests/integration_tests.rs +++ b/crates/mq-run/tests/integration_tests.rs @@ -230,7 +230,8 @@ In {year}, the snowfall was above average. "##, - Some(r##"{Chart} + Some(r##"import {Chart} from './snowfall.js' +export const year = 2023 {year} @@ -796,7 +797,9 @@ In {year}, the snowfall was above average. assert .success() .code(0) - .stdout("{Chart}\n{year}\n\n\n"); + .stdout( + "import {Chart} from './snowfall.js'\nexport const year = 2023\n{year}\n\n\n", + ); Ok(()) } diff --git a/crates/mq-wasm/src/script.rs b/crates/mq-wasm/src/script.rs index b91e8532c..20d785e73 100644 --- a/crates/mq-wasm/src/script.rs +++ b/crates/mq-wasm/src/script.rs @@ -688,14 +688,11 @@ pub async fn run(code: &str, content: &str, options: JsValue) -> Result mq_markdown::ListStyle::Dash, - ListStyle::Plus => mq_markdown::ListStyle::Plus, - ListStyle::Star => mq_markdown::ListStyle::Star, - }) - .unwrap_or_default(), + list_style: options.list_style.map(|style| match style { + ListStyle::Dash => mq_markdown::ListStyle::Dash, + ListStyle::Plus => mq_markdown::ListStyle::Plus, + ListStyle::Star => mq_markdown::ListStyle::Star, + }), link_title_style: options .link_title_style .map(|style| match style { @@ -711,6 +708,7 @@ pub async fn run(code: &str, content: &str, options: JsValue) -> Result mq_markdown::UrlSurroundStyle::None, }) .unwrap_or_default(), + mdx: matches!(options.input_format, Some(InputFormat::Mdx)), }); markdown.to_string() }) diff --git a/docs/books/src/SUMMARY.md b/docs/books/src/SUMMARY.md index 9e09d40eb..098ef0ea7 100644 --- a/docs/books/src/SUMMARY.md +++ b/docs/books/src/SUMMARY.md @@ -15,6 +15,7 @@ - [Language Bindings](start/language_bindings.md) - [Embed mq in Rust](start/embedding-rust.md) - [Example](start/example.md) + - [Input and Output Behavior](start/input-and-output.md) - [Modules](start/modules.md) - [Compiled Programs (Experimental)](start/compiled-programs.md) - [Cookbook](cookbook/index.md) diff --git a/docs/books/src/cookbook/extract-mdx-components.md b/docs/books/src/cookbook/extract-mdx-components.md index a88554d3e..639ecce44 100644 --- a/docs/books/src/cookbook/extract-mdx-components.md +++ b/docs/books/src/cookbook/extract-mdx-components.md @@ -32,7 +32,9 @@ Another paragraph. ```markdown -Content + + Content + ``` ## Notes diff --git a/docs/books/src/sitemap.xml b/docs/books/src/sitemap.xml index 557772002..88bdd2bc9 100644 --- a/docs/books/src/sitemap.xml +++ b/docs/books/src/sitemap.xml @@ -68,6 +68,10 @@ https://mqlang.org/book/start/example 1.0 + + https://mqlang.org/book/start/input-and-output + 1.0 + https://mqlang.org/book/start/modules 1.0 diff --git a/docs/books/src/start/input-and-output.md b/docs/books/src/start/input-and-output.md new file mode 100644 index 000000000..61783b6d0 --- /dev/null +++ b/docs/books/src/start/input-and-output.md @@ -0,0 +1,90 @@ +# Input and Output Behavior + +How mq reads Markdown and writes it back, and the options that change it. For the full list of flags, see the [CLI reference](../reference/cli.md). + +## HTML output + +`-F html` renders the result as HTML. + +### Callouts + +A block quote that starts with `[!TYPE]` is a callout. It becomes a `div` with the class `callout` and the type in `data-callout`. A fold marker (`-` or `+`) makes it a `details` element instead, open when the marker is `+`. + +```markdown +> [!NOTE] Title +> body + +> [!TIP]- Folded +> hidden +``` + +```html +
    +
    Title
    +
    +

    body

    +
    +
    +
    +Folded +
    +

    hidden

    +
    +
    +``` + +### Wikilinks and embeds + +`[[target]]` and `[[target|text]]` become links with the class `internal-link`. The target is used as the `href` as written. `![[target]]` becomes an `img`, `audio`, `video` or `embed` element when the file is an image, audio, video or PDF, and a link with the classes `internal-link embed` for a note. + +### Raw HTML + +Raw HTML in the input is kept, except for the tags that GFM filters: ` +``` + +```html +<script>alert(1)</script> +``` + +## List style + +Without `--list-style`, each list keeps the marker it was written with (`-`, `+` or `*`). Pass `--list-style dash`, `plus` or `star` to write every list with one marker. + +```sh +$ printf '* a\n* b\n' | mq '.' +* a +* b +$ printf '* a\n* b\n' | mq --list-style dash '.' +- a +- b +``` + +The `listStyle` option of `mq-web` and `mq-nodejs` works the same way: leave it out to keep the markers. + +## Frontmatter + +A document that starts with a `---` line (or `+++`) and has a closing line is read with frontmatter. The frontmatter is not part of the rendered HTML, and `.yaml` and `.toml` select it. + +Pass `--no-frontmatter` for a document that only happens to start with `---`, such as one that opens with a horizontal rule. The lines are then read as Markdown: the first `---` is a rule, and the text up to the next `---` is a paragraph or a heading. + +```sh +$ cat doc.md +--- +not: frontmatter +--- +body +$ mq '.' doc.md +--- +not: frontmatter +--- +body +$ mq --no-frontmatter '.' doc.md +--- +## not: frontmatter +body +``` + +`--no-frontmatter` applies to Markdown and MDX input. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 1ea106ec3..2e8e73605 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,6 +13,7 @@ arbitrary = { workspace = true } itertools = { workspace = true } libfuzzer-sys = { workspace = true } mq-lang = { workspace = true, features = ["mqc"] } +mq-markdown = { workspace = true, features = ["json", "obsidian"] } sha2 = { workspace = true } [lib] @@ -31,3 +32,17 @@ doc = false name = "mqc" path = "fuzz_targets/mqc.rs" test = false + +[[bin]] +bench = false +doc = false +name = "markdown" +path = "fuzz_targets/markdown.rs" +test = false + +[[bin]] +bench = false +doc = false +name = "markdown_scaling" +path = "fuzz_targets/markdown_scaling.rs" +test = false diff --git a/fuzz/README.md b/fuzz/README.md index 0408d9eea..47112e98d 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -15,3 +15,14 @@ just test-fuzz mqc ``` The `mqc` target compiles a known program, overwrites bytes of the file, and recomputes its checksum so the input reaches the decoder and the bytecode verifier. A file that loads must then run without panicking, since the VM trusts verified bytecode. + +Run the Markdown parser targets: + +```sh +just test-fuzz markdown +just test-fuzz markdown_scaling +``` + +`markdown` reads generated Markdown and MDX and renders it as Markdown, HTML, text and JSON, so a panic, an abort or a stack overflow is reported. A document is put together from markup tokens, repeats of one token and free text, which reaches nesting and repeated constructs that random bytes seldom do. + +`markdown_scaling` repeats the document to 16 KiB and to four times that, and fails when the second takes more than 12 times as long as the first. Linear work takes four times as long and quadratic work sixteen times. A reported input is slow in itself, so check it with `cargo fuzz run markdown_scaling `. diff --git a/fuzz/fuzz_targets/markdown.rs b/fuzz/fuzz_targets/markdown.rs new file mode 100644 index 000000000..394eb2ffa --- /dev/null +++ b/fuzz/fuzz_targets/markdown.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use mq_fuzz::MarkdownInput; + +fuzz_target!(|input: MarkdownInput| { + mq_fuzz::read_markdown(&input); +}); diff --git a/fuzz/fuzz_targets/markdown_scaling.rs b/fuzz/fuzz_targets/markdown_scaling.rs new file mode 100644 index 000000000..b4aba9d7c --- /dev/null +++ b/fuzz/fuzz_targets/markdown_scaling.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use mq_fuzz::MarkdownInput; + +fuzz_target!(|input: MarkdownInput| { + mq_fuzz::check_markdown_scaling(&input); +}); diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs index e2ad227e8..cb685fb94 100644 --- a/fuzz/src/lib.rs +++ b/fuzz/src/lib.rs @@ -1,5 +1,6 @@ //! Shared input generation for the fuzz targets: `interpreter` feeds generated scripts -//! through [`mq_lang::DefaultEngine`], and `mqc` loads corrupted `.mqc` files. +//! through [`mq_lang::DefaultEngine`], `mqc` loads corrupted `.mqc` files, and `markdown` and +//! `markdown_scaling` read and render generated Markdown and MDX. use arbitrary::Arbitrary; use itertools::Itertools; @@ -176,3 +177,213 @@ pub fn load_and_run_mqc(input: &MqcInput) { let _ = engine.eval_compiled(&program, input.into_iter()); } } + +/// Markup that the `markdown` targets join into documents, so that they reach the block and inline +/// rules that random bytes seldom do. +const MARKDOWN_TOKENS: &[&str] = &[ + "\n", + "\n\n", + "\r\n", + "\r", + " ", + " ", + " ", + "\t", + " \n", + "\\", + "\0", + "\u{feff}", + "# ", + "###### ", + "> ", + ">", + "- ", + "* ", + "+ ", + "1. ", + "1) ", + "- [ ] ", + "- [x] ", + "---", + "***", + "___", + "===", + "+++\n", + "---\n", + "```", + "~~~", + "``", + "`", + "*", + "**", + "_", + "__", + "~", + "~~", + "[", + "]", + "](", + ")", + "![", + "[^1]", + "[^1]: ", + "[a]: ", + "<", + ">", + "", + "
    ", + "
    ", + "", + "", + "
    ", + "https://a.b", + "www.a.b", + "a@b.c", + "|", + "| --- |", + "| :-: |", + "&", + "#", + "�", + "&bogus;", + "$", + "$$", + "{", + "}", + "{...a}", + " [!NOTE]\n", + "> [!", + "#tag", + "^id", + "%%", + "a", + "b", + "0", + "é", + "日本語", +]; + +/// The longest source a generated input makes, so that repeats do not slow each run down. +const MARKDOWN_MAX_LEN: usize = 64 * 1024; + +/// The longest source that is written as JSON. The output is pretty printed, so nested nodes make it a +/// few thousand times the source (165 MB for 64 KiB of `[^1]: `), which fills the memory of a run. +const JSON_MAX_LEN: usize = 8 * 1024; + +#[derive(Debug, Clone, Arbitrary)] +enum MarkdownPart { + Token(u8), + Repeat(u8, u8), + Text(String), +} + +/// A Markdown or MDX document put together from markup tokens, repeats of one token and free text. +#[derive(Debug, Clone, Arbitrary)] +pub struct MarkdownInput { + mdx: bool, + frontmatter: bool, + parts: Vec, +} + +impl MarkdownInput { + fn source(&self) -> String { + let token = |index: u8| MARKDOWN_TOKENS[usize::from(index) % MARKDOWN_TOKENS.len()]; + let mut source = String::new(); + for part in &self.parts { + match part { + MarkdownPart::Token(index) => source.push_str(token(*index)), + MarkdownPart::Repeat(index, count) => { + for _ in 0..*count { + source.push_str(token(*index)); + } + } + MarkdownPart::Text(text) => source.push_str(text), + } + if source.len() >= MARKDOWN_MAX_LEN { + break; + } + } + source + } +} + +/// Reads `source` and writes it as Markdown, HTML, text and JSON. A `Result::Err` from the reader is +/// fine (MDX reports unclosed expressions and elements); a panic, an abort or a stack overflow is not. +fn read_and_render(source: &str, mdx: bool, frontmatter: bool) { + use mq_markdown::{Markdown, ParseOptions}; + + let options = ParseOptions::default().with_frontmatter(frontmatter); + let _ = mq_markdown::to_html(source); + let parsed = if mdx { + Markdown::from_mdx_str_with(source, options) + } else { + Markdown::from_markdown_str_with(source, options) + }; + if let Ok(markdown) = parsed { + let _ = markdown.to_string(); + let _ = markdown.to_html(); + let _ = markdown.to_text(); + if source.len() <= JSON_MAX_LEN { + let _ = markdown.to_json(); + } + } +} + +/// Reads and renders the generated document. +pub fn read_markdown(input: &MarkdownInput) { + read_and_render(&input.source(), input.mdx, input.frontmatter); +} + +/// Fails when the time to read and render a document grows faster than its size. +/// +/// The document, cut at `BASE_LEN` bytes, is repeated up to that length and again to four times that. Work that is linear +/// takes four times as long, quadratic work sixteen times; the check allows `MAX_RATIO`. A run that +/// is over is measured once more, as a single run can hit a pause. +pub fn check_markdown_scaling(input: &MarkdownInput) { + use std::time::{Duration, Instant}; + + const BASE_LEN: usize = 16 * 1024; + const MAX_RATIO: u32 = 12; + const FLOOR: Duration = Duration::from_millis(1); + + let source = input.source(); + let end = (0..=BASE_LEN.min(source.len())) + .rev() + .find(|&end| source.is_char_boundary(end)) + .unwrap_or(0); + let source = &source[..end]; + if source.is_empty() { + return; + } + let base = source.repeat(BASE_LEN.div_ceil(source.len())); + let large = base.repeat(4); + let time = |text: &str| { + let start = Instant::now(); + read_and_render(text, input.mdx, input.frontmatter); + start.elapsed() + }; + + let base_time = time(&base).min(time(&base)).max(FLOOR); + let allowed = base_time * MAX_RATIO; + if time(&large) > allowed && time(&large) > allowed { + panic!( + "time grows faster than the size: {base_time:?} for {} bytes, more than {allowed:?} for {} bytes", + base.len(), + large.len() + ); + } +} diff --git a/justfile b/justfile index 68fb12130..639c30204 100644 --- a/justfile +++ b/justfile @@ -124,6 +124,11 @@ bench-check-phases: test-gfm-spec: cargo test -p mq-markdown --test gfm_roundtrip_fidelity -- --ignored --nocapture +# Render every example of the CommonMark, GFM and GFM extensions specs (fetches them over the network) +test-spec: + cargo test -p mq-markdown --lib -- --ignored spec_examples + cargo test -p mq-markdown --lib --features obsidian -- --ignored spec_examples + test-doc: cargo test --doc --workspace diff --git a/packages/mq-nodejs/README.md b/packages/mq-nodejs/README.md index 10b8f6545..81323c2c6 100644 --- a/packages/mq-nodejs/README.md +++ b/packages/mq-nodejs/README.md @@ -150,7 +150,7 @@ const markdown = `- Apple - Banana - Cherry`; -// Change list style +// Change list style (without listStyle, each list keeps the marker it was written with) const starList = await run(".[]", markdown, { listStyle: "star" }); // Output: * Apple\n* Banana\n* Cherry diff --git a/packages/mq-nodejs/src/core.ts b/packages/mq-nodejs/src/core.ts index aaae146e1..08cb8679d 100644 --- a/packages/mq-nodejs/src/core.ts +++ b/packages/mq-nodejs/src/core.ts @@ -61,7 +61,7 @@ export async function run( return await wasmModule.run(code, content, { isUpdate: false, inputFormat: "markdown", - listStyle: "dash", + listStyle: null, linkUrlStyle: "none", linkTitleStyle: "paren", ...options, diff --git a/packages/mq-nodejs/test/index.test.ts b/packages/mq-nodejs/test/index.test.ts index 409d044df..95bf38763 100644 --- a/packages/mq-nodejs/test/index.test.ts +++ b/packages/mq-nodejs/test/index.test.ts @@ -16,6 +16,15 @@ describe("run", () => { expect(result).toBe("- item 1\n- item 2\n"); }); + it.each([ + ["-", "- a\n- b\n"], + ["*", "* a\n* b\n"], + ["+", "+ a\n+ b\n"], + ])("keeps the list marker %s without a list style", async (_marker, markdown) => { + const result = await run(".[]", markdown); + expect(result).toBe(markdown); + }); + it("changes list style to star", async () => { const markdown = "- First item\n- Second item\n- Third item"; const result = await run(".[]", markdown, { listStyle: "star" }); diff --git a/packages/mq-web/README.md b/packages/mq-web/README.md index 447137740..a7b3b1bf5 100644 --- a/packages/mq-web/README.md +++ b/packages/mq-web/README.md @@ -124,7 +124,7 @@ const markdown = `- Apple - Banana - Cherry`; -// Change list style +// Change list style (without listStyle, each list keeps the marker it was written with) const starList = await run(".[]", markdown, { listStyle: "star" }); // Output: * Apple\n* Banana\n* Cherry diff --git a/packages/mq-web/src/core.ts b/packages/mq-web/src/core.ts index 0b85e85a6..2fcd2e7bb 100644 --- a/packages/mq-web/src/core.ts +++ b/packages/mq-web/src/core.ts @@ -80,7 +80,7 @@ export async function run( return await wasm.run(code, content, { isUpdate: false, inputFormat: "markdown", - listStyle: "dash", + listStyle: null, linkUrlStyle: "none", linkTitleStyle: "paren", ...options, diff --git a/packages/mq-web/test/index.test.ts b/packages/mq-web/test/index.test.ts index 409d044df..95bf38763 100644 --- a/packages/mq-web/test/index.test.ts +++ b/packages/mq-web/test/index.test.ts @@ -16,6 +16,15 @@ describe("run", () => { expect(result).toBe("- item 1\n- item 2\n"); }); + it.each([ + ["-", "- a\n- b\n"], + ["*", "* a\n* b\n"], + ["+", "+ a\n+ b\n"], + ])("keeps the list marker %s without a list style", async (_marker, markdown) => { + const result = await run(".[]", markdown); + expect(result).toBe(markdown); + }); + it("changes list style to star", async () => { const markdown = "- First item\n- Second item\n- Third item"; const result = await run(".[]", markdown, { listStyle: "star" });