diff --git a/Cargo.lock b/Cargo.lock index c82e7b3..e312475 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "codebook-tree-sitter-typst" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f785ad90ce80edd9e8b3d00890af0bc163e9a2c38f03daa18aede98d343d093" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "compact_str" version = "0.9.1" @@ -2003,6 +2013,7 @@ dependencies = [ name = "vamp" version = "0.1.0" dependencies = [ + "codebook-tree-sitter-typst", "crossterm", "ignore", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index dd78548..d8aeb53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ tree-sitter-python = "0.25.0" tree-sitter-ruby = "0.23.1" tree-sitter-rust = "0.24.2" tree-sitter-toml-ng = "0.7.0" +tree-sitter-typst = { package = "codebook-tree-sitter-typst", version = "=0.12.1" } tree-sitter-typescript = "0.23.2" tree-sitter-yaml = "0.7.2" tree-sitter-zig = "1.1.2" diff --git a/TODO.md b/TODO.md index 5f58303..08f3d51 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,5 @@ +- [x] feature: syntax/lsp - add a default configuration for typst. + - for the lsp, i'm thinking tinymist is a good candidate. - [ ] feature: diff view - GitHub code review integration after the local review UX is usable. - publish local reviews explicitly: general PR comments, inline line/range comments, review summaries, approval, and requests for changes. - preserve explicit PR identity and reviewed commit IDs; validate anchors against GitHub's PR diff and recheck the PR head before submission. diff --git a/docs/configuration.md b/docs/configuration.md index 12dcca7..255a165 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -400,6 +400,7 @@ Vamp.configure do |config| config.lsp["rust"] = true # rust-analyzer, extensions: ["rs"] config.lsp["ruby"] = true # solargraph stdio, extensions: ["rb"] config.lsp["python"] = true # ty server + ruff server, extensions: ["py"] + config.lsp["typst"] = true # tinymist lsp, extensions: ["typ"] config.lsp["my-language"] = { command: ["my-language-server", "--stdio"], @@ -414,12 +415,14 @@ Vamp.configure do |config| end ``` -Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: `"rust"` uses `rust-analyzer`/`rs`, `"python"` uses `ty server` plus `ruff server`/`py`, and `"ruby"` uses `solargraph stdio`/`rb`. Unknown keys need a configuration object with nonempty `command` argv or a `servers` list, plus `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. An explicit `command` replaces the entire preset with one server: `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` retains `rs`, while a Python command override does not also launch Ruff. +Keys are arbitrary nonempty strings, used as the protocol's `languageId`; they need no Tree-sitter grammar or hardcoded language enum. Absent/`false` entries are disabled. `true` enables a built-in preset: `"rust"` uses `rust-analyzer`/`rs`, `"python"` uses `ty server` plus `ruff server`/`py`, `"ruby"` uses `solargraph stdio`/`rb`, and `"typst"` uses `tinymist lsp`/`typ`. Unknown keys need a configuration object with nonempty `command` argv or a `servers` list, plus `extensions` (without leading dots). Objects also explicitly enable servers; known presets supply omitted command/extensions. An explicit `command` replaces the entire preset with one server: `config.lsp["rust"] = { command: ["/custom/path/rust-analyzer"] }` retains `rs`, while a Python command override does not also launch Ruff. Only `command`, `extensions`, `initialization_options`, `settings`, and `servers` are accepted. Options/settings must be JSON-compatible objects. Without an explicit command/list, options apply to every default server; explicit `initialization_options` replaces preset defaults. `servers` must be a nonempty ordered list of objects with explicit `command` and optional per-server `initialization_options`/`settings`; it shares language-level extensions and cannot be combined with top-level command/options/settings. Invalid declarations reject configuration with a notice rather than activating a guessed server. Commands are executable/argument arrays, never shell commands; use an absolute path if the executable is not on PATH. No server installation or restart is automatic. Install Solargraph yourself with `gem install solargraph` in the project's Ruby environment. The Ruby preset launches `["solargraph", "stdio"]`, following [Solargraph's stdio integration](https://solargraph.org/guides/language-server), and uses [YARD annotations](https://solargraph.org/guides/yard) for type information. Explicit `command` overrides replace the entire argv: for a bundled installation, use `config.lsp["ruby"] = { command: ["bundle", "exec", "solargraph", "stdio"] }`; Vamp retains the `rb` extension but does not append `stdio` to your override. +Install [Tinymist](https://github.com/Myriad-Dreamin/tinymist#installation) separately and enable `config.lsp["typst"] = true` for `.typ` files. The preset launches `["tinymist", "lsp"]` with language ID `typst`; it does not enable preview or add export settings. Tinymist builds that return overlapping semantic tokens despite Vamp advertising no overlap support retain Tree-sitter highlighting and report the rejected tokens; hover and other LSP requests remain available. This occurs with the installed Typst 0.14.2-based build (`tinymist/v0.14.6-rc2`). + **Config-file lint style:** Solargraph's RuboCop reporter discovers `.rubocop.yml` from the Ruby file's directory, not Vamp's workspace. A config under `~/.config/vamp/` therefore does not inherit this repository's policy. To keep double quotes, multiline trailing argument commas, commented-out settings without a space after `#`, and no required frozen-string header, place this `.rubocop.yml` beside `config.rb` (also for a custom `--config` path): ```yaml diff --git a/docs/development.md b/docs/development.md index 7ffcc7a..7dd7d6a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,6 +23,8 @@ The default workspace member is the editor. `vendor/crossterm` is included to te `vendor/tree-sitter-honey` contains a minimal Rust binding plus the unchanged generated parser, parser header, and highlight query from [`valdemar/tree-sitter-honey`](https://github.com/valdemar/tree-sitter-honey), revision `10f1ff746fe57736ae7d7c8c402f7b265680b26c` (MIT). Builds do not depend on a sibling checkout or an installed Tree-sitter CLI. To update, copy `src/parser.c`, `src/tree_sitter/parser.h`, and `queries/highlights.scm` from the same grammar revision, update the revision recorded here, and run `cargo test --lib syntax::tests`. +Typst uses the published [`codebook-tree-sitter-typst` 0.12.1 grammar](https://docs.rs/codebook-tree-sitter-typst/0.12.1/codebook_tree_sitter_typst/), aliased as `tree-sitter-typst` and pinned because the crate does not export queries. `src/syntax/languages.rs` supplies supported highlight captures and standard raw-code injection captures against that grammar's AST. After changing the grammar or query, run `cargo test --lib syntax::`; coverage includes Typst markup/code, incremental edits, Markdown fences, and embedded Rust. Live network-denied PTY smoke verifies heading/keyword/string/function colors, the opt-in Tinymist status, an unknown-variable diagnostic, and a `text()` hover signature with unchanged source bytes. + ## Ruby types Use YARD `@param`, `@return`, and `@yieldparam` annotations for Ruby APIs, and `@type` for locals where inference needs help. Examples annotate DSL block parameters before the call, without adding runtime assignments or dependencies. `Vamp::Action` / `Vamp::Mode` field types and the dynamically defined mode methods use Solargraph-supported directives. diff --git a/docs/editing.md b/docs/editing.md index 871cca7..b281143 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -629,7 +629,7 @@ config.bind "leader f", config.core.follow_link ## Syntax highlighting -Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, Go, Zig (`.zig`), and Honey (`.hon`). Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it. +Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, Go, Zig (`.zig`), Honey (`.hon`), and Typst (`.typ`). Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it. Files remain editable above the syntax budget: sources over 1 MiB, or with any line over 64 KiB, use plain text and show the reason in the language status. Syntax highlighting resumes automatically after shrinking below both limits. This avoids building a full parse tree for huge generated files; navigation, search, selections, undo/redo, and saving remain available. @@ -637,6 +637,8 @@ Colors retain the terminal palette: comments dark gray, strings green, keywords Honey uses its bundled grammar and highlight queries, including declarations, primitive types, literals, and comments. Markdown fences accept `honey`, `honeylang`, or `hon`; the language ID is `honey`. +Typst highlights headings, emphasis, references, raw code, comments, strings, numbers, keywords, and function calls without an LSP. Markdown fences accept `typst` or `typ`; named raw-code blocks inside Typst use the same bundled language registry. The language ID is `typst`. Native [Tinymist configuration](configuration.md#native-lsp) is opt-in. + Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. List markers (`-`, `+`, `*`, and ordered prefixes such as `1.` or `1)`) also use cyan markup, including nested and blockquoted lists. Plain-text hashes and other punctuation keep their existing styling. An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Edits retain semantic colors outside the changed range and shift them with the text until updated tokens arrive, avoiding whole-buffer flashes. Tree-sitter fills gaps, including edited ranges whose semantic tokens were discarded. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing. diff --git a/examples/config.rb b/examples/config.rb index 99e1f18..5b9291e 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -45,6 +45,7 @@ Vamp.configure do |config| # config.lsp["rust"] = true # built-in rust-analyzer preset # config.lsp["ruby"] = true # built-in Solargraph stdio preset (gem install solargraph) # config.lsp["python"] = true # built-in ty + Ruff stack (install both separately) + # config.lsp["typst"] = true # built-in Tinymist LSP preset (install tinymist separately) # config.lsp["my-language"] = { # command: ["my-language-server", "--stdio"], extensions: ["myl"], # initialization_options: {}, settings: {} diff --git a/src/lsp_config.rs b/src/lsp_config.rs index 19fd37b..52e89bf 100644 --- a/src/lsp_config.rs +++ b/src/lsp_config.rs @@ -14,6 +14,7 @@ pub fn resolve(value: Value) -> Result, String> { crate::syntax::RUST_ID => Some((&[&["rust-analyzer"]], "rs")), crate::syntax::PYTHON_ID => Some((&[&["ty", "server"], &["ruff", "server"]], "py")), crate::syntax::RUBY_ID => Some((&[&["solargraph", "stdio"]], "rb")), + crate::syntax::TYPST_ID => Some((&[&["tinymist", "lsp"]], "typ")), _ => None, }; let mut options = match value { diff --git a/src/main.rs b/src/main.rs index 1a8884b..15600ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,6 +125,7 @@ Set VAMP_RUBY to choose the Ruby executable. Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio + typst -> tinymist lsp Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] } Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] } LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover diff --git a/src/syntax.rs b/src/syntax.rs index a19555d..3cbaf05 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -13,6 +13,7 @@ use languages::LanguageSpec; pub(crate) const RUST_ID: &str = "rust"; pub(crate) const RUBY_ID: &str = "ruby"; pub(crate) const PYTHON_ID: &str = "python"; +pub(crate) const TYPST_ID: &str = "typst"; // Full trees and their indexes are optional; editing remains available beyond these budgets. const MAX_SYNTAX_BYTES: usize = 1024 * 1024; diff --git a/src/syntax/languages.rs b/src/syntax/languages.rs index 5068db0..a766f33 100644 --- a/src/syntax/languages.rs +++ b/src/syntax/languages.rs @@ -90,6 +90,32 @@ const CPP_INJECTIONS: &str = " (raw_string_content) @injection.content) "; +// The Typst crate exports its grammar but not queries. Use its AST nodes with +// Vamp's supported capture roles, and standard captures for raw-code injections. +const TYPST_HIGHLIGHTS: &str = r#" +(comment) @comment +(string) @string +(number) @number +[(bool) (none) (auto)] @keyword +[ + "let" "if" "else" "while" "for" "in" "import" "as" "include" + "show" "set" "return" "break" "continue" "context" "and" "or" "not" +] @keyword +(call item: (ident) @function) +(call item: (field field: (ident) @function)) +[ + (heading) (emph) (strong) (url) (label) (ref) (symbol) + (raw_blck) (raw_span) +] @markup +(item "-" @markup.list) +(term ["/" ":"] @markup.list) +"#; +const TYPST_INJECTIONS: &str = " +(raw_blck + lang: (ident) @injection.language + (blob) @injection.content) +"; + fn javascript_injections() -> &'static str { // The trailing upstream hbs/@glimmer rule uses a nonstandard #offset! directive, // not injection.* captures. Glimmer is outside the selected language set; @@ -118,7 +144,7 @@ fn spec( } } -static LANGUAGES: LazyLock<[LanguageSpec; 20]> = LazyLock::new(|| { +static LANGUAGES: LazyLock<[LanguageSpec; 21]> = LazyLock::new(|| { [ spec( super::RUST_ID, @@ -280,6 +306,14 @@ static LANGUAGES: LazyLock<[LanguageSpec; 20]> = LazyLock::new(|| { "", "", ), + spec( + super::TYPST_ID, + "Typst", + tree_sitter_typst::LANGUAGE.into(), + TYPST_HIGHLIGHTS, + TYPST_INJECTIONS, + "", + ), ] }); @@ -320,6 +354,7 @@ pub(super) fn by_name(name: &str) -> Option<&'static LanguageSpec> { ("golang", "Go"), ("hon", "Honey"), ("honeylang", "Honey"), + ("typ", "Typst"), ]; let name = ALIASES .iter() @@ -376,6 +411,7 @@ fn detect_path(path: &Path) -> Option<&'static LanguageSpec> { "go" => "Go", "zig" => "Zig", "hon" => "Honey", + "typ" => "Typst", _ => return None, } } diff --git a/tests/unit/ruby.rs b/tests/unit/ruby.rs index 96b1cf4..7ba1685 100644 --- a/tests/unit/ruby.rs +++ b/tests/unit/ruby.rs @@ -1280,6 +1280,7 @@ fn lsp_configuration_is_disabled_unless_explicitly_enabled() { r#"Vamp.configure do |config| config.lsp["rust"] = false config.lsp["python"] = false + config.lsp["typst"] = false config.lsp["not-a-preset"] = false end"#, ] { @@ -1299,13 +1300,15 @@ fn lsp_presets_custom_languages_and_overrides_reach_native_configuration() { config.lsp["rust"] = true config.lsp["python"] = true config.lsp["ruby"] = true + config.lsp["typst"] = true end"#, ); let config = load_config(&path).unwrap(); - assert_eq!(config.lsp.len(), 4); + assert_eq!(config.lsp.len(), 5); for (language, command, extension) in [ ("rust", &["rust-analyzer"][..], "rs"), ("ruby", &["solargraph", "stdio"][..], "rb"), + ("typst", &["tinymist", "lsp"][..], "typ"), ] { let server = config .lsp diff --git a/tests/unit/syntax.rs b/tests/unit/syntax.rs index 78b3eae..ba9890d 100644 --- a/tests/unit/syntax.rs +++ b/tests/unit/syntax.rs @@ -648,6 +648,40 @@ fn honey_highlights_declarations_literals_edits_and_markdown_fences() { } } +#[test] +fn typst_highlights_markup_code_edits_and_fenced_languages() { + let source = "= Heading\n#let message = \"hello é\"\n#let count = 42\n\ + #text(message) // note\n*strong* and _emphasis_\n\ + ```rust\nfn main() { let n = 7; }\n```"; + let mut cache = self::cache(source, "main.typ"); + assert_eq!(cache.language(), Some("Typst")); + assert!(!cache.tree.as_ref().unwrap().root_node().has_error()); + for (row, token, kind) in [ + (0, "Heading", SyntaxKind::Markup), + (1, "let", SyntaxKind::Keyword), + (1, "hello é", SyntaxKind::String), + (2, "42", SyntaxKind::Number), + (3, "text", SyntaxKind::Function), + (3, "// note", SyntaxKind::Comment), + (4, "strong", SyntaxKind::Markup), + (4, "emphasis", SyntaxKind::Markup), + (6, "fn", SyntaxKind::Keyword), + (6, "7", SyntaxKind::Number), + ] { + assert_token(&cache, source, row, token, kind); + } + let edited = source.replace("\"hello é\"", "false"); + cache.update(&lines(&edited), Some(Path::new("main.typ")), 1); + assert_token(&cache, &edited, 1, "false", SyntaxKind::Keyword); + assert_fresh(&cache, &edited, "main.typ"); + for tag in ["typst", "typ"] { + let markdown = format!("```{tag}\n#let count = 42\n```"); + let injected = self::cache(&markdown, "README.md"); + assert_token(&injected, &markdown, 1, "let", SyntaxKind::Keyword); + assert_token(&injected, &markdown, 1, "42", SyntaxKind::Number); + } +} + #[test] fn filenames_shebang_save_as_and_tsx_use_actual_grammar_identity() { let source = lines("\u{feff}#!/usr/bin/env python3\nreturn 42"); diff --git a/tests/unit/syntax_languages.rs b/tests/unit/syntax_languages.rs index 1ebb159..97986b6 100644 --- a/tests/unit/syntax_languages.rs +++ b/tests/unit/syntax_languages.rs @@ -22,6 +22,8 @@ fn selected_files_and_auxiliary_grammars_are_registered() { ("main.C", "C++"), ("main.go", "Go"), ("main.zig", "Zig"), + ("main.typ", "Typst"), + ("main.TYP", "Typst"), ] { assert_eq!( detect(Some(Path::new(filename)), None).map(|spec| spec.name),