Skip to content

Commit b6556a6

Browse files
chrisbobbegnprice
authored andcommitted
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 b79bd77 commit b6556a6

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-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.isNotEmpty && !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

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

0 commit comments

Comments
 (0)