├── .gitignore ├── README.md ├── svelte-ts-mode.el └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /*.elc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte tree-sitter mode 2 | 3 | An Emacs tree-sitter major mode for `.svelte` files. 4 | 5 | It requires 6 | 7 | - Emacs `(>= emacs-major-version 29)` with tree-sitter support. 8 | 9 | Upstream grammars: 10 | 11 | - [tree-sitter-svelte][3]: mandatory 12 | - [tree-sitter-typescript][4]: optional 13 | - [tree-sitter-javascript][5]: optional 14 | - [tree-sitter-css][6]: optional 15 | 16 | Example `language-source-alist` for `treesit-language-source-alist` can be found 17 | in `svelte-ts-mode-language-source-alist` constant in [svelte-ts-mode.el](./svelte-ts-mode.el) 18 | 19 | You can use command `M-x treesit-install-language-grammar` to install these grammars 20 | once you have configured them in your `treesit-language-source-alist`. 21 | 22 | ## Installation 23 | 24 | NOTE, for Emacs master user, please use the `emacs-master` branch (i.e. you should add 25 | option `:branch "emacs-master"` in your elpaca configuration or clone the `emacs-master` 26 | branch locally). 27 | 28 | > [!CAUTION] 29 | > Still in early development. 30 | 31 | - [MELPA][2] 32 | 33 | Not ready yet. 34 | 35 | - Elpaca 36 | 37 | ```emacs-lisp 38 | (use-package svelte-ts-mode 39 | :ensure (:host github :repo "leafOfTree/svelte-ts-mode")) 40 | ``` 41 | 42 | - Manually 43 | 44 | ```bash 45 | git clone https://github.com/leafOfTree/svelte-ts-mode --depth 1 46 | ``` 47 | 48 | ```lisp 49 | ; ~/.emacs 50 | (add-to-list 'load-path "/path/to/svelte-ts-mode") 51 | (require 'svelte-ts-mode) 52 | ``` 53 | 54 | For [Spacemacs][1], put them inside `dotspacemacs/user-config`. 55 | 56 | ```lisp 57 | ; ~/.spacemacs 58 | (defun dotspacemacs/user-config () 59 | 60 | (add-to-list 'load-path "/path/to/svelte-ts-mode") 61 | (require 'svelte-ts-mode) 62 | ``` 63 | 64 | ## Example Configuration 65 | 66 | ```emacs-lisp 67 | (use-package svelte-ts-mode 68 | :after eglot 69 | :ensure (:host github :repo "leafOfTree/svelte-ts-mode") 70 | :config 71 | (add-to-list 'eglot-server-programs '(svelte-ts-mode . ("svelteserver" "--stdio")))) 72 | ``` 73 | 74 | ## Tips 75 | 76 | ### Set project-wide indentation offset for different languages 77 | 78 | Create `.dir-locals.el` at the project root directory, and write content like the 79 | following into it: 80 | 81 | ```emacs-lisp 82 | ;;; Directory Local Variables -*- no-byte-compile: t; -*- 83 | ;;; For more information see (info "(emacs) Directory Variables") 84 | 85 | ((nil . ((typescript-ts-mode-indent-offset . 4) 86 | (js-indent-level . 4) 87 | (css-indent-offset . 4) 88 | (svelte-ts-mode-indent-offset . 2)))) 89 | ``` 90 | 91 | This ensures indentation for a specific language(e.g. typescript) between its major mode 92 | and `svelte-ts-mode` are the same. 93 | 94 | ## Credits 95 | 96 | Inspired by `mhtml-ts-mode` 97 | 98 | [1]: https://github.com/syl20bnr/spacemacs 99 | [2]: https://melpa.org/#/svelte-mode 100 | [3]: https://github.com/Himujjal/tree-sitter-svelte 101 | [4]: https://github.com/tree-sitter/tree-sitter-typescript 102 | [5]: https://github.com/tree-sitter/tree-sitter-javascript 103 | [6]: https://github.com/tree-sitter/tree-sitter-css 104 | 105 | -------------------------------------------------------------------------------- /svelte-ts-mode.el: -------------------------------------------------------------------------------- 1 | ;;; svelte-ts-mode.el --- Emacs major mode for Svelte -*- lexical-binding:t -*- 2 | ;; Copyright (C) 2025 Leaf. 3 | 4 | ;; Author: Leaf 5 | ;; Author: Meow King 6 | ;; Created: 5 Dec 2024 7 | ;; Keywords: svelte languages tree-sitter 8 | ;; Homepage: https://github.com/leafOfTree/svelte-ts-mode 9 | ;; Version: 1.0.0 10 | ;; Package-Requires: ((emacs "29.1")) 11 | 12 | ;; This file is NOT part of GNU Emacs. 13 | ;; You can redistribute it and/or modify it under the terms of 14 | ;; the GNU Lesser General Public License v3.0. 15 | 16 | ;;; Commentary: 17 | 18 | ;; This major mode includes typescript-ts-mode and css-mode 19 | ;; to support basic treesit svelte syntax and indent 20 | 21 | 22 | ;; Note this mode advises `comment-normalize-vars' to create special behavior 23 | ;; for `svelte-ts-mode' only. You can set `svelte-ts-mode-enable-comment-advice' 24 | ;; to nil to avoid advice. 25 | 26 | ;;; Code: 27 | 28 | (require 'treesit) 29 | 30 | (defgroup svelte-ts nil 31 | "`svelte-ts-mode'." 32 | :group 'languages) 33 | 34 | (defcustom svelte-ts-mode-indent-offset 2 35 | "Number of spaces for each indentation step in `svelte-ts-mode'." 36 | :type 'integer 37 | :safe 'integerp 38 | :group 'svelte-ts 39 | :package-version '(svelte-ts-mode . "1.0.0")) 40 | 41 | (defcustom svelte-ts-mode-enable-comment-advice t 42 | "Enable language-wise comment inside `svlete-ts-mode' buffer." 43 | :type 'boolean 44 | :safe 'booleanp 45 | :group 'svelte-ts 46 | :package-version '(svelte-ts-mode . "1.0.0")) 47 | 48 | (defconst svelte-ts-mode-language-source-alist 49 | '((svelte . ("https://github.com/Himujjal/tree-sitter-svelte")) 50 | (typescript . ("https://github.com/tree-sitter/tree-sitter-typescript" nil 51 | "typescript/src")) 52 | (javascript . ("https://github.com/tree-sitter/tree-sitter-javascript")) 53 | (css . ("https://github.com/tree-sitter/tree-sitter-css"))) 54 | "Language source for `svelte-ts-mode'. 55 | User can easily add it to their `treesit-language-source-alist'. 56 | In the future, we may pin a version.") 57 | 58 | (defconst svelte-ts-mode--indent-close-types-regexp 59 | (concat 60 | "\\`" 61 | (regexp-opt 62 | '(">" "/>" "}" 63 | "end_tag" 64 | "else_if_statement" "else_statement" "if_end_expr" ; if 65 | "key_end_expr" ; key 66 | "else_statement" "each_end_expr" ; each 67 | "then_statement" "catch_statement" "await_end_expr" ; await 68 | "snippet_end_expr" ; snippet 69 | )) 70 | "\\'")) 71 | 72 | (defconst svelte-ts-mode--container-node-types-regexp 73 | (concat 74 | "\\`" 75 | (regexp-opt 76 | '(; html 77 | "element" "script_element" "style_element" 78 | 79 | ;; svelte 80 | ;; if 81 | "if_statement" "if_start_expr" 82 | "else_if_statement" "else_if_expr" 83 | "else_statement" "else_expr" 84 | "if_end_expr" 85 | 86 | ;; key 87 | "key_statement" "key_start_expr" "key_end_expr" 88 | 89 | ;; each 90 | "each_statement" "each_start_expr" 91 | "else_each_statement" "else_expr" 92 | "each_end_expr" 93 | 94 | ;; await 95 | "await_statement" "await_start_expr" 96 | "then_statement" "then_expr" 97 | "catch_statement" "catch_expr" 98 | "await_end_expr" 99 | 100 | ;; snippet 101 | "snippet_statement" "snippet_start_expr" "snippet_end_expr" 102 | 103 | ;; misc expression 104 | "expression" "html_expr" "const_expr" "render_expr" "debug_expr")) 105 | "\\'")) 106 | 107 | 108 | (defvar svelte-ts-mode-font-lock-settings 109 | (treesit-font-lock-rules 110 | :language 'svelte 111 | :feature 'error 112 | '((erroneous_end_tag_name) @font-lock-warning-face) 113 | 114 | :language 'svelte 115 | :feature 'definition 116 | '((tag_name) @font-lock-function-name-face) 117 | 118 | 119 | :language 'svelte 120 | :feature 'comment 121 | '((comment) @font-lock-comment-face) 122 | 123 | :language 'svelte 124 | :feature 'keyword 125 | '([(special_block_keyword) 126 | (then) 127 | (as)] 128 | @font-lock-keyword-face) 129 | 130 | :language 'svelte 131 | :feature 'punctuation 132 | '(["{" "}"] @font-lock-bracket-face 133 | "=" @font-lock-operator-face 134 | ["<" ">" "" "#" ":" "/" "@"] @font-lock-delimiter-face) 135 | 136 | :language 'svelte 137 | :feature 'property 138 | '((attribute_name) @font-lock-property-name-face) 139 | 140 | :language 'svelte 141 | :feature 'string 142 | '([(attribute_value) 143 | (quoted_attribute_value)] 144 | @font-lock-string-face))) 145 | 146 | (defconst svelte-ts-mode-font-lock-features-list 147 | '((error definition comment keyword) 148 | (property string) 149 | (punctuation) 150 | ())) 151 | 152 | (defvar-local svelte-ts-mode--treesit-buffer-ready 153 | '((svelte . nil) 154 | (javascript . nil) 155 | (typescript . nil) 156 | (css . nil)) 157 | "Memoized treesit-ready-p output for required modes in a buffer local alist.") 158 | 159 | (defun svelte-ts-mode--treesit-buffer-ready (mode) 160 | "Returns memoized value of treesit-ready-p." 161 | (let ((cell (assoc mode svelte-ts-mode--treesit-buffer-ready))) 162 | (if cell 163 | (or (cdr cell) (setcdr cell (treesit-ready-p mode))) 164 | (error "Unsupported treesit mode")))) 165 | 166 | (defun svelte-ts-mode--indent-inside-container-nodes-p (_node parent _bol) 167 | "Whether the ancestor node(also itself) of PARENT is of container node type. 168 | NODE, PARENT and BOL see `treesit-simple-indent-rules'." 169 | (treesit-parent-until 170 | ;; NODE can be nil (hit return), so we use PARENT 171 | parent 172 | (lambda (node) 173 | (string-match-p 174 | svelte-ts-mode--container-node-types-regexp 175 | (treesit-node-type node))) 176 | t)) 177 | 178 | 179 | (defun svelte-ts-mode--indent-ancestor-container-nodes-bol (node parent bol) 180 | "Return the beginning of line position of the closest ancestor container node. 181 | NODE, PARENT and BOL see `treesit-simple-indent-rules'." 182 | (save-excursion 183 | (goto-char 184 | (treesit-node-start 185 | (svelte-ts-mode--indent-inside-container-nodes-p node parent bol))) 186 | (back-to-indentation) 187 | (point))) 188 | 189 | (defun svelte-ts-mode--indent-close-type-find-matching-parent-bol (node parent bol) 190 | (let* ((node-type (treesit-node-type node)) 191 | (parent-node-raw-regexp ; `nil' means direct parent is the matching parent 192 | (pcase node-type 193 | ;; if 194 | ("else_if_statement" "if_statement") 195 | ("if_end_expr" "if_statement") 196 | 197 | ;; key 198 | ("key_end_expr" "key_statement") 199 | 200 | ;; each 201 | ("each_end_expr" "each_statement") 202 | 203 | ;; await 204 | ("then_statement" "await_statement") 205 | ("catch_statement" "await_statement") 206 | ("await_end_expr" "await_statement") 207 | 208 | ;; snippet 209 | ("snippet_end_expr" "snippet_statement") 210 | 211 | ;; common 212 | ("else_statement" (regexp-opt '("if_statement" "each_statement"))))) 213 | (parent-node-raw-regexp 214 | (when parent-node-raw-regexp 215 | (concat "\\`" parent-node-raw-regexp "\\'")))) 216 | (save-excursion 217 | (goto-char 218 | (treesit-node-start 219 | (if (not parent-node-raw-regexp) 220 | parent 221 | (treesit-parent-until 222 | parent 223 | (lambda (node) 224 | (string-match-p parent-node-raw-regexp (treesit-node-type node))) 225 | t)))) 226 | (back-to-indentation) 227 | (point)))) 228 | 229 | 230 | (defvar svelte-ts-mode--indent-rules 231 | `((svelte 232 | ;; ((lambda (node parent bol) 233 | ;; (message "%s: %s %s %s %s %s" 234 | ;; (point) node parent 235 | ;; (treesit-node-parent parent) 236 | ;; (treesit-node-parent (treesit-node-parent parent)) bol) 237 | ;; nil) 238 | ;; parent-bol 0) 239 | 240 | ((parent-is "document") column-0 0) 241 | ((parent-is "comment") prev-adaptive-prefix 0) 242 | 243 | ((node-is ,svelte-ts-mode--indent-close-types-regexp) 244 | svelte-ts-mode--indent-close-type-find-matching-parent-bol 0) 245 | 246 | (svelte-ts-mode--indent-inside-container-nodes-p 247 | svelte-ts-mode--indent-ancestor-container-nodes-bol 248 | svelte-ts-mode-indent-offset))) 249 | "Tree-sitter indent rules.") 250 | 251 | ;; NOTE it's a must to prefix features in Emacs 30. Otherwise embedded CSS code 252 | ;; cannot be highlighted correctly. See 253 | ;; https://github.com/leafOfTree/svelte-ts-mode/issues/7 254 | (defun svelte-ts-mode--prefix-font-lock-features (prefix settings) 255 | "Prefix with PREFIX the font lock features in SETTINGS." 256 | (cl-loop for i from 0 below (length settings) 257 | collect 258 | (mapcar (lambda (f) (intern (format "%s-%s" prefix f))) 259 | (nth i settings)))) 260 | 261 | (defun svelte-ts-mode--prefix-font-lock-settings-features-name (prefix settings) 262 | "Prefix with PREFIX the font lock features in SETTINGS." 263 | (mapcar (lambda (setting) 264 | (pcase setting 265 | (`(,lang ,query ,feature . ,rest) 266 | `(,lang ,query ,(intern (format "%s-%s" prefix feature)) . ,rest)))) 267 | settings)) 268 | 269 | (defun svelte-ts-mode--merge-font-lock-features (a b) 270 | "Merge `treesit-font-lock-feature-list' A with B." 271 | (let* ((len-a (length a)) 272 | (len-b (length b)) 273 | (max-len (max len-a len-b))) 274 | (cl-loop for i from 0 below max-len 275 | collect (seq-uniq 276 | (append 277 | (and (< i len-a) (nth i a) (nth i a)) 278 | (and (< i len-b) (nth i b) (nth i b))))))) 279 | 280 | ;; Copied from Emacs 31's `treesit-simple-indent-modify-rules' 281 | (defun svlete-ts-mode--simple-indent-modify-rules (lang new-rules rules &optional how) 282 | "Pick out rules for LANG in RULES, and modify it according to NEW_RULES. 283 | 284 | RULES should have the same form as `treesit-simple-indent-rules', i.e, a 285 | list of (LANG RULES...). Return a new modified rules in the form 286 | of (LANG RULES...). 287 | 288 | If HOW is omitted or :replace, for each rule in NEW-RULES, find the old 289 | rule that has the same matcher, and replace it. 290 | 291 | If HOW is :prepend, just prepend NEW-RULES to the old rules; if HOW is 292 | :append, append NEW-RULES." 293 | (cond 294 | ((not (alist-get lang rules)) 295 | (error "No rules for language %s in RULES" lang)) 296 | ((not (alist-get lang new-rules)) 297 | (error "No rules for language %s in NEW-RULES" lang)) 298 | (t (let* ((copy-of-rules (copy-tree rules)) 299 | (lang-rules (alist-get lang copy-of-rules)) 300 | (lang-new-rules (alist-get lang new-rules))) 301 | (cond 302 | ((eq how :prepend) 303 | (setf (alist-get lang copy-of-rules) 304 | (append lang-new-rules lang-rules))) 305 | ((eq how :append) 306 | (setf (alist-get lang copy-of-rules) 307 | (append lang-rules lang-new-rules))) 308 | ((or (eq how :replace) t) 309 | (let ((tail-new-rules lang-new-rules) 310 | (tail-rules lang-rules) 311 | (new-rule nil) 312 | (rule nil)) 313 | (while (setq new-rule (car tail-new-rules)) 314 | (while (setq rule (car tail-rules)) 315 | (when (equal (nth 0 new-rule) (nth 0 rule)) 316 | (setf (car tail-rules) new-rule)) 317 | (setq tail-rules (cdr tail-rules))) 318 | (setq tail-new-rules (cdr tail-new-rules)))))) 319 | copy-of-rules)))) 320 | 321 | 322 | (defconst svelte-ts-mode--query-get-script-element-attrs 323 | (when (treesit-ready-p 'svelte) 324 | (treesit-query-compile 'svelte '((start_tag (attribute) @attr))))) 325 | 326 | (defun svelte-ts-mode--treesit-language-at-point (pos) 327 | (let* ((node (treesit-node-at pos 'svelte)) 328 | (parent (treesit-node-parent node)) 329 | (js-ready (svelte-ts-mode--treesit-buffer-ready 'javascript)) 330 | (ts-ready (svelte-ts-mode--treesit-buffer-ready 'typescript)) 331 | (css-ready (svelte-ts-mode--treesit-buffer-ready 'css))) 332 | (cond 333 | ((and node parent 334 | (equal (treesit-node-type node) "raw_text") 335 | (equal (treesit-node-type parent) "script_element")) 336 | (if (and js-ready ts-ready) 337 | (if (null (treesit-query-capture 338 | parent svelte-ts-mode--query-get-script-element-attrs)) 339 | 'javascript 340 | 'typescript) 341 | (if ts-ready 342 | 'typescrpt 343 | (if js-ready 344 | 'javscript 345 | 'svelte)))) 346 | ((and node parent 347 | (equal (treesit-node-type node) "raw_text") 348 | (equal (treesit-node-type parent) "style_element")) 349 | (if css-ready 350 | 'css 351 | 'svelte)) 352 | (t 'svelte)))) 353 | 354 | ;; reference: mhtml-ts-mode--js-css-tag-bol 355 | (defun svelte-ts-mode--script-style-tag-bol (_node _parent &rest _) 356 | "Find the first non-space characters of html tags