mdwire/lib.rs
1//! mdwire — 에이전트가 만든 마크다운을 채팅 채널로 안전하게 내보낸다.
2//!
3//! 세 가지를 한 파이프라인에서 한다. 순서가 곧 설계다.
4//!
5//! 1. **정규화** — LLM 출력은 올바른 CommonMark 가 아니다. 짝이 안 맞는 강조,
6//! 줄을 넘는 강조, 안 닫힌 코드펜스가 일상이다. 먼저 복구한다.
7//! 2. **채널 렌더링** — 타깃이 받는 문법으로 옮긴다. 타깃이 못 받는 구문은
8//! 파싱할 이유도 없다 (아래 `Channel` 주석 참고).
9//! 3. **안전 분할** — 채널 한도와 스트리밍 경계에서, 마크업 한가운데를 자르지 않는다.
10//!
11//! # 왜 의존성이 없나
12//!
13//! 기성 파서는 전부 **배치형**이다 — 문서 전체를 받아 AST 를 만든 뒤 렌더한다.
14//! 토큰이 흘러들어오는 대로 내보내야 하는 이 문제에는 처음부터 맞지 않는다.
15//! 그리고 CJK 인접 강조 정책은 파서 안에 박혀 있어서, 남의 것을 쓰면 못 바꾼다.
16//! 그게 이 라이브러리가 고치려는 바로 그 문제다.
17
18#![forbid(unsafe_code)]
19
20mod block;
21mod inline;
22mod sink;
23mod vocab;
24pub mod width;
25
26use block::Engine;
27use sink::{PartsSink, StringSink};
28use vocab::Vocab;
29
30/// 내보낼 채널. 받는 문법이 채널마다 다르고, **출력이 좁은 쪽이 파싱 범위를 정한다**.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Channel {
33 /// Telegram `parse_mode=HTML`. 허용 태그 9개:
34 /// `b i u s code pre a blockquote tg-spoiler`. 표·헤딩 없음. 4096자.
35 TelegramHtml,
36 /// Slack `markdown_text`. 표준 마크다운을 슬랙이 직접 변환한다. 12,000자.
37 /// 변환이 거의 필요 없고, 남는 일은 정규화와 분할뿐이다.
38 SlackMarkdown,
39 /// 모든 마크업 제거. 폴백 경로.
40 Plain,
41}
42
43impl Channel {
44 /// 코퍼스 디렉토리와 CLI 인자에서 쓰는 이름. 채널을 문자열로 다루는 곳의 정본이다.
45 pub fn name(self) -> &'static str {
46 match self {
47 Channel::TelegramHtml => "telegram-html",
48 Channel::SlackMarkdown => "slack-markdown",
49 Channel::Plain => "plain",
50 }
51 }
52
53 /// 내보낼 수 있는 채널 전부. 코퍼스와 하네스가 이 목록을 돈다.
54 pub fn all() -> [Channel; 3] {
55 [Channel::TelegramHtml, Channel::SlackMarkdown, Channel::Plain]
56 }
57
58 /// 이름으로 채널을 찾는다.
59 pub fn parse(name: &str) -> Option<Channel> {
60 Self::all().into_iter().find(|c| c.name() == name)
61 }
62
63 /// 이 채널의 메시지 길이 한도(문자 수). 분할의 기준이다.
64 pub fn limit(self) -> usize {
65 match self {
66 Channel::TelegramHtml => 4096,
67 Channel::SlackMarkdown | Channel::Plain => 12_000,
68 }
69 }
70}
71
72/// 입력 방언 — 에이전트가 무슨 표기로 썼는가.
73///
74/// 기본은 표준 마크다운이다. 슬랙에 답하는 에이전트는 흔히 **레거시 `mrkdwn`** 으로 쓴다
75/// (슬랙 문서가 그렇게 가르친다) — `*굵게*` · `_기울임_` · `~취소~`. 표준으로 읽으면
76/// `*굵게*` 가 기울임이 되고 `~취소~` 는 글자로 남는다. 출력 채널과는 따로 정한다.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum Dialect {
79 /// 표준 마크다운(CommonMark · GFM).
80 #[default]
81 Markdown,
82 /// 슬랙 레거시 `mrkdwn`. 별표는 몇 개든 굵게, 물결은 하나든 둘이든 취소선이다.
83 /// 표준 표기(`**굵게**` · `~~취소~~` · `[텍스트](url)`)가 섞여도 같은 뜻으로 읽는다.
84 SlackMrkdwn,
85}
86
87impl Dialect {
88 /// CLI 인자와 바인딩에서 쓰는 이름.
89 pub fn name(self) -> &'static str {
90 match self {
91 Dialect::Markdown => "markdown",
92 Dialect::SlackMrkdwn => "slack-mrkdwn",
93 }
94 }
95
96 /// 이름으로 방언을 찾는다.
97 pub fn parse(name: &str) -> Option<Dialect> {
98 [Dialect::Markdown, Dialect::SlackMrkdwn].into_iter().find(|d| d.name() == name)
99 }
100}
101
102/// 변환 옵션. 지금은 입력 방언 하나다.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub struct Options {
105 pub from: Dialect,
106}
107
108/// 정규화가 고친 것의 개수. **모델이 얼마나 자주 서식을 깨는지**를 재는 데 쓴다.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub struct Repairs {
111 /// 블록이 끝나도록 안 닫혀서 닫아 준 강조(`**영향 범위` 처럼).
112 pub closed_emphasis: usize,
113 /// 문서 끝까지 안 닫혀서 닫아 준 코드펜스.
114 pub closed_fence: usize,
115 /// 짝이 없어 코드가 아니라 글자로 되돌린 백틱 런.
116 pub reverted_code_span: usize,
117 /// 짝 잃은 채 버린 `**` (`꼬리**` 처럼 앞이 글자인 것).
118 pub dropped_marker: usize,
119}
120
121impl Repairs {
122 pub(crate) fn add(&mut self, other: Repairs) {
123 self.closed_emphasis += other.closed_emphasis;
124 self.closed_fence += other.closed_fence;
125 self.reverted_code_span += other.reverted_code_span;
126 self.dropped_marker += other.dropped_marker;
127 }
128
129 /// 하나라도 고쳤는가.
130 pub fn any(&self) -> bool {
131 *self != Repairs::default()
132 }
133}
134
135/// [`render_with`] 의 결과 — 조각과 고친 것.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Rendered {
138 pub parts: Vec<String>,
139 pub repairs: Repairs,
140}
141
142/// 스트리밍 변환기.
143///
144/// 조각을 넣으면 **지금 안전하게 내보낼 수 있는 만큼만** 돌려준다.
145/// 경계에 걸린 마크업(`**굵` 에서 끊긴 것)은 안에 남겨 두고 다음 조각을 기다린다.
146/// 이것이 이 라이브러리의 핵심이다 — 완성본 변환은 이미 남들이 푼 문제고,
147/// 경계 문제는 스트리밍을 하는 한 채널과 무관하게 생긴다.
148///
149/// # 예
150///
151/// ```
152/// use mdwire::{Channel, Streamer};
153///
154/// let mut s = Streamer::new(Channel::TelegramHtml);
155/// let mut out = String::new();
156/// // 조각 경계가 `**` 한가운데를 지나가도 반쪽으로 나가지 않는다.
157/// out.push_str(s.push("앞말 **굵"));
158/// out.push_str(s.push("게** 뒷말"));
159/// out.push_str(s.finish());
160/// assert_eq!(out, "앞말 <b>굵게</b> 뒷말");
161/// ```
162pub struct Streamer {
163 engine: Engine,
164 /// [`Streamer::push`] 가 빌려주는 버퍼. 재사용하므로 조각마다 할당하지 않는다.
165 buf: String,
166 /// 줄바꿈이 아닌 글자를 하나라도 내보냈는가.
167 ///
168 /// **앞머리 빈 줄은 내보내지 않는다.** 문서가 주석이나 `<br>` 로 시작하면 첫 블록이
169 /// 비고 그 뒤의 줄바꿈만 남는데, 완성본은 조각 앞머리의 줄바꿈을 털고 시작한다
170 /// (`sink::PartsSink`). 스트리밍도 같아야 한다 — 그래야 둘이 같은 답을 낸다.
171 started: bool,
172}
173
174impl Streamer {
175 pub fn new(channel: Channel) -> Self {
176 Self::with_options(channel, Options::default())
177 }
178
179 /// 옵션을 주고 만든다 — 입력 방언 따위.
180 pub fn with_options(channel: Channel, options: Options) -> Self {
181 Self { engine: Engine::new(channel, options), buf: String::new(), started: false }
182 }
183
184 /// 지금까지 정규화가 고친 것. `finish` 뒤에 보면 문서 전체의 값이다.
185 pub fn repairs(&self) -> Repairs {
186 self.engine.repairs()
187 }
188
189 /// `from` 뒤에 새로 붙은 출력에서 앞머리 줄바꿈을 턴다. 첫 글자가 나올 때까지만이다.
190 fn trim_leading(&mut self, out: &mut String, from: usize) {
191 if self.started {
192 return;
193 }
194 let fresh = &out[from..];
195 let keep = fresh.len() - fresh.trim_start_matches('\n').len();
196 if keep > 0 {
197 out.drain(from..from + keep);
198 }
199 if out.len() > from {
200 self.started = true;
201 }
202 }
203
204 /// 조각을 밀어 넣고, 지금 내보낼 수 있는 출력을 받는다.
205 ///
206 /// 돌려주는 슬라이스는 **다음 호출 전까지만** 유효하다. 할당을 아예 없애려면
207 /// [`Streamer::push_into`] 를 쓴다 — 둘은 같은 코드를 부른다(`SPEC.md` 5절).
208 pub fn push(&mut self, chunk: &str) -> &str {
209 let mut buf = std::mem::take(&mut self.buf);
210 buf.clear();
211 self.push_into(chunk, &mut buf);
212 self.buf = buf;
213 &self.buf
214 }
215
216 /// 호출자 버퍼에 직접 쓴다. 정본 서명 — 조각당 할당이 0 이다.
217 pub fn push_into(&mut self, chunk: &str, out: &mut String) {
218 let from = out.len();
219 let mut sink = StringSink(out);
220 self.engine.feed(chunk, &mut sink);
221 self.trim_leading(out, from);
222 }
223
224 /// 입력이 끝났다. 남은 것을 전부 내보낸다(열린 마크업은 닫는다).
225 pub fn finish(&mut self) -> &str {
226 let mut buf = std::mem::take(&mut self.buf);
227 buf.clear();
228 self.finish_into(&mut buf);
229 self.buf = buf;
230 &self.buf
231 }
232
233 /// [`Streamer::finish`] 의 무할당 판.
234 pub fn finish_into(&mut self, out: &mut String) {
235 let from = out.len();
236 let mut sink = StringSink(out);
237 self.engine.finish(&mut sink);
238 self.trim_leading(out, from);
239 }
240
241 /// **지금까지 받은 것을 그대로 보내도 되게 만든다.** 상태는 건드리지 않으므로
242 /// 붙인 뒤에도 스트리밍은 이어진다.
243 ///
244 /// 강조는 짝이 맞을 때까지 안에 붙들려 있어 이미 균형이 맞지만, 블록의 여는
245 /// 마크업(`<blockquote>`·`<pre>`·헤딩의 `<b>`)은 블록이 끝나기 전에 나간다 —
246 /// 코드블록이 끝날 때까지 출력을 멈추면 스트리밍이 아니기 때문이다. 누적본을
247 /// 중간에 채널로 보내는 쪽(토큰이 오는 대로 메시지를 편집하는 경우)은 보내기
248 /// 직전에 이걸 덧붙인다. **누적본 자체에는 넣지 않는다** — 다음 조각이 이어진다.
249 ///
250 /// ```
251 /// use mdwire::{Channel, Streamer};
252 ///
253 /// let mut s = Streamer::new(Channel::TelegramHtml);
254 /// let mut acc = String::new();
255 /// s.push_into("> 인용이 시작되고", &mut acc);
256 ///
257 /// let mut snapshot = acc.clone();
258 /// s.close_open(&mut snapshot); // 지금 보내도 되는 모양
259 /// assert_eq!(snapshot, "<blockquote>인용이 시작되고</blockquote>");
260 ///
261 /// s.push_into("\n> 이어진다\n", &mut acc); // 누적본은 그대로 이어진다
262 /// s.finish_into(&mut acc);
263 /// assert_eq!(acc, "<blockquote>인용이 시작되고\n이어진다</blockquote>");
264 /// ```
265 pub fn close_open(&self, out: &mut String) {
266 self.engine.close_open(out);
267 }
268}
269
270/// 완성된 문서를 한 번에 변환한다. 한도를 넘으면 안전한 지점에서 나눈다.
271///
272/// 나누는 자리는 **렌더 결과가 아니라 구조에서** 고른다 — 블록이 끝나 열린 마크업이
273/// 없는 지점만 경계가 된다. 변환 후에 문자 수로 자르면 `<code>` 가 열린 채 잘리고,
274/// 채널은 400 을 준다(`DESIGN.md`).
275///
276/// # 예
277///
278/// ```
279/// use mdwire::{render, Channel};
280///
281/// let parts = render("## 제목\n\n**굵게** 있는 문단", Channel::TelegramHtml);
282/// assert_eq!(parts, vec!["<b>제목</b>\n\n<b>굵게</b> 있는 문단"]);
283/// ```
284pub fn render(input: &str, channel: Channel) -> Vec<String> {
285 render_with(input, channel, Options::default()).parts
286}
287
288/// [`render`] 에 옵션을 주고, 정규화가 고친 것도 같이 받는다.
289///
290/// ```
291/// use mdwire::{render_with, Channel, Dialect, Options};
292///
293/// let out = render_with("*굵게* 는 **영향 범위", Channel::SlackMarkdown, Options { from: Dialect::SlackMrkdwn });
294/// assert_eq!(out.parts, vec!["**굵게** 는 **영향 범위**"]);
295/// assert_eq!(out.repairs.closed_emphasis, 1);
296/// ```
297pub fn render_with(input: &str, channel: Channel, options: Options) -> Rendered {
298 let mut engine = Engine::new(channel, options);
299 let mut sink = PartsSink::new(Vocab::new(channel));
300 engine.feed(input, &mut sink);
301 engine.finish(&mut sink);
302 Rendered { repairs: engine.repairs(), parts: sink.into_parts() }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn channel_limits_are_channel_specific() {
311 assert_eq!(Channel::TelegramHtml.limit(), 4096);
312 assert_eq!(Channel::SlackMarkdown.limit(), 12_000);
313 }
314
315 /// **조각 하나가 곧 메시지 하나다.** 태그 한가운데서 끊으면 조각이 `… <a ` 로
316 /// 끝나고, 채널은 그 메시지를 통째로 거절한다. 실제 문서(링크가 달린 긴 목록)에서
317 /// 나온 고장이다.
318 #[test]
319 fn parts_never_end_inside_a_tag() {
320 let mut input = String::new();
321 for i in 0..40 {
322 input.push_str(&format!(
323 "- `method{i}(options?: SomeLongOptionsType{i}): string` — 주소의 뒤집힌 꼴을 \
324 돌려준다 [src](https://example.com/owner/repo/blob/master/src/mod{i}.ts#L{i}28)\n"
325 ));
326 }
327 let parts = render(&input, Channel::TelegramHtml);
328 assert!(parts.len() > 1, "한도를 넘겨서 나뉘어야 하는 입력이다");
329 for (i, p) in parts.iter().enumerate() {
330 assert_eq!(
331 p.matches('<').count(),
332 p.matches('>').count(),
333 "조각 {i} 가 태그 한가운데서 끊겼다: …{}",
334 &p[p.len().saturating_sub(40)..]
335 );
336 assert!(p.chars().count() <= Channel::TelegramHtml.limit(), "조각 {i} 가 한도를 넘었다");
337 }
338 }
339}