Skip to main content

devscout_rs/
parse.rs

1// tree-sitter runtime + C# and TS/JS grammars behind one Language seam. Full
2// extraction is extract.rs; this module owns parser access, the seed `parse`
3// text dump, and the `spans` diagnostic subcommand.
4
5use std::fs;
6use std::process;
7
8use tree_sitter::{Node, Parser};
9
10const TYPE_KINDS: &[&str] = &[
11    "class_declaration",
12    "interface_declaration",
13    "struct_declaration",
14    "enum_declaration",
15    "record_declaration",
16];
17
18fn new_parser() -> Parser {
19    let mut parser = Parser::new();
20    parser
21        .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
22        .expect("failed to load C# grammar");
23    parser
24}
25
26// ---------------------------------------------------------------------------
27// Encoding seam -- every parse in this crate feeds the grammar UTF-16.
28//
29// This is not cosmetic. tree-sitter's bounded error-recovery search scores
30// candidate repairs partly by SKIPPED BYTES of the input buffer
31// (ERROR_COST_PER_SKIPPED_CHAR), so the same grammar on the same source picks
32// DIFFERENT repairs depending on whether that source arrived as UTF-8 (one
33// byte per ASCII char) or UTF-16 (two). Fixing the buffer encoding at UTF-16
34// makes error recovery deterministic across inputs -- for example a vendored
35// file whose `#if/#else` convention duplicates a member header across both arms
36// and shares one body could otherwise be repaired two ways, one of which nests
37// the second declaration as a local function inside the first arm's still-open
38// block, collapsing two member scopes into one and cancelling every receiver
39// fact in them.
40//
41// Feeding a UTF-16 view removes that whole divergence class at its root instead
42// of compensating for individual damaged shapes. It also makes node spans
43// natively UTF-16 -- the convention every artifact this crate emits already
44// uses -- so span translation is a halving, not a table lookup: tree-sitter
45// reports BYTE offsets into the buffer it parsed, and a UTF-16 buffer has
46// exactly two bytes per code unit, astral pairs included.
47/// Encodes source text as UTF-16 code units.
48pub fn utf16_units(source: &str) -> Vec<u16> {
49    source.encode_utf16().collect()
50}
51
52/// The same units as a little-endian byte buffer, which is what node byte
53/// offsets index into and therefore what every `text()` helper slices.
54pub fn utf16_bytes(units: &[u16]) -> Vec<u8> {
55    units.iter().flat_map(|u| u.to_le_bytes()).collect()
56}
57
58/// Text of `node` out of a `utf16_bytes` buffer -- the UTF-16 counterpart of
59/// `Node::utf8_text`, and the only way to read source text in this crate.
60pub fn node_text(node: Node, src: &[u8]) -> String {
61    let range = node.byte_range();
62    if range.start > range.end || range.end > src.len() {
63        return String::new();
64    }
65    let units: Vec<u16> = src[range]
66        .chunks_exact(2)
67        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
68        .collect();
69    String::from_utf16_lossy(&units)
70}
71
72/// UTF-16 code-unit index for a byte offset (or column) reported by a tree
73/// parsed out of a `utf16_bytes` buffer.
74pub fn utf16_index(byte: usize) -> usize {
75    byte / 2
76}
77
78// ---------------------------------------------------------------------------
79// `parse` subcommand -- seed text dump.
80// ---------------------------------------------------------------------------
81
82/// Parses the C# file at `path` and prints its syntax tree.
83pub fn run_parse(path: &str) {
84    let source = fs::read_to_string(path).unwrap_or_else(|err| {
85        eprintln!("failed to read {path}: {err}");
86        process::exit(1);
87    });
88
89    let mut parser = new_parser();
90    let units = utf16_units(&source);
91    let tree = parser
92        .parse_utf16_le(&units, None)
93        .expect("parse returned no tree");
94    walk(tree.root_node(), &utf16_bytes(&units), 0);
95}
96
97fn walk(node: Node, src: &[u8], depth: usize) {
98    if TYPE_KINDS.contains(&node.kind()) {
99        print_type_decl(node, src, depth);
100    }
101
102    let mut cursor = node.walk();
103    for child in node.children(&mut cursor) {
104        walk(child, src, depth + 1);
105    }
106}
107
108fn print_type_decl(node: Node, src: &[u8], depth: usize) {
109    let indent = "  ".repeat(depth.saturating_sub(1));
110    let kind = node.kind();
111    let name = node
112        .child_by_field_name("name")
113        .map(|n| text(n, src))
114        .unwrap_or_else(|| "<anonymous>".to_string());
115    let bases = base_list_text(node, src);
116
117    println!("{indent}{kind} {name} : [{bases}]");
118
119    if kind == "enum_declaration" {
120        if let Some(body) = node.child_by_field_name("body") {
121            let mut cursor = body.walk();
122            for member in body.named_children(&mut cursor) {
123                if member.kind() == "enum_member_declaration" {
124                    if let Some(mname) = member.child_by_field_name("name") {
125                        println!("{indent}  enum member: {}", text(mname, src));
126                    }
127                }
128            }
129        }
130    }
131}
132
133fn base_list_text(node: Node, src: &[u8]) -> String {
134    let mut cursor = node.walk();
135    for child in node.children(&mut cursor) {
136        if child.kind() == "base_list" {
137            let mut names = Vec::new();
138            let mut bl_cursor = child.walk();
139            for base_child in child.named_children(&mut bl_cursor) {
140                if base_child.kind() != "argument_list" {
141                    names.push(text(base_child, src));
142                }
143            }
144            return names.join(", ");
145        }
146    }
147    String::new()
148}
149
150fn text(node: Node, src: &[u8]) -> String {
151    node_text(node, src)
152}
153
154// ---------------------------------------------------------------------------
155// `spans` subcommand -- span diagnostics.
156// ---------------------------------------------------------------------------
157
158// Same node kinds as `TYPE_KINDS` plus enum members -- the `spans` scope of
159// "every type declaration + enum-member node".
160const SPAN_KINDS: &[&str] = &[
161    "class_declaration",
162    "interface_declaration",
163    "struct_declaration",
164    "record_declaration",
165    "enum_declaration",
166    "enum_member_declaration",
167];
168
169/// Represents `SpanRecord`.
170pub struct SpanRecord {
171    /// The kind value.
172    pub kind: String,
173    /// The name value.
174    pub name: String,
175    /// The start byte value.
176    pub start_byte: usize,
177    /// The end byte value.
178    pub end_byte: usize,
179    /// The start row value.
180    pub start_row: usize,
181    /// The start col value.
182    pub start_col: usize,
183    /// The end row value.
184    pub end_row: usize,
185    /// The end col value.
186    pub end_col: usize,
187}
188
189/// Collects declaration spans from the C# file at `path` and prints them as JSON.
190pub fn run_spans(path: &str) {
191    let source = fs::read_to_string(path).unwrap_or_else(|err| {
192        eprintln!("failed to read {path}: {err}");
193        process::exit(1);
194    });
195    let records = collect_spans(&source);
196    println!("{}", spans_json(&records));
197}
198
199/// Parses C# source and returns its namespace, type, and member declaration spans.
200pub fn collect_spans(source: &str) -> Vec<SpanRecord> {
201    let mut parser = new_parser();
202    let units = utf16_units(source);
203    let tree = parser
204        .parse_utf16_le(&units, None)
205        .expect("parse returned no tree");
206    let mut out = Vec::new();
207    walk_spans(tree.root_node(), &utf16_bytes(&units), &mut out);
208    out.sort_by(|a, b| {
209        a.start_byte
210            .cmp(&b.start_byte)
211            .then(a.end_byte.cmp(&b.end_byte))
212            .then(a.kind.cmp(&b.kind))
213            .then(a.name.cmp(&b.name))
214    });
215    out
216}
217
218fn walk_spans(node: Node, src: &[u8], out: &mut Vec<SpanRecord>) {
219    if SPAN_KINDS.contains(&node.kind()) {
220        if let Some(name_node) = node.child_by_field_name("name") {
221            let start_point = node.start_position();
222            let end_point = node.end_position();
223            let (start_byte, start_col) = (
224                utf16_index(node.start_byte()),
225                utf16_index(start_point.column),
226            );
227            let (end_byte, end_col) = (utf16_index(node.end_byte()), utf16_index(end_point.column));
228            out.push(SpanRecord {
229                kind: node.kind().to_string(),
230                name: text(name_node, src),
231                start_byte,
232                end_byte,
233                start_row: start_point.row,
234                start_col,
235                end_row: end_point.row,
236                end_col,
237            });
238        }
239    }
240
241    let mut cursor = node.walk();
242    for child in node.named_children(&mut cursor) {
243        walk_spans(child, src, out);
244    }
245}
246
247// Hand-rolled 2-space-indented JSON so the output format is fixed and does not
248// depend on a serializer's formatting choices.
249/// Serializes span records as a JSON array.
250pub fn spans_json(records: &[SpanRecord]) -> String {
251    if records.is_empty() {
252        return "[]".to_string();
253    }
254    let mut out = String::from("[\n");
255    let last = records.len() - 1;
256    for (i, r) in records.iter().enumerate() {
257        out.push_str("  {\n");
258        out.push_str(&format!("    \"kind\": {},\n", json_string(&r.kind)));
259        out.push_str(&format!("    \"name\": {},\n", json_string(&r.name)));
260        out.push_str(&format!("    \"start_byte\": {},\n", r.start_byte));
261        out.push_str(&format!("    \"end_byte\": {},\n", r.end_byte));
262        out.push_str(&format!("    \"start_row\": {},\n", r.start_row));
263        out.push_str(&format!("    \"start_col\": {},\n", r.start_col));
264        out.push_str(&format!("    \"end_row\": {},\n", r.end_row));
265        out.push_str(&format!("    \"end_col\": {}\n", r.end_col));
266        out.push_str(if i == last { "  }\n" } else { "  },\n" });
267    }
268    out.push(']');
269    out
270}
271
272fn json_string(s: &str) -> String {
273    let mut out = String::with_capacity(s.len() + 2);
274    out.push('"');
275    for ch in s.chars() {
276        match ch {
277            '"' => out.push_str("\\\""),
278            '\\' => out.push_str("\\\\"),
279            '\n' => out.push_str("\\n"),
280            '\r' => out.push_str("\\r"),
281            '\t' => out.push_str("\\t"),
282            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
283            c => out.push(c),
284        }
285    }
286    out.push('"');
287    out
288}
289
290// ---------------------------------------------------------------------------
291// TS/JS grammar seam. Second grammar set alongside C#, PURPOSES ONLY. No graph
292// fragment ever comes out of this seam; see extract.rs's `extract_ts_purpose`
293// and mapcmd.rs's per-file dispatch for where that boundary is enforced. The
294// per-extension grammar pin is deliberate: `.ts` gets the dedicated typescript
295// grammar (the tsx grammar misparses a legal `.ts` cast-syntax expression),
296// `.tsx` gets the dedicated tsx grammar (plain typescript can't parse real
297// JSX), `.js`/`.jsx` both get the dedicated javascript grammar (kept off
298// tsx-as-superset so TS-only syntax landing in a `.js` file still surfaces as
299// an ERROR node).
300// ---------------------------------------------------------------------------
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303/// Represents `TsGrammar`.
304pub enum TsGrammar {
305    /// Represents `Typescript`.
306    Typescript,
307    /// Represents `Tsx`.
308    Tsx,
309    /// Represents `Javascript`.
310    Javascript,
311}
312
313/// Maps a file extension to its grammar. Based on the last `.` in `rel`, not
314/// path-aware: a dotted directory segment on a file with no extension of its
315/// own would misfire (a known, accepted edge case).
316pub fn ts_grammar_for(rel: &str) -> Option<TsGrammar> {
317    let dot = rel.rfind('.')?;
318    match &rel[dot..] {
319        ".ts" => Some(TsGrammar::Typescript),
320        ".tsx" => Some(TsGrammar::Tsx),
321        ".js" | ".jsx" => Some(TsGrammar::Javascript),
322        _ => None,
323    }
324}
325
326/// Whether `rel` is a TS/JS file -- `ts_grammar_for(rel).is_some()`.
327pub fn is_ts_js(rel: &str) -> bool {
328    ts_grammar_for(rel).is_some()
329}
330
331fn new_ts_parser(grammar: TsGrammar) -> Parser {
332    let mut parser = Parser::new();
333    let language = match grammar {
334        TsGrammar::Typescript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
335        TsGrammar::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
336        TsGrammar::Javascript => tree_sitter_javascript::LANGUAGE.into(),
337    };
338    parser
339        .set_language(&language)
340        .expect("failed to load TS/JS grammar");
341    parser
342}
343
344/// One parse of `source` under `grammar`. `Parser::parse` returns `None` only
345/// on cancellation/malformed-input-size edge cases, never on ordinary syntax
346/// errors (those surface as ERROR nodes inside a still-present tree).
347pub fn parse_ts_js(units: &[u16], grammar: TsGrammar) -> Option<tree_sitter::Tree> {
348    new_ts_parser(grammar).parse_utf16_le(units, None)
349}
350
351#[cfg(test)]
352mod ts_grammar_tests {
353    use super::*;
354
355    #[test]
356    fn extension_maps_to_the_rt1_pinned_grammar() {
357        assert_eq!(ts_grammar_for("a/b.ts"), Some(TsGrammar::Typescript));
358        assert_eq!(ts_grammar_for("a/b.tsx"), Some(TsGrammar::Tsx));
359        assert_eq!(ts_grammar_for("a/b.js"), Some(TsGrammar::Javascript));
360        assert_eq!(ts_grammar_for("a/b.jsx"), Some(TsGrammar::Javascript));
361        assert_eq!(ts_grammar_for("a/b.cs"), None);
362        assert_eq!(ts_grammar_for("a/b"), None);
363    }
364
365    #[test]
366    fn is_ts_js_true_only_for_the_four_extensions() {
367        assert!(is_ts_js("x.ts"));
368        assert!(is_ts_js("x.tsx"));
369        assert!(is_ts_js("x.js"));
370        assert!(is_ts_js("x.jsx"));
371        assert!(!is_ts_js("x.cs"));
372        assert!(!is_ts_js("x.json"));
373    }
374
375    #[test]
376    fn each_grammar_loads_and_parses_without_panicking() {
377        assert!(parse_ts_js(
378            &utf16_units("const x: number = 1;\n"),
379            TsGrammar::Typescript
380        )
381        .is_some());
382        assert!(parse_ts_js(&utf16_units("const x = <div>hi</div>;\n"), TsGrammar::Tsx).is_some());
383        assert!(parse_ts_js(
384            &utf16_units("function f() { return 1; }\n"),
385            TsGrammar::Javascript
386        )
387        .is_some());
388    }
389}