Skip to content

Commit fd79d07

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 4e11ea7 commit fd79d07

File tree

2 files changed

+328
-0
lines changed

2 files changed

+328
-0
lines changed

lib/model/compose.dart

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 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 getUnusedBacktickFenceLength(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+
// CommonMark doesn't require closing fences to be paired:
77+
// https://github.com/zulip/zulip-flutter/pull/179#discussion_r1228712591
78+
//
79+
// - We need our opening fence to be long enough that it won't be closed by
80+
// any fence in the content.
81+
// - We need our closing fence to be long enough that it will close any
82+
// outstanding opening fences in the content.
83+
final fenceLength = getUnusedBacktickFenceLength(content);
84+
85+
resultBuffer.write('`' * fenceLength);
86+
if (infoString != null) {
87+
resultBuffer.write(infoString);
88+
}
89+
resultBuffer.write('\n');
90+
resultBuffer.write(content);
91+
if (!content.endsWith('\n')) {
92+
resultBuffer.write('\n');
93+
}
94+
resultBuffer.write('`' * fenceLength);
95+
resultBuffer.write('\n');
96+
return resultBuffer.toString();
97+
}
98+
99+
// TODO more, like /near links to messages in conversations
100+
// (also to be used in quote-and-reply)

test/model/compose_test.dart

+228
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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` and removes the opening and
12+
/// closing fences.
13+
///
14+
/// Then we have the input to the test, as well as the expected output.
15+
void checkFenceWrap(String expected, {String? infoString, bool chopNewline = false}) {
16+
final re = RegExp(r'^.*?\n(.*\n).*\n$', dotAll: true);
17+
String content = re.firstMatch(expected)![1]!;
18+
if (chopNewline) content = content.substring(0, content.length - 1);
19+
check(wrapWithBacktickFence(content: content, infoString: infoString)).equals(expected);
20+
}
21+
22+
test('empty content', () {
23+
checkFenceWrap(chopNewline: true, '''
24+
```
25+
26+
```
27+
''');
28+
});
29+
30+
test('content consisting of blank lines', () {
31+
checkFenceWrap('''
32+
```
33+
34+
35+
36+
```
37+
''');
38+
});
39+
40+
test('single line with no code blocks', () {
41+
checkFenceWrap('''
42+
```
43+
hello world
44+
```
45+
''');
46+
});
47+
48+
test('multiple lines with no code blocks', () {
49+
checkFenceWrap('''
50+
```
51+
hello
52+
world
53+
```
54+
''');
55+
});
56+
57+
test('no code blocks; incomplete final line', () {
58+
checkFenceWrap(chopNewline: true, '''
59+
```
60+
hello
61+
world
62+
```
63+
''');
64+
});
65+
66+
test('three-backtick block', () {
67+
checkFenceWrap('''
68+
````
69+
hello
70+
```
71+
code
72+
```
73+
world
74+
````
75+
''');
76+
});
77+
78+
test('three-backtick block; incomplete final line', () {
79+
checkFenceWrap(chopNewline: true, '''
80+
```
81+
hello
82+
world
83+
```
84+
''');
85+
});
86+
87+
test('multiple three-backtick blocks; one has info string', () {
88+
checkFenceWrap('''
89+
````
90+
hello
91+
```
92+
code
93+
```
94+
world
95+
```javascript
96+
// more code
97+
```
98+
````
99+
''');
100+
});
101+
102+
test('whitespace around info string', () {
103+
checkFenceWrap('''
104+
````
105+
``` javascript
106+
// hello world
107+
```
108+
````
109+
''');
110+
});
111+
112+
test('four-backtick block', () {
113+
checkFenceWrap('''
114+
`````
115+
````
116+
hello world
117+
````
118+
`````
119+
''');
120+
});
121+
122+
test('five-backtick block', () {
123+
checkFenceWrap('''
124+
``````
125+
`````
126+
hello world
127+
`````
128+
``````
129+
''');
130+
});
131+
132+
test('five-backtick block; incomplete final line', () {
133+
checkFenceWrap(chopNewline: true, '''
134+
``````
135+
`````
136+
hello world
137+
`````
138+
``````
139+
''');
140+
});
141+
142+
test('three-, four-, and five-backtick blocks', () {
143+
checkFenceWrap('''
144+
``````
145+
```
146+
hello world
147+
```
148+
149+
````
150+
hello world
151+
````
152+
153+
`````
154+
hello world
155+
`````
156+
``````
157+
''');
158+
});
159+
160+
test('dangling opening fence', () {
161+
checkFenceWrap('''
162+
`````
163+
````javascript
164+
// hello world
165+
`````
166+
''');
167+
});
168+
169+
test('code blocks marked by indentation or tilde fences don\'t affect result', () {
170+
checkFenceWrap('''
171+
```
172+
// hello world
173+
174+
~~~~~~
175+
code
176+
~~~~~~
177+
```
178+
''');
179+
});
180+
181+
test('backtick fences may be indented up to three spaces', () {
182+
checkFenceWrap('''
183+
````
184+
```
185+
````
186+
''');
187+
checkFenceWrap('''
188+
````
189+
```
190+
````
191+
''');
192+
checkFenceWrap('''
193+
````
194+
```
195+
````
196+
''');
197+
// but at 4 spaces of indentation it no longer counts:
198+
checkFenceWrap('''
199+
```
200+
```
201+
```
202+
''');
203+
});
204+
205+
test('fence ignored if info string has backtick', () {
206+
checkFenceWrap('''
207+
```
208+
```java`script
209+
hello
210+
```
211+
''');
212+
});
213+
214+
test('with info string', () {
215+
checkFenceWrap(infoString: 'info', '''
216+
`````info
217+
```
218+
hello
219+
```
220+
info
221+
````python
222+
hello
223+
````
224+
`````
225+
''');
226+
});
227+
});
228+
}

0 commit comments

Comments
 (0)