Skip to content

Commit 6c3ef88

Browse files
claudesinelaw
authored andcommitted
feat(highlight): bundled TextMate TypeScript grammar for embedded contexts
<script lang="ts"> is the dominant real-world Vue pattern, but it fell back to JS-approximation highlighting because fresh had no TextMate TypeScript grammar (TS buffers are tree-sitter). Add a compact house-style TS grammar (like the existing dart/kotlin ones) so embedded regions resolve "ts" to real TS-aware highlighting — interface/type/ enum/namespace keywords, primitive-type names, decorators — in Vue blocks and Markdown ```ts fences alike. Vendoring Sublime's official TypeScript syntax is not an option: it relies on branch_point, which no released syntect supports (checked: 5.3.0, Sep 2025, is current and fresh already uses it). Buffer routing is unchanged by design: the grammar catalog skips "TypeScript" by name exactly like the existing "JavaScript" skip, so .ts files keep the richer tree-sitter highlighting while find_syntax_by_token("ts") still resolves the new grammar for embedded regions. The pre-existing backend-selection test (test.ts → tree-sitter) pins that this doesn't flip. The unresolvable-lang fallback test now uses a genuinely unknown token, since lang="ts" resolving is the new behavior (covered by its own tests: TS-only constructs get categories the JS fallback never gave). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKX2Du2zizG7k9yZgMeZvS
1 parent f8e855a commit 6c3ef88

5 files changed

Lines changed: 203 additions & 10 deletions

File tree

crates/fresh-editor/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ fn generate_syntax_packdump() -> Result<(), Box<dyn std::error::Error>> {
372372
("src/grammars/earthfile.sublime-syntax", "Earthfile"),
373373
("src/grammars/gomod.sublime-syntax", "Go Module"),
374374
("src/grammars/vue.sublime-syntax", "Vue"),
375+
("src/grammars/typescript.sublime-syntax", "TypeScript"),
375376
("src/grammars/svelte.sublime-syntax", "Svelte"),
376377
("src/grammars/astro.sublime-syntax", "Astro"),
377378
("src/grammars/hyprlang.sublime-syntax", "Hyprlang"),
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
%YAML 1.2
2+
---
3+
# TypeScript syntax highlighting for Fresh editor.
4+
#
5+
# NOTE: `.ts` buffers are highlighted by tree-sitter, not by this grammar —
6+
# the grammar catalog deliberately skips "TypeScript" the same way it skips
7+
# "JavaScript" (see grammar/types.rs). This grammar exists for embedded
8+
# contexts resolved by language token: Vue `<script lang="ts">` blocks and
9+
# Markdown ```ts fences (docs/internal/embedded-language-highlighting.md).
10+
name: TypeScript
11+
file_extensions:
12+
- ts
13+
- tsx
14+
- mts
15+
- cts
16+
scope: source.ts
17+
18+
contexts:
19+
main:
20+
- include: comments
21+
- include: template-strings
22+
- include: strings
23+
- include: decorators
24+
- include: keywords
25+
- include: numbers
26+
- include: functions
27+
- include: types
28+
29+
comments:
30+
- match: '//'
31+
scope: punctuation.definition.comment.ts
32+
push:
33+
- meta_scope: comment.line.double-slash.ts
34+
- match: '$'
35+
pop: true
36+
- match: '/\*'
37+
scope: punctuation.definition.comment.begin.ts
38+
push:
39+
- meta_scope: comment.block.ts
40+
- match: '\*/'
41+
scope: punctuation.definition.comment.end.ts
42+
pop: true
43+
44+
strings:
45+
- match: '"'
46+
scope: punctuation.definition.string.begin.ts
47+
push:
48+
- meta_scope: string.quoted.double.ts
49+
- match: '\\.'
50+
scope: constant.character.escape.ts
51+
- match: '"'
52+
scope: punctuation.definition.string.end.ts
53+
pop: true
54+
- match: '$'
55+
pop: true
56+
- match: "'"
57+
scope: punctuation.definition.string.begin.ts
58+
push:
59+
- meta_scope: string.quoted.single.ts
60+
- match: '\\.'
61+
scope: constant.character.escape.ts
62+
- match: "'"
63+
scope: punctuation.definition.string.end.ts
64+
pop: true
65+
- match: '$'
66+
pop: true
67+
68+
template-strings:
69+
- match: '`'
70+
scope: punctuation.definition.string.begin.ts
71+
push:
72+
- meta_scope: string.quoted.template.ts
73+
- match: '\\.'
74+
scope: constant.character.escape.ts
75+
- match: '\$\{[^}]*\}'
76+
scope: variable.interpolation.ts
77+
- match: '`'
78+
scope: punctuation.definition.string.end.ts
79+
pop: true
80+
81+
decorators:
82+
- match: '@[A-Za-z_$][\w$]*'
83+
scope: entity.name.decorator.ts
84+
85+
keywords:
86+
- match: '\b(import|export|from|default|as)\b'
87+
scope: keyword.other.ts
88+
- match: '\b(if|else|for|while|do|switch|case|break|continue|return|try|catch|finally|throw|yield|await)\b'
89+
scope: keyword.control.ts
90+
- match: '\b(const|let|var|function|class|extends|implements|interface|type|enum|namespace|module|declare|abstract|readonly|public|private|protected|static|get|set|async|constructor|satisfies|infer|asserts|is|keyof|override|accessor)\b'
91+
scope: keyword.declaration.ts
92+
- match: '\b(new|delete|typeof|instanceof|void|in|of)\b'
93+
scope: keyword.operator.ts
94+
- match: '\b(true|false|null|undefined|NaN|Infinity|this|super|globalThis)\b'
95+
scope: constant.language.ts
96+
97+
numbers:
98+
- match: '\b0[xX][0-9a-fA-F_]+n?\b'
99+
scope: constant.numeric.hex.ts
100+
- match: '\b0[bB][01_]+n?\b'
101+
scope: constant.numeric.binary.ts
102+
- match: '\b0[oO][0-7_]+n?\b'
103+
scope: constant.numeric.octal.ts
104+
- match: '\b\d[\d_]*(\.[\d_]+)?([eE][+-]?\d+)?n?\b'
105+
scope: constant.numeric.decimal.ts
106+
107+
functions:
108+
- match: '(?<=\bfunction\s)[A-Za-z_$][\w$]*'
109+
scope: entity.name.function.ts
110+
- match: '\b[A-Za-z_$][\w$]*(?=\s*\()'
111+
scope: variable.function.ts
112+
113+
types:
114+
- match: '\b(string|number|boolean|any|unknown|never|object|symbol|bigint)\b'
115+
scope: support.type.primitive.ts
116+
- match: '\b[A-Z][\w$]*\b'
117+
scope: support.class.ts

