1use serde::{Deserialize, Serialize};
42
43pub const SCHEMA_VERSION: u32 = 1;
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Language {
50 pub id: String,
52 pub name: String,
54 pub scope_name: String,
56 pub extensions: Vec<String>,
57 pub line_comment: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct Sigil {
65 pub sigil: String,
66 pub kind: String,
68 pub scope_suffix: String,
70 pub reserved: bool,
74 pub note: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[serde(tag = "kind", rename_all = "snake_case")]
81pub enum RuleBody {
82 Words {
84 lexemes: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
90 followed_by: Option<String>,
91 },
92 Symbols { lexemes: Vec<String> },
94 Annotations { lexemes: Vec<String> },
97 Sigils,
99 Match {
102 pattern: String,
103 vim: String,
112 },
113 Span {
115 begin: String,
116 end: String,
117 escapes: bool,
119 },
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub struct Rule {
126 pub name: String,
128 pub scope: String,
130 #[serde(flatten)]
131 pub body: RuleBody,
132 pub note: Option<String>,
134}
135
136impl Rule {
137 fn new(name: &str, scope: &str, body: RuleBody) -> Self {
138 Rule {
139 name: name.to_string(),
140 scope: scope.to_string(),
141 body,
142 note: None,
143 }
144 }
145
146 fn with_note(mut self, note: &str) -> Self {
147 self.note = Some(note.to_string());
148 self
149 }
150
151 pub fn lexemes(&self) -> &[String] {
153 match &self.body {
154 RuleBody::Words { lexemes, .. } | RuleBody::Symbols { lexemes } | RuleBody::Annotations { lexemes } => {
155 lexemes
156 }
157 _ => &[],
158 }
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct SyntaxManifest {
164 pub schema_version: u32,
165 pub language: Language,
166 pub sigils: Vec<Sigil>,
167 pub rules: Vec<Rule>,
169}
170
171fn words(v: &[&str]) -> RuleBody {
172 RuleBody::Words {
173 lexemes: v.iter().map(|s| s.to_string()).collect(),
174 followed_by: None,
175 }
176}
177
178fn words_before(v: &[&str], followed_by: &str) -> RuleBody {
180 RuleBody::Words {
181 lexemes: v.iter().map(|s| s.to_string()).collect(),
182 followed_by: Some(followed_by.to_string()),
183 }
184}
185
186fn symbols(v: &[&str]) -> RuleBody {
187 RuleBody::Symbols {
188 lexemes: v.iter().map(|s| s.to_string()).collect(),
189 }
190}
191
192fn annotations(v: &[&str]) -> RuleBody {
193 RuleBody::Annotations {
194 lexemes: v.iter().map(|s| s.to_string()).collect(),
195 }
196}
197
198fn matches(pattern: &str, vim: &str) -> RuleBody {
200 RuleBody::Match {
201 pattern: pattern.to_string(),
202 vim: vim.to_string(),
203 }
204}
205
206impl Default for SyntaxManifest {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212impl SyntaxManifest {
213 pub fn new() -> Self {
218 SyntaxManifest {
219 schema_version: SCHEMA_VERSION,
220 language: Language {
221 id: "typr".to_string(),
222 name: "typR".to_string(),
223 scope_name: "source.typr".to_string(),
224 extensions: vec![".ty".to_string()],
225 line_comment: "#".to_string(),
226 },
227 sigils: Self::sigils(),
228 rules: Self::rules(),
229 }
230 }
231
232 fn sigils() -> Vec<Sigil> {
236 let live = |sigil: &str, kind: &str, suffix: &str| Sigil {
237 sigil: sigil.to_string(),
238 kind: kind.to_string(),
239 scope_suffix: suffix.to_string(),
240 reserved: false,
241 note: None,
242 };
243 let reserved = |sigil: &str| Sigil {
244 sigil: sigil.to_string(),
245 kind: "Reserved".to_string(),
246 scope_suffix: "reserved".to_string(),
247 reserved: true,
248 note: Some(
249 "Reserved by sigils.md §3.2.1 for a future kind, not parsed yet. \
250 Already live as an operator, so generators must not emit it."
251 .to_string(),
252 ),
253 };
254 vec![
255 live("#", "Number", "number"),
256 live("%", "Record", "record"),
257 live("@", "Interface", "interface"),
258 live("^", "String", "string"),
259 live("?", "Boolean", "boolean"),
260 live("$", "Label", "label"),
261 reserved("~"),
262 reserved("&"),
263 reserved("!"),
264 ]
265 }
266
267 fn rules() -> Vec<Rule> {
268 vec![
269 Rule::new(
277 "strings.raw-r",
278 "string.quoted.other.raw.typr",
279 RuleBody::Span {
280 begin: "r#\"".to_string(),
281 end: "\"#".to_string(),
282 escapes: false,
283 },
284 )
285 .with_note(
286 "`extern (...) -> T r#\"...\"#` raw R body — verbatim, no escape processing. \
287 Ahead of `comments` so its inner `#` never opens one.",
288 ),
289 Rule::new(
290 "comments",
291 "comment.line.number-sign.typr",
292 matches(
293 "#(?!(?:Self|[A-Z])(?![A-Za-z0-9_])).*$",
294 "#%(%(Self|[A-Z])[A-Za-z0-9_]@!)@!.*$",
295 ),
296 )
297 .with_note(
298 "TypR comments are `#` only — never `//`, which the parser rejects outright \
299 (`wrong_comment` in parsing/mod.rs). The lookahead is the one place a \
300 regex grammar has to guess where the parser uses context: `#N` and `#Self` \
301 are the Number kind sigil, so they are left to `types.sigil-generic`; \
302 anything else after `#` opens a comment.",
303 ),
304 Rule::new(
305 "strings.double",
306 "string.quoted.double.typr",
307 RuleBody::Span {
308 begin: "\"".to_string(),
309 end: "\"".to_string(),
310 escapes: true,
311 },
312 ),
313 Rule::new(
314 "strings.single",
315 "string.quoted.single.typr",
316 RuleBody::Span {
317 begin: "'".to_string(),
318 end: "'".to_string(),
319 escapes: true,
320 },
321 ),
322 Rule::new(
323 "strings.backtick",
324 "string.quoted.other.backtick.typr",
325 RuleBody::Span {
326 begin: "`".to_string(),
327 end: "`".to_string(),
328 escapes: false,
329 },
330 )
331 .with_note("R-style non-syntactic name (`parsing/elements.rs::quoted_variable`)."),
332 Rule::new(
333 "numbers.float",
334 "constant.numeric.float.typr",
335 matches("\\b[0-9]+\\.[0-9]+\\b", "<[0-9]+\\.[0-9]+>"),
336 )
337 .with_note("`parsing/elements.rs::number` — digits, a dot, digits. No exponent form exists."),
338 Rule::new(
339 "numbers.integer",
340 "constant.numeric.integer.typr",
341 matches("\\b[0-9]+\\b", "<[0-9]+>"),
342 ),
343 Rule::new(
344 "annotations",
345 "storage.modifier.annotation.typr",
346 annotations(&["@export", "@pub", "@testable", "@extern", "@importFrom"]),
347 )
348 .with_note(
349 "Visibility/interop annotations (RFC-TR-032). Ahead of the `@T` interface \
350 sigil and of any bare-`@` rule, both of which would eat their `@`.",
351 ),
352 Rule::new(
353 "keywords.control",
354 "keyword.control.typr",
355 words(&["if", "else", "match", "for", "while", "loop", "break", "next", "return"]),
356 )
357 .with_note(
358 "No `continue`: the loop-skip keyword is `next;` (R spelling), \
359 `parsing/elements.rs::next_exp`.",
360 ),
361 Rule::new(
362 "keywords.declaration",
363 "keyword.declaration.typr",
364 words(&[
365 "let",
366 "fn",
367 "function",
368 "type",
369 "opaque",
370 "typeconstructor",
371 "recursive",
372 "interface",
373 "record",
374 "object",
375 "module",
376 "mod",
377 "import",
378 "use",
379 "extern",
380 "embed",
381 ]),
382 )
383 .with_note(
384 "No `impl`/`trait`/`struct`/`enum`/`where`/`mut`/`pub`: none has ever existed \
385 in TypR — they were Rust leftovers in the hand-written grammars. Visibility \
386 is spelled with the `@pub`/`@export` annotations.",
387 ),
388 Rule::new("keywords.cast", "keyword.operator.cast.typr", words(&["as!", "as"]))
389 .with_note("`as!` first, or the `as` alternative eats its prefix."),
390 Rule::new(
391 "keywords.operator-word",
392 "keyword.operator.word.typr",
393 words(&["and", "or", "in"]),
394 )
395 .with_note("Word-spelled operators (`components/language/operators.rs::bool_op`/`op`)."),
396 Rule::new(
397 "constants",
398 "constant.language.typr",
399 words(&["true", "TRUE", "false", "FALSE", "null", "NULL", "na", "NA"]),
400 )
401 .with_note(
402 "Both spellings are real: `parsing/elements.rs` accepts the R form and the \
403 lowercase TypR form. No `NaN`/`Inf` — the parser has a tag for neither.",
404 ),
405 Rule::new(
406 "types.primitive",
407 "support.type.primitive.typr",
408 words(&["int", "num", "char", "bool", "logic", "Any", "Empty", "Self"]),
409 )
410 .with_note("`logic` is the accepted alias of `bool` (`parsing/types.rs::boolean_type`)."),
411 Rule::new(
412 "types.builtin",
413 "support.type.builtin.typr",
414 words(&[
415 "Vec",
416 "Array",
417 "Tuple",
418 "Record",
419 "UnknownFunction",
420 "dataframe",
421 "data.frame",
422 "data__frame",
423 "list",
424 "tuple",
425 ]),
426 )
427 .with_note(
428 "No `Option`/`Result`/`List`/`Matrix`: TypR has none of them. These are the \
429 names usable bare in a type position; the ones that only exist in front of \
430 a delimiter are in `types.constructor` and `keywords.block`.",
431 ),
432 Rule::new(
433 "types.builtin-indexed",
434 "support.type.builtin.typr",
435 words_before(&["df"], "\\s*\\["),
436 )
437 .with_note(
438 "`df` is a type name the parser accepts bare too (`parsing/types.rs::dataframe_type`), \
439 but `df` is also the single most common data-frame *variable* name in R. \
440 Coloring it only in `df[...]` under-colors a bare `x: df` annotation; the \
441 alternative over-colors every `df` in every program, which is worse. \
442 `dataframe`, the unambiguous spelling, stays in `types.builtin`.",
443 ),
444 Rule::new(
445 "types.constructor",
446 "support.function.builtin.typr",
447 words_before(&["c", "seq", "Class", "library"], "\\s*[\\[(]"),
448 )
449 .with_note(
450 "The parser only ever matches these glued to their opening delimiter \
451 (`c(`, `seq[`, `Class(`, `library(`), so the delimiter is part of the rule. \
452 Without it, every variable named `c` would be colored as a builtin.",
453 ),
454 Rule::new(
455 "keywords.block",
456 "keyword.other.block.typr",
457 words_before(&["R", "JS", "Test"], "\\s*[\\[{]"),
458 )
459 .with_note(
460 "Escape-hatch and test block heads: `R { ... }`, `JS { ... }`, `Test { ... }`, \
461 `Test[...]`. Delimiter-guarded for the same reason as `types.constructor` — \
462 and `R` in particular is a single uppercase letter, i.e. also a valid \
463 generic name, so an unguarded rule would fight `types.generic`.",
464 ),
465 Rule::new(
466 "types.variant",
467 "entity.name.type.variant.typr",
468 matches("\\.[A-Z][A-Za-z0-9_]*\\b", "\\.[A-Z][A-Za-z0-9_]*>"),
469 )
470 .with_note("Union-variant tag, `.Variant` (`parsing/elements.rs::tag_exp`)."),
471 Rule::new(
472 "types.sigil-generic",
473 "entity.name.type.parameter.typr",
474 RuleBody::Sigils,
475 )
476 .with_note(
477 "Sigil + generic. A generic name is a *single* uppercase letter or `Self` \
478 (`parsing/types.rs::upper_case_generic`), so the rule stops there instead \
479 of running on into a PascalCase alias.",
480 ),
481 Rule::new(
482 "types.generic",
483 "entity.name.type.parameter.typr",
484 matches("\\b[A-Z](?![A-Za-z0-9_])", "<[A-Z][A-Za-z0-9_]@!"),
485 ),
486 Rule::new(
487 "types.alias",
488 "entity.name.type.typr",
489 matches("\\b[A-Z][A-Za-z0-9_]+\\b", "<[A-Z][A-Za-z0-9_]+>"),
490 )
491 .with_note("PascalCase alias / type-constructor name (`parsing/types.rs::pascal_case_no_space`)."),
492 Rule::new("operators.arrow", "keyword.operator.arrow.typr", symbols(&["->", "=>"]))
493 .with_note("Ahead of comparison and arithmetic, which own `-`, `=` and `>`."),
494 Rule::new("operators.bind", "keyword.operator.assignment.typr", symbols(&["<-"]))
495 .with_note("Ahead of comparison, whose `<` would otherwise split `<-`."),
496 Rule::new(
497 "operators.comparison",
498 "keyword.operator.comparison.typr",
499 symbols(&["==", "!=", "<=", ">=", "<", ">"]),
500 ),
501 Rule::new(
502 "operators.logical",
503 "keyword.operator.logical.typr",
504 symbols(&["&&", "||", "&", "!"]),
505 )
506 .with_note(
507 "After comparison, so `!=` is not read as `!` then `=`. `|` belongs to \
508 `operators.type-union`, which it doubles as.",
509 ),
510 Rule::new("operators.pipe", "keyword.operator.pipe.typr", symbols(&["|>"])).with_note(
511 "No `|>>`: it has no type-checking or transpiling arm and no stdlib \
512 signature — see the tokenizer purge note on `operators.rs::op`.",
513 ),
514 Rule::new(
515 "operators.type-union",
516 "keyword.operator.type-union.typr",
517 matches("\\|(?!>)", "\\|\\>@!"),
518 ),
519 Rule::new(
520 "operators.custom",
521 "keyword.operator.custom.typr",
522 matches("%[^%\\s]*%", "\\%[^%\\s]*\\%"),
523 )
524 .with_note("R-style custom infix (`operators.rs::custom_op`). Ahead of the bare `%` modulo."),
525 Rule::new(
526 "operators.arithmetic",
527 "keyword.operator.arithmetic.typr",
528 symbols(&["+", "-", "*", "/", "%"]),
529 )
530 .with_note(
531 "No `^`/`++`/`--`/`**`/`//`: `op()` recognizes none of them. `^` is the String \
532 kind sigil and nothing else — the hand-written grammars listed it as \
533 exponentiation, which TypR does not have.",
534 ),
535 Rule::new(
536 "operators.vectorial-block",
537 "keyword.operator.vectorial.typr",
538 symbols(&["@{", "}@"]),
539 )
540 .with_note("`@{ ... }@` vectorized block (`parsing/elements.rs::vectorial_bloc`)."),
541 Rule::new(
542 "operators.spread",
543 "keyword.operator.spread.typr",
544 symbols(&["...", ".."]),
545 )
546 .with_note(
547 "`...` runtime spread / variadic, `..` nominal spread (`Point:{ ..source }`). \
548 Longest-first inside the rule, and ahead of `operators.access`'s `.`.",
549 ),
550 Rule::new(
551 "operators.access",
552 "keyword.operator.access.typr",
553 symbols(&["::", "$", "."]),
554 ),
555 Rule::new("operators.assign", "keyword.operator.assignment.typr", symbols(&["="])).with_note(
556 "Bare `=` is never an infix operator in TypR — only a binder, a named-field \
557 separator and a default-value separator. Last of the `=`-shaped rules so \
558 `==`, `=>` and `!=` are already claimed.",
559 ),
560 Rule::new(
561 "operators.lambda",
562 "keyword.operator.lambda.typr",
563 matches("\\\\(?=[({:])", "\\\\[({:]@="),
564 )
565 .with_note("`\\(x) ...` lambda shorthand (`parsing/elements.rs::lambda`)."),
566 Rule::new(
567 "functions.call",
568 "entity.name.function.typr",
569 matches("\\b([a-z_][A-Za-z0-9_]*)\\s*(?=\\()", "<[a-z_][A-Za-z0-9_]*\\s*\\(@="),
570 )
571 .with_note("After every keyword rule, so `if (`/`while (` stay keywords."),
572 Rule::new(
573 "variables.parameter",
574 "variable.parameter.typr",
575 matches(
576 "\\b([a-z_][A-Za-z0-9_]*)\\s*(?=:(?!:))",
577 "<[a-z_][A-Za-z0-9_]*\\s*%(:%(:@!))@=",
578 ),
579 )
580 .with_note("`(?!:)` keeps the `x` of `x::y` out — that is namespace access, not an annotation."),
581 Rule::new(
582 "variables.other",
583 "variable.other.typr",
584 matches("\\b[a-z_][A-Za-z0-9_]*\\b", "<[a-z_][A-Za-z0-9_]*>"),
585 )
586 .with_note(
587 "An identifier starts lowercase-or-underscore and continues in `[A-Za-z0-9_]` — \
588 no dots, unlike R (`parsing/elements.rs::starting_char`/`body_char`). \
589 `data.frame` is a single dedicated tag, not an identifier.",
590 ),
591 Rule::new("punctuation.terminator", "punctuation.terminator.typr", symbols(&[";"])),
592 Rule::new(
593 "punctuation.separator",
594 "punctuation.separator.typr",
595 symbols(&[",", ":"]),
596 ),
597 Rule::new(
598 "punctuation.brackets",
599 "punctuation.section.brackets.typr",
600 matches("[\\[\\](){}]", "[][(){}]"),
601 ),
602 ]
603 }
604
605 pub fn all_lexemes(&self) -> Vec<&str> {
608 self.rules
609 .iter()
610 .flat_map(|r| r.lexemes())
611 .map(|s| s.as_str())
612 .collect()
613 }
614
615 pub fn to_json(&self) -> String {
616 serde_json::to_string_pretty(self).expect("the syntax manifest is plain data; it cannot fail to serialize")
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use std::collections::HashSet;
624
625 const PARSER_SOURCES: &[(&str, &str)] = &[
628 (
629 "parsing/elements.rs",
630 include_str!("../../processes/parsing/elements.rs"),
631 ),
632 ("parsing/mod.rs", include_str!("../../processes/parsing/mod.rs")),
633 ("parsing/types.rs", include_str!("../../processes/parsing/types.rs")),
634 (
635 "parsing/indexation.rs",
636 include_str!("../../processes/parsing/indexation.rs"),
637 ),
638 ("language/operators.rs", include_str!("../language/operators.rs")),
639 ];
640
641 const NOT_A_LEXEME: &[(&str, &str)] = &[
644 (
645 "@",
646 "Bare `@` heads a module signature declaration (`@name: T;`) and prefixes \
647 the annotations. Painting a lone `@` would break `@export` and the `@T` \
648 interface sigil, both of which the manifest already covers.",
649 ),
650 (
651 "logical",
652 "Not TypR syntax: `RIndex::Logical`'s parser in parsing/indexation.rs \
653 matches R's `TRUE`/`FALSE`, and this literal is only in a rustdoc line.",
654 ),
655 ];
656
657 fn scan_tag_literals(source: &str) -> Vec<String> {
660 let mut out = Vec::new();
661 let bytes: Vec<char> = source.chars().collect();
662 let needle: Vec<char> = "tag(\"".chars().collect();
663 let mut i = 0;
664 while i + needle.len() <= bytes.len() {
665 if bytes[i..i + needle.len()] != needle[..] {
666 i += 1;
667 continue;
668 }
669 let preceded_by_ident = i > 0 && (bytes[i - 1].is_alphanumeric() || bytes[i - 1] == '_');
672 if preceded_by_ident {
673 i += 1;
674 continue;
675 }
676 let mut j = i + needle.len();
677 let mut literal = String::new();
678 while j < bytes.len() {
679 match bytes[j] {
680 '\\' if j + 1 < bytes.len() => {
681 literal.push(bytes[j + 1]);
682 j += 2;
683 }
684 '"' => break,
685 c => {
686 literal.push(c);
687 j += 1;
688 }
689 }
690 }
691 out.push(literal);
692 i = j.max(i + 1);
693 }
694 out
695 }
696
697 fn normalize(literal: &str) -> String {
701 literal.trim_end_matches(['(', '[', ';', ' ']).to_string()
702 }
703
704 fn is_word_like(lexeme: &str) -> bool {
708 let mut chars = lexeme.chars();
709 match chars.next() {
710 Some(c) if c.is_ascii_alphabetic() || c == '@' => {}
711 _ => return false,
712 }
713 let rest: Vec<char> = chars.collect();
714 let (body, _) = match rest.split_last() {
715 Some((&'!', body)) => (body, true),
716 _ => (&rest[..], false),
717 };
718 body.iter().all(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '.')
719 }
720
721 #[test]
725 fn every_parser_tag_is_in_the_manifest() {
726 let manifest = SyntaxManifest::new();
727 let known: HashSet<&str> = manifest.all_lexemes().into_iter().collect();
728 let exempt: HashSet<&str> = NOT_A_LEXEME.iter().map(|(l, _)| *l).collect();
729
730 let mut missing: Vec<(String, &str)> = Vec::new();
731 for (file, source) in PARSER_SOURCES {
732 for literal in scan_tag_literals(source) {
733 let lexeme = normalize(&literal);
734 if lexeme.is_empty() || !is_word_like(&lexeme) {
735 continue;
736 }
737 if known.contains(lexeme.as_str()) || exempt.contains(lexeme.as_str()) {
738 continue;
739 }
740 missing.push((lexeme, file));
741 }
742 }
743 missing.sort();
744 missing.dedup();
745
746 assert!(
747 missing.is_empty(),
748 "these lexemes are parsed by TypR but absent from the syntax manifest, so no \
749 editor would color them:\n{}\n\nAdd each to a rule in \
750 components/syntax/mod.rs (or to NOT_A_LEXEME with a reason), then regenerate \
751 the grammars with `typr syntax --write`.",
752 missing
753 .iter()
754 .map(|(lexeme, file)| format!(" - `{lexeme}` (from {file})"))
755 .collect::<Vec<_>>()
756 .join("\n")
757 );
758 }
759
760 #[test]
766 fn manifest_claims_no_word_the_parser_does_not_know() {
767 let mut parsed: HashSet<String> = HashSet::new();
768 for (_, source) in PARSER_SOURCES {
769 for literal in scan_tag_literals(source) {
770 parsed.insert(normalize(&literal));
771 }
772 }
773 for extra in ["function", "and", "or"] {
776 parsed.insert(extra.to_string());
777 }
778
779 let manifest = SyntaxManifest::new();
780 let invented: Vec<&str> = manifest
781 .all_lexemes()
782 .into_iter()
783 .filter(|l| is_word_like(l))
784 .filter(|l| !parsed.contains(*l))
785 .collect();
786
787 assert!(
788 invented.is_empty(),
789 "the manifest colors words TypR does not have: {invented:?}. \
790 Remove them — this is exactly the Rust-keyword copy-paste the manifest replaced."
791 );
792 }
793
794 #[test]
795 fn reserved_sigils_are_flagged() {
796 let manifest = SyntaxManifest::new();
797 let reserved: Vec<&str> = manifest
798 .sigils
799 .iter()
800 .filter(|s| s.reserved)
801 .map(|s| s.sigil.as_str())
802 .collect();
803 assert_eq!(reserved, vec!["~", "&", "!"]);
806 assert_eq!(manifest.sigils.iter().filter(|s| !s.reserved).count(), 6);
807 }
808
809 #[test]
810 fn rule_names_are_unique() {
811 let manifest = SyntaxManifest::new();
812 let mut names: Vec<&str> = manifest.rules.iter().map(|r| r.name.as_str()).collect();
813 let count = names.len();
814 names.sort();
815 names.dedup();
816 assert_eq!(names.len(), count, "rule names double as TextMate repository keys");
817 }
818}