Skip to content

Commit 93c0d1e

Browse files
committed
model: Add wrapWithBacktickFence, to use with quote-and-reply
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
1 parent 631f4d6 commit 93c0d1e

File tree

2 files changed

+293
-0
lines changed

2 files changed

+293
-0
lines changed

lib/model/compose.dart

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import 'dart:math';
2+
3+
//
4+
// Put functions for nontrivial message-content generation in this file.
5+
//
6+
// If it's complicated enough to need tests, it should go in here.
7+
//
8+
9+
// https://spec.commonmark.org/0.30/#fenced-code-blocks
10+
final RegExp _openingBacktickFenceRegex = (() {
11+
// Recognize a fence with "up to three spaces of indentation".
12+
// Servers don't recognize fences that start with spaces, as of Server 7.0:
13+
// https://chat.zulip.org/#narrow/stream/6-frontend/topic/quote-and-reply.20fence.20length/near/1588273
14+
// but that's a bug, since those fences are valid in the spec.
15+
// Still, it's harmless to make our own fence longer even if the server
16+
// wouldn't notice the internal fence that we're steering clear of,
17+
// and if servers *do* start following the spec by noticing indented internal
18+
// fences, then this client behavior will be nice.
19+
const lineStart = r'^ {0,3}';
20+
21+
// The backticks, captured so we can see how many.
22+
const backticks = r'(`{3,})';
23+
24+
// The "info string" plus (meaningless) leading or trailing spaces or tabs.
25+
// It can't contain backticks.
26+
const trailing = r'[^`]*$';
27+
return RegExp(lineStart + backticks + trailing, multiLine: true);
28+
})();
29+
30+
/// The shortest opening backtick fence that's longer than any in [content].
31+
///
32+
/// Expressed as a number of backticks.
33+
///
34+
/// Use this for quote-and-reply or anything else that requires wrapping
35+
/// Markdown in a backtick fence.
36+
///
37+
/// See the CommonMark spec, which Zulip servers should but don't always follow:
38+
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
39+
int getUnusedOpeningBacktickFenceLength(String content) {
40+
final matches = _openingBacktickFenceRegex.allMatches(content);
41+
int result = 3;
42+
for (final match in matches) {
43+
result = max(result, match[1]!.length + 1);
44+
}
45+
return result;
46+
}
47+
48+
/// Wrap Markdown [content] with opening and closing backtick fences.
49+
///
50+
/// For example, for this Markdown:
51+
///
52+
/// ```javascript
53+
/// console.log('Hello world!');
54+
/// ```
55+
///
56+
/// this function, with `infoString: 'quote'`, gives
57+
///
58+
/// ````quote
59+
/// ```javascript
60+
/// console.log('Hello world!');
61+
/// ```
62+
/// ````
63+
///
64+
/// See the CommonMark spec, which Zulip servers should but don't always follow:
65+
/// https://spec.commonmark.org/0.30/#fenced-code-blocks
66+
// In [content], indented code blocks
67+
// ( https://spec.commonmark.org/0.30/#indented-code-blocks )
68+
// and code blocks fenced with tildes should make no difference to the
69+
// backtick fences we choose here; this function ignores them.
70+
String wrapWithBacktickFence({required String content, String? infoString}) {
71+
assert(infoString == null || !infoString.contains('`'));
72+
assert(infoString == null || infoString.trim() == infoString);
73+
74+
StringBuffer resultBuffer = StringBuffer();
75+
76+
// (A) Why not panic on dangling opening fences
77+
// (ones without a corresponding closing fence)?
78+
// Because the spec allows leaving the closing fence implicit:
79+
// > If the end of the containing block (or document) is reached
80+
// > and no closing code fence has been found,
81+
// > the code block contains all of the lines after the opening code fence
82+
// > until the end of the containing block (or document).
83+
//
84+
// (B) Why not look for dangling closing fences (ones without an opening fence)?
85+
// Because technically there's no such thing:
86+
// they would be indistinguishable from dangling opening fences,
87+
// and parsers (and this function) will treat them that way.
88+
final fenceLength = getUnusedOpeningBacktickFenceLength(content);
89+
90+
resultBuffer.write('`' * fenceLength);
91+
if (infoString != null) {
92+
resultBuffer.write(infoString);
93+
}
94+
resultBuffer.write('\n');
95+
resultBuffer.write(content);
96+
resultBuffer.write('\n');
97+
resultBuffer.write('`' * fenceLength);
98+
resultBuffer.write('\n');
99+
return resultBuffer.toString();
100+
}
101+
102+
// TODO more, like /near links to messages in conversations
103+
// (also to be used in quote-and-reply)

