@rohal12/spindle-lsp 0.5.4 → 0.7.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.
package/dist/bin.js CHANGED
@@ -210,6 +210,10 @@ var init_passage_index = __esm({
210
210
  getStoryVariables() {
211
211
  return this.getPassage("StoryVariables");
212
212
  }
213
+ /** Get the StoryTransients passage, if any. */
214
+ getStoryTransients() {
215
+ return this.getPassage("StoryTransients");
216
+ }
213
217
  /** Get the StoryInit passage, if any. */
214
218
  getStoryInit() {
215
219
  return this.getPassage("StoryInit");
@@ -412,14 +416,16 @@ var init_macro_registry = __esm({
412
416
  });
413
417
 
414
418
  // src/core/workspace/variable-tracker.ts
415
- var varRefRegex, EXCLUDED_PASSAGES, CLEAN_PATTERNS, VariableTracker;
419
+ var varRefRegex, transientRefRegex, EXCLUDED_PASSAGES, CLEAN_PATTERNS, VariableTracker;
416
420
  var init_variable_tracker = __esm({
417
421
  "src/core/workspace/variable-tracker.ts"() {
418
422
  "use strict";
419
423
  init_passage_parser();
420
424
  varRefRegex = /\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
425
+ transientRefRegex = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
421
426
  EXCLUDED_PASSAGES = /* @__PURE__ */ new Set([
422
427
  "StoryVariables",
428
+ "StoryTransients",
423
429
  "StoryInit",
424
430
  "StoryData",
425
431
  "StoryScript",
@@ -442,8 +448,12 @@ var init_variable_tracker = __esm({
442
448
  VariableTracker = class {
443
449
  declared = /* @__PURE__ */ new Map();
444
450
  _hasStoryVariables = false;
451
+ declaredTransient = /* @__PURE__ */ new Map();
452
+ _hasStoryTransients = false;
445
453
  /** Per-URI list of variable usages. */
446
454
  usagesByUri = /* @__PURE__ */ new Map();
455
+ /** Per-URI list of transient variable usages. */
456
+ transientUsagesByUri = /* @__PURE__ */ new Map();
447
457
  /**
448
458
  * Parse the StoryVariables passage content for declarations.
449
459
  * Each line like `$name = value` becomes a declaration.
@@ -474,14 +484,46 @@ var init_variable_tracker = __esm({
474
484
  this.declared.set(name, decl);
475
485
  }
476
486
  }
487
+ /**
488
+ * Parse the StoryTransients passage content for declarations.
489
+ * Each line like `%name = value` becomes a declaration.
490
+ */
491
+ parseStoryTransients(content) {
492
+ this.declaredTransient.clear();
493
+ this._hasStoryTransients = true;
494
+ const lines = content.split("\n");
495
+ for (const line of lines) {
496
+ const trimmed = line.trim();
497
+ if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("<!--")) continue;
498
+ const match = /^%([A-Za-z_$][\w$]*)\s*=\s*(.*)$/.exec(trimmed);
499
+ if (!match) continue;
500
+ const name = match[1];
501
+ const expr = match[2].trim();
502
+ const decl = { name, sigil: "%" };
503
+ if (expr.startsWith("{")) {
504
+ const fieldRegex = /(\w+)\s*:/g;
505
+ let fieldMatch;
506
+ const fields = [];
507
+ while ((fieldMatch = fieldRegex.exec(expr)) !== null) {
508
+ fields.push(fieldMatch[1]);
509
+ }
510
+ if (fields.length > 0) {
511
+ decl.fields = fields;
512
+ }
513
+ }
514
+ this.declaredTransient.set(name, decl);
515
+ }
516
+ }
477
517
  /**
478
518
  * Scan a document for variable usages.
479
519
  * Identifies passages in the document and scans non-special ones.
480
520
  */
481
521
  scanDocument(uri, text, _macros) {
482
522
  this.usagesByUri.delete(uri);
523
+ this.transientUsagesByUri.delete(uri);
483
524
  const lines = text.split("\n");
484
525
  const usages = [];
526
+ const transientUsages = [];
485
527
  const passageBoundaries = [];
486
528
  for (let i = 0; i < lines.length; i++) {
487
529
  const header = parsePassageHeader(lines[i], i);
@@ -523,10 +565,32 @@ var init_variable_tracker = __esm({
523
565
  };
524
566
  usages.push({ uri, baseName, fullName, range });
525
567
  }
568
+ const tre = new RegExp(transientRefRegex.source, "g");
569
+ let tmatch;
570
+ while ((tmatch = tre.exec(cleaned)) !== null) {
571
+ const fullName = tmatch[1];
572
+ const baseName = fullName.split(".")[0];
573
+ const charOffset = tmatch.index;
574
+ let localLine = 0;
575
+ for (let i = 0; i < lineOffsets.length; i++) {
576
+ if (lineOffsets[i] > charOffset) break;
577
+ localLine = i;
578
+ }
579
+ const character = charOffset - lineOffsets[localLine];
580
+ const absoluteLine = contentStartLine + localLine;
581
+ const range = {
582
+ start: { line: absoluteLine, character },
583
+ end: { line: absoluteLine, character: character + tmatch[0].length }
584
+ };
585
+ transientUsages.push({ uri, baseName, fullName, range });
586
+ }
526
587
  }
527
588
  if (usages.length > 0) {
528
589
  this.usagesByUri.set(uri, usages);
529
590
  }
591
+ if (transientUsages.length > 0) {
592
+ this.transientUsagesByUri.set(uri, transientUsages);
593
+ }
530
594
  }
531
595
  /** Get all declared variables. */
532
596
  getDeclared() {
@@ -562,6 +626,40 @@ var init_variable_tracker = __esm({
562
626
  hasStoryVariables() {
563
627
  return this._hasStoryVariables;
564
628
  }
629
+ /** Get all declared transient variables. */
630
+ getDeclaredTransient() {
631
+ return this.declaredTransient;
632
+ }
633
+ /** Get all usages of a transient variable by base name. */
634
+ getTransientUsages(name) {
635
+ const results = [];
636
+ for (const usages of this.transientUsagesByUri.values()) {
637
+ for (const u of usages) {
638
+ if (u.baseName === name) {
639
+ results.push({ uri: u.uri, range: u.range });
640
+ }
641
+ }
642
+ }
643
+ return results;
644
+ }
645
+ /** Get undeclared transient variable usages in a specific document. */
646
+ getUndeclaredTransient(uri) {
647
+ const usages = this.transientUsagesByUri.get(uri);
648
+ if (!usages) return [];
649
+ const results = [];
650
+ const seen = /* @__PURE__ */ new Set();
651
+ for (const u of usages) {
652
+ if (!this.declaredTransient.has(u.baseName) && !seen.has(u.baseName)) {
653
+ seen.add(u.baseName);
654
+ results.push({ name: u.baseName, range: u.range });
655
+ }
656
+ }
657
+ return results;
658
+ }
659
+ /** Whether a StoryTransients passage has been parsed. */
660
+ hasStoryTransients() {
661
+ return this._hasStoryTransients;
662
+ }
565
663
  };
566
664
  }
567
665
  });
@@ -721,7 +819,7 @@ var variableInterpolationRegex, macroRegex;
721
819
  var init_macro_parser = __esm({
722
820
  "src/core/parsing/macro-parser.ts"() {
723
821
  "use strict";
724
- variableInterpolationRegex = /(?<!\\)\{([$_@][A-Za-z_$][\w$.]*)\}/g;
822
+ variableInterpolationRegex = /(?<!\\)\{([$_@%][A-Za-z_$][\w$.]*)\}/g;
725
823
  macroRegex = /(?<!\\)\{(\/)?(?:((?:[#.][a-zA-Z][\w-]*\s*)*)([A-Za-z][\w-]*))(?:\s+((?:(?:`(?:\\.|[^`\\])*?`)|(?:"(?:\\.|[^"\\])*?")|(?:'(?:\\.|[^'\\])*?')|(?:\[(?:[<>]?[Ii][Mm][Gg])?\[[^\r\n]*?\]\]+)|[^}]|(?:}))*?))?\}/gmi;
726
824
  }
727
825
  });
@@ -799,12 +897,12 @@ var init_macro_supplements = __esm({
799
897
  },
800
898
  set: {
801
899
  name: "set",
802
- description: "Sets variables based on the given expression.\n\nUsage: `{set $var to value}`\n\nSupports `$var` (story), `_var` (temporary), and `@var` (local) variables.",
900
+ description: "Sets variables based on the given expression.\n\nUsage: `{set $var to value}`\n\nSupports `$var` (story), `_var` (temporary), `@var` (local), and `%var` (transient) variables.",
803
901
  parameters: ["...text"]
804
902
  },
805
903
  unset: {
806
904
  name: "unset",
807
- description: "Unsets one or more variables.\n\nUsage: `{unset $var}`",
905
+ description: "Unsets one or more variables.\n\nUsage: `{unset $var}`\n\nSupports `$var` (story), `_var` (temporary), `@var` (local), and `%var` (transient) variables.",
808
906
  parameters: ["...text"]
809
907
  },
810
908
  computed: {
@@ -1143,6 +1241,23 @@ var init_workspace_model = __esm({
1143
1241
  this.variables.parseStoryVariables(content);
1144
1242
  }
1145
1243
  }
1244
+ const storyTransients = this.passages.getStoryTransients();
1245
+ if (storyTransients) {
1246
+ const text = this.documents.getText(storyTransients.uri);
1247
+ if (text) {
1248
+ const lines = text.split("\n");
1249
+ const contentStart = storyTransients.headerEnd.end.line + 1;
1250
+ let contentEnd = lines.length;
1251
+ for (let i = contentStart; i < lines.length; i++) {
1252
+ if (/^::\s+/.test(lines[i])) {
1253
+ contentEnd = i;
1254
+ break;
1255
+ }
1256
+ }
1257
+ const content = lines.slice(contentStart, contentEnd).join("\n");
1258
+ this.variables.parseStoryTransients(content);
1259
+ }
1260
+ }
1146
1261
  for (const uri of this.documents.getUris()) {
1147
1262
  const text = this.documents.getText(uri);
1148
1263
  if (text) {
@@ -1190,10 +1305,12 @@ var init_diagnostic_codes = __esm({
1190
1305
  UndeclaredVariable: "SP200",
1191
1306
  UndeclaredField: "SP201",
1192
1307
  NoStoryVariables: "SP202",
1308
+ UndeclaredTransient: "SP203",
1193
1309
  BrokenPassageLink: "SP300",
1194
1310
  WidgetArgCountMismatch: "SP301",
1195
1311
  DeadEndPassage: "SP400",
1196
- UnreachablePassage: "SP401"
1312
+ UnreachablePassage: "SP401",
1313
+ LineTooLong: "SP500"
1197
1314
  };
1198
1315
  severityMap = {
1199
1316
  SP100: "warning",
@@ -1210,10 +1327,12 @@ var init_diagnostic_codes = __esm({
1210
1327
  SP200: "warning",
1211
1328
  SP201: "warning",
1212
1329
  SP202: "info",
1330
+ SP203: "warning",
1213
1331
  SP300: "warning",
1214
1332
  SP301: "warning",
1215
1333
  SP400: "hint",
1216
- SP401: "hint"
1334
+ SP401: "hint",
1335
+ SP500: "warning"
1217
1336
  };
1218
1337
  }
1219
1338
  });
@@ -1409,7 +1528,7 @@ var varTestRegexp, EOF, Lexer, notSpaceRegex, spaceRegex;
1409
1528
  var init_argument_lexer = __esm({
1410
1529
  "src/core/parsing/argument-lexer.ts"() {
1411
1530
  "use strict";
1412
- varTestRegexp = /^[$_@][$A-Z_a-z][$0-9A-Z_.a-z]*/;
1531
+ varTestRegexp = /^[$_@%][$A-Z_a-z][$0-9A-Z_.a-z]*/;
1413
1532
  EOF = -1;
1414
1533
  Lexer = class {
1415
1534
  source;
@@ -2092,7 +2211,7 @@ var init_link_parser = __esm({
2092
2211
  });
2093
2212
 
2094
2213
  // src/plugins/diagnostics.ts
2095
- function computeDiagnostics(uri, workspace) {
2214
+ function computeDiagnostics(uri, workspace, options) {
2096
2215
  if (!workspace.initialized) return [];
2097
2216
  try {
2098
2217
  const text = workspace.documents.getText(uri);
@@ -2124,6 +2243,12 @@ function computeDiagnostics(uri, workspace) {
2124
2243
  validateWidgetInvocations(macros, workspace, diagnostics);
2125
2244
  } catch {
2126
2245
  }
2246
+ if (options?.maxLineLength) {
2247
+ try {
2248
+ validateLineLength(text, passages, options.maxLineLength, diagnostics);
2249
+ } catch {
2250
+ }
2251
+ }
2127
2252
  return diagnostics;
2128
2253
  } catch {
2129
2254
  return [];
@@ -2284,7 +2409,7 @@ function validateArguments(macros, workspace, passageNames, diagnostics) {
2284
2409
  }
2285
2410
  function validateVariables(uri, workspace, diagnostics) {
2286
2411
  if (!workspace.variables.hasStoryVariables()) {
2287
- const undeclared2 = workspace.variables.getUndeclared(uri);
2412
+ const undeclared = workspace.variables.getUndeclared(uri);
2288
2413
  const text = workspace.documents.getText(uri);
2289
2414
  if (text && /\$[A-Za-z_$]/.test(text)) {
2290
2415
  const passages = workspace.passages.getPassagesInDocument(uri);
@@ -2300,15 +2425,25 @@ function validateVariables(uri, workspace, diagnostics) {
2300
2425
  ));
2301
2426
  }
2302
2427
  }
2303
- return;
2428
+ } else {
2429
+ const undeclared = workspace.variables.getUndeclared(uri);
2430
+ for (const u of undeclared) {
2431
+ diagnostics.push(makeDiag(
2432
+ u.range,
2433
+ DiagnosticCode.UndeclaredVariable,
2434
+ `Variable '$${u.name}' is not declared in StoryVariables`
2435
+ ));
2436
+ }
2304
2437
  }
2305
- const undeclared = workspace.variables.getUndeclared(uri);
2306
- for (const u of undeclared) {
2307
- diagnostics.push(makeDiag(
2308
- u.range,
2309
- DiagnosticCode.UndeclaredVariable,
2310
- `Variable '$${u.name}' is not declared in StoryVariables`
2311
- ));
2438
+ if (workspace.variables.hasStoryTransients()) {
2439
+ const undeclaredTransient = workspace.variables.getUndeclaredTransient(uri);
2440
+ for (const u of undeclaredTransient) {
2441
+ diagnostics.push(makeDiag(
2442
+ u.range,
2443
+ DiagnosticCode.UndeclaredTransient,
2444
+ `Transient variable '%${u.name}' is not declared in StoryTransients`
2445
+ ));
2446
+ }
2312
2447
  }
2313
2448
  }
2314
2449
  function validateLinks(text, passages, passageNames, diagnostics) {
@@ -2342,6 +2477,26 @@ function validateWidgetInvocations(macros, workspace, diagnostics) {
2342
2477
  }
2343
2478
  }
2344
2479
  }
2480
+ function validateLineLength(text, passages, maxLength, diagnostics) {
2481
+ const excludedTags = /* @__PURE__ */ new Set(["script", "stylesheet"]);
2482
+ const excludedPassages = new Set(passages.filter((p) => p.tags?.some((t) => excludedTags.has(t))).map((p) => p.name));
2483
+ const lines = text.split("\n");
2484
+ for (let i = 0; i < lines.length; i++) {
2485
+ const line = lines[i];
2486
+ if (/^::\s+/.test(line)) continue;
2487
+ if (/^\s*<[a-zA-Z]/.test(line)) continue;
2488
+ if (line.length > maxLength) {
2489
+ diagnostics.push(makeDiag(
2490
+ {
2491
+ start: { line: i, character: maxLength },
2492
+ end: { line: i, character: line.length }
2493
+ },
2494
+ DiagnosticCode.LineTooLong,
2495
+ `Line exceeds ${maxLength} characters (${line.length})`
2496
+ ));
2497
+ }
2498
+ }
2499
+ }
2345
2500
  function makeDiag(range, code, message) {
2346
2501
  return {
2347
2502
  range,
@@ -2639,6 +2794,7 @@ function parseArgs(args2) {
2639
2794
  format: "pretty",
2640
2795
  severity: null,
2641
2796
  configPath: null,
2797
+ maxLineLength: null,
2642
2798
  patterns: []
2643
2799
  };
2644
2800
  let i = 0;
@@ -2674,6 +2830,14 @@ function parseArgs(args2) {
2674
2830
  } else if (arg.startsWith("--config=")) {
2675
2831
  options.configPath = arg.slice("--config=".length);
2676
2832
  i++;
2833
+ } else if (arg === "--max-line-length" && i + 1 < args2.length) {
2834
+ const n = parseInt(args2[i + 1], 10);
2835
+ if (!isNaN(n) && n > 0) options.maxLineLength = n;
2836
+ i += 2;
2837
+ } else if (arg.startsWith("--max-line-length=")) {
2838
+ const n = parseInt(arg.slice("--max-line-length=".length), 10);
2839
+ if (!isNaN(n) && n > 0) options.maxLineLength = n;
2840
+ i++;
2677
2841
  } else if (!arg.startsWith("--")) {
2678
2842
  options.patterns.push(arg);
2679
2843
  i++;
@@ -2751,10 +2915,14 @@ async function runCheck(args2) {
2751
2915
  if (Object.keys(projectConfig.macros).length > 0) {
2752
2916
  workspace.macros.loadSupplements(projectConfig.macros);
2753
2917
  }
2918
+ const diagOptions = {};
2919
+ if (options.maxLineLength !== null) {
2920
+ diagOptions.maxLineLength = options.maxLineLength;
2921
+ }
2754
2922
  const allResults = [];
2755
2923
  let hasErrors = false;
2756
2924
  for (const uri of fileContents.keys()) {
2757
- let diags = computeDiagnostics(uri, workspace);
2925
+ let diags = computeDiagnostics(uri, workspace, diagOptions);
2758
2926
  if (options.severity) {
2759
2927
  diags = filterBySeverity(diags, options.severity);
2760
2928
  }
@@ -3175,7 +3343,10 @@ async function formatDocument(text, options) {
3175
3343
  }
3176
3344
  continue;
3177
3345
  }
3178
- const indented = indentMacros(region.lines, isBlock, isDedenting);
3346
+ let indented = indentMacros(region.lines, isBlock, isDedenting);
3347
+ if (options?.maxLineLength) {
3348
+ indented = wrapLines(indented, options.maxLineLength);
3349
+ }
3179
3350
  resultLines.push(...indented);
3180
3351
  }
3181
3352
  }
@@ -3209,12 +3380,78 @@ function indentMacros(lines, isBlock, isDedenting) {
3209
3380
  if (openMatch && isBlock(openMatch[1])) {
3210
3381
  indentLevel++;
3211
3382
  }
3383
+ const closeGlobal = /\{\/[A-Za-z][\w-]*\s*\}/g;
3384
+ let cm;
3385
+ while ((cm = closeGlobal.exec(trimmed)) !== null) {
3386
+ if (cm.index > 0) {
3387
+ indentLevel = Math.max(0, indentLevel - 1);
3388
+ }
3389
+ }
3212
3390
  }
3213
3391
  return result;
3214
3392
  }
3215
3393
  async function formatRange(text, _range, options) {
3216
3394
  return formatDocument(text, options);
3217
3395
  }
3396
+ function wrapLines(lines, maxLen) {
3397
+ const result = [];
3398
+ for (const line of lines) {
3399
+ if (line.length <= maxLen) {
3400
+ result.push(line);
3401
+ continue;
3402
+ }
3403
+ const trimmed = line.trim();
3404
+ if (trimmed === "" || /^::/.test(trimmed) || /^\{[\/#.]/.test(trimmed) || // closing/opening-only macro lines
3405
+ /^\[\[/.test(trimmed) || // links
3406
+ /^\{choice\b/.test(trimmed) || // choice macros
3407
+ /^\{choices\b/.test(trimmed)) {
3408
+ result.push(line);
3409
+ continue;
3410
+ }
3411
+ const indentMatch = line.match(/^(\s*)/);
3412
+ const indent = indentMatch ? indentMatch[1] : "";
3413
+ const wrapped = wordWrap(trimmed, maxLen, indent);
3414
+ result.push(...wrapped);
3415
+ }
3416
+ return result;
3417
+ }
3418
+ function wordWrap(text, maxLen, indent) {
3419
+ const lines = [];
3420
+ let remaining = text;
3421
+ while (remaining.length > 0) {
3422
+ const currentMax = lines.length === 0 ? maxLen - indent.length : maxLen - indent.length;
3423
+ if (remaining.length <= currentMax) {
3424
+ lines.push(indent + remaining);
3425
+ break;
3426
+ }
3427
+ let breakIdx = -1;
3428
+ let inMacro = 0;
3429
+ for (let i = 0; i < remaining.length && i <= currentMax; i++) {
3430
+ if (remaining[i] === "{") inMacro++;
3431
+ else if (remaining[i] === "}") inMacro = Math.max(0, inMacro - 1);
3432
+ else if (remaining[i] === " " && inMacro === 0 && i > 0) {
3433
+ breakIdx = i;
3434
+ }
3435
+ }
3436
+ if (breakIdx === -1) {
3437
+ for (let i = currentMax + 1; i < remaining.length; i++) {
3438
+ if (remaining[i] === "{") inMacro++;
3439
+ else if (remaining[i] === "}") inMacro = Math.max(0, inMacro - 1);
3440
+ else if (remaining[i] === " " && inMacro === 0) {
3441
+ breakIdx = i;
3442
+ break;
3443
+ }
3444
+ }
3445
+ }
3446
+ if (breakIdx === -1) {
3447
+ lines.push(indent + remaining);
3448
+ break;
3449
+ }
3450
+ lines.push(indent + remaining.substring(0, breakIdx));
3451
+ remaining = remaining.substring(breakIdx + 1);
3452
+ }
3453
+ return lines;
3454
+ }
3218
3455
  function normalizePassageHeader(line) {
3219
3456
  const headerMatch = line.match(/^::\s+(.*)/);
3220
3457
  if (!headerMatch) return line;
@@ -3304,13 +3541,28 @@ import { glob as glob2 } from "glob";
3304
3541
  function parseArgs2(args2) {
3305
3542
  const options = {
3306
3543
  check: false,
3544
+ maxLineLength: null,
3307
3545
  patterns: []
3308
3546
  };
3309
- for (const arg of args2) {
3547
+ let i = 0;
3548
+ while (i < args2.length) {
3549
+ const arg = args2[i];
3310
3550
  if (arg === "--check") {
3311
3551
  options.check = true;
3552
+ i++;
3553
+ } else if (arg === "--max-line-length" && i + 1 < args2.length) {
3554
+ const n = parseInt(args2[i + 1], 10);
3555
+ if (!isNaN(n) && n > 0) options.maxLineLength = n;
3556
+ i += 2;
3557
+ } else if (arg.startsWith("--max-line-length=")) {
3558
+ const n = parseInt(arg.slice("--max-line-length=".length), 10);
3559
+ if (!isNaN(n) && n > 0) options.maxLineLength = n;
3560
+ i++;
3312
3561
  } else if (!arg.startsWith("--")) {
3313
3562
  options.patterns.push(arg);
3563
+ i++;
3564
+ } else {
3565
+ i++;
3314
3566
  }
3315
3567
  }
3316
3568
  if (options.patterns.length === 0) {
@@ -3335,11 +3587,15 @@ async function runFormat(args2) {
3335
3587
  console.log("No files found");
3336
3588
  return 0;
3337
3589
  }
3590
+ const formatOpts = {};
3591
+ if (options.maxLineLength !== null) {
3592
+ formatOpts.maxLineLength = options.maxLineLength;
3593
+ }
3338
3594
  const unformatted = [];
3339
3595
  for (const filePath of uniqueFiles) {
3340
3596
  try {
3341
3597
  const text = readFileSync5(filePath, "utf-8");
3342
- const formatted = await formatDocument(text);
3598
+ const formatted = await formatDocument(text, formatOpts);
3343
3599
  if (formatted !== text) {
3344
3600
  if (options.check) {
3345
3601
  const relative = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
@@ -3615,6 +3871,10 @@ function getCompletions(uri, position, triggerChar, workspace) {
3615
3871
  if (/\{\/[A-Za-z\w-]*$/.test(lineText)) {
3616
3872
  return getClosingMacroCompletions(text, position, workspace);
3617
3873
  }
3874
+ const transientDotPathMatch = /%([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
3875
+ if (transientDotPathMatch) {
3876
+ return getTransientDotPathCompletions(transientDotPathMatch[1], workspace);
3877
+ }
3618
3878
  const dotPathMatch = /\$([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
3619
3879
  if (dotPathMatch) {
3620
3880
  return getDotPathCompletions(dotPathMatch[1], workspace);
@@ -3628,6 +3888,9 @@ function getCompletions(uri, position, triggerChar, workspace) {
3628
3888
  if (/@[A-Za-z_$]?[\w$]*$/.test(lineText)) {
3629
3889
  return getLocalVariableCompletions(text);
3630
3890
  }
3891
+ if (/%[A-Za-z_$]?[\w$]*$/.test(lineText) && !/%[A-Za-z_$][\w$]*\./.test(lineText)) {
3892
+ return getTransientVariableCompletions(workspace);
3893
+ }
3631
3894
  if (/\[\[[^\]]*$/.test(lineText)) {
3632
3895
  return getPassageNameCompletions(workspace);
3633
3896
  }
@@ -3755,6 +4018,33 @@ function getDotPathCompletions(varName, workspace) {
3755
4018
  detail: `field of $${varName}`
3756
4019
  }));
3757
4020
  }
4021
+ function getTransientVariableCompletions(workspace) {
4022
+ const declared = workspace.variables.getDeclaredTransient();
4023
+ if (declared.size === 0) return [];
4024
+ const items = [];
4025
+ for (const [name, decl] of declared) {
4026
+ items.push({
4027
+ label: `%${name}`,
4028
+ kind: 6,
4029
+ // CompletionItemKind.Variable
4030
+ detail: "transient variable",
4031
+ insertText: name,
4032
+ documentation: decl.fields && decl.fields.length > 0 ? `Fields: ${decl.fields.join(", ")}` : void 0
4033
+ });
4034
+ }
4035
+ return items;
4036
+ }
4037
+ function getTransientDotPathCompletions(varName, workspace) {
4038
+ const declared = workspace.variables.getDeclaredTransient();
4039
+ const decl = declared.get(varName);
4040
+ if (!decl || !decl.fields || decl.fields.length === 0) return [];
4041
+ return decl.fields.map((field) => ({
4042
+ label: field,
4043
+ kind: 5,
4044
+ // CompletionItemKind.Field
4045
+ detail: `field of %${varName}`
4046
+ }));
4047
+ }
3758
4048
  function getPassageNameCompletions(workspace) {
3759
4049
  const passages = workspace.passages.getAllPassages();
3760
4050
  if (passages.length === 0) return [];
@@ -3774,7 +4064,7 @@ var init_completions = __esm({
3774
4064
  id: "completions",
3775
4065
  capabilities: {
3776
4066
  completionProvider: {
3777
- triggerCharacters: ["{", "$", "_", "@", "[", "."]
4067
+ triggerCharacters: ["{", "$", "_", "@", "%", "[", "."]
3778
4068
  }
3779
4069
  },
3780
4070
  initialize(ctx) {
@@ -3898,6 +4188,28 @@ Fields: ${decl.fields.map((f) => `\`${f}\``).join(", ")}` : "";
3898
4188
  }
3899
4189
  }
3900
4190
  }
4191
+ {
4192
+ const re = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
4193
+ let match;
4194
+ while ((match = re.exec(line)) !== null) {
4195
+ const start = match.index;
4196
+ const end = start + match[0].length;
4197
+ if (position.character >= start && position.character <= end) {
4198
+ const baseName = match[1].split(".")[0];
4199
+ const decl = workspace.variables.getDeclaredTransient().get(baseName);
4200
+ const typeInfo = decl?.fields && decl.fields.length > 0 ? `
4201
+
4202
+ Fields: ${decl.fields.map((f) => `\`${f}\``).join(", ")}` : "";
4203
+ return {
4204
+ contents: `**Transient variable** \`%${match[1]}\`${typeInfo}`,
4205
+ range: {
4206
+ start: { line: position.line, character: start },
4207
+ end: { line: position.line, character: end }
4208
+ }
4209
+ };
4210
+ }
4211
+ }
4212
+ }
3901
4213
  return null;
3902
4214
  }
3903
4215
  function getWidgetHover(line, position, workspace) {
@@ -4180,6 +4492,18 @@ function findReferences(uri, position, workspace, includeDeclaration) {
4180
4492
  }
4181
4493
  }
4182
4494
  }
4495
+ {
4496
+ const transRegex = /(?<!\w)%([A-Za-z_$][\w$]*)/g;
4497
+ let match;
4498
+ while ((match = transRegex.exec(line)) !== null) {
4499
+ const start = match.index;
4500
+ const end = start + match[0].length;
4501
+ if (position.character >= start && position.character <= end) {
4502
+ const varName = match[1];
4503
+ return findTransientReferences(varName, workspace, includeDeclaration);
4504
+ }
4505
+ }
4506
+ }
4183
4507
  {
4184
4508
  const widgetRegex = /\{([A-Za-z_$][\w$]*)/g;
4185
4509
  let match;
@@ -4274,6 +4598,20 @@ function findVariableReferences(varName, workspace, includeDeclaration) {
4274
4598
  }
4275
4599
  return locations;
4276
4600
  }
4601
+ function findTransientReferences(varName, workspace, includeDeclaration) {
4602
+ const results = [];
4603
+ if (includeDeclaration) {
4604
+ const decl = workspace.variables.getDeclaredTransient().get(varName);
4605
+ if (decl?.declarationUri && decl.declarationRange) {
4606
+ results.push({ uri: decl.declarationUri, range: decl.declarationRange });
4607
+ }
4608
+ }
4609
+ const usages = workspace.variables.getTransientUsages(varName);
4610
+ for (const u of usages) {
4611
+ results.push({ uri: u.uri, range: u.range });
4612
+ }
4613
+ return results;
4614
+ }
4277
4615
  function findWidgetReferences(widgetName, workspace, includeDeclaration) {
4278
4616
  const locations = [];
4279
4617
  if (includeDeclaration) {
@@ -4366,8 +4704,9 @@ function computeRename(uri, position, newName, workspace) {
4366
4704
  break;
4367
4705
  }
4368
4706
  case "variable": {
4369
- const bareName = newName.startsWith("$") ? newName.slice(1) : newName;
4370
- const refs = findVariableReferences(symbol.name, workspace, true);
4707
+ const bareName = newName.startsWith("$") ? newName.slice(1) : newName.startsWith("%") ? newName.slice(1) : newName;
4708
+ const isTransient = workspace.variables.getDeclaredTransient().has(symbol.name);
4709
+ const refs = isTransient ? findTransientReferences(symbol.name, workspace, true) : findVariableReferences(symbol.name, workspace, true);
4371
4710
  for (const ref of refs) {
4372
4711
  addEdit(ref.uri, ref.range, bareName);
4373
4712
  }
@@ -4446,6 +4785,24 @@ function resolveSymbolAtCursor(uri, position, workspace) {
4446
4785
  }
4447
4786
  }
4448
4787
  }
4788
+ {
4789
+ const transRegex = /(?<!\w)%([A-Za-z_$][\w$]*)/g;
4790
+ let match;
4791
+ while ((match = transRegex.exec(line)) !== null) {
4792
+ const start = match.index;
4793
+ const end = start + match[0].length;
4794
+ if (position.character >= start && position.character <= end) {
4795
+ return {
4796
+ kind: "variable",
4797
+ name: match[1],
4798
+ range: {
4799
+ start: { line: position.line, character: start },
4800
+ end: { line: position.line, character: end }
4801
+ }
4802
+ };
4803
+ }
4804
+ }
4805
+ }
4449
4806
  {
4450
4807
  const widgetDefRegex2 = /\{widget\s+"([^"]+)"/gi;
4451
4808
  let match;
@@ -4643,6 +5000,7 @@ function computeInlayHints(uri, range, workspace) {
4643
5000
  const hints = [];
4644
5001
  addWidgetParamHints(text, range, workspace, hints);
4645
5002
  addVariableTypeHints(uri, text, range, workspace, hints);
5003
+ addTransientTypeHints(uri, text, range, workspace, hints);
4646
5004
  return hints;
4647
5005
  }
4648
5006
  function addWidgetParamHints(text, range, workspace, hints) {
@@ -4699,6 +5057,28 @@ function addVariableTypeHints(uri, text, range, workspace, hints) {
4699
5057
  });
4700
5058
  }
4701
5059
  }
5060
+ function addTransientTypeHints(uri, text, range, workspace, hints) {
5061
+ const storyTransientsPassage = workspace.passages.getStoryTransients();
5062
+ if (!storyTransientsPassage) return;
5063
+ if (storyTransientsPassage.uri !== uri) return;
5064
+ const lines = text.split("\n");
5065
+ const startLine = Math.max(range.start.line, 0);
5066
+ const endLine = Math.min(range.end.line, lines.length - 1);
5067
+ for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
5068
+ const line = lines[lineNum];
5069
+ const declMatch = line.match(/^%([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
5070
+ if (!declMatch) continue;
5071
+ const valueStr = declMatch[2].trim();
5072
+ const inferredType = inferType(valueStr);
5073
+ if (!inferredType) continue;
5074
+ const eqIdx = line.indexOf("=");
5075
+ hints.push({
5076
+ position: { line: lineNum, character: eqIdx },
5077
+ label: `: ${inferredType}`,
5078
+ kind: "type"
5079
+ });
5080
+ }
5081
+ }
4702
5082
  function inferType(value) {
4703
5083
  if (/^\d+(\.\d+)?$/.test(value)) return "number";
4704
5084
  if (/^"[^"]*"$/.test(value) || /^'[^']*'$/.test(value)) return "string";
@@ -4803,6 +5183,7 @@ function computeSemanticTokensAbsolute(uri, workspace) {
4803
5183
  const storyVarRegex = /(?<!\w)\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
4804
5184
  const tempVarRegex = /(?<!\w)_([A-Za-z_$][\w$]*)/g;
4805
5185
  const localVarRegex = /(?<!\w)@([A-Za-z_$][\w$]*)/g;
5186
+ const transientVarRegex = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
4806
5187
  const sugarKeywordRegex = /\b(to|is|isnot|eq|neq|gt|gte|lt|lte|and|or|not|def|ndef)\b/g;
4807
5188
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
4808
5189
  if (headerLines.has(lineIndex)) continue;
@@ -4838,6 +5219,16 @@ function computeSemanticTokensAbsolute(uri, workspace) {
4838
5219
  tokenModifiers: encodeModifiers(["readonly"])
4839
5220
  });
4840
5221
  }
5222
+ transientVarRegex.lastIndex = 0;
5223
+ while ((m = transientVarRegex.exec(line)) !== null) {
5224
+ tokens.push({
5225
+ line: lineIndex,
5226
+ startChar: m.index,
5227
+ length: m[0].length,
5228
+ tokenType: encodeType("variable"),
5229
+ tokenModifiers: encodeModifiers(["defaultLibrary"])
5230
+ });
5231
+ }
4841
5232
  sugarKeywordRegex.lastIndex = 0;
4842
5233
  while ((m = sugarKeywordRegex.exec(line)) !== null) {
4843
5234
  tokens.push({
@@ -4942,6 +5333,11 @@ function computeCodeActions(uri, diagnostics, workspace) {
4942
5333
  if (action) actions.push(action);
4943
5334
  break;
4944
5335
  }
5336
+ case DiagnosticCode.UndeclaredTransient: {
5337
+ const action = fixUndeclaredTransient(diag, workspace);
5338
+ if (action) actions.push(action);
5339
+ break;
5340
+ }
4945
5341
  }
4946
5342
  }
4947
5343
  return actions;
@@ -5024,6 +5420,40 @@ function fixNoStoryVariables(uri, workspace) {
5024
5420
  }]
5025
5421
  };
5026
5422
  }
5423
+ function fixUndeclaredTransient(diag, workspace) {
5424
+ const match = diag.message.match(/'%(\w+)'/);
5425
+ if (!match) return null;
5426
+ const varName = match[1];
5427
+ const storyTransients = workspace.passages.getStoryTransients();
5428
+ if (!storyTransients) return null;
5429
+ const storyTransientsUri = storyTransients.uri;
5430
+ const text = workspace.documents.getText(storyTransientsUri);
5431
+ if (text === void 0) return null;
5432
+ const lines = text.split("\n");
5433
+ const contentStart = storyTransients.headerEnd.end.line + 1;
5434
+ let contentEnd = lines.length;
5435
+ for (let i = contentStart; i < lines.length; i++) {
5436
+ if (/^::\s+/.test(lines[i])) {
5437
+ contentEnd = i;
5438
+ break;
5439
+ }
5440
+ }
5441
+ const insertLine = contentEnd;
5442
+ return {
5443
+ title: `Declare '%${varName}' in StoryTransients`,
5444
+ kind: "quickfix",
5445
+ diagnosticCodes: [DiagnosticCode.UndeclaredTransient],
5446
+ edits: [{
5447
+ uri: storyTransientsUri,
5448
+ range: {
5449
+ start: { line: insertLine, character: 0 },
5450
+ end: { line: insertLine, character: 0 }
5451
+ },
5452
+ newText: `%${varName} = null
5453
+ `
5454
+ }]
5455
+ };
5456
+ }
5027
5457
  function toLspRange6(r) {
5028
5458
  return {
5029
5459
  start: { line: r.start.line, character: r.start.character },
@@ -5589,8 +6019,34 @@ var init_server2 = __esm({
5589
6019
  });
5590
6020
 
5591
6021
  // src/bin.ts
6022
+ import { createRequire as createRequire2 } from "node:module";
5592
6023
  var args = process.argv.slice(2);
5593
- if (args[0] === "check") {
6024
+ var VERSION = createRequire2(import.meta.url)("../package.json").version;
6025
+ var HELP = `spindle-lsp v${VERSION}
6026
+ Language Server Protocol server for Spindle story format
6027
+
6028
+ Usage:
6029
+ spindle-lsp Start the LSP server (stdio)
6030
+ spindle-lsp check <files> Lint .twee/.tw files
6031
+ spindle-lsp format <files> Format .twee/.tw files in place
6032
+ spindle-lsp mcp Start the MCP server
6033
+
6034
+ Options:
6035
+ --help, -h Show this help message
6036
+ --version, -v Show version number
6037
+
6038
+ Check options:
6039
+ --format <pretty|json|sarif> Output format (default: pretty)
6040
+ --severity <level> Minimum severity: error, warning, info, hint
6041
+ --config <path> Path to config file
6042
+ --max-line-length <n> Warn on lines exceeding n characters`;
6043
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
6044
+ console.log(HELP);
6045
+ process.exit(0);
6046
+ } else if (args.includes("--version") || args.includes("-v")) {
6047
+ console.log(VERSION);
6048
+ process.exit(0);
6049
+ } else if (args[0] === "check") {
5594
6050
  const { runCheck: runCheck2 } = await Promise.resolve().then(() => (init_check(), check_exports));
5595
6051
  const exitCode = await runCheck2(args.slice(1));
5596
6052
  process.exit(exitCode);