Packages
mdex
0.13.2
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.5
retired
0.12.4
0.12.3
0.12.2
0.12.1
0.12.0
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.2
0.6.1
0.6.0
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.13
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Fast and extensible Markdown for Elixir
Current section
Files
Jump to
Current section
Files
lib/mdex/fragment_parser.ex
defmodule MDEx.FragmentParser do
@moduledoc false
import NimbleParsec
@ws_chars [9, 10, 11, 12, 13, 32]
@bullet_markers ["* ", "*\t", "+ ", "+\t", "- ", "-\t"]
@task_markers ["- [ ] ", "- [ ]\t", "- [x] ", "- [x]\t", "- [X] ", "- [X]\t"]
@ordered_delims [". ", ".\t", ") ", ")\t"]
@tokens ["**", "__", "~~", "*", "_", "~"]
@fence_chars ["`", "~"]
defcombinatorp(:space_prefix, ascii_string([32], min: 0, max: 3))
defcombinatorp(:bullet_marker, choice(Enum.map(@bullet_markers, &string/1)))
defcombinatorp(:task_marker, choice(Enum.map(@task_markers, &string/1)))
defcombinatorp(
:ordered_marker,
ascii_string([?0..?9], min: 1)
|> concat(choice(Enum.map(@ordered_delims, &string/1)))
)
defcombinatorp(
:list_marker_prefix,
parsec(:space_prefix)
|> concat(choice([parsec(:task_marker), parsec(:bullet_marker), parsec(:ordered_marker)]))
)
defparsecp(:list_prefix_parser, parsec(:list_marker_prefix))
defmodule State do
@moduledoc false
defstruct last_unclosed_token: nil, last_flush_ended_with_newline: true, pending_html: nil
@type t() :: %__MODULE__{
last_unclosed_token: String.t() | nil,
last_flush_ended_with_newline: boolean(),
pending_html: String.t() | nil
}
end
@spec complete_with_state(String.t(), State.t() | nil) :: {String.t(), State.t()}
def complete_with_state(raw_fragment, state) do
state = state || %State{}
prefix = state.last_unclosed_token || ""
fragment = (state.pending_html || "") <> raw_fragment
{completed, pending_html} = complete_fragment(fragment, prefix: prefix, preserve_pending_html: true)
# Detect what token we closed (if any) to pass as prefix next time
new_unclosed = extract_unclosed_token(fragment, prefix)
new_state = %{
state
| last_unclosed_token: new_unclosed,
last_flush_ended_with_newline: trailing_newline?(raw_fragment),
pending_html: pending_html
}
{completed, new_state}
end
defp trailing_newline?(<<>>), do: false
defp trailing_newline?(binary), do: :binary.last(binary) in [?\n, ?\r]
defp extract_unclosed_token(fragment, prefix) do
full = if prefix != "", do: prefix <> fragment, else: fragment
suffix = unmatched_suffix(full)
if suffix != "", do: suffix, else: nil
end
@spec complete(String.t(), keyword()) :: String.t()
def complete(fragment, options \\ []) do
{completed, _pending_html} = complete_fragment(fragment, options)
completed
end
defp complete_fragment(fragment, options) do
prefix = options[:prefix] || ""
if list_marker_line?(fragment) do
{trailing_ws, core_with_leading} = split_trailing_ws(fragment)
{completed, flag, pending_html} = complete_core(core_with_leading, prefix, trailing_ws, options)
{completed <> adjust_trailing(trailing_ws, flag), pending_html}
else
{leading, core, trailing} = split_ws(fragment)
leading = maybe_preserve_leading(leading)
{completed, flag, pending_html} = complete_core(core, prefix, trailing, options)
{leading <> completed <> adjust_trailing(trailing, flag), pending_html}
end
end
defp split_trailing_ws(binary) do
{core, trailing} = take_trailing_ws(binary)
{trailing, core}
end
defp split_ws(binary) do
{leading, rest} = take_leading_ws(binary)
{core, trailing} = take_trailing_ws(rest)
{leading, core, trailing}
end
defp take_leading_ws(binary) do
size = byte_size(binary)
leading_size = leading_ws_size(binary, size, 0)
{
binary_part(binary, 0, leading_size),
binary_part(binary, leading_size, size - leading_size)
}
end
defp take_trailing_ws(binary) do
size = byte_size(binary)
trailing_size = trailing_ws_size(binary, size - 1, 0)
core_size = size - trailing_size
{
binary_part(binary, 0, core_size),
binary_part(binary, core_size, trailing_size)
}
end
defp adjust_trailing(trailing, {:consume_trailing, consumed}) when consumed != "" do
if String.starts_with?(trailing, consumed) do
drop_prefix(trailing, consumed)
else
trailing
end
end
defp adjust_trailing(trailing, _flag), do: trailing
defp complete_core("", _prefix, _trailing, _options), do: {"", :none, nil}
defp complete_core(core, prefix, trailing, options) do
{completed, flag} =
with nil <- maybe_complete_fence(core, trailing, options),
nil <- maybe_complete_table(core, trailing),
nil <- maybe_complete_math(core, trailing) do
complete_core_text(core, prefix, trailing)
end
{completed, pending_html} = strip_incomplete_html(completed, options[:preserve_pending_html] == true)
{completed, flag, pending_html}
end
defp complete_core_text(core, prefix, trailing) do
line = last_line(core)
cond do
skip_completion?(core, line) ->
{core, :none}
unmatched_backtick?(core) ->
complete_backtick(core, prefix)
true ->
complete_inline(core, prefix, trailing, line)
end
end
defp complete_backtick(core, prefix) do
if prefix_has_open_backtick?(prefix) and String.ends_with?(core, "`") do
{prefix <> core, :none}
else
{core <> "`", :backtick_appended}
end
end
defp complete_inline(core, prefix, trailing, line) do
line_suffix = unmatched_suffix(line)
opening_append = opening_completion_append(core, line_suffix)
cond do
opening_append != "" -> {core <> opening_append, :none}
closer = closing_token(core) -> close_with_prefix(core, prefix, closer)
list_marker_line?(line) -> {complete_list_line(core, line), :none}
true -> complete_incomplete_link(core, trailing, line_suffix)
end
end
defp close_with_prefix(core, prefix, closer) do
if prefix != "" and prefix == closer do
{prefix <> core, :none}
else
{core, :none}
end
end
defp complete_incomplete_link(core, trailing, line_suffix) do
case incomplete_link_completion(core, trailing) do
nil -> if line_suffix != "", do: {core <> line_suffix, :none}, else: {core, :none}
completion -> {completion, :none}
end
end
defp maybe_complete_fence(core, trailing, _options) do
case unclosed_fence_info(core) do
nil ->
nil
info ->
complete_fence(core, trailing, info, fence_partial_closing_gap(core, info))
end
end
defp complete_fence(core, _trailing, _info, {:append, append}), do: {core <> append, :none}
defp complete_fence(core, trailing, %{indent: indent, char: char, run: run}, :none) do
# reuse a trailing newline when available so the appended delimiter matches expectations
{consumed, _remaining?} = consume_trailing_for_fence(trailing)
closing_line = indent <> String.duplicate(char, run)
append = fence_leading_segment(core, consumed) <> closing_line
flag = if consumed == "", do: :none, else: {:consume_trailing, consumed}
{core <> append, flag}
end
defp fence_leading_segment(_core, consumed) when consumed != "", do: consumed
defp fence_leading_segment(core, _consumed), do: if(String.ends_with?(core, "\n"), do: "", else: "\n")
defp fence_partial_closing_gap(core, %{indent: indent, char: char, run: run}) do
case parse_fence_line(last_line(core)) do
%{indent: ^indent, char: ^char, run: partial_run, rest: rest}
when partial_run > 0 and partial_run < run ->
if only_spaces?(rest) do
missing = run - partial_run
{:append, String.duplicate(char, missing)}
else
:none
end
_ ->
:none
end
end
defp unclosed_fence_info(core) do
core
|> String.split("\n", trim: false)
|> find_unclosed_fence(0, nil)
|> normalize_fence_info()
end
defp find_unclosed_fence([], _index, open_fence), do: open_fence
defp find_unclosed_fence([line | rest], index, open_fence) do
case parse_fence_line(line) do
%{run: run} = info when run >= 3 ->
updated_info = Map.merge(info, %{line_index: index, has_body: false})
cond do
open_fence == nil ->
find_unclosed_fence(rest, index + 1, updated_info)
closes_open_fence?(updated_info, open_fence) ->
find_unclosed_fence(rest, index + 1, nil)
true ->
find_unclosed_fence(rest, index + 1, updated_info)
end
_ ->
open_fence = maybe_mark_body(open_fence, index)
find_unclosed_fence(rest, index + 1, open_fence)
end
end
defp maybe_mark_body(nil, _index), do: nil
defp maybe_mark_body(%{line_index: line_index} = info, index) when index > line_index do
Map.put(info, :has_body, true)
end
defp maybe_mark_body(info, _index), do: info
defp normalize_fence_info(nil), do: nil
defp normalize_fence_info(%{has_body: false}), do: nil
defp normalize_fence_info(info), do: Map.drop(info, [:has_body])
defp closes_open_fence?(%{indent: indent, char: char, run: run, rest: rest}, %{indent: indent, char: char, run: open_run}) do
run >= open_run and only_spaces?(rest)
end
defp parse_fence_line(line) do
{indent, rest} = take_fence_indent(line)
with <<char_code, _::binary>> <- rest,
char = <<char_code>>,
true <- char in @fence_chars do
run = count_fence_run(rest, char_code, 0)
remainder = binary_part(rest, run, byte_size(rest) - run)
%{indent: indent, char: char, run: run, rest: remainder}
else
_ -> nil
end
end
defp take_fence_indent(line), do: take_fence_indent(line, 0)
defp take_fence_indent(<<" "::utf8, rest::binary>>, count) when count < 3 do
take_fence_indent(rest, count + 1)
end
defp take_fence_indent(rest, count) do
{String.duplicate(" ", count), rest}
end
defp count_fence_run(<<char_code, rest::binary>>, char_code, acc) do
count_fence_run(rest, char_code, acc + 1)
end
defp count_fence_run(_rest, _char_code, acc), do: acc
defp only_spaces?(""), do: true
defp only_spaces?(<<char, rest::binary>>) when char in [32, 9], do: only_spaces?(rest)
defp only_spaces?(_), do: false
defp consume_trailing_for_fence(trailing) do
case trailing do
<<?\n, rest::binary>> -> {"\n", rest}
_ -> {"", trailing}
end
end
defp drop_prefix(binary, prefix) do
prefix_size = byte_size(prefix)
binary_part(binary, prefix_size, byte_size(binary) - prefix_size)
end
@doc false
def merge_stream_buffer(existing_markdown, buffer, last_node, state \\ %State{}) do
existing_markdown
|> maybe_strip_trailing_fence(last_node)
|> prepare_stream_boundary(state)
|> append_buffer(buffer)
end
defp maybe_strip_trailing_fence(markdown, %{__struct__: MDEx.CodeBlock, fenced: true} = block) do
closing_line = closing_fence_line(block)
trim_trailing_line(markdown, closing_line)
end
defp maybe_strip_trailing_fence(markdown, _), do: markdown
defp prepare_stream_boundary(markdown, %State{last_flush_ended_with_newline: false}) do
trim_trailing_newline(markdown)
end
defp prepare_stream_boundary(markdown, _state), do: ensure_trailing_newline(markdown)
defp ensure_trailing_newline(<<>>), do: ""
defp ensure_trailing_newline(binary) do
case :binary.last(binary) do
?\n -> binary
_ -> binary <> "\n"
end
end
defp trim_trailing_newline(binary) do
cond do
String.ends_with?(binary, "\r\n") -> binary_part(binary, 0, byte_size(binary) - 2)
String.ends_with?(binary, "\n") -> binary_part(binary, 0, byte_size(binary) - 1)
true -> binary
end
end
defp append_buffer(existing, buffer) do
IO.iodata_to_binary([existing | Enum.reverse(buffer)])
end
defp closing_fence_line(%{fence_char: char, fence_length: length, fence_offset: offset}) do
indent = if offset > 0, do: String.duplicate(" ", offset), else: ""
indent <> String.duplicate(char, length)
end
defp trim_trailing_line(markdown, closing_line) do
newline_closing = "\n" <> closing_line
nc_size = byte_size(newline_closing)
nc_nl_size = nc_size + 1
cond do
String.ends_with?(markdown, newline_closing <> "\n") ->
binary_part(markdown, 0, byte_size(markdown) - nc_nl_size)
String.ends_with?(markdown, newline_closing) ->
binary_part(markdown, 0, byte_size(markdown) - nc_size)
true ->
markdown
end
end
defp whitespace_char?(char), do: char in @ws_chars
defp leading_ws_size(_binary, size, size), do: size
defp leading_ws_size(binary, size, index) do
char = :binary.at(binary, index)
if whitespace_char?(char) do
leading_ws_size(binary, size, index + 1)
else
index
end
end
defp trailing_ws_size(_binary, index, count) when index < 0, do: count
defp trailing_ws_size(binary, index, count) do
char = :binary.at(binary, index)
if whitespace_char?(char) do
trailing_ws_size(binary, index - 1, count + 1)
else
count
end
end
defp maybe_preserve_leading(""), do: ""
defp maybe_preserve_leading(leading) do
if preserve_leading?(leading) do
leading
else
""
end
end
defp preserve_leading?(leading) do
contains_line_break?(leading) or code_block_indented?(leading)
end
defp contains_line_break?(leading) do
:binary.match(leading, <<10>>) != :nomatch or :binary.match(leading, <<13>>) != :nomatch
end
defp code_block_indented?(leading) do
:binary.match(leading, <<9>>) != :nomatch or final_space_run(leading) >= 4
end
defp final_space_run(binary), do: final_space_run(binary, 0)
defp final_space_run(<<>>, count), do: count
defp final_space_run(<<10, rest::binary>>, _count), do: final_space_run(rest, 0)
defp final_space_run(<<13, rest::binary>>, _count), do: final_space_run(rest, 0)
defp final_space_run(<<32, rest::binary>>, count), do: final_space_run(rest, count + 1)
defp final_space_run(<<_char, rest::binary>>, count), do: final_space_run(rest, count)
defp maybe_complete_table(core, trailing) do
case trailing do
<<?\n, _::binary>> ->
line = last_line(core)
if table_header_line?(line) do
separator = generate_table_separator(line)
{core <> "\n" <> separator, {:consume_trailing, "\n"}}
else
nil
end
_ ->
nil
end
end
defp table_header_line?(line) do
trimmed = String.trim(line)
String.starts_with?(trimmed, "|") and String.ends_with?(trimmed, "|")
end
defp generate_table_separator(line) do
pipe_count = count_char(line, ?|)
if pipe_count > 1 do
columns = pipe_count - 1
columns
|> build_table_separator(["|"])
|> IO.iodata_to_binary()
else
""
end
end
defp build_table_separator(0, acc), do: acc
defp build_table_separator(columns, acc) do
build_table_separator(columns - 1, [acc, " - |"])
end
defp maybe_complete_math(core, trailing) do
cond do
has_unclosed_delimiter?(core, "$$") ->
suffix =
if String.ends_with?(core, "$") and not String.ends_with?(core, "$$") do
"$"
else
"$$"
end
case trailing do
<<?\n, _::binary>> ->
{core <> "\n" <> suffix, {:consume_trailing, "\n"}}
_ ->
{core <> suffix, :none}
end
has_unclosed_delimiter?(core, "$") ->
{core <> "$", :none}
true ->
nil
end
end
defp has_unclosed_delimiter?(text, "$") do
count = count_math_dollars(text, 0)
rem(count, 2) == 1
end
defp has_unclosed_delimiter?(text, delimiter) do
count = count_occurrences(text, delimiter)
rem(count, 2) == 1
end
defp count_math_dollars(<<>>, acc), do: acc
defp count_math_dollars(<<"\\$", rest::binary>>, acc), do: count_math_dollars(rest, acc)
defp count_math_dollars(<<"$", digit, rest::binary>>, acc) when digit in ?0..?9 do
count_math_dollars(rest, acc)
end
defp count_math_dollars(<<"$", rest::binary>>, acc), do: count_math_dollars(rest, acc + 1)
defp count_math_dollars(<<_char, rest::binary>>, acc), do: count_math_dollars(rest, acc)
defp complete_list_line(core, line) do
case extract_list_content(line) do
{prefix, content} when content != "" -> complete_list_content(core, prefix, content)
_ -> core
end
end
defp complete_list_content(core, prefix, content) do
case unmatched_suffix(content) do
suffix when suffix != "" ->
replace_last_line(core, prefix <> content <> suffix)
"" ->
complete_list_link(core, prefix, content)
end
end
defp complete_list_link(core, prefix, content) do
case incomplete_link_completion(content, "") do
nil -> core
completion -> replace_last_line(core, prefix <> completion)
end
end
defp skip_completion?(core, line) do
fence_candidate?(core) or fence_line_start?(line)
end
defp unmatched_suffix(text) do
# Collect all unmatched suffixes with the position of their last opening delimiter,
# then sort by position descending so innermost (latest) closes first.
do_unmatched_suffix(text, [{"*", "**"}, {"_", "__"}, {"~", "~~"}, {"+", "++"}, {"=", "=="}], [])
end
defp do_unmatched_suffix(_text, [], []), do: ""
defp do_unmatched_suffix(_text, [], [{_pos, suffix}]), do: suffix
defp do_unmatched_suffix(_text, [], acc) do
acc
|> Enum.sort_by(fn {pos, _} -> pos end, :desc)
|> Enum.map_join("", fn {_, suffix} -> suffix end)
end
defp do_unmatched_suffix(text, [{single, double} | rest], acc) do
case unmatched_emph_suffix(text, single, double) do
suffix when suffix != "" ->
pos = last_unmatched_opening_pos(text, single, double)
do_unmatched_suffix(text, rest, [{pos, suffix} | acc])
_ ->
do_unmatched_suffix(text, rest, acc)
end
end
@double_only_delimiters ["+", "="]
@double_only_delimiters_set MapSet.new(@double_only_delimiters)
defp last_unmatched_opening_pos(text, single, double) do
if MapSet.member?(@double_only_delimiters_set, single) do
last_match_pos(text, double)
else
# Find all single-char positions that aren't part of doubles
single_byte = :binary.first(single)
double_positions = binary_match_positions(text, double)
all_single = binary_match_positions(text, <<single_byte>>)
# Filter out positions that are part of double delimiters
standalone = reject_double_occupied(all_single, double_positions)
case standalone do
[] -> last_match_pos(text, double)
positions -> List.last(positions)
end
end
end
# Reject single positions that overlap with any double delimiter position
defp reject_double_occupied(singles, []), do: singles
defp reject_double_occupied(singles, doubles) do
occupied =
Enum.reduce(doubles, MapSet.new(), fn pos, acc ->
acc |> MapSet.put(pos) |> MapSet.put(pos + 1)
end)
Enum.reject(singles, &MapSet.member?(occupied, &1))
end
defp last_match_pos(text, token) do
case :binary.matches(text, token) do
[] -> 0
matches -> elem(List.last(matches), 0)
end
end
defp binary_match_positions(text, token) do
case :binary.matches(text, token) do
[] -> []
matches -> Enum.map(matches, fn {pos, _} -> pos end)
end
end
defp opening_completion_append(core, line_suffix) do
with token when token != nil <- opening_token(core),
append when append != "" <- missing_trailing_for(core, token),
true <- rem(count_occurrences(core, token), 2) == 1,
# Don't use opening_append if there are other unclosed delimiters
# that need to close first (nesting order matters)
false <- line_suffix != "" and line_suffix != append do
append
else
_ -> ""
end
end
defp opening_token(bin) do
Enum.find(@tokens, &String.starts_with?(bin, &1))
end
defp closing_token(bin) do
Enum.find(@tokens, &String.ends_with?(bin, &1))
end
defp missing_trailing_for(bin, token) do
token_byte = :binary.first(token)
needed = byte_size(token)
present = trailing_byte_run(bin, token_byte)
if present < needed do
String.duplicate(<<token_byte>>, needed - present)
else
""
end
end
# Count how many consecutive occurrences of `byte` are at the end of `bin`
defp trailing_byte_run(bin, byte) do
trailing_byte_run(bin, byte, byte_size(bin) - 1, 0)
end
defp trailing_byte_run(_bin, _byte, index, count) when index < 0, do: count
defp trailing_byte_run(bin, byte, index, count) do
if :binary.at(bin, index) == byte do
trailing_byte_run(bin, byte, index - 1, count + 1)
else
count
end
end
defp unmatched_backtick?(bin), do: rem(count_char(bin, ?`), 2) == 1
defp prefix_has_open_backtick?(""), do: false
defp prefix_has_open_backtick?(prefix), do: unmatched_backtick?(prefix)
defp unmatched_emph_suffix(core, single, double) do
double_count = count_occurrences(core, double)
unmatched_double = rem(double_count, 2)
if single in @double_only_delimiters do
unmatched_double_only_suffix(core, double)
else
calculate_unmatched_mixed_suffix(core, single, double, double_count, unmatched_double)
end
end
defp unmatched_double_only_suffix(core, double) do
flanking = count_flanking_delimiters(core, double)
if rem(flanking, 2) == 1, do: double, else: ""
end
defp calculate_unmatched_mixed_suffix(core, single, double, double_count, unmatched_double) do
single_code = :binary.first(single)
total = count_emphasis_char(core, single_code)
unmatched_single = rem(max(total - double_count * 2, 0), 2)
unmatched_mixed_suffix(core, single, double, unmatched_double, unmatched_single)
end
defp unmatched_mixed_suffix(core, single, double, 1, 1) do
if String.ends_with?(core, single) and not String.ends_with?(core, double) do
single
else
double <> single
end
end
defp unmatched_mixed_suffix(_core, _single, double, 1, _unmatched_single), do: double
defp unmatched_mixed_suffix(_core, single, _double, _unmatched_double, 1), do: single
defp unmatched_mixed_suffix(_core, _single, _double, _unmatched_double, _unmatched_single), do: ""
# Count occurrences of a double delimiter (like ++ or ==) that appear in
# flanking position — preceded by whitespace/SOL or followed by whitespace/EOL.
# This prevents false positives like "C++17" or "x==1".
defp count_flanking_delimiters(bin, delim) do
delim_size = byte_size(delim)
do_count_flanking(bin, delim, delim_size, 0)
end
defp do_count_flanking(bin, delim, delim_size, acc) do
case :binary.match(bin, delim) do
:nomatch ->
acc
{pos, _len} ->
before_ok = pos == 0 or non_alnum_at?(bin, pos - 1)
after_pos = pos + delim_size
after_ok = after_pos >= byte_size(bin) or non_alnum_at?(bin, after_pos)
new_acc = if before_ok or after_ok, do: acc + 1, else: acc
rest_start = pos + delim_size
rest = binary_part(bin, rest_start, byte_size(bin) - rest_start)
do_count_flanking(rest, delim, delim_size, new_acc)
end
end
defp non_alnum_at?(bin, pos) do
char = :binary.at(bin, pos)
not (char in ?0..?9 or char in ?A..?Z or char in ?a..?z)
end
defp count_emphasis_char(bin, ?*), do: count_emphasis_asterisk(bin)
defp count_emphasis_char(bin, ?_), do: count_emphasis_underscore(bin)
defp count_emphasis_char(bin, char), do: count_char(bin, char)
defp count_char(bin, char) when is_integer(char) do
count_token_matches(bin, <<char>>, 0)
end
defp count_occurrences(bin, token) do
count_token_matches(bin, token, 0)
end
defp count_token_matches(bin, token, acc) do
case :binary.match(bin, token) do
:nomatch ->
acc
{pos, len} ->
next = pos + len
rest = binary_part(bin, next, byte_size(bin) - next)
count_token_matches(rest, token, acc + 1)
end
end
defp count_emphasis_asterisk(bin) do
count_emphasis_asterisk(bin, :boundary, 0)
end
defp count_emphasis_asterisk(<<>>, _prev_type, acc), do: acc
defp count_emphasis_asterisk(<<char, rest::binary>>, prev_type, acc) do
cond do
char == ?* ->
next_type = next_char_type_ws(rest)
new_acc = if prev_type == :space and next_type == :space, do: acc, else: acc + 1
count_emphasis_asterisk(rest, :asterisk, new_acc)
char in @ws_chars ->
count_emphasis_asterisk(rest, :space, acc)
true ->
count_emphasis_asterisk(rest, :non_space, acc)
end
end
defp next_char_type_ws(<<>>), do: :boundary
defp next_char_type_ws(<<char, _rest::binary>>) when char in [9, 10, 11, 12, 13, 32], do: :space
defp next_char_type_ws(_), do: :non_space
defp count_emphasis_underscore(bin) do
count_emphasis_underscore(bin, :boundary, 0)
end
defp count_emphasis_underscore(<<>>, _prev_type, acc), do: acc
defp count_emphasis_underscore(<<char::utf8, rest::binary>>, prev_type, acc) do
if char == ?_ do
next_type = next_char_type(rest)
eligible? = not (prev_type == :word and next_type == :word)
new_acc = if eligible?, do: acc + 1, else: acc
count_emphasis_underscore(rest, :underscore, new_acc)
else
count_emphasis_underscore(rest, char_type(char), acc)
end
end
defp next_char_type(<<>>), do: :boundary
defp next_char_type(<<char::utf8, _rest::binary>>), do: char_type(char)
defp char_type(char) when char in ?0..?9, do: :word
defp char_type(char) when char in ?A..?Z, do: :word
defp char_type(char) when char in ?a..?z, do: :word
defp char_type(_char), do: :boundary
defp strip_incomplete_html(text, preserve_pending_html) do
case last_byte_pos(text, ?<) do
nil -> {text, nil}
pos -> strip_incomplete_html_at(text, preserve_pending_html, pos)
end
end
defp strip_incomplete_html_at(text, preserve_pending_html, pos) do
after_lt_size = byte_size(text) - pos
if :binary.match(text, ">", [{:scope, {pos, after_lt_size}}]) != :nomatch do
{text, nil}
else
after_lt = binary_part(text, pos, after_lt_size)
incomplete_html_result(text, preserve_pending_html, pos, after_lt)
end
end
defp incomplete_html_result(text, preserve_pending_html, pos, after_lt) do
cond do
preserve_pending_html and after_lt == "<" and not inside_inline_code?(text, pos) ->
strip_pending_html(text, pos, after_lt)
tag_start?(after_lt) ->
strip_tag_start(text, pos, after_lt)
true ->
{text, nil}
end
end
defp strip_tag_start(text, pos, after_lt) do
if inside_inline_code?(text, pos), do: {text, nil}, else: strip_pending_html(text, pos, after_lt)
end
defp strip_pending_html(text, pos, after_lt) do
{String.trim_trailing(binary_part(text, 0, pos)), after_lt}
end
defp last_byte_pos(text, byte), do: last_byte_pos(text, byte, byte_size(text) - 1)
defp last_byte_pos(_text, _byte, index) when index < 0, do: nil
defp last_byte_pos(text, byte, index) do
if :binary.at(text, index) == byte do
index
else
last_byte_pos(text, byte, index - 1)
end
end
defp tag_start?(<<"<", char, _rest::binary>>) when char in ?a..?z or char in ?A..?Z or char == ?/, do: true
defp tag_start?(_), do: false
defp inside_inline_code?(text, pos) do
prefix = binary_part(text, 0, pos)
rem(count_char(prefix, ?`), 2) == 1
end
defp incomplete_link_completion(core, trailing) do
if String.starts_with?(trailing, "\n") do
nil
else
cond do
has_incomplete_link_url?(core) ->
core <> ")"
incomplete_link_brackets?(core) ->
ensure_placeholder_prefix(core, "](mdex:incomplete-link)")
incomplete_link_destination?(core) ->
ensure_placeholder_prefix(core, "(mdex:incomplete-link)")
true ->
nil
end
end
end
defp has_incomplete_link_url?(core) do
matches = :binary.matches(core, "](")
case matches do
[] ->
false
list ->
{pos, _len} = List.last(list)
has_open_bracket_before?(core, pos) and no_closing_paren_after?(core, pos)
end
end
defp has_open_bracket_before?(core, bracket_close_pos) do
# Scan backwards from the ] position to find a matching [
prefix = binary_part(core, 0, bracket_close_pos)
# Check for [ or  - after_start)
not has_unmatched_close_paren?(remaining, 0)
end
# Walk the string tracking paren depth. A ) at depth 0 means
# the link destination is closed.
defp has_unmatched_close_paren?(<<>>, _depth), do: false
defp has_unmatched_close_paren?(<<"(", rest::binary>>, depth), do: has_unmatched_close_paren?(rest, depth + 1)
defp has_unmatched_close_paren?(<<")", _rest::binary>>, 0), do: true
defp has_unmatched_close_paren?(<<")", rest::binary>>, depth), do: has_unmatched_close_paren?(rest, depth - 1)
defp has_unmatched_close_paren?(<<_char, rest::binary>>, depth), do: has_unmatched_close_paren?(rest, depth)
defp ensure_placeholder_prefix(core, placeholder) do
if String.ends_with?(core, placeholder), do: core, else: core <> placeholder
end
defp incomplete_link_brackets?(core) do
case unclosed_bracket_start(core, byte_size(core) - 1, 0) do
nil -> false
start -> not contains_newline_from?(core, start)
end
end
defp unclosed_bracket_start(_core, index, _depth) when index < 0, do: nil
defp unclosed_bracket_start(core, index, depth) do
case :binary.at(core, index) do
?] -> unclosed_bracket_start(core, index - 1, depth + 1)
?[ when depth == 0 -> index
?[ -> unclosed_bracket_start(core, index - 1, depth - 1)
_ -> unclosed_bracket_start(core, index - 1, depth)
end
end
defp incomplete_link_destination?(core) do
case trailing_link_label(core) do
nil -> false
_span -> true
end
end
defp fence_candidate?(""), do: false
defp fence_candidate?(bin) do
not String.contains?(bin, "\n") and has_fence_start?(bin)
end
defp fence_line_start?(line) do
has_fence_start?(line)
end
# Check if a line starts with >= 3 consecutive fence chars
defp has_fence_start?(bin) do
case bin do
<<"```", _::binary>> -> true
<<"~~~", _::binary>> -> true
_ -> false
end
end
defp last_line(""), do: ""
defp last_line(bin) do
case find_last_newline(bin, byte_size(bin) - 1) do
nil ->
bin
index ->
start = index + 1
binary_part(bin, start, byte_size(bin) - start)
end
end
defp replace_last_line("", new_line), do: new_line
defp replace_last_line(bin, new_line) do
case find_last_newline(bin, byte_size(bin) - 1) do
nil ->
new_line
index ->
binary_part(bin, 0, index + 1) <> new_line
end
end
defp find_last_newline(_binary, index) when index < 0, do: nil
defp find_last_newline(binary, index) do
case :binary.at(binary, index) do
?\n -> index
_ -> find_last_newline(binary, index - 1)
end
end
defp list_marker_line?(line) do
match?({:ok, _, _, _, _, _}, list_prefix_parser(line))
end
defp extract_list_content(line) do
case list_prefix_parser(line) do
{:ok, parts, rest, _, _, _} -> {Enum.join(parts), rest}
_ -> {"", line}
end
end
defp trailing_link_label(<<>>), do: nil
defp trailing_link_label(core) do
size = byte_size(core)
if size == 0 do
nil
else
last_index = size - 1
case :binary.at(core, last_index) do
?] -> trailing_label_span(core, last_index)
_ -> nil
end
end
end
defp trailing_label_span(core, last_index) do
case find_label_start(core, last_index - 1) do
nil -> label_span_from_start(core, nil, last_index)
start -> label_span_from_start(core, start, last_index)
end
end
defp label_span_from_start(_core, nil, _last_index), do: nil
defp label_span_from_start(core, start, last_index) do
if contains_newline_from?(core, start) do
nil
else
{start, last_index - start + 1}
end
end
defp find_label_start(_binary, index) when index < 0, do: nil
defp find_label_start(binary, index) do
case :binary.at(binary, index) do
?[ -> index
?] -> nil
_ -> find_label_start(binary, index - 1)
end
end
defp contains_newline_from?(binary, start) do
length = byte_size(binary) - start
case :binary.match(binary, "\n", [{:scope, {start, length}}]) do
:nomatch -> false
_ -> true
end
end
end