inlineSafeLength function
How much of source can be shown without its styling changing later.
Returns source's length when nothing is pending, so the common case costs
one scan and no allocation.
Deliberately a scan for unbalanced delimiters rather than a second parse: it runs on the tail segment for every chunk that arrives, and it only has to be conservative. Holding a character that would not have changed costs a few milliseconds of latency; showing one that does change is the artefact this exists to remove.
html is the HTML the parse understands; MarkdownHtml.defaults when
null. A tag outside it is text, and never held.
Implementation
int inlineSafeLength(
String source, {
bool holdMathDollars = false,
MarkdownHtml? html,
}) {
final tags = html ?? MarkdownHtml.defaults;
var limit = source.length;
// Each delimiter carries its own patience. Holding is only ever worth it
// while the closer is plausibly still coming.
void holdAt(int index, int cap) {
if (index < 0 || index >= limit) {
return;
}
if (source.length - index > cap) {
return;
}
limit = index;
}
// A code span runs to its closing backtick and swallows any other delimiter
// on the way, so it is resolved first and the rest is checked outside it.
var i = 0;
var lastOpenTick = -1;
final outside = StringBuffer();
while (i < source.length) {
if (source.codeUnitAt(i) == 0x60 /* ` */ ) {
// A run of N backticks closes at the next run of exactly N.
final span = codeSpanAt(source, i);
if (span == null) {
lastOpenTick = i;
break;
}
// Keep the offsets aligned without copying the span's contents.
final end = span.close + span.run;
outside.write(' ' * (end - i));
i = end;
continue;
}
outside.writeCharCode(source.codeUnitAt(i));
i += 1;
}
holdAt(lastOpenTick, proseDelimiterHold);
// A `*` that begins a line and is followed by whitespace is a list bullet,
// and a line made only of `*`/`-`/`_` is a thematic break — neither is an
// emphasis delimiter, and counting them as one made every streamed `*`
// bullet invisible for the length of the leash. They are blanked out
// before the emphasis scans, offsets preserved.
final rest = _maskLineMarkers(outside.toString());
// Paired delimiters: an odd count means the last one is still open.
for (final token in const ['**', '~~', r'$$']) {
holdAt(_lastUnpaired(rest, token), proseDelimiterHold);
}
// Single `*` for italic, but only the ones that are not part of `**`.
holdAt(_lastUnpairedStar(rest), proseDelimiterHold);
// `_` and `__`, but only where they can open: an underscore inside a word
// (`snake_case`) never does, so it is never waited on.
holdAt(_lastOpenUnderscore(rest), proseDelimiterHold);
// Delimiters with distinct open and close forms.
for (final pair in const [(r'\(', r'\)'), (r'\[', r'\]'), ('<!--', '-->')]) {
final open = rest.lastIndexOf(pair.$1);
if (open != -1 && rest.indexOf(pair.$2, open + pair.$1.length) == -1) {
holdAt(open, markupDelimiterHold);
}
}
// An HTML tag the parser understands — `<b>`, `<a href>`, `<span>`,
// `<script>` — is text until its closing tag arrives.
if (!tags.isEmpty) {
holdAt(_lastUnclosedHtmlTag(rest, tags), markupDelimiterHold);
}
// A link or image: `[label](href)` is prose until its closing paren.
final bracket = rest.lastIndexOf('[');
if (bracket != -1) {
final closeBracket = rest.indexOf(']', bracket + 1);
if (closeBracket == -1) {
holdAt(bracket, markupDelimiterHold);
} else if (closeBracket + 1 < rest.length &&
rest.codeUnitAt(closeBracket + 1) == 0x28 /* ( */ &&
rest.indexOf(')', closeBracket + 1) == -1) {
holdAt(bracket, markupDelimiterHold);
}
}
// A table row is atomic: a line beginning with `|` is held until its
// newline arrives, so the parser only ever sees complete rows. And while
// the delimiter row is still arriving, the header is held with it — a
// partial delimiter is invalid, so without this the table formed on one
// cut (`|-`), fell apart on the next (`|:`), and re-formed, several times
// per row.
final lastNewline = source.lastIndexOf('\n');
final lastLine = source.substring(lastNewline + 1);
if (lastLine.trimLeft().startsWith('|')) {
var holdIndex = lastNewline + 1;
if (lastNewline > 0 && _partialTableSeparator.hasMatch(lastLine.trim())) {
final prevNewline = source.lastIndexOf('\n', lastNewline - 1);
final prevLine = source.substring(prevNewline + 1, lastNewline);
// Only above a header. Under a finished delimiter row, a `| ` is the
// first body row starting, and holding the delimiter row back with it
// turned the header into a paragraph for a chunk — then into a table.
final prevIsDelimiterRow =
prevLine.contains('-') &&
_partialTableSeparator.hasMatch(prevLine.trim());
if (prevLine.trimLeft().startsWith('|') && !prevIsDelimiterRow) {
holdIndex = prevNewline + 1;
}
}
holdAt(holdIndex, markupDelimiterHold);
}
// A finished `|` line with nothing after it yet may be a table's header,
// waiting for its delimiter row. Shown, it reads as a paragraph, and then
// turns into a table — a different height — a chunk later. Only the first
// `|` line: a row under an existing table is already final.
if (lastLine.isEmpty && lastNewline > 0) {
final prevStart = source.lastIndexOf('\n', lastNewline - 1) + 1;
final prevLine = source.substring(prevStart, lastNewline);
if (prevLine.trimLeft().startsWith('|')) {
// `prevStart - 2` is -1 when the line above is the first, after a
// leading newline — and `lastIndexOf` throws on a negative start.
final beforeStart = prevStart > 1
? source.lastIndexOf('\n', prevStart - 2) + 1
: (prevStart == 1 ? 0 : -1);
final before = beforeStart >= 0
? source.substring(beforeStart, prevStart - 1)
: '';
if (!before.trimLeft().startsWith('|')) {
holdAt(prevStart, markupDelimiterHold);
}
}
}
// Single-`$` maths, only when the caller treats `$…$` as maths at all —
// to everyone else a dollar is a dollar.
if (holdMathDollars) {
holdAt(_lastUnpairedDollar(rest), markupDelimiterHold);
}
// A last line that is not finished deciding what it is:
//
// * `---` / `===` under a paragraph makes it a heading, but `-` is also the
// start of `- item`. Shown early, the paragraph above flickers into a
// heading and back.
// * `[label]: url` is a definition, hidden once it is one — but only once
// its URL has arrived. Shown early, it flashes as text and vanishes.
if (lastNewline >= 0 && setextUnderlineLevel(lastLine) != null) {
holdAt(lastNewline + 1, proseDelimiterHold);
}
if (_definitionStart.hasMatch(lastLine)) {
holdAt(lastNewline + 1, markupDelimiterHold);
}
// * `[label]:` with its destination on the next line, which has not
// arrived yet.
if (lastNewline > 0) {
final prevStart = source.lastIndexOf('\n', lastNewline - 1) + 1;
if (_bareDefinitionLabel.hasMatch(
source.substring(prevStart, lastNewline),
)) {
holdAt(prevStart, markupDelimiterHold);
}
}
// * `2` or `2.` or `*` alone is the start of a list item still arriving.
// Shown early, it renders as a paragraph under the list — a line taller —
// and the list jumps back up a chunk later when it becomes an item.
// It can be the first line too: a list after a blank line starts a new
// segment, so its first marker has no line above it.
if (_partialListMarker.hasMatch(lastLine)) {
holdAt(lastNewline + 1, proseDelimiterHold);
}
// A trailing character that is only the first half of an opener: `\` may
// become `\(` and `<` may become `<u>`. Un-held it is shown, revealed, and
// then vanishes when the second half lands — the one leak the rules above
// cannot see. The very next character decides, so this holds a couple of
// characters at most and cannot stall.
final partial = _partialTrailingOpener.firstMatch(source);
if (partial != null) {
holdAt(partial.start, proseDelimiterHold);
}
// A tag or an autolink whose `>` has not arrived: `<b`, `<a href="…`,
// `<https://exa…`. Shown, it reads as text and then turns into
// formatting or a link.
holdAt(_partialTag(source, tags), markupDelimiterHold);
// A hold that ends just after a list marker would show an empty item —
// `2. ` with its content held back — which is a different height from the
// item it becomes, so the list jumps when the content lands. Hold the
// marker with its content instead.
if (limit > 0 && limit < source.length) {
final lineStart = source.lastIndexOf('\n', limit - 1) + 1;
if (_bareListMarker.hasMatch(source.substring(lineStart, limit))) {
limit = lineStart;
}
}
return limit;
}