Skip to main content

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}