Skip to main content

syntect_tmlanguage/
translate.rs

1//! Translates a parsed [`RawGrammar`] into `syntect`'s own
2//! [`SyntaxDefinition`].
3//!
4//! ## Why this goes through `.sublime-syntax` YAML text
5//!
6//! The obvious approach — build `syntect::parsing::syntax_definition::{
7//! Context, MatchPattern, ContextReference, ...}` values directly — turned
8//! out not to be possible from outside the `syntect` crate: `syntax_definition.rs`
9//! says "Everything here is public", but `ContextReference` (and each of its
10//! variants) is `#[non_exhaustive]` with **no public constructor function**,
11//! so external code cannot construct a `ContextReference::Named(..)` or
12//! `::ByScope { .. }` value at all (confirmed against `syntect` 5.3.0; see
13//! `TODO.canvas.md`, "Correction: `ContextReference` isn't externally
14//! constructible").
15//!
16//! `syntect`'s own actually-public, stable construction path is
17//! [`syntect::parsing::SyntaxDefinition::load_from_str`] — the same entry
18//! point `.sublime-syntax` files go through. So this crate instead emits
19//! `.sublime-syntax`-shaped YAML (via `yaml-rust`, the exact YAML crate
20//! `syntect` itself parses with) and hands that text to `syntect`'s own
21//! loader, which builds the real tree for us. This is arguably a better fit
22//! for the project's Goal item 3 anyway ("works offline: convert once, ship
23//! a generated `.sublime-syntax`") — see [`to_sublime_syntax_yaml`], which
24//! exposes that intermediate text directly, valid to save as a
25//! `.sublime-syntax` file as-is.
26//!
27//! ## Mapping from TextMate's model to sublime-syntax's
28//!
29//! - Top-level `patterns` become the `main` context (the conventional
30//!   entry point `syntect` looks up by name).
31//! - Each `repository` entry becomes its own named context, addressable via
32//!   `include: "#name"`.
33//! - `include: "$self"` / `"$base"` become `include: main`; any other
34//!   `include` string is treated as an external scope name
35//!   (`include: "scope:<name>"`).
36//! - A `begin`/`end` rule becomes a `push` match on `begin` into a freshly
37//!   named body context, whose own `pop: true` match on `end` is ordered
38//!   relative to the body's own `patterns` per `applyEndPatternLast`
39//!   (`end` first when absent/`false` — TextMate's default — last when
40//!   `true`). `name`/`contentName` become that body context's
41//!   `meta_scope`/`meta_content_scope`.
42//!
43//! `with_prototype` is **not** part of the `.tmLanguage.json` spec at all
44//! (it's a `sublime-syntax`-only concept for prototype injection without
45//! nesting under `patterns`) — nothing to translate.
46//!
47//! `begin`/`while` rules **are** real — VS Code's own `markdown.tmLanguage.json`
48//! uses them extensively (blockquotes, most fenced-code-block variants) —
49//! but `syntect` has no engine-level equivalent whatsoever: no YAML
50//! `while:` key, no `MatchOperation` variant for it, checked by grepping
51//! its source. A `begin`/`while` rule fails translation with
52//! [`Error::WhileNotSupported`] rather than being silently mistranslated;
53//! this means real-world grammars built around `while` (markdown chief
54//! among them) can't be fully translated today. See `TODO.canvas.md` for
55//! the analysis (this was originally — wrongly — listed as "not part of
56//! the spec"; VS Code's markdown grammar disproved that).
57//!
58//! Injections (the grammar's `injections` key, VS Code's
59//! `injectionSelector`) genuinely aren't handled either: structural
60//! embedding (a fixed-boundary `begin`/`end` region whose body `include`s
61//! another grammar's scope) already works through the mapping above and is
62//! covered by a real end-to-end test, but true selector-based injection
63//! isn't something `syntect` itself supports at all (checked by grepping
64//! its source for "injection": zero matches) — see `TODO.canvas.md` for
65//! the full analysis.
66//!
67//! `\G` anchors are rejected outright ([`Error::GAnchorNotSupported`]),
68//! checked on every `match`/`begin`/`end` regex: confirmed empirically
69//! (`examples/g_anchor_never_matches.rs`) that `\G` never matches at all
70//! through `syntect`'s `onig` backend, in any position, because `syntect`
71//! runs each pattern as an independent search rather than the iterative
72//! same-string session real `\G` semantics depend on. This isn't a rare
73//! corner case — VS Code's real `shell-unix-bash.tmLanguage.json` and
74//! `html.tmLanguage.json` both hit it in commonly-exercised paths (adjacent
75//! quoted command names; `<script>`/`<style>` content detection), so both
76//! fail translation entirely today. Silently letting `\G` through would be
77//! worse than rejecting it: the affected context just never closes,
78//! corrupting everything parsed afterward, with no error at all.
79//!
80//! Capture indexes that `syntect`'s own YAML loader can't line up with a
81//! "consuming" group in the regex (via its `get_consuming_capture_indexes`,
82//! backed by the `regex-syntax` crate rather than true Oniguruma semantics)
83//! are silently dropped by `syntect` itself, not by this crate.
84
85use std::collections::HashMap;
86use std::fmt::Write as _;
87use std::path::{Path, PathBuf};
88
89use syntect::parsing::{ParseSyntaxError, SyntaxDefinition};
90use yaml_rust::yaml::Hash as YamlHash;
91use yaml_rust::{Yaml, YamlEmitter};
92
93use crate::raw::{RawCapture, RawGrammar, RawRule};
94
95#[derive(Debug, thiserror::Error)]
96pub enum Error {
97    #[error("invalid JSON: {0}")]
98    Json(#[from] serde_json::Error),
99    #[error("failed to read grammar file {path:?}: {source}")]
100    Io {
101        path: PathBuf,
102        #[source]
103        source: std::io::Error,
104    },
105    #[error("a `begin` rule needs a matching `end` pattern")]
106    BeginWithoutEnd,
107    #[error(
108        "`begin`/`while` rules (scope {scope:?}, while pattern {while_pattern:?}) aren't supported: \
109         `syntect` has no engine-level equivalent of TextMate's `while` at all, so there's nothing \
110         to translate this into (not just an unimplemented case in this crate)"
111    )]
112    WhileNotSupported {
113        scope: Option<String>,
114        while_pattern: String,
115    },
116    #[error(
117        "regex {pattern:?} uses a `\\G` anchor, which never matches through `syntect`'s `onig` \
118         backend (confirmed empirically: `syntect` runs each pattern as an independent search, \
119         not the iterative same-string session `\\G` needs to mean anything) — translating it \
120         would silently produce wrong parses (a context that never closes) rather than an error, \
121         so this is rejected instead"
122    )]
123    GAnchorNotSupported { pattern: String },
124    #[error("rule has none of `match`, `begin`/`end`, `include`, or nested `patterns`")]
125    EmptyRule,
126    #[error("non-numeric capture group index: {0:?}")]
127    BadCaptureIndex(String),
128    #[error("failed to emit intermediate sublime-syntax YAML: {0}")]
129    YamlEmit(#[from] std::fmt::Error),
130    #[error("failed to emit intermediate sublime-syntax YAML: {0}")]
131    YamlDump(#[from] yaml_rust::EmitError),
132    #[error("syntect rejected the generated sublime-syntax YAML: {0}")]
133    Syntax(#[from] ParseSyntaxError),
134}
135
136/// Parses and translates a `.tmLanguage.json` grammar into a `syntect`
137/// [`SyntaxDefinition`] in one step.
138pub fn load(source: &str) -> Result<SyntaxDefinition, Error> {
139    let grammar = RawGrammar::from_json(source)?;
140    translate(&grammar)
141}
142
143/// Reads and translates a `.tmLanguage.json` grammar from an arbitrary
144/// local file at runtime — the "load a custom grammar the user dropped in,
145/// no bundling required" case from the project's Goal.
146///
147/// The result is a plain [`SyntaxDefinition`]; add it to a
148/// [`syntect::parsing::SyntaxSetBuilder`] (alongside any other syntaxes)
149/// and call `.build()` the same way you would for a hand-written
150/// `.sublime-syntax` file — this crate doesn't wrap that step, since
151/// `syntect`'s own builder already does it well.
152///
153/// For the build-time-bundling case (Goal item 3's "convert once, ship a
154/// generated `.sublime-syntax`"), either save [`to_sublime_syntax_yaml`]'s
155/// output as a `.sublime-syntax` file directly, or build a [`syntect::parsing::SyntaxSet`]
156/// once from this function's output and cache it with
157/// [`syntect::dumps::dump_to_file`]/[`syntect::dumps::from_dump_file`] — both
158/// already handle any `Serialize`/`DeserializeOwned` type, `SyntaxSet`
159/// included, so no wrapper is needed here either.
160pub fn load_file(path: impl AsRef<Path>) -> Result<SyntaxDefinition, Error> {
161    let path = path.as_ref();
162    let source = std::fs::read_to_string(path).map_err(|source| Error::Io {
163        path: path.to_path_buf(),
164        source,
165    })?;
166    load(&source)
167}
168
169pub fn translate(grammar: &RawGrammar) -> Result<SyntaxDefinition, Error> {
170    let yaml_text = to_sublime_syntax_yaml(grammar)?;
171    SyntaxDefinition::load_from_str(&yaml_text, false, grammar.name.as_deref())
172        .map_err(Error::Syntax)
173}
174
175/// Renders `grammar` as `.sublime-syntax`-shaped YAML text — the same
176/// intermediate [`translate`] feeds to `syntect`'s own loader, exposed
177/// directly because it's also valid to write out as a `.sublime-syntax`
178/// file for offline/build-time use.
179pub fn to_sublime_syntax_yaml(grammar: &RawGrammar) -> Result<String, Error> {
180    let mut contexts = YamlHash::new();
181    let mut namer = Namer::default();
182
183    let main_items = translate_patterns(&grammar.patterns, &mut contexts, &mut namer)?;
184    contexts.insert(Yaml::String("main".to_string()), Yaml::Array(main_items));
185
186    let mut repo_names: Vec<&String> = grammar.repository.keys().collect();
187    repo_names.sort();
188    for name in repo_names {
189        let rule = &grammar.repository[name];
190        let items = repository_entry_to_context(rule, &mut contexts, &mut namer)?;
191        contexts.insert(Yaml::String(name.clone()), Yaml::Array(items));
192    }
193
194    let mut doc = YamlHash::new();
195    doc.insert(
196        Yaml::String("name".to_string()),
197        Yaml::String(
198            grammar
199                .name
200                .clone()
201                .unwrap_or_else(|| grammar.scope_name.clone()),
202        ),
203    );
204    doc.insert(
205        Yaml::String("scope".to_string()),
206        Yaml::String(grammar.scope_name.clone()),
207    );
208    if !grammar.file_types.is_empty() {
209        doc.insert(
210            Yaml::String("file_extensions".to_string()),
211            Yaml::Array(
212                grammar
213                    .file_types
214                    .iter()
215                    .cloned()
216                    .map(Yaml::String)
217                    .collect(),
218            ),
219        );
220    }
221    if let Some(flm) = &grammar.first_line_match {
222        doc.insert(
223            Yaml::String("first_line_match".to_string()),
224            Yaml::String(flm.clone()),
225        );
226    }
227    doc.insert(Yaml::String("contexts".to_string()), Yaml::Hash(contexts));
228
229    let mut out = String::new();
230    // `syntect::parsing::yaml_load` expects a `%YAML 1.2 ---` header, same
231    // as real `.sublime-syntax` files; `YamlEmitter` only emits the `---`.
232    writeln!(out, "%YAML 1.2")?;
233    YamlEmitter::new(&mut out).dump(&Yaml::Hash(doc))?;
234    Ok(out)
235}
236
237fn repository_entry_to_context(
238    rule: &RawRule,
239    contexts: &mut YamlHash,
240    namer: &mut Namer,
241) -> Result<Vec<Yaml>, Error> {
242    if rule.match_.is_none() && rule.begin.is_none() && rule.include.is_none() {
243        // A bare group entry (only `patterns`) — its patterns list *is* the context.
244        return translate_patterns(&rule.patterns, contexts, namer);
245    }
246    let item = rule_to_item(rule, contexts, namer)?;
247    Ok(vec![item])
248}
249
250fn translate_patterns(
251    rules: &[RawRule],
252    contexts: &mut YamlHash,
253    namer: &mut Namer,
254) -> Result<Vec<Yaml>, Error> {
255    rules
256        .iter()
257        .map(|r| rule_to_item(r, contexts, namer))
258        .collect()
259}
260
261fn rule_to_item(rule: &RawRule, contexts: &mut YamlHash, namer: &mut Namer) -> Result<Yaml, Error> {
262    if let Some(include) = &rule.include {
263        let mut item = YamlHash::new();
264        item.insert(
265            Yaml::String("include".to_string()),
266            Yaml::String(resolve_include(include)),
267        );
268        return Ok(Yaml::Hash(item));
269    }
270
271    if let Some(begin) = &rule.begin {
272        if let Some(while_pattern) = &rule.while_ {
273            return Err(Error::WhileNotSupported {
274                scope: rule.name.clone(),
275                while_pattern: while_pattern.clone(),
276            });
277        }
278        let end = rule.end.as_ref().ok_or(Error::BeginWithoutEnd)?;
279        reject_g_anchor(begin)?;
280        reject_g_anchor(end)?;
281
282        let body_name = namer.next("begin-end-body");
283        let mut body_items = Vec::new();
284        if let Some(name) = &rule.name {
285            let mut meta = YamlHash::new();
286            meta.insert(
287                Yaml::String("meta_scope".to_string()),
288                Yaml::String(name.clone()),
289            );
290            body_items.push(Yaml::Hash(meta));
291        }
292        if let Some(content_name) = &rule.content_name {
293            let mut meta = YamlHash::new();
294            meta.insert(
295                Yaml::String("meta_content_scope".to_string()),
296                Yaml::String(content_name.clone()),
297            );
298            body_items.push(Yaml::Hash(meta));
299        }
300
301        let mut end_item = YamlHash::new();
302        end_item.insert(Yaml::String("match".to_string()), Yaml::String(end.clone()));
303        if let Some(captures) = captures_yaml(&rule.end_captures)? {
304            end_item.insert(Yaml::String("captures".to_string()), captures);
305        }
306        end_item.insert(Yaml::String("pop".to_string()), Yaml::Boolean(true));
307        let end_item = Yaml::Hash(end_item);
308
309        let body_patterns = translate_patterns(&rule.patterns, contexts, namer)?;
310        // `applyEndPatternLast` (default `false`) controls whether `end` is
311        // tried before or after the body's own patterns at each position.
312        if rule.apply_end_pattern_last {
313            body_items.extend(body_patterns);
314            body_items.push(end_item);
315        } else {
316            body_items.push(end_item);
317            body_items.extend(body_patterns);
318        }
319        contexts.insert(
320            Yaml::String(body_name.clone()),
321            Yaml::Array(body_items),
322        );
323
324        let mut begin_item = YamlHash::new();
325        begin_item.insert(
326            Yaml::String("match".to_string()),
327            Yaml::String(begin.clone()),
328        );
329        if let Some(captures) = captures_yaml(&rule.begin_captures)? {
330            begin_item.insert(Yaml::String("captures".to_string()), captures);
331        }
332        begin_item.insert(Yaml::String("push".to_string()), Yaml::String(body_name));
333        return Ok(Yaml::Hash(begin_item));
334    }
335
336    if let Some(m) = &rule.match_ {
337        reject_g_anchor(m)?;
338        let mut item = YamlHash::new();
339        item.insert(Yaml::String("match".to_string()), Yaml::String(m.clone()));
340        if let Some(name) = &rule.name {
341            item.insert(
342                Yaml::String("scope".to_string()),
343                Yaml::String(name.clone()),
344            );
345        }
346        if let Some(captures) = captures_yaml(&rule.captures)? {
347            item.insert(Yaml::String("captures".to_string()), captures);
348        }
349        return Ok(Yaml::Hash(item));
350    }
351
352    if !rule.patterns.is_empty() {
353        // An anonymous group node appearing directly in a `patterns` list.
354        let name = namer.next("group");
355        let items = translate_patterns(&rule.patterns, contexts, namer)?;
356        contexts.insert(Yaml::String(name.clone()), Yaml::Array(items));
357        let mut item = YamlHash::new();
358        item.insert(Yaml::String("include".to_string()), Yaml::String(name));
359        return Ok(Yaml::Hash(item));
360    }
361
362    Err(Error::EmptyRule)
363}
364
365/// Rejects a regex using an unescaped `\G` anchor — see
366/// [`Error::GAnchorNotSupported`] for why this can't just be passed
367/// through. Uses the same escape-parity scan as backreference detection
368/// (an odd run of backslashes right before `G` means it's live).
369fn reject_g_anchor(pattern: &str) -> Result<(), Error> {
370    let mut backslash_run = 0usize;
371    for c in pattern.chars() {
372        if c == '\\' {
373            backslash_run += 1;
374            continue;
375        }
376        if c == 'G' && backslash_run % 2 == 1 {
377            return Err(Error::GAnchorNotSupported {
378                pattern: pattern.to_string(),
379            });
380        }
381        backslash_run = 0;
382    }
383    Ok(())
384}
385
386fn resolve_include(include: &str) -> String {
387    if include == "$self" || include == "$base" {
388        return "main".to_string();
389    }
390    if let Some(name) = include.strip_prefix('#') {
391        return name.to_string();
392    }
393    format!("scope:{include}")
394}
395
396fn captures_yaml(
397    captures: &Option<HashMap<String, RawCapture>>,
398) -> Result<Option<Yaml>, Error> {
399    let Some(map) = captures else {
400        return Ok(None);
401    };
402    let mut hash = YamlHash::new();
403    for (key, capture) in map {
404        let index: i64 = key
405            .parse()
406            .map_err(|_| Error::BadCaptureIndex(key.clone()))?;
407        let Some(name) = &capture.name else {
408            continue;
409        };
410        hash.insert(Yaml::Integer(index), Yaml::String(name.clone()));
411    }
412    if hash.is_empty() {
413        return Ok(None);
414    }
415    Ok(Some(Yaml::Hash(hash)))
416}
417
418#[derive(Default)]
419struct Namer {
420    counter: usize,
421}
422
423impl Namer {
424    fn next(&mut self, prefix: &str) -> String {
425        self.counter += 1;
426        format!("{prefix}-{}", self.counter)
427    }
428}