Skip to content

Commit

Permalink
model: Add wrapWithBacktickFence, to use with quote-and-reply
Browse files Browse the repository at this point in the history
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 14, 2023
1 parent 4e11ea7 commit 2fd52b4
Show file tree
Hide file tree
Showing 2 changed files with 293 additions and 0 deletions.
103 changes: 103 additions & 0 deletions lib/model/compose.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 = (() {
// Recognize a fence with "up to three spaces of indentation".
// Servers don't recognize fences that start with spaces, as of Server 7.0:
// https://chat.zulip.org/#narrow/stream/6-frontend/topic/quote-and-reply.20fence.20length/near/1588273
// but that's a bug, since those fences are valid in the spec.
// Still, it's harmless to make our own fence longer even if the server
// wouldn't notice the internal fence that we're steering clear of,
// and if servers *do* start following the spec by noticing indented internal
// fences, then this client behavior will be nice.
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 + 1);
}
return result;
}

/// Wrap Markdown [content] with opening and closing backtick fences.
///
/// For example, for this Markdown:
///
/// ```javascript
/// console.log('Hello world!');
/// ```
///
/// this function, with `infoString: 'quote'`, gives
///
/// ````quote
/// ```javascript
/// console.log('Hello world!');
/// ```
/// ````
///
/// See the CommonMark spec, which Zulip servers should but don't always follow:
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
// 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.
String wrapWithBacktickFence({required String content, String? infoString}) {
assert(infoString == null || !infoString.contains('`'));
assert(infoString == null || infoString.trim() == infoString);

StringBuffer resultBuffer = StringBuffer();

// (A) Why not panic on 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 (and this function) will treat them that way.
final fenceLength = getUnusedOpeningBacktickFenceLength(content);

resultBuffer.write('`' * fenceLength);
if (infoString != null) {
resultBuffer.write(infoString);
}
resultBuffer.write('\n');
resultBuffer.write(content);
resultBuffer.write('\n');
resultBuffer.write('`' * fenceLength);
resultBuffer.write('\n');
return resultBuffer.toString();
}

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

void main() {
group('wrapWithBacktickFence', () {
/// Check `wrapWithBacktickFence` on example input and expected output.
///
/// The intended input (content passed to `wrapWithBacktickFence`)
/// is straightforward to infer from `expected`.
/// To do that, this helper takes `expected`, validates it
/// (as a courtesy for the test author), and removes the opening and
/// closing fences.
///
/// Then we have the input to the test, as well as the expected output.
void checkFenceWrap(String expected, {String? infoString}) {
final lines = expected.split('\n');
final firstLineMatch = RegExp(r'^(`{3,})[^`]*$').allMatches(lines.removeAt(0)).singleOrNull;
assert(firstLineMatch != null,
'test error: opening fence not found in `expected`');
assert(lines.removeAt(lines.length - 1) == '',
'test error: `expected` should end with a newline');
final openingFenceLength = firstLineMatch![1]!.length;
assert(RegExp(r'^`{' + openingFenceLength.toString() + r'}$').hasMatch(lines.removeAt(lines.length - 1)),
'test error: closing fence not found in `expected`');
final content = lines.join('\n');
check(wrapWithBacktickFence(content: content, infoString: infoString)).equals(expected);
}

test('single line with no code blocks', () {
checkFenceWrap('''
```
hello world
```
''');
});

test('multiple lines with no code blocks', () {
checkFenceWrap('''
```
hello
world
```
''');
});

test('three-backtick block', () {
checkFenceWrap('''
````
hello
```
code
```
world
````
''');
});

test('multiple three-backtick blocks; one has info string', () {
checkFenceWrap('''
````
hello
```
code
```
world
```javascript
// more code
```
````
''');
});

test('whitespace around info string', () {
checkFenceWrap('''
````
``` javascript
// hello world
```
````
''');
});

test('four-backtick block', () {
checkFenceWrap('''
`````
````
hello world
````
`````
''');
});

test('five-backtick block', () {
checkFenceWrap('''
``````
`````
hello world
`````
``````
''');
});

test('three-, four-, and five-backtick blocks', () {
checkFenceWrap('''
``````
```
hello world
```
````
hello world
````
`````
hello world
`````
``````
''');
});

test('dangling opening fence', () {
checkFenceWrap('''
`````
````javascript
// hello world
`````
''');
});

test('code blocks marked by indentation or tilde fences don\'t affect result', () {
checkFenceWrap('''
```
// hello world
~~~~~~
code
~~~~~~
```
''');
});

test('backtick fences may be indented up to three spaces', () {
checkFenceWrap('''
````
```
````
''');
checkFenceWrap('''
````
```
````
''');
checkFenceWrap('''
````
```
````
''');
// but at 4 spaces of indentation it no longer counts:
checkFenceWrap('''
```
```
```
''');
});

test('fence ignored if info string has backtick', () {
checkFenceWrap('''
```
```java`script
hello
```
''');
});

test('with info string', () {
checkFenceWrap(infoString: 'info', '''
`````info
```
hello
```
info
````python
hello
````
`````
''');
});
});
}

0 comments on commit 2fd52b4

Please sign in to comment.