handoff-mcp-server 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +52 -11
- package/Cargo.toml +2 -1
- package/README.md +31 -2
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +24 -0
- package/skills/handoff-docs/SKILL.md +233 -0
- package/skills/handoff-load/SKILL.md +10 -2
- package/skills/handoff-memory/SKILL.md +20 -0
- package/src/context/injection.rs +239 -0
- package/src/context/mod.rs +129 -0
- package/src/lib.rs +1 -0
- package/src/mcp/handlers/docs.rs +1043 -0
- package/src/mcp/handlers/docs_query.rs +1335 -0
- package/src/mcp/handlers/get_task.rs +9 -0
- package/src/mcp/handlers/import_context.rs +1 -0
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +13 -0
- package/src/mcp/handlers/update_task.rs +2 -0
- package/src/mcp/tools.rs +170 -0
- package/src/storage/docs/mod.rs +478 -0
- package/src/storage/docs/model.rs +537 -0
- package/src/storage/docs/reassemble.rs +167 -0
- package/src/storage/docs/split.rs +377 -0
- package/src/storage/mod.rs +1 -0
- package/src/storage/tasks.rs +131 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//! v5 (spec §3.1): a document's `.md` file *is* the original body, so
|
|
2
|
+
//! reassembly from physical fragment files is no longer needed. What
|
|
3
|
+
//! remains useful is byte-offset section slicing: given a document's full
|
|
4
|
+
//! body and a [`SectionIndex`](super::model::SectionIndex) entry (as
|
|
5
|
+
//! computed by [`super::split::compute_sections`]), extract just that
|
|
6
|
+
//! section's text.
|
|
7
|
+
|
|
8
|
+
use anyhow::{bail, Result};
|
|
9
|
+
|
|
10
|
+
use super::model::SectionIndex;
|
|
11
|
+
|
|
12
|
+
/// Concatenates `fragment_bodies` (already in order) into a single `String`.
|
|
13
|
+
/// Kept as a small utility for callers that still hold a list of body slices
|
|
14
|
+
/// (e.g. `split::SplitDocument::fragments`) and want to reconstruct the full
|
|
15
|
+
/// body without going through file I/O.
|
|
16
|
+
pub fn reassemble(fragment_bodies: &[&str]) -> String {
|
|
17
|
+
fragment_bodies.concat()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Extracts one section's text from `body` by byte-offset slice, per
|
|
21
|
+
/// `section.byte_offset`/`section.byte_length`.
|
|
22
|
+
///
|
|
23
|
+
/// `body` must be the same byte sequence the section indexes were computed
|
|
24
|
+
/// against (i.e. the document body after BOM/frontmatter stripping — the
|
|
25
|
+
/// same body persisted to `_doc.<slug>.md`). Since `body` is read fresh from
|
|
26
|
+
/// disk on every call, it can drift out from under `section` (edited
|
|
27
|
+
/// out-of-band, e.g. truncated) between when `sections` was computed and
|
|
28
|
+
/// when this is called — so this returns `Err` rather than panicking:
|
|
29
|
+
///
|
|
30
|
+
/// - `Err` if `section`'s byte range doesn't fit within `body` at all (would
|
|
31
|
+
/// otherwise panic on slice-index-out-of-bounds), or lands on a non-UTF8
|
|
32
|
+
/// char boundary.
|
|
33
|
+
/// - `Err` if the range fits but `section.content_hash` doesn't match the
|
|
34
|
+
/// hash of the extracted slice (body changed but still long enough to
|
|
35
|
+
/// slice) — this is the drift check the doc comment used to merely
|
|
36
|
+
/// recommend callers perform themselves; it's now built in so every
|
|
37
|
+
/// caller gets it for free.
|
|
38
|
+
pub fn extract_section<'a>(body: &'a str, section: &SectionIndex) -> Result<&'a str> {
|
|
39
|
+
let end = section
|
|
40
|
+
.byte_offset
|
|
41
|
+
.checked_add(section.byte_length)
|
|
42
|
+
.filter(|&end| end <= body.len())
|
|
43
|
+
.ok_or_else(|| {
|
|
44
|
+
anyhow::anyhow!(
|
|
45
|
+
"section seq={} byte range [{}, {}) is out of bounds for body of length {} \
|
|
46
|
+
(document body has drifted since sections were indexed)",
|
|
47
|
+
section.seq,
|
|
48
|
+
section.byte_offset,
|
|
49
|
+
section.byte_offset + section.byte_length,
|
|
50
|
+
body.len()
|
|
51
|
+
)
|
|
52
|
+
})?;
|
|
53
|
+
if !body.is_char_boundary(section.byte_offset) || !body.is_char_boundary(end) {
|
|
54
|
+
bail!(
|
|
55
|
+
"section seq={} byte range [{}, {}) does not fall on a UTF-8 character \
|
|
56
|
+
boundary in the current body (document body has drifted)",
|
|
57
|
+
section.seq,
|
|
58
|
+
section.byte_offset,
|
|
59
|
+
end
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let slice = &body[section.byte_offset..end];
|
|
63
|
+
let actual_hash = lexsim::content_hash(slice);
|
|
64
|
+
if actual_hash != section.content_hash {
|
|
65
|
+
bail!(
|
|
66
|
+
"section seq={} content_hash mismatch: expected {}, got {} \
|
|
67
|
+
(document body has drifted since sections were indexed)",
|
|
68
|
+
section.seq,
|
|
69
|
+
section.content_hash,
|
|
70
|
+
actual_hash
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
Ok(slice)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[cfg(test)]
|
|
77
|
+
mod tests {
|
|
78
|
+
use super::*;
|
|
79
|
+
|
|
80
|
+
#[test]
|
|
81
|
+
fn reassemble_concatenates_bodies() {
|
|
82
|
+
assert_eq!(reassemble(&["a", "b", "c"]), "abc");
|
|
83
|
+
assert_eq!(reassemble(&[]), "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn extract_section_slices_by_byte_offset() {
|
|
88
|
+
let body = "Preamble.\n## A\nBody A\n";
|
|
89
|
+
let section = SectionIndex {
|
|
90
|
+
seq: 1,
|
|
91
|
+
heading: "A".to_string(),
|
|
92
|
+
level: 2,
|
|
93
|
+
byte_offset: 10,
|
|
94
|
+
byte_length: "## A\nBody A\n".len(),
|
|
95
|
+
content_hash: lexsim::content_hash("## A\nBody A\n"),
|
|
96
|
+
};
|
|
97
|
+
assert_eq!(extract_section(body, §ion).unwrap(), "## A\nBody A\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[test]
|
|
101
|
+
fn extract_section_at_start_of_body() {
|
|
102
|
+
let body = "Preamble text.\n";
|
|
103
|
+
let section = SectionIndex {
|
|
104
|
+
seq: 0,
|
|
105
|
+
heading: String::new(),
|
|
106
|
+
level: 0,
|
|
107
|
+
byte_offset: 0,
|
|
108
|
+
byte_length: body.len(),
|
|
109
|
+
content_hash: lexsim::content_hash(body),
|
|
110
|
+
};
|
|
111
|
+
assert_eq!(extract_section(body, §ion).unwrap(), body);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Regression test for a MAJOR bug found in review: `extract_section`
|
|
115
|
+
/// used to slice `body[byte_offset..byte_offset+byte_length]`
|
|
116
|
+
/// unconditionally, which panics with a slice-bounds error when `body`
|
|
117
|
+
/// has drifted (e.g. truncated by an out-of-band edit) so it's shorter
|
|
118
|
+
/// than the section's recorded range. Both `doc_get(format=section)` and
|
|
119
|
+
/// `doc_query` call this with a `body` freshly read from disk on every
|
|
120
|
+
/// request, so a panic here broke the JSON-RPC contract silently (no
|
|
121
|
+
/// response ever sent for that request). Must return `Err`, not panic.
|
|
122
|
+
#[test]
|
|
123
|
+
fn extract_section_errors_when_body_truncated_shorter_than_range() {
|
|
124
|
+
let full_body = "## A\nBody A that is fairly long\n";
|
|
125
|
+
let section = SectionIndex {
|
|
126
|
+
seq: 1,
|
|
127
|
+
heading: "A".to_string(),
|
|
128
|
+
level: 2,
|
|
129
|
+
byte_offset: 0,
|
|
130
|
+
byte_length: full_body.len(),
|
|
131
|
+
content_hash: lexsim::content_hash(full_body),
|
|
132
|
+
};
|
|
133
|
+
// Simulate drift: body on disk was truncated independently of the
|
|
134
|
+
// stored section index.
|
|
135
|
+
let drifted_body = "## A\n";
|
|
136
|
+
let result = extract_section(drifted_body, §ion);
|
|
137
|
+
assert!(result.is_err(), "expected Err, got {result:?}");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// Companion drift case: body is long enough to slice without an
|
|
141
|
+
/// out-of-bounds panic, but the content at that range no longer matches
|
|
142
|
+
/// what was indexed (edited in place). Must be caught by the
|
|
143
|
+
/// `content_hash` check, not silently return stale/wrong text.
|
|
144
|
+
#[test]
|
|
145
|
+
fn extract_section_errors_when_body_edited_in_place_hash_mismatch() {
|
|
146
|
+
let original = "## A\nOriginal body\n";
|
|
147
|
+
let section = SectionIndex {
|
|
148
|
+
seq: 1,
|
|
149
|
+
heading: "A".to_string(),
|
|
150
|
+
level: 2,
|
|
151
|
+
byte_offset: 0,
|
|
152
|
+
byte_length: original.len(),
|
|
153
|
+
content_hash: lexsim::content_hash(original),
|
|
154
|
+
};
|
|
155
|
+
let edited = "## A\nEditedd body\n";
|
|
156
|
+
assert_eq!(
|
|
157
|
+
edited.len(),
|
|
158
|
+
original.len(),
|
|
159
|
+
"test fixture must keep byte_length identical to isolate the hash check"
|
|
160
|
+
);
|
|
161
|
+
let result = extract_section(edited, §ion);
|
|
162
|
+
assert!(
|
|
163
|
+
result.is_err(),
|
|
164
|
+
"expected Err on hash mismatch, got {result:?}"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
//! Splits a single authored Markdown body into byte-exact fragments at ATX
|
|
2
|
+
//! heading boundaries, using `pulldown-cmark`'s byte-offset event stream so
|
|
3
|
+
//! that `#`/`##` inside fenced code blocks or block quotes is never
|
|
4
|
+
//! misdetected as a heading (see wiki/130-document-management.md §5.1 "M1").
|
|
5
|
+
//!
|
|
6
|
+
//! The split is purely byte-slicing: heading lines are never re-rendered, so
|
|
7
|
+
//! [`reassemble`](super::reassemble::reassemble) can reconstruct the original
|
|
8
|
+
//! document exactly by concatenating fragment bodies in `seq` order.
|
|
9
|
+
|
|
10
|
+
use anyhow::{bail, Result};
|
|
11
|
+
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
|
|
12
|
+
|
|
13
|
+
use super::model::SectionIndex;
|
|
14
|
+
|
|
15
|
+
const BOM: &str = "\u{FEFF}";
|
|
16
|
+
|
|
17
|
+
/// Default ATX heading level at which a document is split into fragments
|
|
18
|
+
/// (`##` = level 2). Overridable per call and, once P1-2 storage/config
|
|
19
|
+
/// wiring lands, per `config.toml` `doc_split_level`.
|
|
20
|
+
pub const DEFAULT_SPLIT_LEVEL: u8 = 2;
|
|
21
|
+
|
|
22
|
+
/// One fragment produced by [`split`]. `body` is a raw byte slice of the
|
|
23
|
+
/// input `body` string passed to `split` — it is never re-rendered, so
|
|
24
|
+
/// concatenating all fragments' `body` in `seq` order reproduces the
|
|
25
|
+
/// original document (minus a stripped BOM/frontmatter, which `split`
|
|
26
|
+
/// reports separately for the caller to persist).
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
28
|
+
pub struct SplitFragment<'a> {
|
|
29
|
+
/// 0-based position in the document. seq 0 is always the preamble
|
|
30
|
+
/// (text before the first heading at or above `split_level`), even if
|
|
31
|
+
/// empty.
|
|
32
|
+
pub seq: usize,
|
|
33
|
+
/// The heading text (without the leading `#` markers or surrounding
|
|
34
|
+
/// whitespace) that starts this fragment, or `None` for the seq-0
|
|
35
|
+
/// preamble when the document has no heading before it.
|
|
36
|
+
pub heading: Option<String>,
|
|
37
|
+
/// ATX heading level (1-6) that starts this fragment, or 0 for the
|
|
38
|
+
/// seq-0 preamble.
|
|
39
|
+
pub level: u8,
|
|
40
|
+
/// Raw byte slice of this fragment's body, including its own leading
|
|
41
|
+
/// heading line (if any) verbatim.
|
|
42
|
+
pub body: &'a str,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Result of [`split`]: the fragment list plus document-level facts needed
|
|
46
|
+
/// to persist and losslessly reassemble the original body (BOM presence,
|
|
47
|
+
/// line-ending style, extracted frontmatter).
|
|
48
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
49
|
+
pub struct SplitDocument<'a> {
|
|
50
|
+
/// Fragments in `seq` order, covering the document body *after* the BOM
|
|
51
|
+
/// and frontmatter (if any) have been stripped out of `fragments[0]`.
|
|
52
|
+
pub fragments: Vec<SplitFragment<'a>>,
|
|
53
|
+
/// `true` if `body` started with a UTF-8 BOM (`\u{FEFF}`).
|
|
54
|
+
pub has_bom: bool,
|
|
55
|
+
/// `"lf"` or `"crlf"`, detected from the first line ending encountered.
|
|
56
|
+
/// A document with no line endings at all (single line) is `"lf"`.
|
|
57
|
+
pub line_ending: &'static str,
|
|
58
|
+
/// The raw YAML frontmatter block (between the `---` fences, exclusive),
|
|
59
|
+
/// if the document starts with one. Excluded from `fragments[0].body`
|
|
60
|
+
/// per spec §5.1 scope rule 6.
|
|
61
|
+
pub frontmatter: Option<&'a str>,
|
|
62
|
+
/// `true` if the original document had a line ending immediately after
|
|
63
|
+
/// the closing `---` fence (e.g. `"---\ntitle: Foo\n---\nbody"`), `false`
|
|
64
|
+
/// if the closing fence was the last thing in the document (e.g.
|
|
65
|
+
/// `"---\ntitle: Foo\n---"` with nothing after it, not even a newline).
|
|
66
|
+
/// Meaningless when `frontmatter` is `None`. Callers reassembling the
|
|
67
|
+
/// document must conditionally omit the eol after `---` when this is
|
|
68
|
+
/// `false`, or they will invent a byte that was never in the original
|
|
69
|
+
/// body (see `extract_frontmatter` doc comment for why the eol cannot be
|
|
70
|
+
/// inferred from `frontmatter`/`fragments[0]` alone).
|
|
71
|
+
pub frontmatter_trailing_eol: bool,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Splits `body` into fragments at ATX heading boundaries of `split_level`
|
|
75
|
+
/// or higher (i.e. `level <= split_level`, since `H1` < `H2` numerically).
|
|
76
|
+
///
|
|
77
|
+
/// Returns `Err` if `body` mixes LF and CRLF line endings (spec §5.1: mixed
|
|
78
|
+
/// CRLF is rejected outright rather than guessed at).
|
|
79
|
+
pub fn split(body: &str, split_level: u8) -> Result<SplitDocument<'_>> {
|
|
80
|
+
let line_ending = detect_line_ending(body)?;
|
|
81
|
+
|
|
82
|
+
let has_bom = body.starts_with(BOM);
|
|
83
|
+
let after_bom = if has_bom { &body[BOM.len()..] } else { body };
|
|
84
|
+
|
|
85
|
+
let (frontmatter, after_frontmatter, frontmatter_byte_len, frontmatter_trailing_eol) =
|
|
86
|
+
extract_frontmatter(after_bom, line_ending);
|
|
87
|
+
|
|
88
|
+
let heading_bounds = collect_heading_bounds(after_frontmatter, split_level);
|
|
89
|
+
|
|
90
|
+
let mut fragments = Vec::with_capacity(heading_bounds.len() + 1);
|
|
91
|
+
|
|
92
|
+
// seq 0: preamble before the first qualifying heading (possibly empty).
|
|
93
|
+
let first_start = heading_bounds
|
|
94
|
+
.first()
|
|
95
|
+
.map(|h| h.start)
|
|
96
|
+
.unwrap_or(after_frontmatter.len());
|
|
97
|
+
fragments.push(SplitFragment {
|
|
98
|
+
seq: 0,
|
|
99
|
+
heading: None,
|
|
100
|
+
level: 0,
|
|
101
|
+
body: &after_frontmatter[..first_start],
|
|
102
|
+
});
|
|
103
|
+
let mut cursor = first_start;
|
|
104
|
+
|
|
105
|
+
for (i, bound) in heading_bounds.iter().enumerate() {
|
|
106
|
+
let end = heading_bounds
|
|
107
|
+
.get(i + 1)
|
|
108
|
+
.map(|next| next.start)
|
|
109
|
+
.unwrap_or(after_frontmatter.len());
|
|
110
|
+
fragments.push(SplitFragment {
|
|
111
|
+
seq: i + 1,
|
|
112
|
+
heading: Some(bound.text.clone()),
|
|
113
|
+
level: bound.level,
|
|
114
|
+
body: &after_frontmatter[cursor..end],
|
|
115
|
+
});
|
|
116
|
+
cursor = end;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Frontmatter length is reported to the caller via the returned struct;
|
|
120
|
+
// it is intentionally not folded back into any fragment body (spec
|
|
121
|
+
// §5.1 scope rule 6: frontmatter goes to `source.frontmatter`, not
|
|
122
|
+
// fragment seq 0).
|
|
123
|
+
let _ = frontmatter_byte_len;
|
|
124
|
+
|
|
125
|
+
Ok(SplitDocument {
|
|
126
|
+
fragments,
|
|
127
|
+
has_bom,
|
|
128
|
+
line_ending,
|
|
129
|
+
frontmatter,
|
|
130
|
+
frontmatter_trailing_eol,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Converts a [`SplitDocument`]'s fragments into a [`SectionIndex`] manifest
|
|
135
|
+
/// (v5, spec §3.1): one entry per fragment, with a cumulative `byte_offset`
|
|
136
|
+
/// into the *body as reported by `split`* (i.e. after BOM/frontmatter
|
|
137
|
+
/// stripping — the same body callers persist to `_doc.<slug>.md`'s section
|
|
138
|
+
/// range), `byte_length`, and a content hash of each fragment's body slice.
|
|
139
|
+
/// Performs no file I/O — this is purely an in-memory transform.
|
|
140
|
+
pub fn compute_sections(split_doc: &SplitDocument<'_>) -> Vec<SectionIndex> {
|
|
141
|
+
let mut offset = 0usize;
|
|
142
|
+
split_doc
|
|
143
|
+
.fragments
|
|
144
|
+
.iter()
|
|
145
|
+
.map(|frag| {
|
|
146
|
+
let section = SectionIndex {
|
|
147
|
+
seq: frag.seq,
|
|
148
|
+
heading: frag.heading.clone().unwrap_or_default(),
|
|
149
|
+
level: frag.level,
|
|
150
|
+
byte_offset: offset,
|
|
151
|
+
byte_length: frag.body.len(),
|
|
152
|
+
content_hash: lexsim::content_hash(frag.body),
|
|
153
|
+
};
|
|
154
|
+
offset += frag.body.len();
|
|
155
|
+
section
|
|
156
|
+
})
|
|
157
|
+
.collect()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// A single heading boundary: byte offset (into the frontmatter-stripped
|
|
161
|
+
/// body) where the heading line starts, its level, and its trimmed text.
|
|
162
|
+
struct HeadingBound {
|
|
163
|
+
start: usize,
|
|
164
|
+
level: u8,
|
|
165
|
+
text: String,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Runs the pulldown-cmark offset-tracking parser and collects the byte
|
|
169
|
+
/// start of every ATX heading whose level is `<= split_level`. Fenced code
|
|
170
|
+
/// blocks and block quotes are handled by the parser itself, so a `##`
|
|
171
|
+
/// inside a fence never appears here.
|
|
172
|
+
fn collect_heading_bounds(text: &str, split_level: u8) -> Vec<HeadingBound> {
|
|
173
|
+
let parser = Parser::new_ext(text, Options::all());
|
|
174
|
+
let mut bounds = Vec::new();
|
|
175
|
+
let mut in_qualifying_heading: Option<(usize, u8)> = None;
|
|
176
|
+
let mut heading_text = String::new();
|
|
177
|
+
|
|
178
|
+
for (event, range) in parser.into_offset_iter() {
|
|
179
|
+
match event {
|
|
180
|
+
Event::Start(Tag::Heading { level, .. }) => {
|
|
181
|
+
let numeric_level = heading_level_to_u8(level);
|
|
182
|
+
if numeric_level <= split_level && is_atx_heading(text, &range) {
|
|
183
|
+
in_qualifying_heading = Some((range.start, numeric_level));
|
|
184
|
+
heading_text.clear();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
Event::Text(ref t) | Event::Code(ref t) if in_qualifying_heading.is_some() => {
|
|
188
|
+
heading_text.push_str(t);
|
|
189
|
+
}
|
|
190
|
+
Event::End(pulldown_cmark::TagEnd::Heading(_)) => {
|
|
191
|
+
if let Some((start, level)) = in_qualifying_heading.take() {
|
|
192
|
+
bounds.push(HeadingBound {
|
|
193
|
+
start,
|
|
194
|
+
level,
|
|
195
|
+
text: heading_text.trim().to_string(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
_ => {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
bounds
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// Returns `true` if the heading `range` (as reported by pulldown-cmark's
|
|
207
|
+
/// offset iterator) begins with a literal `#` in the source `text`.
|
|
208
|
+
///
|
|
209
|
+
/// pulldown-cmark emits the identical `Event::Start(Tag::Heading { level,
|
|
210
|
+
/// .. })` for both ATX (`## Foo`) and setext (`Foo\n---`) headings, with no
|
|
211
|
+
/// syntax discriminator on the event itself. Per spec §5.1 scope restriction,
|
|
212
|
+
/// setext headings must never be treated as split boundaries (they stay
|
|
213
|
+
/// embedded in the enclosing fragment's body), so this raw-byte check is the
|
|
214
|
+
/// only reliable way to reject them: an ATX heading's byte range always
|
|
215
|
+
/// starts at the line's leading `#`, whereas a setext heading's range starts
|
|
216
|
+
/// at the title text itself (the underline is a separate, later span).
|
|
217
|
+
fn is_atx_heading(text: &str, range: &std::ops::Range<usize>) -> bool {
|
|
218
|
+
text.as_bytes().get(range.start).is_some_and(|&b| b == b'#')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
fn heading_level_to_u8(level: HeadingLevel) -> u8 {
|
|
222
|
+
match level {
|
|
223
|
+
HeadingLevel::H1 => 1,
|
|
224
|
+
HeadingLevel::H2 => 2,
|
|
225
|
+
HeadingLevel::H3 => 3,
|
|
226
|
+
HeadingLevel::H4 => 4,
|
|
227
|
+
HeadingLevel::H5 => 5,
|
|
228
|
+
HeadingLevel::H6 => 6,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// Detects the document's line-ending style. Returns an error if both `\r\n`
|
|
233
|
+
/// and a bare `\n` (not preceded by `\r`) appear in the same document.
|
|
234
|
+
fn detect_line_ending(body: &str) -> Result<&'static str> {
|
|
235
|
+
let bytes = body.as_bytes();
|
|
236
|
+
let mut saw_crlf = false;
|
|
237
|
+
let mut saw_lone_lf = false;
|
|
238
|
+
|
|
239
|
+
for (i, &b) in bytes.iter().enumerate() {
|
|
240
|
+
if b == b'\n' {
|
|
241
|
+
let preceded_by_cr = i > 0 && bytes[i - 1] == b'\r';
|
|
242
|
+
if preceded_by_cr {
|
|
243
|
+
saw_crlf = true;
|
|
244
|
+
} else {
|
|
245
|
+
saw_lone_lf = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if saw_crlf && saw_lone_lf {
|
|
251
|
+
bail!("document mixes CRLF and LF line endings; mixed line endings are not supported");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
Ok(if saw_crlf { "crlf" } else { "lf" })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Extracts a leading YAML frontmatter block (`---\n...\n---\n`) using
|
|
258
|
+
/// pulldown-cmark's `MetadataBlock` event, so the same fenced-code-aware
|
|
259
|
+
/// parser that drives heading detection also drives frontmatter detection
|
|
260
|
+
/// (no separate ad hoc regex).
|
|
261
|
+
///
|
|
262
|
+
/// Returns `(frontmatter_text, remaining_body, frontmatter_byte_len,
|
|
263
|
+
/// trailing_eol_present)`. When there is no frontmatter, returns `(None,
|
|
264
|
+
/// text, 0, false)` unchanged.
|
|
265
|
+
///
|
|
266
|
+
/// `line_ending` (`"lf"` or `"crlf"`, as already detected from the whole
|
|
267
|
+
/// document by [`detect_line_ending`]) selects the fence delimiter
|
|
268
|
+
/// (`"---\n"` vs `"---\r\n"`) so that a CRLF document's opening fence is
|
|
269
|
+
/// actually recognized instead of falling through to the `unwrap_or(block)`
|
|
270
|
+
/// fallback, which would otherwise leave both `---` fences embedded in the
|
|
271
|
+
/// reported frontmatter and corrupt the byte-identical round trip.
|
|
272
|
+
///
|
|
273
|
+
/// pulldown-cmark's `MetadataBlock` range ends right at the closing `---`
|
|
274
|
+
/// fence and never includes the line ending that follows it (verified across
|
|
275
|
+
/// LF/CRLF and with/without trailing content): e.g. for
|
|
276
|
+
/// `"---\ntitle: Foo\n---\n# Title\n"`, `range.end` is `18` (just past the
|
|
277
|
+
/// closing `---`), leaving the `\n` at byte 18 unconsumed and still present
|
|
278
|
+
/// at the start of `after_frontmatter`. If left as-is, the caller
|
|
279
|
+
/// (`handle_doc_get`/`handle_doc_list`) which re-adds `---{eol}` after the
|
|
280
|
+
/// frontmatter on reassembly would double that line ending. So this function
|
|
281
|
+
/// consumes one extra `eol` past `range.end` here, when present, to keep
|
|
282
|
+
/// `after_frontmatter` exactly what followed the frontmatter block's own
|
|
283
|
+
/// trailing newline. Whether that extra `eol` was actually present is
|
|
284
|
+
/// reported back as `trailing_eol_present`, since a caller re-adding
|
|
285
|
+
/// `---{eol}` on reassembly cannot otherwise tell a document that ended
|
|
286
|
+
/// exactly at the closing fence (no eol at all) apart from one that had
|
|
287
|
+
/// content following it (see [`SplitDocument::frontmatter_trailing_eol`]).
|
|
288
|
+
fn extract_frontmatter<'a>(
|
|
289
|
+
text: &'a str,
|
|
290
|
+
line_ending: &'static str,
|
|
291
|
+
) -> (Option<&'a str>, &'a str, usize, bool) {
|
|
292
|
+
let parser = Parser::new_ext(text, Options::all());
|
|
293
|
+
// Only the very first event can be a metadata block (pulldown-cmark only
|
|
294
|
+
// recognizes YAML frontmatter at the start of the document), so a single
|
|
295
|
+
// peek is enough.
|
|
296
|
+
let Some((event, range)) = parser.into_offset_iter().next() else {
|
|
297
|
+
return (None, text, 0, false);
|
|
298
|
+
};
|
|
299
|
+
if !matches!(event, Event::Start(Tag::MetadataBlock(_))) {
|
|
300
|
+
return (None, text, 0, false);
|
|
301
|
+
}
|
|
302
|
+
// `range` for the Start event already covers the whole block
|
|
303
|
+
// (`---\n...\n---`), since pulldown-cmark treats metadata blocks as an
|
|
304
|
+
// atomic (non-nested) event span, but excludes the line ending after the
|
|
305
|
+
// closing fence (see doc comment above).
|
|
306
|
+
let block = &text[range.clone()];
|
|
307
|
+
let eol = if line_ending == "crlf" { "\r\n" } else { "\n" };
|
|
308
|
+
let fence_with_eol = format!("---{eol}");
|
|
309
|
+
let inner = block
|
|
310
|
+
.strip_prefix(fence_with_eol.as_str())
|
|
311
|
+
.and_then(|s| {
|
|
312
|
+
s.strip_suffix(fence_with_eol.as_str())
|
|
313
|
+
.or_else(|| s.strip_suffix("---"))
|
|
314
|
+
})
|
|
315
|
+
.unwrap_or(block);
|
|
316
|
+
let trailing_eol_present = text[range.end..].starts_with(eol);
|
|
317
|
+
let end = if trailing_eol_present {
|
|
318
|
+
range.end + eol.len()
|
|
319
|
+
} else {
|
|
320
|
+
range.end
|
|
321
|
+
};
|
|
322
|
+
(Some(inner), &text[end..], end, trailing_eol_present)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#[cfg(test)]
|
|
326
|
+
mod tests {
|
|
327
|
+
use super::*;
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn compute_sections_assigns_cumulative_byte_offsets() {
|
|
331
|
+
let body = "Preamble.\n\n## A\nBody A\n## B\nBody B\n";
|
|
332
|
+
let split_doc = split(body, 2).unwrap();
|
|
333
|
+
let sections = compute_sections(&split_doc);
|
|
334
|
+
|
|
335
|
+
assert_eq!(sections.len(), split_doc.fragments.len());
|
|
336
|
+
let mut expected_offset = 0usize;
|
|
337
|
+
for (section, frag) in sections.iter().zip(split_doc.fragments.iter()) {
|
|
338
|
+
assert_eq!(section.seq, frag.seq);
|
|
339
|
+
assert_eq!(section.level, frag.level);
|
|
340
|
+
assert_eq!(section.heading, frag.heading.clone().unwrap_or_default());
|
|
341
|
+
assert_eq!(section.byte_offset, expected_offset);
|
|
342
|
+
assert_eq!(section.byte_length, frag.body.len());
|
|
343
|
+
assert_eq!(section.content_hash, lexsim::content_hash(frag.body));
|
|
344
|
+
expected_offset += frag.body.len();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#[test]
|
|
349
|
+
fn compute_sections_offsets_slice_the_split_body_correctly() {
|
|
350
|
+
let body = "Preamble.\n\n## A\nBody A\n## B\nBody B\n";
|
|
351
|
+
let split_doc = split(body, 2).unwrap();
|
|
352
|
+
let sections = compute_sections(&split_doc);
|
|
353
|
+
|
|
354
|
+
// Reconstruct the after-frontmatter body by concatenating fragments,
|
|
355
|
+
// then verify each section's byte_offset/byte_length slices it back
|
|
356
|
+
// out identically to the fragment body it was computed from.
|
|
357
|
+
let full: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
358
|
+
for (section, frag) in sections.iter().zip(split_doc.fragments.iter()) {
|
|
359
|
+
let slice = &full[section.byte_offset..section.byte_offset + section.byte_length];
|
|
360
|
+
assert_eq!(slice, frag.body);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
#[test]
|
|
365
|
+
fn compute_sections_no_headings_is_single_section_at_offset_zero() {
|
|
366
|
+
let body = "Just plain text.\n";
|
|
367
|
+
let split_doc = split(body, 2).unwrap();
|
|
368
|
+
let sections = compute_sections(&split_doc);
|
|
369
|
+
|
|
370
|
+
assert_eq!(sections.len(), 1);
|
|
371
|
+
assert_eq!(sections[0].seq, 0);
|
|
372
|
+
assert_eq!(sections[0].byte_offset, 0);
|
|
373
|
+
assert_eq!(sections[0].byte_length, body.len());
|
|
374
|
+
assert_eq!(sections[0].heading, "");
|
|
375
|
+
assert_eq!(sections[0].level, 0);
|
|
376
|
+
}
|
|
377
|
+
}
|