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
5use std::collections::HashMap;
6
7use streaming_iterator::StreamingIterator;
8pub mod languages;
9
10use tree_sitter::{Parser, Query, QueryCursor};
11
12/// Semantic classes the renderer maps to palette colors. Kept small and
13/// stable; the query capture names map onto these.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Class {
16    Keyword,
17    Function,
18    Type,
19    String,
20    Comment,
21    Number,
22    Operator,
23    Punctuation,
24    Constant,
25    Variable,
26    Attribute,
27}
28
29impl Class {
30    fn from_capture(name: &str) -> Self {
31        let head = name.split('.').next().unwrap_or(name);
32        match head {
33            "keyword" => Class::Keyword,
34            "function" | "constructor" => Class::Function,
35            "type" => Class::Type,
36            "string" | "character" => Class::String,
37            "comment" => Class::Comment,
38            "number" | "float" => Class::Number,
39            "operator" => Class::Operator,
40            "punctuation" => Class::Punctuation,
41            "constant" | "boolean" => Class::Constant,
42            "attribute" | "property" => Class::Attribute,
43            _ => Class::Variable,
44        }
45    }
46}
47
48/// A colored span, in byte offsets.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Span {
51    pub start: usize,
52    pub end: usize,
53    pub class: Class,
54}
55
56/// One language's parser + highlight query. Reparses on demand; the
57/// incremental edit-diff feed (0001 pillar 4) lands when the core reports
58/// edits — prototype correctness first, per-frame cost is invisible at
59/// demo file sizes.
60pub struct Highlighter {
61    parser: Parser,
62    query: Query,
63    /// Capture index → class, resolved once at construction.
64    classes: Vec<Class>,
65    source_hash: u64,
66    spans: Vec<Span>,
67    /// The parse tree covering `tree_revision` (0022 §1: edits apply
68    /// incrementally; a full reparse is the fallback, not the rule).
69    tree: Option<tree_sitter::Tree>,
70    tree_revision: u64,
71}
72
73impl Highlighter {
74    /// Apply the transaction's edits to the kept tree (0022 §1):
75    /// cheap pointer walk at commit time; the reparse stays lazy.
76    pub fn apply_edits(&mut self, edits: &[tree_sitter::InputEdit], revision: u64) {
77        if revision == self.tree_revision {
78            return;
79        }
80        if let Some(tree) = &mut self.tree {
81            for e in edits {
82                tree.edit(e);
83            }
84        }
85        // with no kept tree the next parse builds it — the revision
86        // still advances so reparse-once stays the rule, not per frame
87        self.tree_revision = revision;
88    }
89
90    pub fn for_path(path: &str) -> Option<Self> {
91        let spec = languages::detect(path, None).or_else(|| {
92            // basename/extension both missed: one bounded read of the
93            // first line, and the shebang decides (languages::detect)
94            let line = first_line(path)?;
95            languages::detect(path, Some(&line))
96        })?;
97        Self::from_spec(spec)
98    }
99
100    fn from_spec(spec: languages::LanguageSpec) -> Option<Self> {
101        let mut parser = Parser::new();
102        parser.set_language(&spec.language).ok()?;
103        let query = Query::new(&spec.language, spec.highlights).ok()?;
104        let classes = query
105            .capture_names()
106            .iter()
107            .map(|n| Class::from_capture(n))
108            .collect();
109        Some(Self {
110            parser,
111            query,
112            classes,
113            source_hash: u64::MAX, // never a real revision
114            spans: Vec::new(),
115            tree: None,
116            tree_revision: 0,
117        })
118    }
119
120    /// Highlight spans intersecting `[first_byte, last_byte)` of the rope.
121    /// Reparses only when the text changed. `revision` is the document's
122    /// edit counter (0020 §5: the len+first+last key under-invalidated
123    /// same-length middle edits deterministically).
124    pub fn highlight(
125        &mut self,
126        rope: &ropey::Rope,
127        revision: u64,
128        first_byte: usize,
129        last_byte: usize,
130    ) -> Vec<Span> {
131        let hash = revision;
132        if hash != self.source_hash {
133            // 0022 §1: parse from rope chunks against the kept tree —
134            // no String materialization, no from-scratch parse
135            let text = rope.to_string();
136            let tree = {
137                // random-access chunks (0022 fix): tree-sitter re-requests
138                // earlier bytes on error recovery — a forward-only chunk
139                // iterator underflowed there and panicked (or fed garbage
140                // in release builds)
141                let parse_result = self.parser.parse_with_options(
142                    &mut |byte: usize, _| {
143                        if byte >= rope.len_bytes() {
144                            return "";
145                        }
146                        let (chunk, start, _, _) = rope.chunk_at_byte(byte);
147                        &chunk[byte - start..]
148                    },
149                    self.tree.as_ref(),
150                    None,
151                );
152                match parse_result {
153                    Some(tree) => tree,
154                    None => self.parser.parse(&text, None).unwrap(),
155                }
156            };
157            self.tree = Some(tree.clone());
158            self.tree_revision = revision;
159            let mut cursor = QueryCursor::new();
160            let mut by_byte: HashMap<usize, (usize, Class)> = HashMap::new();
161            let mut matches = cursor.matches(&self.query, tree.root_node(), text.as_bytes());
162            while let Some(m) = { StreamingIterator::next(&mut matches) } {
163                for cap in m.captures {
164                    let node = cap.node;
165                    let class = self.classes[cap.index as usize];
166                    // most specific wins: smallest containing span
167                    let entry = by_byte
168                        .entry(node.start_byte())
169                        .or_insert((node.end_byte(), class));
170                    if node.end_byte() - node.start_byte() <= entry.0 - node.start_byte() {
171                        *entry = (node.end_byte(), class);
172                    }
173                }
174            }
175            let mut spans: Vec<Span> = by_byte
176                .into_iter()
177                .map(|(start, (end, class))| Span { start, end, class })
178                .collect();
179            spans.sort_by_key(|s| (s.start, s.end));
180            self.spans = spans;
181            self.source_hash = hash;
182        }
183        // return only visible spans; spans are sorted, binary search the window
184        let lo = self.spans.partition_point(|s| s.end <= first_byte);
185        let hi = self.spans.partition_point(|s| s.start < last_byte);
186        self.spans[lo..hi.max(lo)].to_vec()
187    }
188}
189
190/// First line of a file, capped at 256 bytes so a minified no-newline
191/// blob can't turn a probe into a full read. `None` on any IO/decoding
192/// hiccup — shebang detection is a best-effort fallback, never an error.
193fn first_line(path: &str) -> Option<String> {
194    use std::io::{BufRead, BufReader, Read};
195    let mut line = String::new();
196    BufReader::new(std::fs::File::open(path).ok()?)
197        .take(256)
198        .read_line(&mut line)
199        .ok()?;
200    Some(line)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn classes_for(path: &str, src: &str) -> Vec<Class> {
208        let mut hl = Highlighter::for_path(path).expect("language");
209        let rope = ropey::Rope::from_str(src);
210        hl.highlight(&rope, 0, 0, src.len())
211            .iter()
212            .map(|s| s.class)
213            .collect()
214    }
215
216    #[test]
217    fn rust_keywords_and_strings() {
218        let classes = classes_for("x.rs", "fn main() { let s = \"hi\"; }\n");
219        assert!(classes.contains(&Class::Keyword), "{classes:?}");
220        assert!(classes.contains(&Class::String), "{classes:?}");
221    }
222
223    #[test]
224    fn cpp_highlights_with_cxx_scanner() {
225        // the 0002 §5 gate: C++ grammar's scanner is C++ — a broken
226        // static-libstdc++ link fails here, per-PR, not at a user's file.
227        let classes = classes_for("x.cpp", "auto edge = hone(blade);\n");
228        assert!(!classes.is_empty(), "cpp grammar produced no spans");
229        assert!(classes.contains(&Class::Type), "{classes:?}"); // auto → @type.builtin
230    }
231
232    #[test]
233    fn python_and_go_and_ts() {
234        assert!(classes_for("x.py", "def f(x):\n    return x\n").contains(&Class::Keyword));
235        assert!(classes_for("x.go", "package main\nfunc main() {}\n").contains(&Class::Keyword));
236        assert!(!classes_for("x.ts", "const x: number = 1;\n").is_empty());
237        assert!(!classes_for("x.json", "{\"a\": 1}\n").is_empty());
238        assert!(!classes_for("x.sh", "#!/bin/sh\necho hi\n").is_empty());
239    }
240
241    #[test]
242    fn fish_lua_and_sql() {
243        // for_path compiles each vendored Helix query against its
244        // grammar — node drift upstream surfaces here as a None.
245        assert!(!classes_for("x.fish", "set -l name rust\n").is_empty());
246        assert!(classes_for("x.lua", "local x = 1\n").contains(&Class::Keyword));
247        assert!(classes_for("x.sql", "SELECT * FROM users;\n").contains(&Class::Keyword));
248    }
249
250    #[test]
251    fn shebang_script_file_resolves() {
252        // extensionless file on disk: for_path must read its first
253        // line once and hand it to the shebang fallback
254        let path =
255            std::env::temp_dir().join(format!("strop-syntax-shebang-{}", std::process::id()));
256        std::fs::write(&path, "#!/usr/bin/env bash\necho hi\n").unwrap();
257        let resolved = Highlighter::for_path(path.to_str().unwrap());
258        std::fs::remove_file(&path).ok();
259        let mut hl = resolved.expect("bash via shebang");
260        let rope = ropey::Rope::from_str("#!/usr/bin/env bash\necho hi\n");
261        assert!(!hl.highlight(&rope, 0, 0, rope.len_bytes()).is_empty());
262    }
263    #[test]
264    fn highlight_survives_backtracking_requests() {
265        // 0022 fix: tree-sitter re-requests earlier bytes on error
266        // recovery in large template-heavy files — the forward-only
267        // chunk iterator underflowed and panicked (the optional crash)
268        let mut big = String::from("namespace std {\n");
269        for i in 0..400 {
270            big.push_str(&format!(
271                "template <typename T{i}> struct O{i} {{ T{i} v; O{i} f() {{ return O{i}{{}}; }} }};\n"
272            ));
273        }
274        big.push_str("}\n");
275        let mut hl = Highlighter::for_path("x.hpp").unwrap();
276        let rope = ropey::Rope::from_str(&big);
277        let spans = hl.highlight(&rope, 1, 0, rope.len_bytes());
278        assert!(!spans.is_empty(), "the big file highlights");
279        // an edit shifts everything — the incremental path must not
280        // panic either (the chunk callback sees arbitrary byte asks)
281        let edited = big.replacen("namespace", "namespace extra_long_name_here", 1);
282        let rope2 = ropey::Rope::from_str(&edited);
283        let spans2 = hl.highlight(&rope2, 2, 0, rope2.len_bytes());
284        assert!(!spans2.is_empty());
285    }
286}