Skip to main content

strop_syntax/
lib.rs

1//! strop-syntax: tree-sitter highlighting. Parsers statically linked
2//! (0002 §2.2 — never dlopen'd grammars); queries are data (0001 §5.11),
3//! embedded defaults now, runtime overrides when config lands (0005).
4//!
5//! The whole pipeline is rope-backed: parsing walks rope chunks, and
6//! query predicates (`#eq?`/`#match?`/…) read node text through a
7//! [`tree_sitter::TextProvider`] over the same chunks — no full-text
8//! `String` is materialized on any input or render path, and language
9//! detection never touches the filesystem.
10
11use streaming_iterator::StreamingIterator;
12mod guides;
13pub mod languages;
14pub use guides::{GuideFrame, IndentGuides};
15mod injections;
16mod spans;
17pub use spans::Emphasis;
18use spans::{CaptureStyle, LayeredSpan};
19
20use ropey::Rope;
21use strop_core::id::BufferRevision;
22use tree_sitter::{Parser, Query, QueryCursor, TextProvider};
23
24/// Semantic classes the renderer maps to palette colors. Kept small and
25/// stable; the query capture names map onto these.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
27pub enum Class {
28    Keyword,
29    Function,
30    Type,
31    String,
32    Comment,
33    Number,
34    Operator,
35    Punctuation,
36    Constant,
37    Variable,
38    Attribute,
39    Heading,
40    Link,
41    Code,
42    Quote,
43    List,
44    Tag,
45}
46
47impl Class {
48    fn from_capture(name: &str) -> Self {
49        if name.starts_with("constant.numeric.") {
50            return Class::Number;
51        }
52        if let Some(markup) = name.strip_prefix("markup.") {
53            return match markup.split('.').next() {
54                Some("heading") => Class::Heading,
55                Some("link") => Class::Link,
56                Some("raw") => Class::Code,
57                Some("quote") => Class::Quote,
58                Some("list") => Class::List,
59                _ => Class::Variable,
60            };
61        }
62        let head = name.split('.').next().unwrap_or(name);
63        match head {
64            "keyword" => Class::Keyword,
65            "function" | "constructor" => Class::Function,
66            "type" | "namespace" | "label" => Class::Type,
67            "string" | "character" => Class::String,
68            "comment" => Class::Comment,
69            "number" | "float" => Class::Number,
70            "operator" => Class::Operator,
71            "punctuation" => Class::Punctuation,
72            "constant" | "boolean" => Class::Constant,
73            "attribute" | "property" => Class::Attribute,
74            "tag" => Class::Tag,
75            _ => Class::Variable,
76        }
77    }
78}
79
80/// A colored span, in byte offsets.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
82pub struct Span {
83    pub start: usize,
84    pub end: usize,
85    pub class: Class,
86    pub emphasis: Emphasis,
87}
88
89/// Highlighting failed out loud: no hidden full-text reparse fallback, no
90/// swallowed error, no panic — the caller decides what the failure means
91/// for its surface.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum HighlightError {
94    /// tree-sitter refused the chunked input (encoding breach or an
95    /// internal limit). Cached spans and the kept tree stay untouched, so
96    /// the next call retries from the same state.
97    Parse,
98    /// Superseded analysis is not a parser failure.
99    Cancelled,
100    /// Recursive injected language structure exceeded the owned parser bound.
101    InjectionDepth,
102}
103
104impl std::fmt::Display for HighlightError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            HighlightError::Parse => f.write_str("tree-sitter produced no parse tree"),
108            HighlightError::Cancelled => f.write_str("syntax analysis superseded"),
109            HighlightError::InjectionDepth => {
110                f.write_str("syntax injection nesting exceeds eight levels")
111            }
112        }
113    }
114}
115
116impl std::error::Error for HighlightError {}
117
118/// Query text source over the rope itself: each node's text is yielded as
119/// the rope's own chunk slices, so a node inside one chunk is compared
120/// with zero copy. Only a node straddling a chunk boundary is assembled —
121/// by tree-sitter, into its reusable buffers, bounded by node size.
122struct RopeText<'a> {
123    rope: &'a Rope,
124}
125
126impl<'a> TextProvider<&'a [u8]> for RopeText<'a> {
127    type I = RopeSlices<'a>;
128
129    fn text(&mut self, node: tree_sitter::Node<'_>) -> Self::I {
130        RopeSlices {
131            rope: self.rope,
132            start: node.start_byte(),
133            end: node.end_byte(),
134        }
135    }
136}
137
138/// The chunk slices covering `[start, end)`, in document order. An empty
139/// range yields nothing — an empty node's text is empty, exactly as with
140/// a plain byte-slice provider.
141struct RopeSlices<'a> {
142    rope: &'a Rope,
143    start: usize,
144    end: usize,
145}
146
147impl<'a> Iterator for RopeSlices<'a> {
148    type Item = &'a [u8];
149
150    fn next(&mut self) -> Option<Self::Item> {
151        if self.start >= self.end {
152            return None;
153        }
154        let (chunk, chunk_start, ..) = self.rope.chunk_at_byte(self.start);
155        let head = &chunk[self.start - chunk_start..];
156        // stop at the chunk's end or the node's end, whichever is first;
157        // both are char boundaries (rope chunks and tree-sitter node
158        // ranges always are), so the slice is valid UTF-8 as-is
159        let take = head.len().min(self.end - self.start);
160        let slice = &head.as_bytes()[..take];
161        self.start += take;
162        Some(slice)
163    }
164}
165
166/// One language's parser + highlight query. The tree tracks the buffer's
167/// journal (0022 §1): edits apply as a cheap pointer walk at commit time
168/// and reparsing is incremental against the kept tree — a full parse is
169/// the cold-start path, not the rule.
170pub struct Highlighter {
171    parser: Parser,
172    query: Query,
173    /// Capture index → class, resolved once at construction.
174    classes: Vec<CaptureStyle>,
175    source_hash: Option<BufferRevision>,
176    spans: Vec<Span>,
177    /// The parse tree covering `tree_revision`.
178    tree: Option<tree_sitter::Tree>,
179    tree_revision: BufferRevision,
180    span_window: Option<(usize, usize)>,
181    injection_query: Option<Query>,
182    injection_depth: usize,
183    children: Vec<injections::InjectedHighlighter>,
184}
185
186impl Highlighter {
187    /// Drop the kept tree (0023: a mutation path that can't produce
188    /// exact edit coordinates invalidates rather than lying).
189    pub fn invalidate(&mut self) {
190        self.tree = None;
191        self.source_hash = None;
192        self.span_window = None;
193        self.children.clear();
194    }
195
196    /// Feed the pre-edit journal to the kept tree (0022 §1): a cheap
197    /// pointer walk at commit time; the reparse stays lazy. The edits
198    /// arrive exactly as `strop-core` published them — tuple points
199    /// converted to tree-sitter points once, here — so the editor hands
200    /// over `&[change.edit]` with `change.revision` untouched.
201    pub fn apply_edits(&mut self, edits: &[strop_core::InputEdit], revision: BufferRevision) {
202        if revision == self.tree_revision {
203            return;
204        }
205        if let Some(tree) = &mut self.tree {
206            for edit in edits {
207                tree.edit(&tree_sitter::InputEdit {
208                    start_byte: edit.start_byte,
209                    old_end_byte: edit.old_end_byte,
210                    new_end_byte: edit.new_end_byte,
211                    start_position: tree_sitter::Point {
212                        row: edit.start_point.0,
213                        column: edit.start_point.1,
214                    },
215                    old_end_position: tree_sitter::Point {
216                        row: edit.old_end_point.0,
217                        column: edit.old_end_point.1,
218                    },
219                    new_end_position: tree_sitter::Point {
220                        row: edit.new_end_point.0,
221                        column: edit.new_end_point.1,
222                    },
223                });
224            }
225        }
226        // with no kept tree the next parse builds it — the revision
227        // still advances so reparse-once stays the rule, not per frame
228        self.tree_revision = revision;
229        for child in &mut self.children {
230            child.apply_edits(edits, revision);
231        }
232    }
233
234    /// Pure constructor: the path plus the rope that backs the document.
235    /// When basename and extension both miss, the rope's first line —
236    /// bounded to 256 bytes, assembled chunk-wise — is the shebang
237    /// fallback. Nothing here reads the filesystem: UI dispatch never
238    /// blocks on disk.
239    pub fn for_path(path: &std::path::Path, rope: &Rope) -> Option<Self> {
240        let spec = languages::detect(path, Some(&first_line_bounded(rope)))?;
241        Self::from_spec(spec)
242    }
243
244    fn from_spec(spec: languages::LanguageSpec) -> Option<Self> {
245        let mut parser = Parser::new();
246        parser.set_language(&spec.language).ok()?;
247        let query = Query::new(&spec.language, spec.highlights).ok()?;
248        let injection_query = if spec.injections.is_empty() {
249            None
250        } else {
251            Some(Query::new(&spec.language, spec.injections).ok()?)
252        };
253        let classes = query
254            .capture_names()
255            .iter()
256            .map(|name| CaptureStyle {
257                class: Class::from_capture(name),
258                emphasis: Emphasis::from_capture(name),
259            })
260            .collect();
261        Some(Self {
262            parser,
263            query,
264            classes,
265            source_hash: None,
266            spans: Vec::new(),
267            tree: None,
268            tree_revision: BufferRevision::new(0),
269            span_window: None,
270            injection_query,
271            injection_depth: 0,
272            children: Vec::new(),
273        })
274    }
275
276    /// Highlight spans intersecting `[first_byte, last_byte)` of the rope.
277    /// Reparses only when the text changed. `revision` is the document's
278    /// edit counter (0020 §5: the len+first+last key under-invalidated
279    /// same-length middle edits deterministically). A parse that cannot
280    /// complete is a typed [`HighlightError`] — never a hidden reparse
281    /// fallback, never a panic.
282    pub fn highlight(
283        &mut self,
284        rope: &Rope,
285        revision: BufferRevision,
286        first_byte: usize,
287        last_byte: usize,
288    ) -> Result<Vec<Span>, HighlightError> {
289        self.highlight_while(rope, revision, first_byte, last_byte, || false)
290    }
291
292    pub fn highlight_while(
293        &mut self,
294        rope: &Rope,
295        revision: BufferRevision,
296        first_byte: usize,
297        last_byte: usize,
298        cancelled: impl Fn() -> bool,
299    ) -> Result<Vec<Span>, HighlightError> {
300        self.highlight_cancellable(rope, revision, first_byte, last_byte, &cancelled)
301    }
302
303    fn highlight_cancellable(
304        &mut self,
305        rope: &Rope,
306        revision: BufferRevision,
307        first_byte: usize,
308        last_byte: usize,
309        cancelled: &dyn Fn() -> bool,
310    ) -> Result<Vec<Span>, HighlightError> {
311        if cancelled() {
312            return Err(HighlightError::Cancelled);
313        }
314        if Some(revision) != self.source_hash {
315            // A skipped edit journal must never make an unchanged old tree
316            // masquerade as the new rope. Exact journals retain incremental parse.
317            if self.tree_revision != revision {
318                self.tree = None;
319            }
320            let mut progress = |_: &tree_sitter::ParseState| cancelled();
321            let tree = self.parser.parse_with_options(
322                &mut |byte: usize, _| {
323                    if byte >= rope.len_bytes() {
324                        return "";
325                    }
326                    let (chunk, start, _, _) = rope.chunk_at_byte(byte);
327                    &chunk[byte - start..]
328                },
329                self.tree.as_ref(),
330                Some(tree_sitter::ParseOptions::new().progress_callback(&mut progress)),
331            );
332            let Some(tree) = tree else {
333                self.parser.reset();
334                return Err(if cancelled() {
335                    HighlightError::Cancelled
336                } else {
337                    HighlightError::Parse
338                });
339            };
340            self.tree = Some(tree);
341            self.tree_revision = revision;
342            self.source_hash = Some(revision);
343            self.span_window = None;
344        }
345        let window = (
346            first_byte.min(rope.len_bytes()),
347            last_byte.min(rope.len_bytes()),
348        );
349        if self.span_window != Some(window) {
350            let tree = self.tree.as_ref().ok_or(HighlightError::Parse)?;
351            let mut cursor = QueryCursor::new();
352            cursor.set_byte_range(window.0..window.1);
353            let mut progress = |_: &tree_sitter::QueryCursorState| cancelled();
354            let mut captures = Vec::new();
355            let mut matches = cursor.matches_with_options(
356                &self.query,
357                tree.root_node(),
358                RopeText { rope },
359                tree_sitter::QueryCursorOptions::new().progress_callback(&mut progress),
360            );
361            while let Some(m) = { StreamingIterator::next(&mut matches) } {
362                if cancelled() {
363                    return Err(HighlightError::Cancelled);
364                }
365                for cap in m.captures {
366                    let node = cap.node;
367                    if node.end_byte() <= window.0 || node.start_byte() >= window.1 {
368                        continue;
369                    }
370                    let style = self.classes[cap.index as usize];
371                    captures.push(LayeredSpan {
372                        span: Span {
373                            start: node.start_byte(),
374                            end: node.end_byte(),
375                            class: style.class,
376                            emphasis: style.emphasis,
377                        },
378                        injected: false,
379                    });
380                }
381            }
382            if cancelled() {
383                return Err(HighlightError::Cancelled);
384            }
385            drop(matches);
386            captures.extend(self.injection_spans(rope, revision, window.0, window.1, cancelled)?);
387            self.spans = spans::flatten(captures, window.0, window.1);
388            self.span_window = Some(window);
389        }
390        Ok(self.spans.clone())
391    }
392}
393
394/// Largest char-boundary-aligned length of `head` that is at most `want`.
395/// A byte cap can land inside a multibyte character; walking back at most
396/// three bytes keeps the slice valid UTF-8.
397fn cut_at_boundary(head: &str, want: usize) -> usize {
398    let mut take = want.min(head.len());
399    while take > 0 && !head.is_char_boundary(take) {
400        take -= 1;
401    }
402    take
403}
404
405/// The rope's first line, capped at 256 bytes so a minified no-newline
406/// blob can't turn shebang detection into a full-text copy. Assembled
407/// chunk-wise, so a first line spanning rope chunks comes out whole
408/// (up to the cap).
409fn first_line_bounded(rope: &Rope) -> String {
410    const CAP: usize = 256;
411    // only a shebang can match — skip the copy for the common case
412    if rope.len_bytes() == 0 || rope.byte(0) != b'#' {
413        return String::new();
414    }
415    let limit = rope.len_bytes().min(CAP);
416    let mut line = String::new();
417    let mut byte = 0;
418    while byte < limit {
419        let (chunk, start, ..) = rope.chunk_at_byte(byte);
420        let head = &chunk[byte - start..];
421        let stop = head.find('\n').unwrap_or(head.len());
422        let take = cut_at_boundary(head, stop.min(limit - byte));
423        if take == 0 {
424            break; // the cap cut inside a multibyte char
425        }
426        line.push_str(&head[..take]);
427        if take == stop {
428            break; // consumed through the newline (or the whole chunk had none)
429        }
430        byte += take;
431    }
432    line
433}
434
435#[cfg(test)]
436mod tests;