pi-tree-sitter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +314 -0
  2. package/README.md +119 -0
  3. package/index.ts +455 -0
  4. package/package.json +45 -0
package/LICENSE ADDED
@@ -0,0 +1,314 @@
1
+ Copyright (c) 2026 - present Marko Kocic <marko@euptera.com>
2
+
3
+ All rights reserved. This program and the accompanying materials
4
+ are made available under the terms of the Eclipse Public License 2.0
5
+ which accompanies this distribution, and is available at
6
+ https://www.eclipse.org/legal/epl-2.0/
7
+
8
+ SPDX-License-Identifier: EPL-2.0
9
+
10
+ Eclipse Public License - v 2.0
11
+
12
+ THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
13
+ PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
14
+ OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
15
+
16
+ 1. DEFINITIONS
17
+
18
+ "Contribution" means:
19
+
20
+ (a) in the case of the initial Contributor, the initial content
21
+ Distributed under this Agreement, and
22
+
23
+ (b) in the case of each subsequent Contributor:
24
+ i) changes to the Program, and
25
+ ii) additions to the Program;
26
+ where such changes and/or additions to the Program originate from
27
+ and are Distributed by that particular Contributor. A Contribution
28
+ "originates" from a Contributor if it was added to the Program by
29
+ such Contributor itself or anyone acting on such Contributor's behalf.
30
+ Contributions do not include changes or additions to the Program that
31
+ are not Modified Works.
32
+
33
+ "Contributor" means any person or entity that Distributes the Program.
34
+
35
+ "Licensed Patents" mean patent claims licensable by a Contributor which
36
+ are necessarily infringed by the use or sale of its Contribution alone
37
+ or when combined with the Program.
38
+
39
+ "Program" means the Contributions Distributed in accordance with this
40
+ Agreement.
41
+
42
+ "Recipient" means anyone who receives the Program under this Agreement
43
+ or any Secondary License (as applicable), including Contributors.
44
+
45
+ "Derivative Works" shall mean any work, whether in Source Code or other
46
+ form, that is based on (or derived from) the Program and for which the
47
+ editorial revisions, annotations, elaborations, or other modifications
48
+ represent, as a whole, an original work of authorship.
49
+
50
+ "Modified Works" shall mean any work in Source Code or other form that
51
+ results from an addition to, deletion from, or modification of the
52
+ contents of the Program, including, for purposes of clarity any new file
53
+ in Source Code form that contains any contents of the Program. Modified
54
+ Works shall not include works that contain only the Program or a portion
55
+ thereof.
56
+
57
+ "Distribute" means the acts of a) distributing or b) making available
58
+ in any manner that enables the transfer of a copy.
59
+
60
+ "Source Code" means the form of a Program preferred for making
61
+ modifications, including but not limited to software source code,
62
+ documentation source, and configuration files.
63
+
64
+ "Secondary License" means either the GNU General Public License,
65
+ Version 2.0, or any later versions of that license, including any
66
+ exceptions or additional permissions as identified by the initial
67
+ Contributor.
68
+
69
+ 2. GRANT OF RIGHTS
70
+
71
+ a) Subject to the terms of this Agreement, each Contributor hereby
72
+ grants Recipient a non-exclusive, worldwide, royalty-free copyright
73
+ license to reproduce, prepare Derivative Works of, publicly display,
74
+ publicly perform, Distribute and sublicense the Contribution of such
75
+ Contributor, if any, and such Derivative Works.
76
+
77
+ b) Subject to the terms of this Agreement, each Contributor hereby
78
+ grants Recipient a non-exclusive, worldwide, royalty-free patent
79
+ license under Licensed Patents to make, use, sell, offer to sell,
80
+ import and otherwise transfer the Program in whole or in part, in
81
+ or without combination with the Contribution of such Contributor, if
82
+ any, and such Derivative Works. The patent license granted shall not
83
+ apply to: (a) any other combinations that include the Contribution,
84
+ or (b) any patent claims that are infringed by the Contribution alone
85
+ or by the combination of the Contribution with the Program to which
86
+ such Contribution was added. If Recipient institutes patent litigation
87
+ against any entity (including a cross-claim or counterclaim in a
88
+ lawsuit) alleging that the Contribution itself (in combination with
89
+ the Program to which such Contribution was added) constitutes direct
90
+ or contributory patent infringement, then any patent licenses granted
91
+ to that Recipient under this Agreement shall terminate as of the date
92
+ such litigation is filed.
93
+
94
+ c) Recipient understands that although each Contributor grants the
95
+ licenses to its Contributions set forth herein, no assurances are
96
+ provided by any Contributor that the Program does not infringe the
97
+ patent or other intellectual property rights of any other entity.
98
+ Each Contributor disclaims any liability to Recipient for claims
99
+ brought by any other entity based on infringement of intellectual
100
+ property rights or otherwise. As a condition to exercising the
101
+ rights and licenses granted hereunder, each Recipient hereby assumes
102
+ sole responsibility to secure any other intellectual property rights
103
+ needed, if any. For example, if a third party patent license is
104
+ required to allow Recipient to Distribute the Program, it is
105
+ Recipient's responsibility to acquire that license before
106
+ distributing the Program.
107
+
108
+ d) Each Contributor represents that to its knowledge it has
109
+ sufficient copyright rights in its Contribution, if any, to grant
110
+ the copyright license set forth in this Agreement.
111
+
112
+ e) Notwithstanding the terms of any Secondary License, no
113
+ Contributor makes additional grants to any Recipient (other than
114
+ those set forth in this Agreement) as a result of such Recipient's
115
+ receipt of the Program under a Secondary License (if permitted under
116
+ the terms of Section 3).
117
+
118
+ 3. REQUIREMENTS
119
+
120
+ 3.1 If a Contributor Distributes the Program in any form, then:
121
+
122
+ a) the Program must also be made available as Source Code, in
123
+ accordance with section 3.2, and the Contributor must accompany
124
+ the Program with a statement that the Source Code for the Program
125
+ is available under this Agreement, and informs Recipients how to
126
+ obtain it in a reasonable manner on or through a medium customarily
127
+ used for software exchange; and
128
+
129
+ b) the Contributor may Distribute the Program under a license
130
+ different than this Agreement, provided that such license:
131
+ i) effectively disclaims on behalf of all other Contributors all
132
+ warranties and conditions, express and implied, including
133
+ warranties or conditions of title and non-infringement, and
134
+ implied warranties or conditions of merchantability and fitness
135
+ for a particular purpose;
136
+
137
+ ii) effectively excludes on behalf of all other Contributors all
138
+ liability for damages, including direct, indirect, special,
139
+ incidental and consequential damages, such as lost profits;
140
+
141
+ iii) does not attempt to limit or alter the recipients' rights
142
+ in the Source Code under section 3.2; and
143
+
144
+ iv) requires any subsequent distribution of the Program by any
145
+ party to be under a license that satisfies the requirements
146
+ of this section 3.
147
+
148
+ 3.2 When the Program is Distributed as Source Code:
149
+
150
+ a) it must be made available under this Agreement, or if the
151
+ Program (i) is combined with other material in a separate file or
152
+ files made available under a Secondary License, and (ii) the initial
153
+ Contributor attached to the Source Code the notice described in
154
+ Exhibit A of this Agreement, then the Program may be made available
155
+ under the terms of such Secondary Licenses, and
156
+
157
+ b) a copy of this Agreement must be included with each copy of
158
+ the Program.
159
+
160
+ 3.3 Contributors may not remove or alter any copyright, patent,
161
+ trademark, attribution notices, disclaimers of warranty, or limitations
162
+ of liability ("notices") contained within the Program from any copy or
163
+ distribution of the Program, provided that Contributors may add their
164
+ own appropriate notices.
165
+
166
+ 4. COMMERCIAL DISTRIBUTION
167
+
168
+ Commercial distributors of software may accept certain
169
+ responsibilities with respect to end users, business partners and the
170
+ like. While this license is intended to facilitate the commercial use
171
+ of the Program, the Contributor who includes the Program in a
172
+ commercial product offering should do so in a manner which does not
173
+ create potential liability for other Contributors. Therefore, if a
174
+ Contributor includes the Program in a commercial product offering,
175
+ such Contributor ("Commercial Contributor") hereby agrees to defend
176
+ and indemnify every other Contributor ("Indemnified Contributor")
177
+ against any losses, damages and costs (collectively "Losses") arising
178
+ from claims, lawsuits and other legal actions brought by a third party
179
+ against the Indemnified Contributor to the extent caused by the acts
180
+ or omissions of such Commercial Contributor in connection with its
181
+ distribution of the Program in a commercial product offering. The
182
+ obligations in this section do not apply to any claims or Losses
183
+ relating to any actual or alleged intellectual property infringement.
184
+ In order to qualify, an Indemnified Contributor must: a) promptly
185
+ notify the Commercial Contributor in writing of such claim, and b)
186
+ allow the Commercial Contributor to control, and cooperate with the
187
+ Commercial Contributor in, the defense and any related settlement
188
+ negotiations. The Indemnified Contributor may participate in any such
189
+ claim at its own expense.
190
+
191
+ For example, a Contributor might include the Program in a commercial
192
+ product offering, Product X. That Contributor is then a Commercial
193
+ Contributor. If that Commercial Contributor then makes performance
194
+ claims, or offers warranties related to Product X, those performance
195
+ claims and warranties are solely the responsibility of that Commercial
196
+ Contributor. Under this section, the Commercial Contributor would have
197
+ to defend claims against the other Contributors related to those
198
+ performance claims and warranties, and if a court requires any other
199
+ Contributor to pay any damages as a result, the Commercial Contributor
200
+ must pay those damages.
201
+
202
+ 5. NO WARRANTY
203
+
204
+ EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
205
+ PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
206
+ BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
207
+ IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
208
+ TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
209
+ PURPOSE. Each Recipient is solely responsible for determining the
210
+ appropriateness of using and distributing the Program and assumes all
211
+ risks associated with its exercise of rights under this Agreement,
212
+ including but not limited to the risks and costs of program errors,
213
+ compliance with applicable laws, damage to or loss of data, programs
214
+ or equipment, and unavailability or interruption of operations.
215
+
216
+ 6. DISCLAIMER OF LIABILITY
217
+
218
+ EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
219
+ PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
220
+ SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
221
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
222
+ PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
223
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
224
+ ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
225
+ EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
226
+ POSSIBILITY OF SUCH DAMAGES.
227
+
228
+ 7. GENERAL
229
+
230
+ If any provision of this Agreement is invalid or unenforceable under
231
+ applicable law, it shall not affect the validity or enforceability of
232
+ the remainder of the terms of this Agreement, and without further
233
+ action by the parties hereto, such provision shall be reformed to the
234
+ minimum extent necessary to make such provision valid and enforceable.
235
+
236
+ If Recipient institutes patent litigation against any entity (including
237
+ a cross-claim or counterclaim in a lawsuit) alleging that the Program
238
+ itself (excluding combinations of the Program with other software or
239
+ hardware) infringes such Recipient's patent(s), then such Recipient's
240
+ rights granted under Section 2(b) shall terminate as of the date such
241
+ litigation is filed.
242
+
243
+ All Recipient's rights under this Agreement shall terminate if it
244
+ fails to comply with any of the material terms or conditions of this
245
+ Agreement and does not cure such failure in a reasonable period of
246
+ time after becoming aware thereof. Notwithstanding the above, if
247
+ Recipient's rights under this Agreement terminate, Recipient agrees to
248
+ cease use and distribution of the Program as soon as reasonably
249
+ practicable. However, Recipient's obligations under this Agreement and
250
+ any licenses granted by Recipient relating to the Program shall
251
+ continue and survive.
252
+
253
+ Everyone is permitted to copy and distribute copies of this Agreement,
254
+ but in order to avoid inconsistency the Agreement is copyrighted and
255
+ may only be modified in the following manner. The Agreement Steward
256
+ reserves the right to publish new versions (including revisions) of
257
+ this Agreement from time to time. No one other than the Agreement
258
+ Steward has the right to modify this Agreement. The Eclipse Foundation
259
+ is the initial Agreement Steward. The Eclipse Foundation may assign the
260
+ responsibility to serve as the Agreement Steward to a suitable separate
261
+ entity. Each new version of the Agreement will be given a distinguishing
262
+ version number. The Program (including Contributions) may always be
263
+ Distributed subject to the version of the Agreement under which it was
264
+ received. In addition, after a new version of the Agreement is published,
265
+ Contributor may elect to Distribute the Program (including its
266
+ Contributions) under the new version.
267
+
268
+ Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
269
+ receives no rights or licenses to the intellectual property of any
270
+ Contributor under this Agreement, whether expressly, by implication,
271
+ estoppel or otherwise. All rights in the Program not expressly granted
272
+ under this Agreement are reserved.
273
+
274
+ Nothing in this Agreement shall be deemed to create an agency between
275
+ the parties. No partnership, joint venture, or employment relationship
276
+ is created. Neither party shall have the power to bind the other
277
+ without the other party's prior written consent. If any provision of
278
+ this Agreement is found by a court of competent jurisdiction to be
279
+ invalid, illegal, or unenforceable, the remaining provisions shall
280
+ continue in full force and effect and the parties shall replace the
281
+ invalid, illegal, or unenforceable provision with a provision that
282
+ reflects the parties' original intent and is valid, legal, and
283
+ enforceable.
284
+
285
+ Neither party shall be liable for any failure or delay in performance
286
+ due to causes beyond its reasonable control. The terms of this
287
+ Agreement shall be governed by the laws of the State of New York,
288
+ USA, without regard to its conflict of laws provisions. Each party
289
+ hereby consents to the exclusive jurisdiction of the federal and state
290
+ courts located in New York, New York, USA, except that either party may
291
+ seek injunctive or other equitable relief in any court of competent
292
+ jurisdiction to protect its intellectual property rights.
293
+
294
+ If any provision of this Agreement is invalid or unenforceable under
295
+ applicable law, it shall not affect the validity or enforceability of
296
+ the remainder of the terms of this Agreement.
297
+
298
+ EXHIBIT A - Form of Secondary Licenses Notice
299
+
300
+ "This Source Code may also be made available under the following
301
+ Secondary Licenses when the conditions for such availability set forth
302
+ in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
303
+ version(s), and exceptions or additional permissions here}."
304
+
305
+ Simply including a copy of this Agreement, including this Exhibit A
306
+ is not sufficient to license the Source Code under Secondary
307
+ Licenses.
308
+
309
+ If it is not possible or desirable to put the notice in a particular
310
+ file, then You may include the notice in a location (such as a
311
+ LICENSE file in a relevant directory) where a recipient would be
312
+ likely to look for such a notice.
313
+
314
+ You may add additional accurate notices of copyright ownership.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # pi-tree-sitter
2
+
3
+ Pre-write syntax validation for [pi](https://pi.dev) using tree-sitter WASM grammars.
4
+
5
+ Hooks into `write` and `edit` tools to validate file content before it hits disk.
6
+ If tree-sitter finds syntax errors, the extension blocks the tool with actionable
7
+ feedback — line, column, source snippet, and for `MISSING` nodes, what token was
8
+ expected. The LLM sees the error in the same turn and self-corrects.
9
+
10
+ Inspired by [dirge](https://github.com/dirge-code/dirge)'s `syntax_validator.rs`.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install pi-tree-sitter
16
+ ```
17
+
18
+ Then register with pi:
19
+
20
+ ```bash
21
+ pi install npm:pi-tree-sitter
22
+ ```
23
+
24
+ ### Git version
25
+
26
+ Install directly from GitHub:
27
+
28
+ ```bash
29
+ pi install git:github.com/markokocic/pi-tree-sitter
30
+ ```
31
+
32
+ Or clone and use locally:
33
+
34
+ ```bash
35
+ git clone https://github.com/markokocic/pi-tree-sitter.git
36
+ cd pi-tree-sitter
37
+ npm install
38
+ pi install .
39
+ ```
40
+
41
+ Run ad-hoc without installing:
42
+
43
+ ```bash
44
+ pi -e ./path/to/pi-tree-sitter
45
+ ```
46
+
47
+ ## Supported Languages
48
+
49
+ | Extension(s) | Grammar | WASM |
50
+ |-------------|---------|------|
51
+ | `.rs` | Rust | ✅ |
52
+ | `.py`, `.pyi` | Python | ✅ |
53
+ | `.ts`, `.mts`, `.cts` | TypeScript | ✅ |
54
+ | `.tsx` | TSX | ✅ |
55
+ | `.js`, `.jsx`, `.mjs`, `.cjs` | JavaScript | ✅ |
56
+ | `.go` | Go | ✅ |
57
+ | `.java` | Java | ✅ |
58
+ | `.rb` | Ruby | ✅ |
59
+ | `.c`, `.h` | C | ✅ |
60
+ | `.cpp`, `.cc`, `.hpp`, `.hh`, `.hxx` | C++ | ✅ |
61
+ | `.sh`, `.bash` | Bash | ✅ |
62
+ | `.css` | CSS | ✅ |
63
+ | `.ex`, `.exs` | Elixir | ✅ |
64
+ | `.hs`, `.lhs` | Haskell | ✅ |
65
+ | `.htm`, `.html` | HTML | ✅ |
66
+ | `.json` | JSON | ✅ |
67
+ | `.kt`, `.kts` | Kotlin | ✅ |
68
+ | `.zig` | Zig | ✅ |
69
+ | `.clj`, `.cljs`, `.cljc`, `.cljd`, `.edn`, `.bb` | Clojure | 🔶 (delimiter balance) |
70
+ | `.fnl` | Fennel | 🔶 (delimiter balance) |
71
+ | `.janet`, `.jdn` | Janet | 🔶 (delimiter balance) |
72
+ | `.scm`, `.ss`, `.rkt` | Scheme | 🔶 (delimiter balance) |
73
+ | `.lisp`, `.lsp`, `.cl` | Common Lisp | 🔶 (delimiter balance) |
74
+ | `.el` | Emacs Lisp | 🔶 (delimiter balance) |
75
+
76
+ Languages marked with 🔶 use a comment/string-aware delimiter-balance scanner
77
+ as a fallback (no standalone WASM grammar available on npm).
78
+
79
+ WASM grammars are loaded on first use via a hybrid strategy:
80
+ local `node_modules` → disk cache (`~/.cache/pi-tree-sitter/`) → CDN.
81
+ No explicit `npm install` of individual grammar packages is required.
82
+
83
+ ## How it works
84
+
85
+ 1. Extension hooks `tool_call` events for `write` and `edit` tools
86
+ 2. Maps file extension to a tree-sitter WASM grammar
87
+ 3. Parses the content with tree-sitter
88
+ 4. Walks the syntax tree collecting `ERROR` and `MISSING` nodes (capped at 10)
89
+ 5. On errors: blocks the tool with `{ block: true, reason: "..." }` — the LLM
90
+ sees the errors and self-corrects in the same turn
91
+ 6. Unknown extensions silently pass through (no validation)
92
+
93
+ ### For `edit` tools
94
+
95
+ The extension reads the current file, applies the edits, and validates the
96
+ resulting content. This is a best-effort check: if an edit can't be applied
97
+ (oldText not found), that edit is skipped and the edit tool's own error
98
+ handling takes over.
99
+
100
+ ## Error Format
101
+
102
+ ```
103
+ Syntax check failed for src/main.rs: 2 error(s) detected by tree-sitter.
104
+ Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)
105
+ missing `}` at 42:1: fn main() {
106
+ syntax error at 15:8: let x =
107
+ ```
108
+
109
+ For delimiter-based languages (Clojure, Fennel, etc.):
110
+
111
+ ```
112
+ Syntax check failed for core.clj: delimiters are unbalanced.
113
+ Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)
114
+ 1 unclosed `(` — add 1 matching `)`
115
+ ```
116
+
117
+ ## License
118
+
119
+ EPL-2.0 — Copyright 2026 Marko Kocic
package/index.ts ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * pi-tree-sitter — Pre-write syntax validation for pi
3
+ *
4
+ * Hooks into `write` and `edit` tools. Parses content with tree-sitter WASM
5
+ * grammars and blocks the tool when syntax errors are found. The LLM sees the
6
+ * actionable feedback (line:col, snippet, expected token) in the same turn and
7
+ * self-corrects.
8
+ *
9
+ * Languages without a WASM grammar (Clojure, Janet, Fennel, Scheme, Elisp)
10
+ * fall back to a comment/string-aware delimiter-balance scanner.
11
+ *
12
+ * Inspired by dirge's syntax_validator.rs.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+ import { resolve, dirname } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { createRequire } from "node:module";
20
+ import { Parser, Language, type Node, type Tree } from "web-tree-sitter";
21
+
22
+ // ── Grammar map ──────────────────────────────────────────────────────────
23
+ // Maps file extension → { npm package, wasm filename } for lazy loading.
24
+
25
+ interface GrammarEntry {
26
+ pkg: string;
27
+ wasm: string;
28
+ }
29
+
30
+ const LANGUAGE_MAP: Record<string, GrammarEntry> = {
31
+ ".rs": { pkg: "tree-sitter-rust", wasm: "tree-sitter-rust.wasm" },
32
+ ".py": { pkg: "tree-sitter-python", wasm: "tree-sitter-python.wasm" },
33
+ ".pyi": { pkg: "tree-sitter-python", wasm: "tree-sitter-python.wasm" },
34
+ ".ts": { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
35
+ ".tsx": { pkg: "tree-sitter-typescript", wasm: "tree-sitter-tsx.wasm" },
36
+ ".mts": { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
37
+ ".cts": { pkg: "tree-sitter-typescript", wasm: "tree-sitter-typescript.wasm" },
38
+ ".js": { pkg: "tree-sitter-javascript", wasm: "tree-sitter-javascript.wasm" },
39
+ ".jsx": { pkg: "tree-sitter-javascript", wasm: "tree-sitter-javascript.wasm" },
40
+ ".mjs": { pkg: "tree-sitter-javascript", wasm: "tree-sitter-javascript.wasm" },
41
+ ".cjs": { pkg: "tree-sitter-javascript", wasm: "tree-sitter-javascript.wasm" },
42
+ ".go": { pkg: "tree-sitter-go", wasm: "tree-sitter-go.wasm" },
43
+ ".java": { pkg: "tree-sitter-java", wasm: "tree-sitter-java.wasm" },
44
+ ".rb": { pkg: "tree-sitter-ruby", wasm: "tree-sitter-ruby.wasm" },
45
+ ".c": { pkg: "tree-sitter-c", wasm: "tree-sitter-c.wasm" },
46
+ ".h": { pkg: "tree-sitter-c", wasm: "tree-sitter-c.wasm" },
47
+ ".cpp": { pkg: "tree-sitter-cpp", wasm: "tree-sitter-cpp.wasm" },
48
+ ".cc": { pkg: "tree-sitter-cpp", wasm: "tree-sitter-cpp.wasm" },
49
+ ".hpp": { pkg: "tree-sitter-cpp", wasm: "tree-sitter-cpp.wasm" },
50
+ ".hh": { pkg: "tree-sitter-cpp", wasm: "tree-sitter-cpp.wasm" },
51
+ ".hxx": { pkg: "tree-sitter-cpp", wasm: "tree-sitter-cpp.wasm" },
52
+ ".sh": { pkg: "tree-sitter-bash", wasm: "tree-sitter-bash.wasm" },
53
+ ".bash": { pkg: "tree-sitter-bash", wasm: "tree-sitter-bash.wasm" },
54
+ ".css": { pkg: "tree-sitter-css", wasm: "tree-sitter-css.wasm" },
55
+ ".ex": { pkg: "tree-sitter-elixir", wasm: "tree-sitter-elixir.wasm" },
56
+ ".exs": { pkg: "tree-sitter-elixir", wasm: "tree-sitter-elixir.wasm" },
57
+ ".hs": { pkg: "tree-sitter-haskell", wasm: "tree-sitter-haskell.wasm" },
58
+ ".htm": { pkg: "tree-sitter-html", wasm: "tree-sitter-html.wasm" },
59
+ ".html": { pkg: "tree-sitter-html", wasm: "tree-sitter-html.wasm" },
60
+ ".json": { pkg: "tree-sitter-json", wasm: "tree-sitter-json.wasm" },
61
+ ".kt": { pkg: "@tree-sitter-grammars/tree-sitter-kotlin", wasm: "tree-sitter-kotlin.wasm" },
62
+ ".kts": { pkg: "@tree-sitter-grammars/tree-sitter-kotlin", wasm: "tree-sitter-kotlin.wasm" },
63
+ ".lhs": { pkg: "tree-sitter-haskell", wasm: "tree-sitter-haskell.wasm" },
64
+ ".zig": { pkg: "@tree-sitter-grammars/tree-sitter-zig", wasm: "tree-sitter-zig.wasm" },
65
+ };
66
+
67
+ // ── Grammar cache (hybrid: local → disk cache → CDN) ─────────────────────
68
+
69
+ const WASM_CDN = "https://cdn.jsdelivr.net/npm";
70
+ const CACHE_DIR = resolve(homedir(), ".cache", "pi-tree-sitter");
71
+
72
+ const require_ = createRequire(import.meta.url);
73
+ const grammarCache = new Map<string, Language | null>();
74
+
75
+ /** Resolve a WASM file: local node_modules → disk cache → CDN fetch. */
76
+ async function loadGrammar(entry: GrammarEntry): Promise<Language | null> {
77
+ const key = `${entry.pkg}/${entry.wasm}`;
78
+ const cached = grammarCache.get(key);
79
+ if (cached !== undefined) return cached;
80
+
81
+ let wasmBytes: Buffer | Uint8Array | null = null;
82
+
83
+ // 1. Local node_modules (zero latency)
84
+ try {
85
+ const wasmPath = require_.resolve(key);
86
+ wasmBytes = await readFile(wasmPath);
87
+ } catch {
88
+ // 2. Persistent disk cache (offline reuse)
89
+ const cachePath = resolve(CACHE_DIR, entry.pkg, entry.wasm);
90
+ try {
91
+ wasmBytes = await readFile(cachePath);
92
+ } catch {
93
+ // 3. CDN fetch (always works, no npm dependency)
94
+ try {
95
+ const url = `${WASM_CDN}/${key}`;
96
+ console.log(`[pi-tree-sitter] downloading ${key} from CDN...`);
97
+ const res = await fetch(url);
98
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
99
+ wasmBytes = new Uint8Array(await res.arrayBuffer());
100
+ console.log(`[pi-tree-sitter] ${key} downloaded (${(wasmBytes.length / 1024).toFixed(0)} KB)`);
101
+ // Persist to disk cache for next time
102
+ await mkdir(dirname(cachePath), { recursive: true });
103
+ await writeFile(cachePath, wasmBytes);
104
+ } catch {
105
+ console.log(`[pi-tree-sitter] failed to download ${key}, skipping validation`);
106
+ // All sources exhausted
107
+ }
108
+ }
109
+ }
110
+
111
+ if (wasmBytes) {
112
+ try {
113
+ const lang = await Language.load(wasmBytes);
114
+ grammarCache.set(key, lang);
115
+ return lang;
116
+ } catch {
117
+ grammarCache.set(key, null);
118
+ return null;
119
+ }
120
+ }
121
+
122
+ grammarCache.set(key, null);
123
+ return null;
124
+ }
125
+
126
+ // ── Error collection ─────────────────────────────────────────────────────
127
+
128
+ const MAX_ERRORS = 10;
129
+
130
+ /** Collect ERROR/MISSING nodes from a syntax tree, capped at MAX_ERRORS. */
131
+ function collectErrors(tree: Tree, source: string): string[] {
132
+ const errors: string[] = [];
133
+ const stack: Node[] = [tree.rootNode];
134
+
135
+ while (stack.length > 0 && errors.length < MAX_ERRORS) {
136
+ const node = stack.pop()!;
137
+
138
+ if (node.isError || node.isMissing) {
139
+ const pos = node.startPosition;
140
+ const raw = source.slice(node.startIndex, Math.min(node.endIndex, source.length));
141
+ const snippet = raw.split("\n")[0].slice(0, 80).trimEnd();
142
+
143
+ if (node.isMissing) {
144
+ // For MISSING nodes, `.type` is the grammar-level expected token
145
+ // (e.g. "}", ")", ";"). This is the most actionable detail.
146
+ errors.push(` missing \`${node.type}\` at ${pos.row + 1}:${pos.column + 1}: ${snippet}`);
147
+ } else {
148
+ errors.push(` syntax error at ${pos.row + 1}:${pos.column + 1}: ${snippet}`);
149
+ }
150
+ // Don't descend into error nodes — their children are noise.
151
+ continue;
152
+ }
153
+
154
+ // Push children in reverse order so the walk is left-to-right.
155
+ const children = node.children;
156
+ for (let i = children.length - 1; i >= 0; i--) {
157
+ stack.push(children[i]);
158
+ }
159
+ }
160
+
161
+ return errors;
162
+ }
163
+
164
+ // ── Delimiter-balance scanner (fallback for Lisp-like languages) ─────────
165
+
166
+ /**
167
+ * Simple line/column counter for the balance scanner.
168
+ * Tracks absolute position for error reporting but is simple enough that
169
+ * we compute line numbers inline for human-readable messages.
170
+ */
171
+ interface LexRules {
172
+ lineComment: string | null; // e.g. ";"
173
+ blockComment: [string, string] | null; // e.g. ["#|", "|#"]
174
+ nestedBlock: boolean;
175
+ backtickLongString: boolean;
176
+ charLiteral: "?" | null; // e.g. Elisp uses ?x for char literals
177
+ }
178
+
179
+ const RULES_LISP: LexRules = { lineComment: ";", blockComment: null, nestedBlock: false, backtickLongString: false, charLiteral: null };
180
+ const RULES_JANET: LexRules = { lineComment: "#", blockComment: ["#|", "|#"], nestedBlock: false, backtickLongString: true, charLiteral: null };
181
+ const RULES_SCHEME: LexRules = { lineComment: ";", blockComment: ["#|", "|#"], nestedBlock: true, backtickLongString: false, charLiteral: null };
182
+
183
+ const BALANCE_RULES: Record<string, LexRules> = {
184
+ ".clj": RULES_LISP,
185
+ ".cljs": RULES_LISP,
186
+ ".cljc": RULES_LISP,
187
+ ".cljd": RULES_LISP,
188
+ ".edn": RULES_LISP,
189
+ ".bb": RULES_LISP,
190
+ ".fnl": RULES_LISP,
191
+ ".janet": RULES_JANET,
192
+ ".jdn": RULES_JANET,
193
+ ".scm": RULES_SCHEME,
194
+ ".ss": RULES_SCHEME,
195
+ ".rkt": RULES_SCHEME,
196
+ ".lisp": RULES_SCHEME,
197
+ ".lsp": RULES_SCHEME,
198
+ ".cl": RULES_SCHEME,
199
+ ".el": { lineComment: ";", blockComment: null, nestedBlock: false, backtickLongString: false, charLiteral: "?" },
200
+ };
201
+
202
+ /** Count delimiters skipping comments, strings, and char literals. */
203
+ function checkDelimiterBalance(path: string, content: string, rules: LexRules): string | null {
204
+ const b = content;
205
+ const n = b.length;
206
+ let i = 0;
207
+ const lineStarts: number[] = [0];
208
+ for (let j = 0; j < n; j++) {
209
+ if (b[j] === "\n") lineStarts.push(j + 1);
210
+ }
211
+
212
+ function lineFor(pos: number): number {
213
+ // Binary search for the nearest line start ≤ pos
214
+ let lo = 0, hi = lineStarts.length - 1;
215
+ while (lo < hi) {
216
+ const mid = (lo + hi + 1) >> 1;
217
+ if (lineStarts[mid] <= pos) lo = mid;
218
+ else hi = mid - 1;
219
+ }
220
+ return lo + 1; // 1-based
221
+ }
222
+
223
+ const stack: Array<{ ch: string; line: number; col: number }> = [];
224
+
225
+ while (i < n) {
226
+ const c = b[i];
227
+ const next = i + 1 < n ? b[i + 1] : null;
228
+
229
+ // Line comment
230
+ if (rules.lineComment && c === rules.lineComment[0]) {
231
+ const eol = b.indexOf("\n", i);
232
+ i = eol === -1 ? n : eol + 1;
233
+ continue;
234
+ }
235
+
236
+ // Block comment
237
+ if (rules.blockComment) {
238
+ const [open, close] = rules.blockComment;
239
+ if (c === open[0] && next === open[1]) {
240
+ i += 2;
241
+ let depth = 1;
242
+ while (i < n && depth > 0) {
243
+ const cc = b[i];
244
+ const nn = i + 1 < n ? b[i + 1] : null;
245
+ if (rules.nestedBlock && cc === open[0] && nn === open[1]) {
246
+ depth++;
247
+ i += 2;
248
+ } else if (cc === close[0] && nn === close[1]) {
249
+ depth--;
250
+ i += 2;
251
+ } else {
252
+ i++;
253
+ }
254
+ }
255
+ continue;
256
+ }
257
+ }
258
+
259
+ // String
260
+ if (c === '"') {
261
+ i++;
262
+ while (i < n) {
263
+ if (b[i] === "\\") { i += 2; continue; }
264
+ if (b[i] === '"') { i++; break; }
265
+ i++;
266
+ }
267
+ continue;
268
+ }
269
+
270
+ // Backtick long-string (Janet)
271
+ if (rules.backtickLongString && c === "`") {
272
+ // Count opening backticks
273
+ let k = 0;
274
+ while (i + k < n && b[i + k] === "`") k++;
275
+ i += k;
276
+ while (i < n) {
277
+ if (b[i] === "`") {
278
+ let j = 0;
279
+ while (i + j < n && b[i + j] === "`") j++;
280
+ if (j >= k) { i += k; break; }
281
+ i += j;
282
+ } else {
283
+ i++;
284
+ }
285
+ }
286
+ continue;
287
+ }
288
+
289
+ // Escape: skip \ and the following character
290
+ if (c === "\\" && i + 1 < n) {
291
+ i += 2;
292
+ continue;
293
+ }
294
+
295
+ // Char literal (e.g., Elisp: ?x, ?\()
296
+ if (rules.charLiteral === "?" && c === "?" && i + 1 < n) {
297
+ if (b[i + 1] === "\\" && i + 2 < n) {
298
+ i += 3; // skip ?\X
299
+ } else {
300
+ i += 2; // skip ?X
301
+ }
302
+ continue;
303
+ }
304
+
305
+ // Count delimiters
306
+ switch (c) {
307
+ case "(": {
308
+ const ln = lineFor(i);
309
+ stack.push({ ch: "(", line: ln, col: i - lineStarts[ln - 1] + 1 });
310
+ break;
311
+ }
312
+ case ")": {
313
+ const top = stack.pop();
314
+ if (!top) return `${path}: stray \`)\` at line ${lineFor(i)} — no matching \`(\` before it`;
315
+ break;
316
+ }
317
+ case "[": {
318
+ const ln = lineFor(i);
319
+ stack.push({ ch: "[", line: ln, col: i - lineStarts[ln - 1] + 1 });
320
+ break;
321
+ }
322
+ case "]": {
323
+ const top = stack.pop();
324
+ if (!top) return `${path}: stray \`]\` at line ${lineFor(i)} — no matching \`[\` before it`;
325
+ if (top.ch !== "[") return `${path}: mismatch at line ${lineFor(i)}: expected \`]\` for \`${top.ch}\` at line ${top.line}`;
326
+ break;
327
+ }
328
+ case "{": {
329
+ const ln = lineFor(i);
330
+ stack.push({ ch: "{", line: ln, col: i - lineStarts[ln - 1] + 1 });
331
+ break;
332
+ }
333
+ case "}": {
334
+ const top = stack.pop();
335
+ if (!top) return `${path}: stray \`}\` at line ${lineFor(i)} — no matching \`{\` before it`;
336
+ if (top.ch !== "{") return `${path}: mismatch at line ${lineFor(i)}: expected \`}\` for \`${top.ch}\` at line ${top.line}`;
337
+ break;
338
+ }
339
+ }
340
+
341
+ i++;
342
+ }
343
+
344
+ if (stack.length > 0) {
345
+ const top = stack[0];
346
+ const opener = top.ch;
347
+ return `${path}: ${stack.length} unclosed \`${opener}\` — the one at line ${top.line} is never closed; add ${stack.length} matching \`${{ "(": ")", "[": "]", "{": "}" }[opener]}\``;
348
+ }
349
+
350
+ return null;
351
+ }
352
+
353
+ // ── Validation helpers ──────────────────────────────────────────────────
354
+
355
+ function formatError(path: string, errors: string[]): string {
356
+ let msg = `Syntax check failed for ${path}: ${errors.length} error(s) detected by tree-sitter.
357
+ Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)
358
+ `;
359
+ msg += errors.join("\n");
360
+ if (errors.length >= MAX_ERRORS) {
361
+ msg += `\n …(truncated at ${MAX_ERRORS} errors; fix the listed issues and re-check)`;
362
+ }
363
+ return msg;
364
+ }
365
+
366
+ function formatBalanceError(path: string, detail: string): string {
367
+ return `Syntax check failed for ${path}: delimiters are unbalanced.
368
+ Fix and re-submit. (This is a pre-write guard — the file was NOT modified.)
369
+ ${detail}`;
370
+ }
371
+
372
+ /**
373
+ * Validate content for a given file path. Returns null (clean), or a
374
+ * formatted error message to surface as the block reason.
375
+ */
376
+ async function validateContent(path: string, content: string): Promise<string | null> {
377
+ const ext = path.match(/\.[^.]+$/)?.[0]?.toLowerCase();
378
+ if (!ext) return null;
379
+
380
+ const entry = LANGUAGE_MAP[ext];
381
+ if (entry) {
382
+ const lang = await loadGrammar(entry);
383
+ if (lang) {
384
+ const parser = new Parser();
385
+ parser.setLanguage(lang);
386
+ const tree = parser.parse(content);
387
+ if (tree && tree.rootNode.hasError) {
388
+ const errors = collectErrors(tree, content);
389
+ if (errors.length > 0) {
390
+ return formatError(path, errors);
391
+ }
392
+ }
393
+ return null;
394
+ }
395
+ // Grammar not available from any source — skip validation
396
+ return null;
397
+ }
398
+
399
+ // Delimiter-balance scanner (only for Lisp-like languages without WASM grammars)
400
+ const rules = ext ? BALANCE_RULES[ext] : undefined;
401
+ if (rules) {
402
+ const err = checkDelimiterBalance(path, content, rules);
403
+ if (err) return formatBalanceError(path, err);
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ // ── Extension entry point ────────────────────────────────────────────────
410
+
411
+ export default async function (pi: ExtensionAPI) {
412
+ // Initialize the web-tree-sitter WASM runtime once.
413
+ await Parser.init();
414
+
415
+ pi.on("tool_call", async (event, ctx) => {
416
+ // ── write ──────────────────────────────────────────────────────────
417
+ if (event.toolName === "write") {
418
+ const input = event.input as { path: string; content: string };
419
+ const err = await validateContent(input.path, input.content);
420
+ if (err) return { block: true, reason: err };
421
+ return;
422
+ }
423
+
424
+ // ── edit ───────────────────────────────────────────────────────────
425
+ if (event.toolName === "edit") {
426
+ const input = event.input as {
427
+ path: string;
428
+ edits: Array<{ oldText: string; newText: string }>;
429
+ };
430
+ if (!input.edits || input.edits.length === 0) return;
431
+
432
+ const absolutePath = resolve(ctx.cwd, input.path);
433
+
434
+ try {
435
+ const rawContent = await readFile(absolutePath, "utf-8");
436
+
437
+ // Apply edits sequentially (best-effort approximation of the final
438
+ // content for validation). If an edit's oldText is not found, skip
439
+ // it — the edit tool itself will report that error.
440
+ let result = rawContent;
441
+ for (const edit of input.edits) {
442
+ const idx = result.indexOf(edit.oldText);
443
+ if (idx === -1) continue;
444
+ result = result.slice(0, idx) + edit.newText + result.slice(idx + edit.oldText.length);
445
+ }
446
+
447
+ const err = await validateContent(input.path, result);
448
+ if (err) return { block: true, reason: err };
449
+ } catch {
450
+ // File doesn't exist or can't be read as UTF-8 — let the edit tool
451
+ // handle the error itself.
452
+ }
453
+ }
454
+ });
455
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-tree-sitter",
3
+ "version": "0.1.0",
4
+ "description": "Pre-write syntax validation for pi using tree-sitter WASM grammars",
5
+ "license": "EPL-2.0",
6
+ "author": "Marko Kocic <marko@euptera.com>",
7
+ "copyright": "Copyright (c) 2026 Marko Kocic",
8
+ "type": "module",
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi-extension",
12
+ "tree-sitter",
13
+ "syntax",
14
+ "validation",
15
+ "linter"
16
+ ],
17
+ "pi": {
18
+ "extensions": [
19
+ "./index.ts"
20
+ ]
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "dependencies": {
26
+ "web-tree-sitter": "^0.26.10",
27
+ "tree-sitter-rust": "^0.24.0",
28
+ "tree-sitter-python": "^0.23.6",
29
+ "tree-sitter-javascript": "^0.23.1",
30
+ "tree-sitter-typescript": "^0.23.2",
31
+ "tree-sitter-go": "^0.23.4",
32
+ "tree-sitter-java": "^0.23.5",
33
+ "tree-sitter-ruby": "^0.23.1",
34
+ "tree-sitter-c": "^0.23.6",
35
+ "tree-sitter-cpp": "^0.23.4",
36
+ "tree-sitter-bash": "^0.25.1",
37
+ "tree-sitter-css": "^0.25.0",
38
+ "tree-sitter-elixir": "^0.3.5",
39
+ "tree-sitter-haskell": "^0.23.1",
40
+ "tree-sitter-html": "^0.23.2",
41
+ "tree-sitter-json": "^0.24.8",
42
+ "@tree-sitter-grammars/tree-sitter-kotlin": "^1.1.0",
43
+ "@tree-sitter-grammars/tree-sitter-zig": "^1.1.2"
44
+ }
45
+ }