test/model/compose_test.dart

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/model/compose.dart';
4+
5+
void main() {
6+
group('wrapWithBacktickFence', () {
7+
/// Check `wrapWithBacktickFence` on example input and expected output.
8+
///
9+
/// The intended input (content passed to `wrapWithBacktickFence`)
10+
/// is straightforward to infer from `expected`.
11+
/// To do that, this helper takes `expected`, validates it
12+
/// (as a courtesy for the test author), and removes the opening and
13+
/// closing fences.
14+
///
15+
/// Then we have the input to the test, as well as the expected output.
16+
void checkFenceWrap(String expected, {String? infoString}) {
17+
final lines = expected.split('\n');
18+
final firstLineMatch = RegExp(r'^(`{3,})[^`]*$').allMatches(lines.removeAt(0)).singleOrNull;
19+
assert(firstLineMatch != null,
20+
'test error: opening fence not found in `expected`');
21+
assert(lines.removeAt(lines.length - 1) == '',
22+
'test error: `expected` should end with a newline');
23+
final openingFenceLength = firstLineMatch![1]!.length;
24+
assert(RegExp(r'^`{' + openingFenceLength.toString() + r'}$').hasMatch(lines.removeAt(lines.length - 1)),
25+
'test error: closing fence not found in `expected`');
26+
final content = lines.join('\n');
27+
check(wrapWithBacktickFence(content: content, infoString: infoString)).equals(expected);
28+
}
29+
30+
test('single line with no code blocks', () {
31+
checkFenceWrap('''
32+
```
33+
hello world
34+
```
35+
''');
36+
});
37+
38+
test('multiple lines with no code blocks', () {
39+
checkFenceWrap('''
40+
```
41+
hello
42+
world
43+
```
44+
''');
45+
});
46+
47+
test('three-backtick block', () {
48+
checkFenceWrap('''
49+
````
50+
hello
51+
```
52+
code
53+
```
54+
world
55+
````
56+
''');
57+
});
58+
59+
test('multiple three-backtick blocks; one has info string', () {
60+
checkFenceWrap('''
61+
````
62+
hello
63+
```
64+
code
65+
```
66+
world
67+
```javascript
68+
// more code
69+
```
70+
````
71+
''');
72+
});
73+
74+
test('whitespace around info string', () {
75+
checkFenceWrap('''
76+
````
77+
``` javascript
78+
// hello world
79+
```
80+
````
81+
''');
82+
});
83+
84+
test('four-backtick block', () {
85+
checkFenceWrap('''
86+
`````
87+
````
88+
hello world
89+
````
90+
`````
91+
''');
92+
});
93+
94+
test('five-backtick block', () {
95+
checkFenceWrap('''
96+
``````
97+
`````
98+
hello world
99+
`````
100+
``````
101+
''');
102+
});
103+
104+
test('three-, four-, and five-backtick blocks', () {
105+
checkFenceWrap('''
106+
``````
107+
```
108+
hello world
109+
```
110+
111+
````
112+
hello world
113+
````
114+
115+
`````
116+
hello world
117+
`````
118+
``````
119+
''');
120+
});
121+
122+
test('dangling opening fence', () {
123+
checkFenceWrap('''
124+
`````
125+
````javascript
126+
// hello world
127+
`````
128+
''');
129+
});
130+
131+
test('code blocks marked by indentation or tilde fences don\'t affect result', () {
132+
checkFenceWrap('''
133+
```
134+
// hello world
135+
136+
~~~~~~
137+
code
138+
~~~~~~
139+
```
140+
''');
141+
});
142+
143+
test('backtick fences may be indented up to three spaces', () {
144+
checkFenceWrap('''
145+
````
146+
```
147+
````
148+
''');
149+
checkFenceWrap('''
150+
````
151+
```
152+
````
153+
''');
154+
checkFenceWrap('''
155+
````
156+
```
157+
````
158+
''');
159+
// but at 4 spaces of indentation it no longer counts:
160+
checkFenceWrap('''
161+
```
162+
```
163+
```
164+
''');
165+
});
166+
167+
test('fence ignored if info string has backtick', () {
168+
checkFenceWrap('''
169+
```
170+
```java`script
171+
hello
172+
```
173+
''');
174+
});
175+
176+
test('with info string', () {
177+
checkFenceWrap(infoString: 'info', '''
178+
`````info
179+
```
180+
hello
181+
```
182+
info
183+
````python
184+
hello
185+
````
186+
`````
187+
''');
188+
});
189+
});
190+
}

0 commit comments

Comments
 (0)