crates/fresh-editor/src/primitives/grammar/types.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ pub const EARTHFILE_GRAMMAR: &str = include_str!("../../grammars/earthfile.subli
236236
pub const GOMOD_GRAMMAR: &str = include_str!("../../grammars/gomod.sublime-syntax");
237237
/// Embedded Vue grammar
238238
pub const VUE_GRAMMAR: &str = include_str!("../../grammars/vue.sublime-syntax");
239+
240+
/// Embedded TypeScript grammar. Serves embedded contexts only (Vue
241+
/// `lang="ts"`, Markdown ```ts fences) — `.ts` buffers stay on
242+
/// tree-sitter via the catalog skip below, mirroring JavaScript.
243+
pub const TYPESCRIPT_GRAMMAR: &str = include_str!("../../grammars/typescript.sublime-syntax");
239244
/// Embedded Svelte grammar
240245
pub const SVELTE_GRAMMAR: &str = include_str!("../../grammars/svelte.sublime-syntax");
241246
/// Embedded Astro grammar
@@ -754,6 +759,7 @@ impl GrammarRegistry {
754759
(EARTHFILE_GRAMMAR, "Earthfile"),
755760
(GOMOD_GRAMMAR, "Go Module"),
756761
(VUE_GRAMMAR, "Vue"),
762+
(TYPESCRIPT_GRAMMAR, "TypeScript"),
757763
(SVELTE_GRAMMAR, "Svelte"),
758764
(ASTRO_GRAMMAR, "Astro"),
759765
(HYPRLANG_GRAMMAR, "Hyprlang"),
@@ -1081,8 +1087,16 @@ impl GrammarRegistry {
10811087
// still returns syntect's grammar via the catalog's fallback path,
10821088
// so markdown popup rendering and other code-string highlighters
10831089
// are unaffected.
1090+
//
1091+
// TypeScript is skipped for the same reason in reverse: its bundled
1092+
// grammar exists only for embedded contexts (Vue `lang="ts"`,
1093+
// Markdown ```ts fences, resolved via `find_syntax_by_token`), and
1094+
// `.ts` buffers must keep the richer tree-sitter highlighting.
10841095
for (idx, syntax) in self.syntax_set.syntaxes().iter().enumerate() {
1085-
if syntax.name == "Plain Text" || syntax.name == "JavaScript" {
1096+
if syntax.name == "Plain Text"
1097+
|| syntax.name == "JavaScript"
1098+
|| syntax.name == "TypeScript"
1099+
{
10861100
continue;
10871101
}
10881102
let (language_id, tree_sitter) = derive_language_id(&syntax.name);

crates/fresh-editor/src/primitives/highlight_engine.rs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,16 +2321,15 @@ mod tests {
23212321
);
23222322
}
23232323

2324-
/// A `lang` naming a language with no syntect grammar (TypeScript is
2325-
/// tree-sitter-only in fresh) falls back to the region's default
2326-
/// language rather than dropping highlighting entirely — `<script
2327-
/// lang=\"ts\">` is the dominant real-world Vue pattern.
2324+
/// A `lang` naming a language with no grammar in the set falls back
2325+
/// to the region's default language rather than dropping
2326+
/// highlighting entirely.
23282327
#[test]
23292328
fn test_vue_unresolvable_lang_falls_back_to_default() {
23302329
let registry =
23312330
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
23322331
let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
2333-
let content = "<script setup lang=\"ts\">\nconst answer = 42;\n</script>\n";
2332+
let content = "<script setup lang=\"mysterylang\">\nconst answer = 42;\n</script>\n";
23342333
let buffer = Buffer::from_str(content, 0, test_fs());
23352334
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
23362335
let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);
@@ -2346,6 +2345,62 @@ mod tests {
23462345
);
23472346
}
23482347

2348+
/// `lang="ts"` resolves to the bundled TypeScript grammar — the
2349+
/// dominant real-world Vue pattern gets TS-aware highlighting
2350+
/// (`interface` is not a keyword to the JS default it previously
2351+
/// fell back to).
2352+
#[test]
2353+
fn test_vue_lang_ts_uses_typescript_grammar() {
2354+
let registry =
2355+
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
2356+
let mut engine = HighlightEngine::for_file(Path::new("App.vue"), None, &registry);
2357+
let content =
2358+
"<script setup lang=\"ts\">\ninterface Greeting {\n message: string;\n}\n</script>\n";
2359+
let buffer = Buffer::from_str(content, 0, test_fs());
2360+
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
2361+
let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);
2362+
2363+
let category_at = |needle: &str| {
2364+
let position = content.find(needle).unwrap();
2365+
spans
2366+
.iter()
2367+
.find(|span| span.range.start <= position && position < span.range.end)
2368+
.and_then(|span| span.category)
2369+
};
2370+
assert_eq!(
2371+
category_at("interface"),
2372+
Some(HighlightCategory::Keyword),
2373+
"TS-only keyword must be styled by the TypeScript grammar"
2374+
);
2375+
assert_eq!(
2376+
category_at("string"),
2377+
Some(HighlightCategory::Type),
2378+
"TS primitive type must be styled by the TypeScript grammar"
2379+
);
2380+
}
2381+
2382+
/// Markdown ```ts fences resolve to the same TypeScript grammar.
2383+
#[test]
2384+
fn test_markdown_ts_fence_uses_typescript_grammar() {
2385+
let registry =
2386+
GrammarRegistry::load(&crate::primitives::grammar::LocalGrammarLoader::embedded_only());
2387+
let mut engine = HighlightEngine::for_file(Path::new("README.md"), None, &registry);
2388+
let content = "```ts\ntype Answer = number;\n```\n";
2389+
let buffer = Buffer::from_str(content, 0, test_fs());
2390+
let theme = Theme::load_builtin(theme::THEME_LIGHT).unwrap();
2391+
let spans = engine.highlight_viewport(&buffer, 0, buffer.len(), &theme, 0);
2392+
2393+
let position = content.find("type").unwrap();
2394+
assert_eq!(
2395+
spans
2396+
.iter()
2397+
.find(|span| span.range.start <= position && position < span.range.end)
2398+
.and_then(|span| span.category),
2399+
Some(HighlightCategory::Keyword),
2400+
"```ts fences must be parsed with the TypeScript grammar"
2401+
);
2402+
}
2403+
23492404
#[test]
23502405
fn test_tree_sitter_direct() {
23512406
// Verify a tree-sitter highlighter can be created directly for a

docs/internal/embedded-language-highlighting.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,16 @@ script and style), each with two scope selectors and an optional default:
7777
- `language_scope` — the scope the host grammar puts on the language
7878
token of the opening line (`constant.other.language-name` for both);
7979
- `default_language` — used when the opening line names no language *or*
80-
names one that doesn't resolve to a syntax in the set. Vue uses js/css
81-
(so `lang="ts"` — TypeScript has no TextMate grammar in fresh — gets
82-
the standard JS approximation instead of nothing); Markdown uses
83-
`None`, meaning such regions keep the host's own raw-code styling.
80+
names one that doesn't resolve to a syntax in the set. Vue uses js/css;
81+
Markdown uses `None`, meaning such regions keep the host's own raw-code
82+
styling.
83+
84+
A compact TextMate TypeScript grammar is bundled *solely* for embedded
85+
contexts (`lang="ts"` in Vue, ```ts fences): the grammar catalog skips it
86+
by name — mirroring the JavaScript skip — so `.ts` buffers keep the richer
87+
tree-sitter highlighting, while `find_syntax_by_token("ts")` still
88+
resolves it for regions. (Vendoring Sublime's official TS grammar is not
89+
an option: it needs `branch_point`, which no released syntect supports.)
8490

8591
Per line, the host parser runs first (inside a region it is in a cheap
8692
"raw" context — it must run regardless, because only the host knows where

0 commit comments

Comments
 (0)