Skip to content

Commit

Permalink
WIP model: Add wrapWithBacktickFence, to use with quote-and-reply
Browse files Browse the repository at this point in the history
TODO tests.

For the corresponding logic used in the web app and zulip-mobile,
see web/shared/src/fenced_code.ts.

I believe the logic here differs from that in just this way: it
follows the CommonMark spec more closely by disqualifying
backtick-fence lines where the "info string" has a backtick, since
that's not allowed:

> If the info string comes after a backtick fence, it may not
> contain any backtick characters. (The reason for this restriction
> is that otherwise some inline code would be incorrectly
> interpreted as the beginning of a fenced code block.)

Regarding the new file lib/model/compose.dart, we do have existing
code that could reasonably move here, but it's pretty simple. It's
the code that gives the upload-file Markdown; see
registerUploadStart and registerUploadEnd in
[ContentTextEditingController].

Related: zulip#116
  • Loading branch information
chrisbobbe committed Jun 13, 2023
1 parent 175cf46 commit 12a1030
Show file tree
Hide file tree
Showing 2 changed files with 124 additions and 0 deletions.
112 changes: 112 additions & 0 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import 'dart:math';

//
// Put functions for nontrivial message-content generation in this file.
//
// If it's complicated enough to need tests, it should go in here.
//

// https://spec.commonmark.org/0.30/#fenced-code-blocks
final RegExp _openingBacktickFenceRegex = (() {
// Allow "up to three spaces of indentation".
// As of Zulip Server 7.0, we don't comply with that detail:
// https://chat.zulip.org/#narrow/stream/6-frontend/topic/quote-and-reply.20fence.20length/near/1588273
// and that's a bug.
const lineStart = r'^ {0,3}';

// The backticks, captured so we can see how many.
const backticks = r'(`{3,})';

// The "info string" plus (meaningless) leading or trailing spaces or tabs.
// It can't contain backticks.
const trailing = r'[^`]*';
return RegExp(lineStart + backticks + trailing, multiLine: true);
})();

/// The shortest opening backtick fence that's longer than any in [content].
///
/// Expressed as a number of backticks.
///
/// Use this for quote-and-reply or anything else that requires wrapping
/// Markdown in a backtick fence.
///
/// See the CommonMark spec, which Zulip servers should but don't always follow:
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
int getUnusedOpeningBacktickFenceLength(String content) {
final matches = _openingBacktickFenceRegex.allMatches(content);
int result = 3;
for (final match in matches) {
result = max(result, match[1]!.length);
}
return result;
}

/// Wrap Markdown [content] with opening and closing backtick fences.
///
/// For example:
///
/// const str =
/// '```javascript\n'
/// 'console.log(\'Hello world!\');\n'
/// '```';
/// print(wrapWithBacktickFence(content, 'quote'));
/// // Output:
/// //
/// // ````quote
/// // ```javascript
/// // console.log('Hello world!');
/// // ```
/// // ````
///
/// This does not parse [content] to make sure its backtick fences are properly
/// paired and nested. If they aren't, it's probably not clear what
/// backtick-fenced output would be more reasonable anyway --
/// especially for callers like quote-and-reply that use this string as a
/// best-effort suggestion for the user to inspect and fix before sending.
/// (Render previews, #178, will help the user with that.)
///
/// In [content], indented code blocks
/// ( https://spec.commonmark.org/0.30/#indented-code-blocks )
/// and code blocks fenced with tildes should make no difference to the
/// backtick fences we choose here; this function ignores them.
///
/// See the CommonMark spec, which Zulip servers should but don't always follow:
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
// TODO(#178) Remove mention of #178 in doc.
String wrapWithBacktickFence({required String content, String? infoString}) {
assert(infoString == null || !infoString.contains('`'));
assert(infoString == null || infoString.trim() == infoString);

StringBuffer resultBuffer = StringBuffer();

// (A) Why not specially handle dangling opening fences
// (ones without a corresponding closing fence)?
// Because the spec allows leaving the closing fence implicit:
// > If the end of the containing block (or document) is reached
// > and no closing code fence has been found,
// > the code block contains all of the lines after the opening code fence
// > until the end of the containing block (or document).
//
// (B) Why not look for dangling closing fences (ones without an opening fence)?
// Because technically there's no such thing:
// they would be indistinguishable from dangling opening fences,
// and parsers will treat them that way. (See A for what that treatment is.)
final fenceLength = getUnusedOpeningBacktickFenceLength(content);

for (int i = 0; i < fenceLength; i++) {
resultBuffer.write('`');
}
if (infoString != null) {
resultBuffer.write(infoString);
}
resultBuffer.write('\n');
resultBuffer.write(content);
resultBuffer.write('\n');
for (int i = 0; i < fenceLength; i++) {
resultBuffer.write('`');
}
return resultBuffer.toString();
}

// TODO more, like /near links to messages in conversations
// (also to be used in quote-and-reply)
12 changes: 12 additions & 0 deletions test/model/compose_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:checks/checks.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/compose.dart';

void main() {
group('wrapWithBacktickFence', () {
// TODO: Find a nice compact way to write test cases. :-) The tricky bit is
// that almost all of them will involve two multiline strings that need to
// be easy to read (input and expected output). Can we e.g. read data from
// a separate file in a format that's convenient to maintain?
});
}

0 comments on commit 12a1030

Please sign in to comment.