@treelsp/cli 0.0.1 → 0.0.2

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.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # @treelsp/cli
2
+
3
+ CLI tool for [treelsp](https://github.com/dhrubomoy/treelsp) — generate Tree-sitter grammars, WASM parsers, and language servers from TypeScript definitions.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -D @treelsp/cli
9
+ # or
10
+ pnpm add -D @treelsp/cli
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ ### `treelsp init`
16
+
17
+ Scaffold a new language project interactively.
18
+
19
+ ```bash
20
+ treelsp init
21
+ ```
22
+
23
+ Prompts for a language name and file extension, then creates a project directory with:
24
+ - `grammar.ts` — language definition template
25
+ - `package.json` — dependencies and scripts
26
+ - `tsconfig.json` — TypeScript configuration
27
+ - `.gitignore`
28
+
29
+ ### `treelsp generate`
30
+
31
+ Generate grammar.js, AST types, manifest, and syntax highlighting queries from a language definition.
32
+
33
+ ```
34
+ Usage: treelsp generate [options]
35
+
36
+ Options:
37
+ -f, --file <file> path to treelsp-config.json or package.json with treelsp field
38
+ -w, --watch watch for changes
39
+ ```
40
+
41
+ **Output files** (written to the output directory, default `generated/`):
42
+ - `grammar.js` — Tree-sitter grammar (CommonJS)
43
+ - `ast.ts` — typed AST interfaces
44
+ - `treelsp.json` — manifest for VS Code extension discovery
45
+ - `queries/highlights.scm` — Tree-sitter syntax highlighting
46
+ - `queries/locals.scm` — Tree-sitter scope/locals
47
+
48
+ ### `treelsp build`
49
+
50
+ Compile the generated grammar to WASM and bundle the language server.
51
+
52
+ ```
53
+ Usage: treelsp build [options]
54
+
55
+ Options:
56
+ -f, --file <file> path to treelsp-config.json or package.json with treelsp field
57
+ ```
58
+
59
+ Requires the [tree-sitter CLI](https://github.com/tree-sitter/tree-sitter/blob/master/cli/README.md) to be installed (`npm install -g tree-sitter-cli` or `cargo install tree-sitter-cli`).
60
+
61
+ **Output files:**
62
+ - `grammar.wasm` — compiled WebAssembly parser
63
+ - `server.bundle.cjs` — self-contained language server bundle
64
+ - `tree-sitter.wasm` — web-tree-sitter runtime
65
+
66
+ ### `treelsp watch`
67
+
68
+ Watch mode — re-runs `generate` + `build` automatically when grammar files change.
69
+
70
+ ```
71
+ Usage: treelsp watch [options]
72
+
73
+ Options:
74
+ -f, --file <file> path to treelsp-config.json or package.json with treelsp field
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ By default, treelsp looks for `grammar.ts` in the current directory and outputs to `generated/`. For multi-language projects, create a config file to specify all languages.
80
+
81
+ ### Config discovery order
82
+
83
+ When `-f` is not provided, the CLI searches from the current directory upward for:
84
+
85
+ 1. `treelsp-config.json`
86
+ 2. A `"treelsp"` field in `package.json`
87
+ 3. Falls back to legacy mode (`grammar.ts` in cwd)
88
+
89
+ ### `treelsp-config.json`
90
+
91
+ ```json
92
+ {
93
+ "languages": [
94
+ { "grammar": "mini-lang/grammar.ts" },
95
+ { "grammar": "schema-lang/grammar.ts" }
96
+ ]
97
+ }
98
+ ```
99
+
100
+ ### `package.json`
101
+
102
+ ```json
103
+ {
104
+ "treelsp": {
105
+ "languages": [
106
+ { "grammar": "src/grammar.ts", "out": "src/generated" }
107
+ ]
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### Schema
113
+
114
+ | Field | Type | Required | Description |
115
+ |-------|------|----------|-------------|
116
+ | `languages` | array | yes | List of language projects |
117
+ | `languages[].grammar` | string | yes | Path to `grammar.ts`, relative to the config file |
118
+ | `languages[].out` | string | no | Output directory (default: `<grammar dir>/generated`) |
119
+
120
+ ## Typical Workflow
121
+
122
+ **Single language (no config needed):**
123
+
124
+ ```bash
125
+ treelsp init # scaffold project
126
+ cd my-lang
127
+ npm install
128
+ treelsp generate # generate grammar.js, ast.ts, etc.
129
+ treelsp build # compile WASM + bundle server
130
+ ```
131
+
132
+ **Multi-language monorepo:**
133
+
134
+ ```bash
135
+ # treelsp-config.json at repo root
136
+ treelsp generate # generates all languages
137
+ treelsp build # builds all languages
138
+ treelsp watch # watches all grammar files
139
+ ```
package/bin/treelsp.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ await import('../dist/index.js');
package/dist/index.js CHANGED
@@ -1,18 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import pc from "picocolors";
3
4
  import prompts from "prompts";
4
5
  import ora from "ora";
5
- import pc from "picocolors";
6
- import { mkdir, writeFile } from "node:fs/promises";
7
- import { resolve } from "node:path";
8
- import { copyFileSync, existsSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
- import { pathToFileURL } from "node:url";
10
- import { generateAstTypes, generateGrammar, generateHighlights, generateLocals, generateManifest } from "treelsp/codegen";
11
- import { execSync } from "node:child_process";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { basename, dirname, relative, resolve } from "node:path";
8
+ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
10
  import { build } from "esbuild";
11
+ import { generateAstTypes, generateManifest, generateTextmate } from "treelsp/codegen";
13
12
  import chokidar from "chokidar";
14
13
 
15
14
  //#region src/commands/init.ts
15
+ /**
16
+ * Read the CLI package version at runtime so scaffolded projects
17
+ * always pin to the version of the CLI that created them.
18
+ */
19
+ async function getCliVersion() {
20
+ try {
21
+ const distDir = dirname(fileURLToPath(import.meta.url));
22
+ const pkgPath = resolve(distDir, "..", "package.json");
23
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
24
+ return pkg.version;
25
+ } catch {
26
+ return "0.0.1";
27
+ }
28
+ }
16
29
  async function init() {
17
30
  console.log(pc.bold("treelsp init\n"));
18
31
  const answers = await prompts([{
@@ -46,13 +59,15 @@ async function init() {
46
59
  process.exit(1);
47
60
  }
48
61
  await mkdir(projectDir);
62
+ const version = await getCliVersion();
63
+ const versionRange = `^${version}`;
49
64
  const packageJson = {
50
65
  name,
51
66
  version: "0.1.0",
52
67
  type: "module",
53
- dependencies: { treelsp: "^0.0.1" },
68
+ dependencies: { treelsp: versionRange },
54
69
  devDependencies: {
55
- "@treelsp/cli": "^0.0.1",
70
+ "@treelsp/cli": versionRange,
56
71
  typescript: "^5.7.3"
57
72
  },
58
73
  scripts: {
@@ -63,8 +78,13 @@ async function init() {
63
78
  };
64
79
  await writeFile(resolve(projectDir, "package.json"), JSON.stringify(packageJson, null, 2) + "\n", "utf-8");
65
80
  const tsconfig = {
66
- extends: "treelsp/tsconfig.base.json",
67
- compilerOptions: { outDir: "./dist" },
81
+ compilerOptions: {
82
+ target: "ES2022",
83
+ module: "NodeNext",
84
+ moduleResolution: "NodeNext",
85
+ strict: true,
86
+ outDir: "./dist"
87
+ },
68
88
  include: ["grammar.ts"]
69
89
  };
70
90
  await writeFile(resolve(projectDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n", "utf-8");
@@ -230,142 +250,189 @@ MIT
230
250
  }
231
251
  }
232
252
 
253
+ //#endregion
254
+ //#region src/backends.ts
255
+ const treeSitterBin = resolve(dirname(fileURLToPath(import.meta.resolve("tree-sitter-cli/cli.js"))), "tree-sitter");
256
+ const BACKENDS = {
257
+ "tree-sitter": async () => {
258
+ const { TreeSitterCodegen } = await import("treelsp/codegen/tree-sitter");
259
+ return new TreeSitterCodegen({ treeSitterBin });
260
+ },
261
+ "lezer": async () => {
262
+ const { LezerCodegen } = await import("treelsp/codegen/lezer");
263
+ return new LezerCodegen();
264
+ }
265
+ };
266
+ /**
267
+ * Resolve a codegen backend by identifier.
268
+ * Defaults to "tree-sitter" if not specified.
269
+ */
270
+ async function getCodegenBackend(id = "tree-sitter") {
271
+ const factory = BACKENDS[id];
272
+ if (!factory) {
273
+ const available = Object.keys(BACKENDS).join(", ");
274
+ throw new Error(`Unknown parser backend: "${id}". Available backends: ${available}`);
275
+ }
276
+ return factory();
277
+ }
278
+
233
279
  //#endregion
234
280
  //#region src/commands/generate.ts
235
- async function generate(options) {
236
- const spinner = ora("Loading grammar.ts...").start();
281
+ /**
282
+ * Generate code for a single language project.
283
+ */
284
+ async function generateProject(project) {
285
+ const label = relative(process.cwd(), project.grammarPath) || project.grammarPath;
286
+ const spinner = ora(`Loading ${label}...`).start();
237
287
  try {
238
- const grammarPath = resolve(process.cwd(), "grammar.ts");
239
- if (!existsSync(grammarPath)) {
240
- spinner.fail("Could not find grammar.ts in current directory");
288
+ if (!existsSync(project.grammarPath)) {
289
+ spinner.fail(`Could not find ${label}`);
241
290
  console.log(pc.dim("\nRun \"treelsp init\" to create a new language project"));
242
- process.exit(1);
291
+ throw new Error(`Grammar file not found: ${project.grammarPath}`);
292
+ }
293
+ const tmpPath = project.grammarPath.replace(/\.ts$/, ".tmp.mjs");
294
+ await build({
295
+ entryPoints: [project.grammarPath],
296
+ bundle: true,
297
+ format: "esm",
298
+ platform: "node",
299
+ outfile: tmpPath,
300
+ packages: "external",
301
+ logLevel: "silent"
302
+ });
303
+ let definition;
304
+ try {
305
+ const grammarUrl = pathToFileURL(tmpPath).href;
306
+ const mod = await import(grammarUrl);
307
+ definition = mod.default;
308
+ } finally {
309
+ try {
310
+ unlinkSync(tmpPath);
311
+ } catch {}
243
312
  }
244
- const grammarUrl = pathToFileURL(grammarPath).href;
245
- const module = await import(grammarUrl);
246
- const definition = module.default;
247
313
  if (!definition || !definition.name || !definition.grammar) {
248
314
  spinner.fail("Invalid language definition");
249
315
  console.log(pc.dim("\nEnsure grammar.ts exports a valid language definition using defineLanguage()"));
250
- process.exit(1);
316
+ throw new Error("Invalid language definition");
251
317
  }
252
318
  spinner.text = `Generating code for ${definition.name}...`;
253
- const grammarJs = generateGrammar(definition);
319
+ const backend = await getCodegenBackend(project.backend);
320
+ const artifacts = backend.generate(definition);
254
321
  const astTypes = generateAstTypes(definition);
255
322
  const manifest = generateManifest(definition);
256
- const highlightsSCM = generateHighlights(definition);
257
- const localsSCM = generateLocals(definition);
258
- const genDir = resolve(process.cwd(), "generated");
259
- const queriesDir = resolve(genDir, "queries");
260
- await mkdir(queriesDir, { recursive: true });
323
+ const textmateGrammar = generateTextmate(definition);
324
+ await mkdir(project.outDir, { recursive: true });
325
+ const artifactDirs = new Set(artifacts.map((a) => a.path.includes("/") ? resolve(project.outDir, a.path, "..") : null).filter((d) => d !== null));
326
+ for (const dir of artifactDirs) await mkdir(dir, { recursive: true });
261
327
  await Promise.all([
262
- writeFile(resolve(genDir, "grammar.js"), grammarJs, "utf-8"),
263
- writeFile(resolve(genDir, "ast.ts"), astTypes, "utf-8"),
264
- writeFile(resolve(genDir, "treelsp.json"), manifest, "utf-8"),
265
- writeFile(resolve(queriesDir, "highlights.scm"), highlightsSCM, "utf-8"),
266
- writeFile(resolve(queriesDir, "locals.scm"), localsSCM, "utf-8")
328
+ ...artifacts.map((a) => writeFile(resolve(project.outDir, a.path), a.content, "utf-8")),
329
+ writeFile(resolve(project.outDir, "ast.ts"), astTypes, "utf-8"),
330
+ writeFile(resolve(project.outDir, "treelsp.json"), manifest, "utf-8"),
331
+ writeFile(resolve(project.outDir, "syntax.tmLanguage.json"), textmateGrammar, "utf-8")
267
332
  ]);
268
- spinner.succeed("Generated grammar.js, ast.ts, treelsp.json, queries/highlights.scm, queries/locals.scm");
269
- if (!options.watch) console.log(pc.dim("\nNext step: Run \"treelsp build\" to compile grammar to WASM"));
333
+ const outLabel = relative(process.cwd(), project.outDir) || project.outDir;
334
+ spinner.succeed(`Generated ${definition.name} -> ${outLabel}/`);
270
335
  } catch (error) {
271
- spinner.fail("Generation failed");
336
+ if (!spinner.isSpinning) {} else spinner.fail(`Generation failed for ${label}`);
272
337
  if (error instanceof Error) if (error.message.includes("Cannot find module")) {
273
- console.error(pc.red("\nFailed to load grammar.ts"));
338
+ console.error(pc.red("\nFailed to load grammar file"));
274
339
  console.log(pc.dim("Ensure the file exists and has no syntax errors"));
275
340
  } else if (error.message.includes("EACCES") || error.message.includes("EPERM")) {
276
- console.error(pc.red("\nPermission denied writing to generated/"));
277
- console.log(pc.dim("Check file permissions in the current directory"));
341
+ console.error(pc.red(`\nPermission denied writing to ${project.outDir}`));
342
+ console.log(pc.dim("Check file permissions"));
278
343
  } else console.error(pc.red(`\n${error.message}`));
279
- process.exit(1);
344
+ throw error;
280
345
  }
281
346
  }
347
+ /**
348
+ * Top-level generate command handler.
349
+ */
350
+ async function generate(options, configResult) {
351
+ for (const project of configResult.projects) await generateProject(project);
352
+ if (!options.watch) console.log(pc.dim("\nNext step: Run \"treelsp build\" to compile grammar to WASM"));
353
+ }
282
354
 
283
355
  //#endregion
284
356
  //#region src/commands/build.ts
285
- async function build$1() {
286
- const spinner = ora("Checking prerequisites...").start();
357
+ /**
358
+ * Map from backend id to the runtime import specifier used in the server entry.
359
+ */
360
+ const BACKEND_RUNTIME_IMPORT = {
361
+ "tree-sitter": {
362
+ specifier: "treelsp/backend/tree-sitter",
363
+ className: "TreeSitterRuntime"
364
+ },
365
+ "lezer": {
366
+ specifier: "treelsp/backend/lezer",
367
+ className: "LezerRuntime"
368
+ }
369
+ };
370
+ /**
371
+ * Build a single language project: compile parser + bundle server.
372
+ */
373
+ async function buildProject(project) {
374
+ const label = relative(process.cwd(), project.projectDir) || project.projectDir;
375
+ const spinner = ora(`Building ${label}...`).start();
287
376
  try {
288
- const grammarPath = resolve(process.cwd(), "generated", "grammar.js");
289
- if (!existsSync(grammarPath)) {
290
- spinner.fail("generated/grammar.js not found");
291
- console.log(pc.dim("\nRun \"treelsp generate\" first to create grammar.js"));
292
- process.exit(1);
293
- }
294
- try {
295
- execSync("tree-sitter --version", { stdio: "ignore" });
296
- } catch {
297
- spinner.fail("tree-sitter CLI not found");
298
- console.log(pc.dim("\nInstall tree-sitter CLI:"));
299
- console.log(pc.dim(" npm install -g tree-sitter-cli"));
300
- console.log(pc.dim(" or: cargo install tree-sitter-cli"));
301
- process.exit(1);
302
- }
303
- spinner.text = "Generating C parser...";
304
- const genDir = resolve(process.cwd(), "generated");
305
- const genPkgJson = resolve(genDir, "package.json");
306
- const hadPkgJson = existsSync(genPkgJson);
307
- if (!hadPkgJson) writeFileSync(genPkgJson, "{\"type\":\"commonjs\"}\n");
308
- try {
309
- execSync("tree-sitter generate generated/grammar.js", {
310
- stdio: "pipe",
311
- cwd: process.cwd()
312
- });
313
- } finally {
314
- if (!hadPkgJson) rmSync(genPkgJson, { force: true });
315
- }
316
- spinner.text = "Compiling to WASM...";
317
- execSync("tree-sitter build --wasm", {
318
- stdio: "pipe",
319
- cwd: process.cwd()
320
- });
321
- spinner.text = "Moving WASM output...";
322
- const cwd = process.cwd();
323
- const wasmFiles = readdirSync(cwd).filter((f) => f.startsWith("tree-sitter-") && f.endsWith(".wasm"));
324
- if (wasmFiles.length === 0) {
325
- spinner.fail("tree-sitter build --wasm did not produce a .wasm file");
326
- process.exit(1);
327
- }
328
- const sourceWasm = resolve(cwd, wasmFiles[0]);
329
- const destWasm = resolve(cwd, "generated", "grammar.wasm");
330
- renameSync(sourceWasm, destWasm);
331
- const cleanupDirs = ["src", "bindings"];
332
- const cleanupFiles = [
333
- "binding.gyp",
334
- "Makefile",
335
- "Package.swift",
336
- ".editorconfig"
337
- ];
338
- for (const dir of cleanupDirs) {
339
- const p = resolve(cwd, dir);
340
- if (existsSync(p)) rmSync(p, {
341
- recursive: true,
342
- force: true
343
- });
344
- }
345
- for (const file of cleanupFiles) {
346
- const p = resolve(cwd, file);
347
- if (existsSync(p)) rmSync(p, { force: true });
377
+ const backend = await getCodegenBackend(project.backend);
378
+ spinner.text = `Compiling parser for ${label}...`;
379
+ await backend.compile(project.projectDir, project.outDir, { onProgress: (msg) => {
380
+ spinner.text = `${msg} (${label})`;
381
+ } });
382
+ if (backend.cleanupPatterns) {
383
+ const { directories, files, globs } = backend.cleanupPatterns;
384
+ if (directories) for (const dir of directories) {
385
+ const p = resolve(project.projectDir, dir);
386
+ if (existsSync(p)) rmSync(p, {
387
+ recursive: true,
388
+ force: true
389
+ });
390
+ }
391
+ if (files) for (const file of files) {
392
+ const p = resolve(project.projectDir, file);
393
+ if (existsSync(p)) rmSync(p, { force: true });
394
+ }
395
+ if (globs) for (const pattern of globs) {
396
+ const parts = pattern.split("*");
397
+ if (parts.length === 2) {
398
+ const [prefix, suffix] = parts;
399
+ for (const f of readdirSync(project.projectDir)) if (f.startsWith(prefix) && f.endsWith(suffix)) rmSync(resolve(project.projectDir, f), { force: true });
400
+ }
401
+ }
348
402
  }
349
- for (const f of readdirSync(cwd)) if (f.startsWith("tree-sitter-") && f.endsWith(".pc")) rmSync(resolve(cwd, f), { force: true });
350
- spinner.text = "Bundling language server...";
351
- const serverEntry = [
403
+ spinner.text = `Bundling language server for ${label}...`;
404
+ const runtimeImport = BACKEND_RUNTIME_IMPORT[project.backend];
405
+ if (!runtimeImport) throw new Error(`No runtime import configured for backend "${project.backend}"`);
406
+ let serverEntry;
407
+ if (project.backend === "lezer") {
408
+ const relOut = "./" + relative(project.projectDir, project.outDir).replace(/\\/g, "/");
409
+ serverEntry = [
410
+ `import { startStdioServer } from 'treelsp/server';`,
411
+ `import { ${runtimeImport.className} } from '${runtimeImport.specifier}';`,
412
+ `import definition from './grammar.ts';`,
413
+ `import { parser } from '${relOut}/parser.bundle.js';`,
414
+ `import parserMeta from '${relOut}/parser-meta.json';`,
415
+ ``,
416
+ `const backend = new ${runtimeImport.className}(parser, parserMeta);`,
417
+ `startStdioServer({ definition, parserPath: '', backend });`
418
+ ].join("\n");
419
+ } else serverEntry = [
352
420
  `import { startStdioServer } from 'treelsp/server';`,
353
- `import { resolve, dirname } from 'node:path';`,
354
- `import { fileURLToPath } from 'node:url';`,
421
+ `import { ${runtimeImport.className} } from '${runtimeImport.specifier}';`,
422
+ `import { resolve } from 'node:path';`,
355
423
  `import definition from './grammar.ts';`,
356
424
  ``,
357
- `const __dirname = dirname(fileURLToPath(import.meta.url));`,
358
- `const wasmPath = resolve(__dirname, 'grammar.wasm');`,
425
+ `const parserPath = resolve(__dirname, 'grammar.wasm');`,
359
426
  ``,
360
- `startStdioServer({ definition, wasmPath });`
427
+ `startStdioServer({ definition, parserPath, backend: new ${runtimeImport.className}() });`
361
428
  ].join("\n");
362
429
  const treelspServer = import.meta.resolve("treelsp/server");
363
430
  const treelspPkg = resolve(new URL(treelspServer).pathname, "..", "..", "..");
364
- const bundlePath = resolve(genDir, "server.bundle.cjs");
431
+ const bundlePath = resolve(project.outDir, "server.bundle.cjs");
365
432
  await build({
366
433
  stdin: {
367
434
  contents: serverEntry,
368
- resolveDir: process.cwd(),
435
+ resolveDir: project.projectDir,
369
436
  loader: "ts"
370
437
  },
371
438
  bundle: true,
@@ -374,50 +441,63 @@ async function build$1() {
374
441
  outfile: bundlePath,
375
442
  sourcemap: true,
376
443
  nodePaths: [resolve(treelspPkg, "node_modules")],
377
- logLevel: "silent"
444
+ logLevel: "warning",
445
+ logOverride: { "empty-import-meta": "silent" }
378
446
  });
379
- const { readFileSync } = await import("node:fs");
380
- let bundleCode = readFileSync(bundlePath, "utf-8");
381
- bundleCode = bundleCode.replace(/var import_meta\s*=\s*\{\s*\};/, "var import_meta = { url: require(\"url\").pathToFileURL(__filename).href };");
382
- writeFileSync(bundlePath, bundleCode);
383
- const treelspNodeModules = resolve(treelspPkg, "node_modules");
384
- const tsWasmSrc = resolve(treelspNodeModules, "web-tree-sitter", "tree-sitter.wasm");
385
- const tsWasmDest = resolve(genDir, "tree-sitter.wasm");
386
- if (existsSync(tsWasmSrc) && !existsSync(tsWasmDest)) copyFileSync(tsWasmSrc, tsWasmDest);
387
- spinner.succeed("Build complete");
388
- console.log(pc.dim("\nGenerated files:"));
389
- console.log(pc.dim(" generated/grammar.js"));
390
- console.log(pc.dim(" generated/grammar.wasm"));
391
- if (existsSync(resolve(genDir, "server.bundle.cjs"))) console.log(pc.dim(" generated/server.bundle.cjs"));
447
+ if (backend.getRuntimeFiles) for (const { src, dest } of backend.getRuntimeFiles(treelspPkg)) {
448
+ const destPath = resolve(project.outDir, dest);
449
+ if (existsSync(src) && !existsSync(destPath)) copyFileSync(src, destPath);
450
+ }
451
+ const outLabel = relative(process.cwd(), project.outDir) || project.outDir;
452
+ spinner.succeed(`Built ${label} -> ${outLabel}/`);
392
453
  } catch (error) {
393
- spinner.fail("Build failed");
454
+ spinner.fail(`Build failed for ${label}`);
394
455
  if (error instanceof Error) {
456
+ const esbuildError = error;
395
457
  const execError = error;
396
- if (execError.stderr) {
458
+ if (esbuildError.errors && esbuildError.errors.length > 0) {
459
+ console.error(pc.red("\nServer bundling failed:"));
460
+ for (const err of esbuildError.errors) {
461
+ const loc = err.location ? ` (${err.location.file}:${err.location.line})` : "";
462
+ console.error(pc.dim(` ${err.text}${loc}`));
463
+ }
464
+ } else if (execError.stderr) {
397
465
  const stderr = execError.stderr.toString();
398
- console.error(pc.red("\nTree-sitter error:"));
466
+ console.error(pc.red("\nBackend compilation error:"));
399
467
  console.error(pc.dim(stderr));
400
- if (stderr.includes("grammar")) console.log(pc.dim("\nSuggestion: Check your grammar definition for errors"));
401
- else if (stderr.includes("emcc") || stderr.includes("compiler")) {
402
- console.log(pc.dim("\nSuggestion: Ensure Emscripten is installed for WASM compilation"));
403
- console.log(pc.dim(" See: https://tree-sitter.github.io/tree-sitter/creating-parsers#tool-overview"));
404
- }
405
468
  } else console.error(pc.red(`\n${error.message}`));
406
469
  }
407
- process.exit(1);
470
+ throw error;
408
471
  }
409
472
  }
473
+ /**
474
+ * Top-level build command handler.
475
+ */
476
+ async function build$1(configResult) {
477
+ for (const project of configResult.projects) await buildProject(project);
478
+ }
410
479
 
411
480
  //#endregion
412
481
  //#region src/commands/watch.ts
413
- async function watch() {
482
+ async function watch(configResult) {
414
483
  console.log(pc.bold("treelsp watch\n"));
415
- if (!existsSync("grammar.ts")) {
416
- console.error(pc.red("Could not find grammar.ts in current directory"));
417
- console.log(pc.dim("\nRun \"treelsp init\" to create a new language project"));
418
- process.exit(1);
484
+ const { projects } = configResult;
485
+ const projectByGrammar = /* @__PURE__ */ new Map();
486
+ const grammarPaths = [];
487
+ for (const project of projects) {
488
+ if (!existsSync(project.grammarPath)) {
489
+ console.error(pc.red(`Could not find ${relative(process.cwd(), project.grammarPath)}`));
490
+ process.exit(1);
491
+ }
492
+ projectByGrammar.set(project.grammarPath, project);
493
+ grammarPaths.push(project.grammarPath);
494
+ }
495
+ if (projects.length > 1) {
496
+ console.log(pc.dim(`Watching ${String(projects.length)} language projects:`));
497
+ for (const p of projects) console.log(pc.dim(` - ${relative(process.cwd(), p.grammarPath)}`));
498
+ console.log("");
419
499
  }
420
- const watcher = chokidar.watch("grammar.ts", {
500
+ const watcher = chokidar.watch(grammarPaths, {
421
501
  persistent: true,
422
502
  awaitWriteFinish: {
423
503
  stabilityThreshold: 100,
@@ -425,20 +505,22 @@ async function watch() {
425
505
  }
426
506
  });
427
507
  let isBuilding = false;
428
- watcher.on("change", (path) => {
508
+ watcher.on("change", (changedPath) => {
429
509
  (async () => {
430
510
  if (isBuilding) {
431
511
  console.log(pc.dim("Build in progress, skipping..."));
432
512
  return;
433
513
  }
434
- console.log(pc.dim(`\n${path} changed`));
514
+ const project = projectByGrammar.get(changedPath);
515
+ if (!project) return;
516
+ console.log(pc.dim(`\n${relative(process.cwd(), changedPath)} changed`));
435
517
  isBuilding = true;
436
518
  try {
437
- await generate({});
438
- await build$1();
439
- console.log(pc.green("Rebuild successful\n"));
440
- } catch (_error) {
441
- console.log(pc.red("Rebuild failed\n"));
519
+ await generateProject(project);
520
+ await buildProject(project);
521
+ console.log(pc.green(" Rebuild successful\n"));
522
+ } catch {
523
+ console.log(pc.red(" Rebuild failed\n"));
442
524
  } finally {
443
525
  isBuilding = false;
444
526
  console.log(pc.dim("Watching for changes..."));
@@ -449,14 +531,116 @@ async function watch() {
449
531
  console.error(pc.red("Watcher error:"), error instanceof Error ? error.message : String(error));
450
532
  });
451
533
  console.log(pc.dim("Running initial build...\n"));
452
- try {
453
- await generate({});
454
- await build$1();
455
- console.log(pc.green("✓ Initial build successful\n"));
456
- } catch (_error) {
457
- console.log(pc.red("✗ Initial build failed\n"));
534
+ for (const project of projects) try {
535
+ await generateProject(project);
536
+ await buildProject(project);
537
+ } catch {
538
+ console.log(pc.red(` ${relative(process.cwd(), project.projectDir)} - FAILED\n`));
539
+ }
540
+ console.log(pc.dim("\nWatching for changes..."));
541
+ }
542
+
543
+ //#endregion
544
+ //#region src/config.ts
545
+ /**
546
+ * Resolve config: use -f flag, auto-discover, or fall back to legacy.
547
+ */
548
+ function resolveConfig(fileFlag) {
549
+ if (fileFlag) return loadConfigFromFile(fileFlag);
550
+ const discovered = discoverConfig();
551
+ if (discovered) return discovered;
552
+ const cwd = process.cwd();
553
+ return {
554
+ source: "legacy",
555
+ projects: [{
556
+ grammarPath: resolve(cwd, "grammar.ts"),
557
+ projectDir: cwd,
558
+ outDir: resolve(cwd, "generated"),
559
+ backend: "tree-sitter"
560
+ }]
561
+ };
562
+ }
563
+ function loadConfigFromFile(filePath) {
564
+ const absPath = resolve(filePath);
565
+ if (!existsSync(absPath)) throw new Error(`Config file not found: ${absPath}`);
566
+ const raw = readFileSync(absPath, "utf-8");
567
+ if (basename(absPath) === "package.json") {
568
+ const pkg = JSON.parse(raw);
569
+ const treelspField = pkg["treelsp"];
570
+ if (!treelspField) throw new Error(`No "treelsp" field found in ${absPath}`);
571
+ const config$1 = validateConfig(treelspField, absPath);
572
+ return {
573
+ source: "package.json",
574
+ configPath: absPath,
575
+ projects: resolveProjects(config$1, dirname(absPath))
576
+ };
458
577
  }
459
- console.log(pc.dim("Watching for changes..."));
578
+ const config = validateConfig(JSON.parse(raw), absPath);
579
+ return {
580
+ source: "file",
581
+ configPath: absPath,
582
+ projects: resolveProjects(config, dirname(absPath))
583
+ };
584
+ }
585
+ function discoverConfig() {
586
+ let dir = resolve(process.cwd());
587
+ while (true) {
588
+ const configFile = resolve(dir, "treelsp-config.json");
589
+ if (existsSync(configFile)) {
590
+ const raw = readFileSync(configFile, "utf-8");
591
+ const config = validateConfig(JSON.parse(raw), configFile);
592
+ return {
593
+ source: "file",
594
+ configPath: configFile,
595
+ projects: resolveProjects(config, dir)
596
+ };
597
+ }
598
+ const pkgFile = resolve(dir, "package.json");
599
+ if (existsSync(pkgFile)) {
600
+ const raw = readFileSync(pkgFile, "utf-8");
601
+ const pkg = JSON.parse(raw);
602
+ if (pkg["treelsp"]) {
603
+ const config = validateConfig(pkg["treelsp"], pkgFile);
604
+ return {
605
+ source: "package.json",
606
+ configPath: pkgFile,
607
+ projects: resolveProjects(config, dir)
608
+ };
609
+ }
610
+ }
611
+ const parent = dirname(dir);
612
+ if (parent === dir) break;
613
+ dir = parent;
614
+ }
615
+ return null;
616
+ }
617
+ function validateConfig(raw, filePath) {
618
+ if (typeof raw !== "object" || raw === null) throw new Error(`Invalid config in ${filePath}: expected an object`);
619
+ const obj = raw;
620
+ if (!Array.isArray(obj["languages"])) throw new Error(`Invalid config in ${filePath}: "languages" must be an array`);
621
+ const languages = obj["languages"];
622
+ if (languages.length === 0) throw new Error(`Invalid config in ${filePath}: "languages" must not be empty`);
623
+ for (let i = 0; i < languages.length; i++) {
624
+ const lang = languages[i];
625
+ if (typeof lang !== "object" || lang === null) throw new Error(`Invalid config in ${filePath}: languages[${String(i)}] must be an object`);
626
+ const langObj = lang;
627
+ if (typeof langObj["grammar"] !== "string" || langObj["grammar"].length === 0) throw new Error(`Invalid config in ${filePath}: languages[${String(i)}].grammar must be a non-empty string`);
628
+ if (langObj["out"] !== void 0 && (typeof langObj["out"] !== "string" || langObj["out"].length === 0)) throw new Error(`Invalid config in ${filePath}: languages[${String(i)}].out must be a non-empty string if provided`);
629
+ }
630
+ return obj;
631
+ }
632
+ function resolveProjects(config, configDir) {
633
+ return config.languages.map((lang) => {
634
+ const grammarPath = resolve(configDir, lang.grammar);
635
+ const projectDir = dirname(grammarPath);
636
+ const outDir = lang.out ? resolve(configDir, lang.out) : resolve(projectDir, "generated");
637
+ return {
638
+ grammarPath,
639
+ projectDir,
640
+ outDir,
641
+ backend: lang.backend ?? "tree-sitter"
642
+ };
643
+ });
460
644
  }
461
645
 
462
646
  //#endregion
@@ -464,9 +648,29 @@ async function watch() {
464
648
  const program = new Command();
465
649
  program.name("treelsp").description("CLI for treelsp - LSP generator using Tree-sitter").version("0.0.1");
466
650
  program.command("init").description("Scaffold a new language project").action(init);
467
- program.command("generate").description("Generate grammar.js, AST types, and server from language definition").option("-w, --watch", "Watch for changes").action(generate);
468
- program.command("build").description("Compile grammar.js to WASM using Tree-sitter CLI").action(build$1);
469
- program.command("watch").description("Watch mode - re-run generate + build on changes").action(watch);
651
+ program.command("generate").description("Generate grammar.js, AST types, and server from language definition").option("-f, --file <file>", "Path to treelsp-config.json or package.json with treelsp field").option("-w, --watch", "Watch for changes").action(async (options) => {
652
+ try {
653
+ const config = resolveConfig(options.file);
654
+ if (config.configPath) console.log(pc.dim(`Using config: ${config.configPath}\n`));
655
+ await generate(options, config);
656
+ } catch {
657
+ process.exit(1);
658
+ }
659
+ });
660
+ program.command("build").description("Compile grammar.js to WASM using Tree-sitter CLI").option("-f, --file <file>", "Path to treelsp-config.json or package.json with treelsp field").action(async (options) => {
661
+ try {
662
+ const config = resolveConfig(options.file);
663
+ if (config.configPath) console.log(pc.dim(`Using config: ${config.configPath}\n`));
664
+ await build$1(config);
665
+ } catch {
666
+ process.exit(1);
667
+ }
668
+ });
669
+ program.command("watch").description("Watch mode - re-run generate + build on changes").option("-f, --file <file>", "Path to treelsp-config.json or package.json with treelsp field").action(async (options) => {
670
+ const config = resolveConfig(options.file);
671
+ if (config.configPath) console.log(pc.dim(`Using config: ${config.configPath}\n`));
672
+ await watch(config);
673
+ });
470
674
  program.parse();
471
675
 
472
676
  //#endregion
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@treelsp/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "description": "CLI tool for treelsp - LSP generator using Tree-sitter",
8
8
  "type": "module",
9
9
  "bin": {
10
- "treelsp": "./dist/index.js"
10
+ "treelsp": "./bin/treelsp.js"
11
11
  },
12
12
  "files": [
13
13
  "dist",
14
+ "bin",
14
15
  "README.md"
15
16
  ],
16
17
  "keywords": [
@@ -21,6 +22,9 @@
21
22
  ],
22
23
  "author": "Dhrubo Moy",
23
24
  "license": "MIT",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
24
28
  "repository": {
25
29
  "type": "git",
26
30
  "url": "https://github.com/dhrubomoy/treelsp.git",
@@ -29,13 +33,15 @@
29
33
  "homepage": "https://github.com/dhrubomoy/treelsp#readme",
30
34
  "bugs": "https://github.com/dhrubomoy/treelsp/issues",
31
35
  "dependencies": {
36
+ "@lezer/generator": "^1.8.0",
32
37
  "chokidar": "^4.0.3",
33
38
  "commander": "^12.1.0",
34
39
  "esbuild": "^0.27.3",
35
40
  "ora": "^8.1.1",
36
41
  "picocolors": "^1.1.1",
37
42
  "prompts": "^2.4.2",
38
- "treelsp": "0.0.1"
43
+ "tree-sitter-cli": "^0.26.5",
44
+ "treelsp": "0.0.2"
39
45
  },
40
46
  "devDependencies": {
41
47
  "@types/prompts": "^2.4.9",