@rohal12/spindle-lsp 0.9.3 → 0.10.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 (3) hide show
  1. package/dist/bin.js +1549 -400
  2. package/dist/bin.js.map +4 -4
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -6777,7 +6777,7 @@ function parseMacros(text) {
6777
6777
  let id = 0;
6778
6778
  macroHeadRegex.lastIndex = 0;
6779
6779
  let match2;
6780
- while ((match2 = macroHeadRegex.exec(cleaned)) !== null) {
6780
+ while ((match2 = macroHeadRegex.exec(text)) !== null) {
6781
6781
  const matchStart = match2.index;
6782
6782
  const closeIdx = scanner.closeBrace(matchStart + 1);
6783
6783
  if (closeIdx === -1) {
@@ -6787,7 +6787,7 @@ function parseMacros(text) {
6787
6787
  const matchEnd = closeIdx + 1;
6788
6788
  macroHeadRegex.lastIndex = matchEnd;
6789
6789
  const closeSlash = match2[1];
6790
- const cssPrefix = (match2[2] || "").trim();
6790
+ const cssPrefix = match2[2] || "";
6791
6791
  const macroName = match2[3];
6792
6792
  const rawArgs = cleaned.slice(matchStart + match2[0].length, closeIdx).replace(/^\s+/, "");
6793
6793
  const open = closeSlash !== "/";
@@ -6838,21 +6838,359 @@ function pairMacros(macros, isBlock, passageStartLines = []) {
6838
6838
  }
6839
6839
  }
6840
6840
  }
6841
- var variableInterpolationRegex, macroHeadRegex, NON_STRING_QUOTE_PREFIX;
6841
+ var variableInterpolationRegex, SELECTOR_PATTERN, macroHeadRegex, NON_STRING_QUOTE_PREFIX;
6842
6842
  var init_macro_parser = __esm({
6843
6843
  "src/core/parsing/macro-parser.ts"() {
6844
6844
  "use strict";
6845
6845
  variableInterpolationRegex = /(?<!\\)\{([$_@%][A-Za-z_$][\w$.]*)\}/g;
6846
- macroHeadRegex = /(?<!\\)\{(\/)?(?:((?:[#.][a-zA-Z][\w-]*\s*)*)([A-Za-z][\w-]*))(?=[\s}])/gi;
6846
+ SELECTOR_PATTERN = String.raw`(?:[.#](?:[\w-]|\{[$_@][\w.]*\})*)+`;
6847
+ macroHeadRegex = new RegExp(
6848
+ String.raw`(?<!\\)\{(?:(\/)|(${SELECTOR_PATTERN}) )?([A-Za-z][\w-]*)(?=[\s}]|\{[$_@%][A-Za-z_$][\w$.]*\})`,
6849
+ "gi"
6850
+ );
6847
6851
  NON_STRING_QUOTE_PREFIX = /[\p{L}\p{N}_\\]/u;
6848
6852
  }
6849
6853
  });
6850
6854
 
6855
+ // src/core/workspace/variable-schema.ts
6856
+ function inferDefaultSchema(expr) {
6857
+ const parser = new LiteralParser(expr.trim());
6858
+ try {
6859
+ const schema = parser.value();
6860
+ parser.skipSpace();
6861
+ return schema && parser.atEnd() ? schema : void 0;
6862
+ } catch (err) {
6863
+ if (err instanceof Unparsable) return void 0;
6864
+ throw err;
6865
+ }
6866
+ }
6867
+ function findUnsupportedValue(expr) {
6868
+ const offset = expr.length - expr.trimStart().length;
6869
+ const parser = new LiteralParser(expr.trim());
6870
+ try {
6871
+ const read = parser.read();
6872
+ parser.skipSpace();
6873
+ const value = read.unsupported;
6874
+ if (!parser.atEnd() || !value) return void 0;
6875
+ return { ...value, start: value.start + offset, end: value.end + offset };
6876
+ } catch (err) {
6877
+ if (err instanceof Unparsable) return void 0;
6878
+ throw err;
6879
+ }
6880
+ }
6881
+ function bigintString(literal2) {
6882
+ const digits = literal2.slice(0, -1).replace(/_/g, "");
6883
+ const negative = digits.startsWith("-");
6884
+ try {
6885
+ const value = BigInt(negative ? digits.slice(1) : digits);
6886
+ return String(negative ? -value : value);
6887
+ } catch {
6888
+ return digits;
6889
+ }
6890
+ }
6891
+ function findPrimitiveFieldAccess(schema, fields) {
6892
+ let current = schema;
6893
+ for (let i = 0; i < fields.length; i++) {
6894
+ if (current.type === "array") return void 0;
6895
+ if (current.type !== "object") return { index: i, type: current.type };
6896
+ const next = current.fields?.get(fields[i]);
6897
+ if (!next) return void 0;
6898
+ current = next;
6899
+ }
6900
+ return void 0;
6901
+ }
6902
+ var Unparsable, IDENTIFIER_RE, BIGINT_RE, NUMBER_RE, INDEX_KEY_RE, UNKNOWN2, LiteralParser;
6903
+ var init_variable_schema = __esm({
6904
+ "src/core/workspace/variable-schema.ts"() {
6905
+ "use strict";
6906
+ Unparsable = class extends Error {
6907
+ };
6908
+ IDENTIFIER_RE = /[A-Za-z_$][\w$]*/y;
6909
+ BIGINT_RE = /-?(?:0[xX][\da-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|0|[1-9][\d_]*)n(?![\w$])/y;
6910
+ NUMBER_RE = /[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?/y;
6911
+ INDEX_KEY_RE = /(?:0|[1-9]\d{0,14})(?![\w$.])/y;
6912
+ UNKNOWN2 = { schema: null };
6913
+ LiteralParser = class {
6914
+ constructor(text) {
6915
+ this.text = text;
6916
+ }
6917
+ pos = 0;
6918
+ atEnd() {
6919
+ return this.pos >= this.text.length;
6920
+ }
6921
+ skipSpace() {
6922
+ while (/\s/.test(this.text[this.pos] ?? "")) this.pos++;
6923
+ }
6924
+ /** A value up to the `,` / `}` / `]` that ends it; null when its type is unknown. */
6925
+ value() {
6926
+ return this.read().schema;
6927
+ }
6928
+ /** A value up to the `,` / `}` / `]` that ends it. */
6929
+ read() {
6930
+ this.skipSpace();
6931
+ const start = this.pos;
6932
+ const read = this.literal();
6933
+ this.skipSpace();
6934
+ const next = this.text[this.pos];
6935
+ if (read && (next === void 0 || next === "," || next === "}" || next === "]")) return read;
6936
+ this.pos = start;
6937
+ this.skipExpression();
6938
+ return UNKNOWN2;
6939
+ }
6940
+ /** A literal, consumed; undefined when the code here is not one. */
6941
+ literal() {
6942
+ const start = this.pos;
6943
+ const ch = this.text[this.pos];
6944
+ if (ch === "{") return this.object();
6945
+ if (ch === "[") {
6946
+ this.pos++;
6947
+ this.skipList("]");
6948
+ return { schema: { type: "array" } };
6949
+ }
6950
+ if (ch === '"' || ch === "'" || ch === "`") {
6951
+ this.stringLiteral();
6952
+ return { schema: { type: "string" } };
6953
+ }
6954
+ if (ch === "(") return this.functionLiteral(start);
6955
+ const word = this.match(IDENTIFIER_RE);
6956
+ if (word === "true" || word === "false") return { schema: { type: "boolean" } };
6957
+ if (word === "null") return this.unsupported("object", start);
6958
+ if (word === "undefined") return this.unsupported("undefined", start);
6959
+ if (word === "void") {
6960
+ this.skipSpace();
6961
+ if (this.match(NUMBER_RE) === void 0) return void 0;
6962
+ return this.unsupported("undefined", start, "undefined");
6963
+ }
6964
+ if (word !== void 0) return this.functionLiteral(start);
6965
+ if (this.match(BIGINT_RE) !== void 0) {
6966
+ return this.unsupported("bigint", start, bigintString(this.text.slice(start, this.pos)));
6967
+ }
6968
+ if (this.match(NUMBER_RE) !== void 0) return { schema: { type: "number" } };
6969
+ return void 0;
6970
+ }
6971
+ /** The value from `start` to here, which Spindle rejects. */
6972
+ unsupported(type, start, text) {
6973
+ const end = this.pos;
6974
+ return { schema: null, unsupported: { type, text: text ?? this.text.slice(start, end), path: [], start, end } };
6975
+ }
6976
+ /**
6977
+ * A function, arrow function or class expression at `start`, consumed;
6978
+ * undefined, with nothing consumed, for any other code.
6979
+ */
6980
+ functionLiteral(start) {
6981
+ this.pos = start;
6982
+ try {
6983
+ if (this.functionSyntax()) return this.unsupported("function", start);
6984
+ } catch (err) {
6985
+ if (!(err instanceof Unparsable)) throw err;
6986
+ }
6987
+ this.pos = start;
6988
+ return void 0;
6989
+ }
6990
+ functionSyntax() {
6991
+ let word = this.match(IDENTIFIER_RE);
6992
+ if (word === "async") {
6993
+ this.skipSpace();
6994
+ word = this.match(IDENTIFIER_RE);
6995
+ }
6996
+ this.skipSpace();
6997
+ if (word === "function") {
6998
+ if (this.text[this.pos] === "*") this.pos++;
6999
+ this.skipSpace();
7000
+ this.match(IDENTIFIER_RE);
7001
+ this.skipSpace();
7002
+ if (!this.skipBracketed("(", ")")) return false;
7003
+ this.skipSpace();
7004
+ return this.skipBracketed("{", "}");
7005
+ }
7006
+ if (word === "class") {
7007
+ if (this.match(IDENTIFIER_RE) !== void 0) this.skipSpace();
7008
+ return this.skipBracketed("{", "}");
7009
+ }
7010
+ if (word === void 0 && !this.skipBracketed("(", ")")) return false;
7011
+ this.skipSpace();
7012
+ if (!this.text.startsWith("=>", this.pos)) return false;
7013
+ this.pos += 2;
7014
+ this.skipSpace();
7015
+ if (this.text[this.pos] === "{") return this.skipBracketed("{", "}");
7016
+ const bodyStart = this.pos;
7017
+ this.skipExpression();
7018
+ while (this.pos > bodyStart && /\s/.test(this.text[this.pos - 1])) this.pos--;
7019
+ return this.pos > bodyStart;
7020
+ }
7021
+ /** Skip a bracketed list that opens here; false if none does. */
7022
+ skipBracketed(open, close) {
7023
+ if (this.text[this.pos] !== open) return false;
7024
+ this.pos++;
7025
+ this.skipList(close);
7026
+ return true;
7027
+ }
7028
+ object() {
7029
+ this.pos++;
7030
+ let fields = /* @__PURE__ */ new Map();
7031
+ const unsupported = /* @__PURE__ */ new Map();
7032
+ for (; ; ) {
7033
+ this.skipSpace();
7034
+ if (this.text[this.pos] === "}") {
7035
+ this.pos++;
7036
+ break;
7037
+ }
7038
+ const key = this.propertyKey();
7039
+ this.skipSpace();
7040
+ if (key !== void 0 && key !== "__proto__" && this.text[this.pos] === ":") {
7041
+ this.pos++;
7042
+ const read = this.read();
7043
+ fields?.set(key, read.schema);
7044
+ unsupported.delete(key);
7045
+ if (read.unsupported) {
7046
+ unsupported.set(key, { ...read.unsupported, path: [key, ...read.unsupported.path] });
7047
+ }
7048
+ } else {
7049
+ fields = void 0;
7050
+ this.skipExpression();
7051
+ }
7052
+ this.skipSpace();
7053
+ const sep2 = this.text[this.pos++];
7054
+ if (sep2 === "}") break;
7055
+ if (sep2 !== ",") throw new Unparsable();
7056
+ }
7057
+ if (!fields) return { schema: { type: "object" } };
7058
+ const [first] = unsupported.values();
7059
+ return { schema: { type: "object", fields }, unsupported: first };
7060
+ }
7061
+ /** A plain property key, consumed; undefined (possibly consumed) for any other. */
7062
+ propertyKey() {
7063
+ const ch = this.text[this.pos];
7064
+ if (ch === '"' || ch === "'") {
7065
+ const raw = this.stringLiteral();
7066
+ return raw.includes("\\") ? void 0 : raw;
7067
+ }
7068
+ return this.match(IDENTIFIER_RE) ?? this.match(INDEX_KEY_RE);
7069
+ }
7070
+ /** Consume a string or template literal and return its raw text. */
7071
+ stringLiteral() {
7072
+ const quote = this.text[this.pos];
7073
+ const start = ++this.pos;
7074
+ while (this.pos < this.text.length) {
7075
+ const ch = this.text[this.pos];
7076
+ if (ch === "\\") {
7077
+ this.pos += 2;
7078
+ continue;
7079
+ }
7080
+ if (quote === "`" && ch === "$" && this.text[this.pos + 1] === "{") throw new Unparsable();
7081
+ if (ch === quote) return this.text.slice(start, this.pos++);
7082
+ this.pos++;
7083
+ }
7084
+ throw new Unparsable();
7085
+ }
7086
+ /** Skip comma-separated expressions up to and including `close`. */
7087
+ skipList(close) {
7088
+ for (; ; ) {
7089
+ this.skipExpression();
7090
+ const ch = this.text[this.pos++];
7091
+ if (ch === close) return;
7092
+ if (ch !== ",") throw new Unparsable();
7093
+ }
7094
+ }
7095
+ /** Skip code up to a `,`, `)`, `]` or `}` that closes nothing, or the end. */
7096
+ skipExpression() {
7097
+ let depth = 0;
7098
+ while (this.pos < this.text.length) {
7099
+ const ch = this.text[this.pos];
7100
+ if (ch === '"' || ch === "'" || ch === "`") {
7101
+ this.stringLiteral();
7102
+ continue;
7103
+ }
7104
+ if (ch === "/") throw new Unparsable();
7105
+ if (ch === "(" || ch === "[" || ch === "{") depth++;
7106
+ else if (ch === ")" || ch === "]" || ch === "}") {
7107
+ if (depth === 0) return;
7108
+ depth--;
7109
+ } else if (ch === "," && depth === 0) return;
7110
+ this.pos++;
7111
+ }
7112
+ if (depth > 0) throw new Unparsable();
7113
+ }
7114
+ match(re) {
7115
+ re.lastIndex = this.pos;
7116
+ const m = re.exec(this.text);
7117
+ if (!m) return void 0;
7118
+ this.pos += m[0].length;
7119
+ return m[0];
7120
+ }
7121
+ };
7122
+ }
7123
+ });
7124
+
7125
+ // src/core/workspace/declaration-check.ts
7126
+ function declaredName(line, sigil) {
7127
+ return DECLARATION_RE[sigil].exec(line.trim())?.[1];
7128
+ }
7129
+ function checkDeclaration(rawLine, sigil) {
7130
+ const line = rawLine.trim();
7131
+ if (!line || line.includes("\r") || line.includes("\xA0")) return void 0;
7132
+ const passage = PASSAGE[sigil];
7133
+ const match2 = DECLARATION_RE[sigil].exec(line);
7134
+ if (!match2) {
7135
+ const comment = /^(?:\/\/|\/\*|<!--|#)/.test(line) ? ` ${passage} has no comment syntax.` : "";
7136
+ return {
7137
+ kind: "invalid",
7138
+ message: `${passage}: Invalid declaration: "${line}". Expected: ${sigil}name = value.${comment}`
7139
+ };
7140
+ }
7141
+ const [, name, expr] = match2;
7142
+ const error48 = compileError(expr);
7143
+ if (error48 !== void 0) {
7144
+ const comment = compileError(expr + "\n") === void 0 ? ` A comment runs to the end of the line and hides the ")" Spindle closes the value with; ${passage} has no comment syntax.` : "";
7145
+ return {
7146
+ kind: "evaluate",
7147
+ message: `${passage}: Failed to evaluate "${sigil}${name} = ${expr}": ${error48}.${comment}`
7148
+ };
7149
+ }
7150
+ const value = findUnsupportedValue(expr);
7151
+ if (!value) return void 0;
7152
+ const exprStart = rawLine.length - rawLine.trimStart().length + line.length - expr.length;
7153
+ if (value.type === "object") {
7154
+ return { kind: "null", field: value.path, start: exprStart + value.start, end: exprStart + value.end };
7155
+ }
7156
+ const where = value.path.length > 0 ? ` (${sigil}${[name, ...value.path].join(".")})` : "";
7157
+ return {
7158
+ kind: "unsupported",
7159
+ message: `${passage}: Unsupported type "${value.type}" for value ${value.text}${where}. Expected number, string, boolean, array, or object.`
7160
+ };
7161
+ }
7162
+ function compileError(expr) {
7163
+ try {
7164
+ new Function("return (" + expr + ")");
7165
+ return void 0;
7166
+ } catch (err) {
7167
+ if (!(err instanceof SyntaxError) || /regular expression/i.test(err.message)) return void 0;
7168
+ return err.message;
7169
+ }
7170
+ }
7171
+ var DECLARATION_RE, PASSAGE;
7172
+ var init_declaration_check = __esm({
7173
+ "src/core/workspace/declaration-check.ts"() {
7174
+ "use strict";
7175
+ init_variable_schema();
7176
+ DECLARATION_RE = { "$": /^\$(\w+)\s*=\s*(.+)$/, "%": /^%(\w+)\s*=\s*(.+)$/ };
7177
+ PASSAGE = { "$": "StoryVariables", "%": "StoryTransients" };
7178
+ }
7179
+ });
7180
+
6851
7181
  // src/core/workspace/variable-tracker.ts
6852
7182
  function replaceCodeLiterals(text, replace) {
6853
7183
  const scanner = createCodeScanner(text);
6854
7184
  let result = "";
6855
7185
  let copied = 0;
7186
+ for (const [open, close] of codeBlocks(text, scanner)) {
7187
+ result += text.slice(copied, open + 1) + replaceLiterals(text, scanner, open + 1, close, replace);
7188
+ copied = close;
7189
+ }
7190
+ return result + text.slice(copied);
7191
+ }
7192
+ function codeBlocks(text, scanner) {
7193
+ const blocks = [];
6856
7194
  for (let i = 0; i < text.length; i++) {
6857
7195
  if (text[i] === "\\" && (text[i + 1] === "{" || text[i + 1] === "}")) {
6858
7196
  i++;
@@ -6861,11 +7199,10 @@ function replaceCodeLiterals(text, replace) {
6861
7199
  if (text[i] !== "{") continue;
6862
7200
  const close = scanner.closeBrace(i + 1);
6863
7201
  if (close === -1) continue;
6864
- result += text.slice(copied, i + 1) + replaceLiterals(text, scanner, i + 1, close, replace);
6865
- copied = close;
7202
+ blocks.push([i, close]);
6866
7203
  i = close;
6867
7204
  }
6868
- return result + text.slice(copied);
7205
+ return blocks;
6869
7206
  }
6870
7207
  function replaceLiterals(text, scanner, from, to, replace) {
6871
7208
  let result = "";
@@ -6891,7 +7228,7 @@ function blankLiteralText(literal2) {
6891
7228
  for (let i = 1; i < literal2.length - 1; i++) {
6892
7229
  if (literal2[i] !== "{") continue;
6893
7230
  const templateCode = literal2[0] === "`" && literal2[i - 1] === "$" && literal2[i - 2] !== "\\";
6894
- if (!templateCode && !/^[$%][A-Za-z_$]/.test(literal2.slice(i + 1, i + 3))) continue;
7231
+ if (!templateCode && !/^[$%][\w$]/.test(literal2.slice(i + 1, i + 3))) continue;
6895
7232
  let depth = 0;
6896
7233
  for (let j = i; j < literal2.length - 1; j++) {
6897
7234
  if (literal2[j] === "{") depth++;
@@ -6904,6 +7241,43 @@ function blankLiteralText(literal2) {
6904
7241
  }
6905
7242
  return literal2.replace(/[^\r\n]/g, (ch, i) => keep[i] ? ch : " ");
6906
7243
  }
7244
+ function checkDeclarations(lines, contentStartLine, sigil) {
7245
+ const check2 = { problems: [], nestedNulls: [], names: /* @__PURE__ */ new Map() };
7246
+ for (let i = 0; i < lines.length; i++) {
7247
+ const line = lines[i];
7248
+ if (line.startsWith("::")) break;
7249
+ const absLine = contentStartLine + i;
7250
+ const problem = checkDeclaration(line, sigil);
7251
+ if (!problem) {
7252
+ const name = /[\r\u00a0]/.test(line.trim()) ? void 0 : declaredName(line, sigil);
7253
+ if (name !== void 0 && !check2.names.has(name)) {
7254
+ check2.names.set(name, declarationLocation(line, name, absLine, void 0).declarationRange);
7255
+ }
7256
+ } else if (problem.kind === "null") {
7257
+ if (problem.field.length > 0) {
7258
+ check2.nestedNulls.push({
7259
+ name: declaredName(line, sigil),
7260
+ sigil,
7261
+ field: problem.field,
7262
+ range: {
7263
+ start: { line: absLine, character: problem.start },
7264
+ end: { line: absLine, character: problem.end }
7265
+ }
7266
+ });
7267
+ }
7268
+ } else {
7269
+ const start = line.length - line.trimStart().length;
7270
+ check2.problems.push({
7271
+ message: problem.message,
7272
+ range: {
7273
+ start: { line: absLine, character: start },
7274
+ end: { line: absLine, character: line.trimEnd().length }
7275
+ }
7276
+ });
7277
+ }
7278
+ }
7279
+ return check2;
7280
+ }
6907
7281
  function findLiteralEnd(text, start) {
6908
7282
  const open = text[start];
6909
7283
  if (open === '"' || open === "'" || open === "`") {
@@ -6960,14 +7334,48 @@ function declarationLocation(line, name, absLine, uri) {
6960
7334
  if (uri !== void 0) location.declarationUri = uri;
6961
7335
  return location;
6962
7336
  }
6963
- var varRefRegex, transientRefRegex, EXCLUDED_PASSAGES, UNCHECKED_PASSAGES, COMMENT_PATTERNS, BUILTIN_STORE_VAR_MACROS, QUOTED_RECEIVER_RE, ARRAY_MEMBERS, VariableTracker;
7337
+ function validatedReferences(lines, passages) {
7338
+ const refs = [];
7339
+ for (let pi = 0; pi < passages.length; pi++) {
7340
+ const passage = passages[pi];
7341
+ if (UNVALIDATED_PASSAGES.has(passage.name) || isScriptOrStylesheetPassage(passage)) continue;
7342
+ const contentStartLine = passage.startLine + 1;
7343
+ const contentEndLine = pi + 1 < passages.length ? passages[pi + 1].startLine : lines.length;
7344
+ const contentLines = lines.slice(contentStartLine, contentEndLine);
7345
+ const forLocals = /* @__PURE__ */ new Set();
7346
+ for (const m of contentLines.join("\n").matchAll(VALIDATED_FOR_LOCAL_RE)) {
7347
+ forLocals.add(m[1]);
7348
+ if (m[2]) forLocals.add(m[2]);
7349
+ }
7350
+ for (let i = 0; i < contentLines.length; i++) {
7351
+ for (const m of contentLines[i].matchAll(VALIDATED_REF_RE)) {
7352
+ const baseName = m[1].split(".")[0];
7353
+ if (forLocals.has(baseName)) continue;
7354
+ const line = contentStartLine + i;
7355
+ refs.push({
7356
+ baseName,
7357
+ path: m[1],
7358
+ range: {
7359
+ start: { line, character: m.index },
7360
+ end: { line, character: m.index + m[0].length }
7361
+ }
7362
+ });
7363
+ }
7364
+ }
7365
+ }
7366
+ return refs;
7367
+ }
7368
+ var varRefRegex, transientRefRegex, DECLARATION_RE2, EXCLUDED_PASSAGES, VALIDATED_REF_RE, VALIDATED_FOR_LOCAL_RE, UNVALIDATED_PASSAGES, COMMENT_PATTERNS, BUILTIN_STORE_VAR_MACROS, QUOTED_RECEIVER_RE, NO_DECLARATIONS, ARRAY_MEMBERS, VariableTracker;
6964
7369
  var init_variable_tracker = __esm({
6965
7370
  "src/core/workspace/variable-tracker.ts"() {
6966
7371
  "use strict";
6967
7372
  init_passage_parser();
6968
7373
  init_macro_parser();
6969
- varRefRegex = /\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
6970
- transientRefRegex = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
7374
+ init_variable_schema();
7375
+ init_declaration_check();
7376
+ varRefRegex = /\$([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
7377
+ transientRefRegex = /(?<!\w)%([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
7378
+ DECLARATION_RE2 = { "$": /^\$(\w+)\s*=\s*(.*)$/, "%": /^%(\w+)\s*=\s*(.*)$/ };
6971
7379
  EXCLUDED_PASSAGES = /* @__PURE__ */ new Set([
6972
7380
  "StoryVariables",
6973
7381
  "StoryTransients",
@@ -6975,7 +7383,14 @@ var init_variable_tracker = __esm({
6975
7383
  "StoryScript",
6976
7384
  "StoryInterface"
6977
7385
  ]);
6978
- UNCHECKED_PASSAGES = /* @__PURE__ */ new Set(["StoryInit"]);
7386
+ VALIDATED_REF_RE = /\$(\w+(?:\.\w+)*)/g;
7387
+ VALIDATED_FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
7388
+ UNVALIDATED_PASSAGES = /* @__PURE__ */ new Set([
7389
+ "StoryVariables",
7390
+ "StoryTransients",
7391
+ "StoryData",
7392
+ "StoryTitle"
7393
+ ]);
6979
7394
  COMMENT_PATTERNS = [
6980
7395
  /<!--[\s\S]*?-->/g,
6981
7396
  // HTML comments
@@ -6993,7 +7408,11 @@ var init_variable_tracker = __esm({
6993
7408
  "textarea",
6994
7409
  "textbox"
6995
7410
  ]);
6996
- QUOTED_RECEIVER_RE = /(?<!\\)(\{(?:[#.][a-zA-Z][\w-]*\s*)*([A-Za-z][\w-]*)\s+(["']))\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\3?(?=[\s}])/g;
7411
+ QUOTED_RECEIVER_RE = new RegExp(
7412
+ String.raw`(?<!\\)(\{(?:${SELECTOR_PATTERN} )?([A-Za-z][\w-]*)\s+(["']))\$([\w$]+(?:\.[A-Za-z_$][\w$]*)*)\3?(?=[\s}])`,
7413
+ "g"
7414
+ );
7415
+ NO_DECLARATIONS = { problems: [], nestedNulls: [], names: /* @__PURE__ */ new Map() };
6997
7416
  ARRAY_MEMBERS = /* @__PURE__ */ new Set([
6998
7417
  ...Object.getOwnPropertyNames(Array.prototype),
6999
7418
  ...Object.getOwnPropertyNames(Object.prototype),
@@ -7012,13 +7431,17 @@ var init_variable_tracker = __esm({
7012
7431
  declared = /* @__PURE__ */ new Map();
7013
7432
  _hasStoryVariables = false;
7014
7433
  _nullDeclarations = [];
7434
+ _variablesCheck = NO_DECLARATIONS;
7015
7435
  declaredTransient = /* @__PURE__ */ new Map();
7016
7436
  _hasStoryTransients = false;
7017
7437
  _nullTransientDeclarations = [];
7438
+ _transientsCheck = NO_DECLARATIONS;
7018
7439
  /** Per-URI list of variable usages. */
7019
7440
  usagesByUri = /* @__PURE__ */ new Map();
7020
7441
  /** Per-URI list of transient variable usages. */
7021
7442
  transientUsagesByUri = /* @__PURE__ */ new Map();
7443
+ /** Per-URI list of the `$` references Spindle validates at startup. */
7444
+ validatedRefsByUri = /* @__PURE__ */ new Map();
7022
7445
  /**
7023
7446
  * Parse the StoryVariables passage content for declarations.
7024
7447
  * Each line like `$name = value` becomes a declaration.
@@ -7028,10 +7451,11 @@ var init_variable_tracker = __esm({
7028
7451
  this._hasStoryVariables = true;
7029
7452
  this._nullDeclarations = [];
7030
7453
  const lines = content.split("\n");
7454
+ this._variablesCheck = checkDeclarations(lines, contentStartLine, "$");
7031
7455
  for (let i = 0; i < lines.length; i++) {
7032
7456
  const trimmed2 = lines[i].trim();
7033
7457
  if (!trimmed2 || trimmed2.startsWith("//") || trimmed2.startsWith("<!--")) continue;
7034
- const match2 = /^\$([A-Za-z_$][\w$]*)\s*=\s*(.*)$/.exec(trimmed2);
7458
+ const match2 = DECLARATION_RE2.$.exec(trimmed2);
7035
7459
  if (!match2) continue;
7036
7460
  const name = match2[1];
7037
7461
  const expr = match2[2].trim();
@@ -7053,6 +7477,8 @@ var init_variable_tracker = __esm({
7053
7477
  const decl = { name, sigil: "$", ...location };
7054
7478
  const type = inferLiteralType(expr);
7055
7479
  if (type) decl.type = type;
7480
+ const schema = inferDefaultSchema(expr);
7481
+ if (schema) decl.schema = schema;
7056
7482
  if (expr.startsWith("{")) {
7057
7483
  const fieldRegex = /(\w+)\s*:/g;
7058
7484
  let fieldMatch;
@@ -7072,12 +7498,14 @@ var init_variable_tracker = __esm({
7072
7498
  this.declared.clear();
7073
7499
  this._hasStoryVariables = false;
7074
7500
  this._nullDeclarations = [];
7501
+ this._variablesCheck = NO_DECLARATIONS;
7075
7502
  }
7076
7503
  /** Forget StoryTransients declarations, e.g. after the passage was removed. */
7077
7504
  clearStoryTransients() {
7078
7505
  this.declaredTransient.clear();
7079
7506
  this._hasStoryTransients = false;
7080
7507
  this._nullTransientDeclarations = [];
7508
+ this._transientsCheck = NO_DECLARATIONS;
7081
7509
  }
7082
7510
  /**
7083
7511
  * Parse the StoryTransients passage content for declarations.
@@ -7088,10 +7516,11 @@ var init_variable_tracker = __esm({
7088
7516
  this._hasStoryTransients = true;
7089
7517
  this._nullTransientDeclarations = [];
7090
7518
  const lines = content.split("\n");
7519
+ this._transientsCheck = checkDeclarations(lines, contentStartLine, "%");
7091
7520
  for (let i = 0; i < lines.length; i++) {
7092
7521
  const trimmed2 = lines[i].trim();
7093
7522
  if (!trimmed2 || trimmed2.startsWith("//") || trimmed2.startsWith("<!--")) continue;
7094
- const match2 = /^%([A-Za-z_$][\w$]*)\s*=\s*(.*)$/.exec(trimmed2);
7523
+ const match2 = DECLARATION_RE2["%"].exec(trimmed2);
7095
7524
  if (!match2) continue;
7096
7525
  const name = match2[1];
7097
7526
  const expr = match2[2].trim();
@@ -7136,6 +7565,7 @@ var init_variable_tracker = __esm({
7136
7565
  scanDocument(uri, text, _macros, storeVarMacros = BUILTIN_STORE_VAR_MACROS) {
7137
7566
  this.usagesByUri.delete(uri);
7138
7567
  this.transientUsagesByUri.delete(uri);
7568
+ this.validatedRefsByUri.delete(uri);
7139
7569
  const lines = text.split("\n");
7140
7570
  const usages = [];
7141
7571
  const transientUsages = [];
@@ -7170,14 +7600,15 @@ var init_variable_tracker = __esm({
7170
7600
  for (let i = 0; i < cleaned.length; i++) {
7171
7601
  if (cleaned[i] === "\n") lineOffsets.push(i + 1);
7172
7602
  }
7173
- const checkedPassage = !UNCHECKED_PASSAGES.has(passage.name);
7603
+ const blocks = codeBlocks(uncommented, createCodeScanner(uncommented));
7604
+ const inCode = (offset) => blocks.some(([open, close]) => open < offset && offset < close);
7174
7605
  const scans = [
7175
- [varRefRegex, usages],
7176
- [transientRefRegex, transientUsages]
7606
+ [varRefRegex, usages, false],
7607
+ [transientRefRegex, transientUsages, true]
7177
7608
  ];
7178
- for (const [regex, out] of scans) {
7609
+ for (const [regex, out, digitsOnlyInCode] of scans) {
7179
7610
  const seen = /* @__PURE__ */ new Set();
7180
- for (const [source, checked] of [[cleaned, checkedPassage], [referenced, false]]) {
7611
+ for (const source of [cleaned, referenced]) {
7181
7612
  const re = new RegExp(regex.source, "g");
7182
7613
  let match2;
7183
7614
  while ((match2 = re.exec(source)) !== null) {
@@ -7186,6 +7617,7 @@ var init_variable_tracker = __esm({
7186
7617
  seen.add(charOffset);
7187
7618
  const fullName = match2[1];
7188
7619
  const baseName = fullName.split(".")[0];
7620
+ if (digitsOnlyInCode && /^\d/.test(baseName) && !inCode(charOffset)) continue;
7189
7621
  let localLine = 0;
7190
7622
  for (let i = 0; i < lineOffsets.length; i++) {
7191
7623
  if (lineOffsets[i] > charOffset) break;
@@ -7197,7 +7629,7 @@ var init_variable_tracker = __esm({
7197
7629
  start: { line: absoluteLine, character },
7198
7630
  end: { line: absoluteLine, character: character + match2[0].length }
7199
7631
  };
7200
- out.push({ uri, baseName, fullName, range: range2, checked });
7632
+ out.push({ uri, baseName, fullName, range: range2 });
7201
7633
  }
7202
7634
  }
7203
7635
  }
@@ -7208,11 +7640,16 @@ var init_variable_tracker = __esm({
7208
7640
  if (transientUsages.length > 0) {
7209
7641
  this.transientUsagesByUri.set(uri, transientUsages);
7210
7642
  }
7643
+ const validated = validatedReferences(lines, passageBoundaries);
7644
+ if (validated.length > 0) {
7645
+ this.validatedRefsByUri.set(uri, validated);
7646
+ }
7211
7647
  }
7212
7648
  /** Forget the usages recorded for a document, e.g. after it was deleted. */
7213
7649
  removeDocument(uri) {
7214
7650
  this.usagesByUri.delete(uri);
7215
7651
  this.transientUsagesByUri.delete(uri);
7652
+ this.validatedRefsByUri.delete(uri);
7216
7653
  }
7217
7654
  /** Get all declared variables. */
7218
7655
  getDeclared() {
@@ -7230,20 +7667,52 @@ var init_variable_tracker = __esm({
7230
7667
  }
7231
7668
  return results;
7232
7669
  }
7233
- /** Get undeclared variable usages in a specific document. */
7670
+ /**
7671
+ * Get undeclared variable references in a specific document: the ones
7672
+ * Spindle rejects when the story starts, wherever they are in the text.
7673
+ */
7234
7674
  getUndeclared(uri) {
7235
- const usages = this.usagesByUri.get(uri);
7236
- if (!usages) return [];
7675
+ const refs = this.validatedRefsByUri.get(uri);
7676
+ if (!refs) return [];
7237
7677
  const results = [];
7238
7678
  const seen = /* @__PURE__ */ new Set();
7239
- for (const u of usages) {
7240
- if (u.checked && !this.declared.has(u.baseName) && !seen.has(u.baseName)) {
7679
+ for (const u of refs) {
7680
+ if (!this.declared.has(u.baseName) && !seen.has(u.baseName)) {
7241
7681
  seen.add(u.baseName);
7242
7682
  results.push({ name: u.baseName, range: u.range });
7243
7683
  }
7244
7684
  }
7245
7685
  return results;
7246
7686
  }
7687
+ /**
7688
+ * Get the `$var.a.b` paths in a document that Spindle rejects at startup
7689
+ * because they access a field of a number, string or boolean, judged by
7690
+ * the StoryVariables defaults as Spindle's validateRef() does. Every
7691
+ * occurrence is reported; defaults that are not literals are not checked.
7692
+ */
7693
+ getPrimitiveFieldAccesses(uri) {
7694
+ const results = [];
7695
+ for (const ref of this.validatedRefsByUri.get(uri) ?? []) {
7696
+ const schema = this.declared.get(ref.baseName)?.schema;
7697
+ if (!schema) continue;
7698
+ const parts = ref.path.split(".");
7699
+ const found = findPrimitiveFieldAccess(schema, parts.slice(1));
7700
+ if (!found) continue;
7701
+ const owner = parts.slice(0, found.index + 1).join(".");
7702
+ const field = parts[found.index + 1];
7703
+ const start = ref.range.start.character + 1 + owner.length + 1;
7704
+ results.push({
7705
+ path: `$${owner}`,
7706
+ field,
7707
+ type: found.type,
7708
+ range: {
7709
+ start: { line: ref.range.start.line, character: start },
7710
+ end: { line: ref.range.start.line, character: start + field.length }
7711
+ }
7712
+ });
7713
+ }
7714
+ return results;
7715
+ }
7247
7716
  /** Whether a StoryVariables passage has been parsed. */
7248
7717
  hasStoryVariables() {
7249
7718
  return this._hasStoryVariables;
@@ -7271,7 +7740,7 @@ var init_variable_tracker = __esm({
7271
7740
  const results = [];
7272
7741
  const seen = /* @__PURE__ */ new Set();
7273
7742
  for (const u of usages) {
7274
- if (u.checked && !this.declaredTransient.has(u.baseName) && !seen.has(u.baseName)) {
7743
+ if (!this.declaredTransient.has(u.baseName) && !seen.has(u.baseName)) {
7275
7744
  seen.add(u.baseName);
7276
7745
  results.push({ name: u.baseName, range: u.range });
7277
7746
  }
@@ -7296,7 +7765,6 @@ var init_variable_tracker = __esm({
7296
7765
  for (const [sigil, usages, declared] of sources) {
7297
7766
  if (!usages) continue;
7298
7767
  for (const u of usages) {
7299
- if (!u.checked) continue;
7300
7768
  const member = u.fullName.split(".")[1];
7301
7769
  if (member === void 0) continue;
7302
7770
  if (declared.get(u.baseName)?.type !== "array") continue;
@@ -7308,11 +7776,32 @@ var init_variable_tracker = __esm({
7308
7776
  }
7309
7777
  /** Get variables declared with null values in StoryVariables. */
7310
7778
  getNullDeclarations() {
7311
- return this._nullDeclarations;
7779
+ return [...this._nullDeclarations, ...this._variablesCheck.nestedNulls];
7780
+ }
7781
+ /** StoryVariables lines that stop Spindle from starting, apart from null values. */
7782
+ getInvalidDeclarations() {
7783
+ return this._variablesCheck.problems;
7784
+ }
7785
+ /**
7786
+ * StoryTransients lines that stop Spindle from starting, apart from null
7787
+ * values, and the names it declares that StoryVariables declares too.
7788
+ */
7789
+ getInvalidTransientDeclarations() {
7790
+ const collisions = [];
7791
+ if (this._hasStoryVariables) {
7792
+ for (const [name, range2] of this._transientsCheck.names) {
7793
+ if (!this._variablesCheck.names.has(name)) continue;
7794
+ collisions.push({
7795
+ message: `StoryTransients: Variable "${name}" is already declared in StoryVariables. Names must be unique across scopes.`,
7796
+ range: range2
7797
+ });
7798
+ }
7799
+ }
7800
+ return [...this._transientsCheck.problems, ...collisions].sort((a, b) => a.range.start.line - b.range.start.line);
7312
7801
  }
7313
7802
  /** Get transient variables declared with null values in StoryTransients. */
7314
7803
  getNullTransientDeclarations() {
7315
- return this._nullTransientDeclarations;
7804
+ return [...this._nullTransientDeclarations, ...this._transientsCheck.nestedNulls];
7316
7805
  }
7317
7806
  };
7318
7807
  }
@@ -7513,11 +8002,11 @@ function discoverMacrosFromSource(source) {
7513
8002
  }
7514
8003
  return macros;
7515
8004
  }
7516
- function discoverMacrosFromStoryInit(passageContent2) {
8005
+ function discoverMacrosFromStoryInit(passageContent3) {
7517
8006
  const macros = [];
7518
8007
  doBlockRegex.lastIndex = 0;
7519
8008
  let doMatch;
7520
- while ((doMatch = doBlockRegex.exec(passageContent2)) !== null) {
8009
+ while ((doMatch = doBlockRegex.exec(passageContent3)) !== null) {
7521
8010
  const jsContent = doMatch[1];
7522
8011
  const found = discoverMacrosFromSource(jsContent);
7523
8012
  macros.push(...found);
@@ -14924,8 +15413,11 @@ function isExcludedMacroSource(path2, root) {
14924
15413
  return (root ? relative(root, path2) : path2).split(/[\\/]/).some(isExcludedSegment);
14925
15414
  }
14926
15415
  function findMacroSourceFiles(root) {
15416
+ return findProjectFiles(root, MACRO_SOURCE_GLOB);
15417
+ }
15418
+ function findProjectFiles(root, pattern) {
14927
15419
  const excluded = (p) => isExcludedMacroSource(p.fullpath(), root);
14928
- return glob(MACRO_SOURCE_GLOB, {
15420
+ return glob(pattern, {
14929
15421
  cwd: root,
14930
15422
  absolute: true,
14931
15423
  nodir: true,
@@ -14975,6 +15467,82 @@ var init_macro_sources = __esm({
14975
15467
  }
14976
15468
  });
14977
15469
 
15470
+ // src/core/workspace/story-format.ts
15471
+ import { readFileSync as readFileSync5 } from "node:fs";
15472
+ function isSpindleFormatName(name) {
15473
+ return name.trim().toLowerCase() === "spindle";
15474
+ }
15475
+ function readStoryDataFormat(content) {
15476
+ let data;
15477
+ try {
15478
+ data = JSON.parse(content);
15479
+ } catch {
15480
+ return void 0;
15481
+ }
15482
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return void 0;
15483
+ const format = data.format;
15484
+ if (typeof format !== "string") return void 0;
15485
+ return format.trim() || void 0;
15486
+ }
15487
+ function resolveStoryFormat(declared) {
15488
+ let foreign;
15489
+ for (const name of declared) {
15490
+ if (name === void 0) continue;
15491
+ if (isSpindleFormatName(name)) return { name: name.trim(), isSpindle: true };
15492
+ foreign ??= name.trim();
15493
+ }
15494
+ return foreign === void 0 ? UNDECLARED_STORY_FORMAT : { name: foreign, isSpindle: false };
15495
+ }
15496
+ function passageContent(passage, text) {
15497
+ return text.split("\n").slice(passage.headerEnd.end.line + 1, passage.range.end.line + 1).join("\n");
15498
+ }
15499
+ function storyDataFormats(passages, getText) {
15500
+ const formats = [];
15501
+ for (const passage of passages) {
15502
+ if (passage.name !== "StoryData") continue;
15503
+ const text = getText(passage.uri);
15504
+ formats.push(text === void 0 ? void 0 : readStoryDataFormat(passageContent(passage, text)));
15505
+ }
15506
+ return formats;
15507
+ }
15508
+ function storyFormatOfTexts(texts) {
15509
+ const index = new PassageIndex();
15510
+ const byUri = /* @__PURE__ */ new Map();
15511
+ for (const text of texts) {
15512
+ if (!text.includes("StoryData")) continue;
15513
+ const uri = `story-format:${byUri.size}`;
15514
+ byUri.set(uri, text);
15515
+ index.rebuild(uri, text);
15516
+ }
15517
+ const formats = storyDataFormats(index.getAllPassages(), (uri) => byUri.get(uri));
15518
+ return { ...resolveStoryFormat(formats), hasStoryData: formats.length > 0 };
15519
+ }
15520
+ async function findStoryFormat(texts, dir) {
15521
+ const own = storyFormatOfTexts(texts);
15522
+ if (own.hasStoryData) return { name: own.name, isSpindle: own.isSpindle };
15523
+ const projectTexts = [];
15524
+ for (const file2 of await findProjectFiles(findProjectRoot(dir), "**/*.{tw,twee}")) {
15525
+ try {
15526
+ projectTexts.push(readFileSync5(file2, "utf-8"));
15527
+ } catch {
15528
+ }
15529
+ }
15530
+ const { name, isSpindle } = storyFormatOfTexts(projectTexts);
15531
+ return { name, isSpindle };
15532
+ }
15533
+ function skippedFormatNote(format) {
15534
+ return `Skipped: story format is ${format.name}, not Spindle`;
15535
+ }
15536
+ var UNDECLARED_STORY_FORMAT;
15537
+ var init_story_format = __esm({
15538
+ "src/core/workspace/story-format.ts"() {
15539
+ "use strict";
15540
+ init_passage_index();
15541
+ init_macro_sources();
15542
+ UNDECLARED_STORY_FORMAT = { name: void 0, isSpindle: true };
15543
+ }
15544
+ });
15545
+
14978
15546
  // src/macro-supplements.json
14979
15547
  var macro_supplements_default;
14980
15548
  var init_macro_supplements = __esm({
@@ -15004,9 +15572,6 @@ var init_macro_supplements = __esm({
15004
15572
  },
15005
15573
  for: {
15006
15574
  name: "for",
15007
- children: [
15008
- { name: "stop" }
15009
- ],
15010
15575
  container: true,
15011
15576
  description: "Loop block. Usage: `{for @item of $list}...{/for}` or `{for @item, @index of $list}...{/for}`",
15012
15577
  skipArgs: true
@@ -15019,8 +15584,8 @@ var init_macro_supplements = __esm({
15019
15584
  },
15020
15585
  stop: {
15021
15586
  name: "stop",
15022
- parents: ["for", "repeat"],
15023
- description: "Breaks out of a `{for}` or `{repeat}` loop.\n\nUsage: `{stop}`",
15587
+ parents: ["repeat"],
15588
+ description: "Stops the enclosing `{repeat}` loop. It has no effect anywhere else, including in a `{for}` loop.\n\nUsage: `{stop}`",
15024
15589
  parameters: []
15025
15590
  },
15026
15591
  switch: {
@@ -15089,8 +15654,8 @@ var init_macro_supplements = __esm({
15089
15654
  { name: "next" }
15090
15655
  ],
15091
15656
  container: true,
15092
- description: "Displays its contents after a specified delay.\n\nUsage:\n\n```\n{timed delay}\n ...\n {next delay}\n ...\n{/timed}\n```",
15093
- parameters: ["text"]
15657
+ description: "Displays its contents after a specified delay (immediately when omitted).\n\nUsage:\n\n```\n{timed delay}\n ...\n {next delay}\n ...\n{/timed}\n```",
15658
+ parameters: ["text", ""]
15094
15659
  },
15095
15660
  repeat: {
15096
15661
  name: "repeat",
@@ -15284,6 +15849,7 @@ var init_workspace_model = __esm({
15284
15849
  init_macro_parser();
15285
15850
  init_macro_discovery();
15286
15851
  init_macro_sources();
15852
+ init_story_format();
15287
15853
  init_macro_supplements();
15288
15854
  WorkspaceModel = class extends EventEmitter3 {
15289
15855
  documents;
@@ -15293,6 +15859,8 @@ var init_workspace_model = __esm({
15293
15859
  widgets;
15294
15860
  /** True after initialize() has completed (full workspace scan done). */
15295
15861
  initialized = false;
15862
+ /** The story format declared by StoryData, updated with the passages. */
15863
+ format = UNDECLARED_STORY_FORMAT;
15296
15864
  debounceTimer = null;
15297
15865
  DEBOUNCE_MS = 200;
15298
15866
  /** Bound listeners for cleanup in dispose(). */
@@ -15347,6 +15915,22 @@ var init_workspace_model = __esm({
15347
15915
  isContainer(name) {
15348
15916
  return this.macros.isBlock(name) || (this.widgets.getWidget(name)?.block ?? false);
15349
15917
  }
15918
+ /**
15919
+ * The story format named by the project's StoryData passage (trimmed), or
15920
+ * undefined if no StoryData passage names one.
15921
+ */
15922
+ get storyFormat() {
15923
+ return this.format.name;
15924
+ }
15925
+ /**
15926
+ * Whether Spindle semantics apply to this project. False only if a
15927
+ * StoryData passage names another story format (SugarCube, Harlowe, ...)
15928
+ * and none names Spindle; a missing or unreadable StoryData counts as
15929
+ * Spindle.
15930
+ */
15931
+ isSpindleProject() {
15932
+ return this.format.isSpindle;
15933
+ }
15350
15934
  /** Clean up listeners and timers. */
15351
15935
  dispose() {
15352
15936
  this.documents.removeListener("documentChanged", this.onDocumentChanged);
@@ -15366,6 +15950,7 @@ var init_workspace_model = __esm({
15366
15950
  }
15367
15951
  this.refreshDiscoveredMacros();
15368
15952
  this.cascade();
15953
+ this.updateStoryFormat();
15369
15954
  this.emit("documentChanged", uri);
15370
15955
  this.emit("passagesUpdated", uri);
15371
15956
  this.scheduleModelReady();
@@ -15376,6 +15961,7 @@ var init_workspace_model = __esm({
15376
15961
  this.variables.removeDocument(uri);
15377
15962
  this.refreshDiscoveredMacros();
15378
15963
  this.cascade();
15964
+ this.updateStoryFormat();
15379
15965
  this.emit("documentClosed", uri);
15380
15966
  this.emit("passagesUpdated", uri);
15381
15967
  this.scheduleModelReady();
@@ -15390,6 +15976,21 @@ var init_workspace_model = __esm({
15390
15976
  }
15391
15977
  this.refreshDiscoveredMacros();
15392
15978
  this.cascade();
15979
+ this.updateStoryFormat();
15980
+ }
15981
+ /**
15982
+ * Re-read the story format from the StoryData passages after the passage
15983
+ * index changed, emitting 'storyFormatChanged' if it differs. Called after
15984
+ * the cascade, so listeners see up-to-date indices.
15985
+ */
15986
+ updateStoryFormat() {
15987
+ const format = resolveStoryFormat(storyDataFormats(
15988
+ this.passages.getAllPassages(),
15989
+ (uri) => this.documents.getText(uri)
15990
+ ));
15991
+ if (format.name === this.format.name && format.isSpindle === this.format.isSpindle) return;
15992
+ this.format = format;
15993
+ this.emit("storyFormatChanged", format);
15393
15994
  }
15394
15995
  /**
15395
15996
  * Re-run static macro discovery over the workspace and replace the
@@ -15505,6 +16106,7 @@ var init_diagnostic_codes = __esm({
15505
16106
  DiagnosticCode = {
15506
16107
  UndefinedMacro: "SP100",
15507
16108
  MalformedContainer: "SP101",
16109
+ MalformedElement: "SP102",
15508
16110
  IllegalClosingTag: "SP104",
15509
16111
  InvalidChildren: "SP107",
15510
16112
  ExpectedNoArguments: "SP108",
@@ -15515,12 +16117,13 @@ var init_diagnostic_codes = __esm({
15515
16117
  ChildMaxExceeded: "SP114",
15516
16118
  ChildMinNotMet: "SP115",
15517
16119
  UndeclaredVariable: "SP200",
15518
- UndeclaredField: "SP201",
16120
+ PrimitiveFieldAccess: "SP201",
15519
16121
  NoStoryVariables: "SP202",
15520
16122
  UndeclaredTransient: "SP203",
15521
16123
  NullVariableValue: "SP204",
15522
16124
  TemporaryAssignedInLoop: "SP205",
15523
16125
  ArrayMemberAccess: "SP206",
16126
+ InvalidDeclaration: "SP207",
15524
16127
  BrokenPassageLink: "SP300",
15525
16128
  WidgetArgCountMismatch: "SP301",
15526
16129
  IncludeWidgetPassage: "SP302",
@@ -15532,6 +16135,7 @@ var init_diagnostic_codes = __esm({
15532
16135
  severityMap = {
15533
16136
  SP100: "warning",
15534
16137
  SP101: "error",
16138
+ SP102: "error",
15535
16139
  SP104: "error",
15536
16140
  SP107: "error",
15537
16141
  SP108: "error",
@@ -15542,12 +16146,13 @@ var init_diagnostic_codes = __esm({
15542
16146
  SP114: "error",
15543
16147
  SP115: "error",
15544
16148
  SP200: "error",
15545
- SP201: "warning",
16149
+ SP201: "error",
15546
16150
  SP202: "info",
15547
16151
  SP203: "warning",
15548
16152
  SP204: "error",
15549
16153
  SP205: "warning",
15550
16154
  SP206: "warning",
16155
+ SP207: "error",
15551
16156
  SP300: "warning",
15552
16157
  SP301: "warning",
15553
16158
  SP302: "warning",
@@ -15829,6 +16434,258 @@ var init_argument_lexer = __esm({
15829
16434
  }
15830
16435
  });
15831
16436
 
16437
+ // src/core/parsing/html-scanner.ts
16438
+ function plainBraceMatches(input) {
16439
+ const match2 = new Int32Array(input.length).fill(-1);
16440
+ const open = [];
16441
+ for (let i = 0; i < input.length; i++) {
16442
+ if (input[i] === "{") open.push(i);
16443
+ else if (input[i] === "}" && open.length > 0) match2[open.pop()] = i;
16444
+ }
16445
+ return match2;
16446
+ }
16447
+ function scanHtmlTags(text) {
16448
+ const n = text.length;
16449
+ const tags = [];
16450
+ const macros = [];
16451
+ const code = createCodeScanner(text);
16452
+ const plain = plainBraceMatches(text);
16453
+ const rawBodies = [];
16454
+ let doCloses;
16455
+ let nextDoClose = 0;
16456
+ const rawBody = (end) => {
16457
+ doCloses ??= [...text.matchAll(/\{\/do\s*\}/gi)].map((match2) => match2.index);
16458
+ while (nextDoClose < doCloses.length && doCloses[nextDoClose] < end) nextDoClose++;
16459
+ if (nextDoClose < doCloses.length) rawBodies.push([end, doCloses[nextDoClose]]);
16460
+ };
16461
+ const rawBodyLimit = (limit2) => {
16462
+ if (rawBodies.length === 0) return limit2;
16463
+ const macroStarts = new Set(macros);
16464
+ let t = 0;
16465
+ for (const [start, close] of rawBodies) {
16466
+ if (start >= limit2) break;
16467
+ if (close < limit2 && !macroStarts.has(close)) {
16468
+ limit2 = start;
16469
+ break;
16470
+ }
16471
+ while (t < tags.length && tags[t].start < start) t++;
16472
+ if (t < tags.length && tags[t].start < Math.min(close, limit2)) limit2 = tags[t].start;
16473
+ }
16474
+ return limit2;
16475
+ };
16476
+ const braceEnd = (open) => {
16477
+ const close = code.closeBrace(open + 1);
16478
+ if (close !== plain[open]) return GIVE_UP;
16479
+ return close === -1 ? open + 1 : close + 1;
16480
+ };
16481
+ const displayEnd = (open, sigil) => {
16482
+ let j = sigil + 1;
16483
+ while (j < n && DISPLAY_NAME_CHAR.test(text[j])) j++;
16484
+ return text[j] === "}" ? j + 1 : braceEnd(open);
16485
+ };
16486
+ const afterBrace = (i2) => {
16487
+ let at = i2 + 1;
16488
+ const selectors = text[at] === "." || text[at] === "#";
16489
+ if (selectors) {
16490
+ at = selectorsEnd(text, at);
16491
+ if (text[at] === " ") at++;
16492
+ }
16493
+ const c3 = text[at];
16494
+ if (c3 === "$" || c3 === "_" || c3 === "@" || c3 === "%") return displayEnd(i2, at);
16495
+ if (c3 === void 0) return i2 + 1;
16496
+ if (LETTER.test(c3) || c3 === "/" && !selectors) {
16497
+ const end = braceEnd(i2);
16498
+ if (end > i2 + 1) {
16499
+ macros.push(i2);
16500
+ if (/^do$/i.test(text.slice(at, end - 1).trim().split(/\s/)[0])) rawBody(end);
16501
+ }
16502
+ return end;
16503
+ }
16504
+ return i2 + 1;
16505
+ };
16506
+ const readTag = (i2) => {
16507
+ let j = i2 + 1;
16508
+ const isClose = text[j] === "/";
16509
+ if (isClose) j++;
16510
+ const nameStart = j;
16511
+ while (j < n && TAG_NAME_CHAR.test(text[j])) j++;
16512
+ const name = text.slice(nameStart, j);
16513
+ if (!name || !LETTER.test(name[0])) return i2 + 1;
16514
+ const isVoid = VOID_ELEMENTS.has(name.toLowerCase());
16515
+ if (isClose) {
16516
+ while (j < n && WHITESPACE.test(text[j])) j++;
16517
+ if (text[j] !== ">") return i2 + 1;
16518
+ if (!isVoid) tags.push({ name, kind: "close", start: i2, end: j + 1 });
16519
+ return j + 1;
16520
+ }
16521
+ const attrs = readAttributes(j);
16522
+ if (attrs.end === GIVE_UP) return GIVE_UP;
16523
+ j = attrs.end;
16524
+ let selfClosing = isVoid;
16525
+ if (text[j] === "/") {
16526
+ selfClosing = true;
16527
+ j++;
16528
+ }
16529
+ if (text[j] === ">") {
16530
+ tags.push({ name, kind: selfClosing ? "void" : "open", start: i2, end: j + 1 });
16531
+ return j + 1;
16532
+ }
16533
+ return attrs.count > 0 ? GIVE_UP : i2 + 1;
16534
+ };
16535
+ const readAttributes = (j) => {
16536
+ let count = 0;
16537
+ while (j < n) {
16538
+ while (j < n && WHITESPACE.test(text[j])) j++;
16539
+ if (j >= n || text[j] === ">" || text[j] === "/" && text[j + 1] === ">") break;
16540
+ const nameStart = j;
16541
+ while (j < n && ATTR_NAME_CHAR.test(text[j])) j++;
16542
+ if (j === nameStart) break;
16543
+ count++;
16544
+ if (text[j] !== "=") continue;
16545
+ j++;
16546
+ const quote = text[j];
16547
+ if (quote === '"' || quote === "'") {
16548
+ const close = quotedValueEnd(j + 1, quote);
16549
+ if (close === GIVE_UP) return { end: GIVE_UP, count };
16550
+ j = close < n ? close + 1 : close;
16551
+ } else {
16552
+ while (j < n && !WHITESPACE.test(text[j]) && text[j] !== ">") j++;
16553
+ }
16554
+ }
16555
+ return { end: j, count };
16556
+ };
16557
+ const quotedValueEnd = (from, quote) => {
16558
+ let depth = 0;
16559
+ let sawBrace = false;
16560
+ let k = from;
16561
+ for (; k < n; k++) {
16562
+ const c3 = text[k];
16563
+ if (c3 === "{") {
16564
+ depth++;
16565
+ sawBrace = true;
16566
+ } else if (c3 === "}") {
16567
+ depth--;
16568
+ sawBrace = true;
16569
+ } else if (c3 === quote && depth <= 0) {
16570
+ break;
16571
+ }
16572
+ }
16573
+ if (!sawBrace) return k;
16574
+ let d = from;
16575
+ while (d < n) {
16576
+ if (text[d] === "{") {
16577
+ const close = code.closeBrace(d + 1);
16578
+ if (close !== -1) {
16579
+ d = close + 1;
16580
+ continue;
16581
+ }
16582
+ } else if (text[d] === quote) {
16583
+ break;
16584
+ }
16585
+ d++;
16586
+ }
16587
+ return d === k ? k : GIVE_UP;
16588
+ };
16589
+ let i = 0;
16590
+ let stoppedAt = -1;
16591
+ while (i < n) {
16592
+ const c3 = text[i];
16593
+ let next;
16594
+ if (c3 === "\\") {
16595
+ let k = i + 1;
16596
+ while (text[k] === "\\") k++;
16597
+ if (text[k] === "{" || text[k] === "}") next = (k - i) % 2 === 1 ? k + 1 : GIVE_UP;
16598
+ else next = k;
16599
+ } else if (c3 === "[" && text[i + 1] === "[") {
16600
+ next = linkEnd(text, i);
16601
+ } else if (c3 === "{") {
16602
+ next = afterBrace(i);
16603
+ } else if (c3 === "<") {
16604
+ next = readTag(i);
16605
+ } else {
16606
+ next = i + 1;
16607
+ }
16608
+ if (next === GIVE_UP) {
16609
+ stoppedAt = i;
16610
+ break;
16611
+ }
16612
+ i = next;
16613
+ }
16614
+ const limit = rawBodyLimit(stoppedAt === -1 ? n : stoppedAt);
16615
+ if (limit === stoppedAt || limit === n) return { tags, stoppedAt, macros };
16616
+ return {
16617
+ tags: tags.filter((tag) => tag.start < limit),
16618
+ stoppedAt: limit,
16619
+ macros: macros.filter((at) => at < limit)
16620
+ };
16621
+ }
16622
+ function selectorsEnd(text, i) {
16623
+ const n = text.length;
16624
+ while (i < n && (text[i] === "." || text[i] === "#")) {
16625
+ i++;
16626
+ while (i < n) {
16627
+ if (SELECTOR_NAME_CHAR.test(text[i])) {
16628
+ i++;
16629
+ } else if (text[i] === "{" && (text[i + 1] === "$" || text[i + 1] === "_" || text[i + 1] === "@")) {
16630
+ let j = i + 2;
16631
+ while (j < n && DISPLAY_NAME_CHAR.test(text[j])) j++;
16632
+ if (text[j] !== "}") break;
16633
+ i = j + 1;
16634
+ } else {
16635
+ break;
16636
+ }
16637
+ }
16638
+ }
16639
+ return i;
16640
+ }
16641
+ function linkEnd(text, i) {
16642
+ const n = text.length;
16643
+ let depth = 1;
16644
+ let j = i + 2;
16645
+ while (j < n) {
16646
+ if (text[j] === "[" && text[j + 1] === "[") {
16647
+ depth++;
16648
+ j += 2;
16649
+ } else if (text[j] === "]" && text[j + 1] === "]") {
16650
+ if (--depth === 0) return j + 2;
16651
+ j += 2;
16652
+ } else {
16653
+ j++;
16654
+ }
16655
+ }
16656
+ return GIVE_UP;
16657
+ }
16658
+ var VOID_ELEMENTS, TAG_NAME_CHAR, LETTER, ATTR_NAME_CHAR, WHITESPACE, SELECTOR_NAME_CHAR, DISPLAY_NAME_CHAR, GIVE_UP;
16659
+ var init_html_scanner = __esm({
16660
+ "src/core/parsing/html-scanner.ts"() {
16661
+ "use strict";
16662
+ init_macro_parser();
16663
+ VOID_ELEMENTS = /* @__PURE__ */ new Set([
16664
+ "area",
16665
+ "base",
16666
+ "br",
16667
+ "col",
16668
+ "embed",
16669
+ "hr",
16670
+ "img",
16671
+ "input",
16672
+ "link",
16673
+ "meta",
16674
+ "param",
16675
+ "source",
16676
+ "track",
16677
+ "wbr"
16678
+ ]);
16679
+ TAG_NAME_CHAR = /[a-zA-Z0-9-]/;
16680
+ LETTER = /[a-zA-Z]/;
16681
+ ATTR_NAME_CHAR = /[a-zA-Z0-9_\-:@]/;
16682
+ WHITESPACE = /[^\S ]/;
16683
+ SELECTOR_NAME_CHAR = /[a-zA-Z0-9_-]/;
16684
+ DISPLAY_NAME_CHAR = /[\w.]/;
16685
+ GIVE_UP = -2;
16686
+ }
16687
+ });
16688
+
15832
16689
  // src/core/parsing/widget-arguments.ts
15833
16690
  function splitWidgetArguments(raw) {
15834
16691
  const args2 = [];
@@ -16331,7 +17188,7 @@ var init_parameter_validator = __esm({
16331
17188
  if (/^[_@%]/.test(name)) {
16332
17189
  return new TypeError2(`Variable receiver must be a story variable ($name), got '${name}'`);
16333
17190
  }
16334
- if (!/^\$?[A-Za-z_][\w$]*(?:\.[\w$]+)*$/.test(name)) {
17191
+ if (!/^(?:\$\w|[A-Za-z_])[\w$]*(?:\.[\w$]+)*$/.test(name)) {
16335
17192
  return new TypeError2(`Argument is not a valid variable receiver: '${name}'`);
16336
17193
  }
16337
17194
  return null;
@@ -16664,6 +17521,7 @@ var init_link_parser = __esm({
16664
17521
  // src/plugins/diagnostics.ts
16665
17522
  function computeDiagnostics(uri, workspace, options) {
16666
17523
  if (!workspace.initialized) return [];
17524
+ if (!workspace.isSpindleProject()) return [];
16667
17525
  try {
16668
17526
  const text = workspace.documents.getText(uri);
16669
17527
  if (text === void 0) return [];
@@ -16679,8 +17537,15 @@ function computeDiagnostics(uri, workspace, options) {
16679
17537
  );
16680
17538
  const allPassages = workspace.passages.getAllPassages();
16681
17539
  const passageNames = new Set(allPassages.map((p) => p.name));
17540
+ let elements = /* @__PURE__ */ new Map();
17541
+ try {
17542
+ const replay = replayElements(markupText, macros, passages, workspace);
17543
+ elements = replay.elements;
17544
+ diagnostics.push(...replay.errors);
17545
+ } catch {
17546
+ }
16682
17547
  try {
16683
- validateMacros(macros, workspace, diagnostics);
17548
+ validateMacros(macros, elements, workspace, diagnostics);
16684
17549
  } catch {
16685
17550
  }
16686
17551
  try {
@@ -16738,7 +17603,7 @@ function maskScriptAndStylesheetPassages(text, passages) {
16738
17603
  }
16739
17604
  return lines.join("\n");
16740
17605
  }
16741
- function validateMacros(macros, workspace, diagnostics) {
17606
+ function validateMacros(macros, elements, workspace, diagnostics) {
16742
17607
  for (let curIndex = 0; curIndex < macros.length; curIndex++) {
16743
17608
  const macro = macros[curIndex];
16744
17609
  const info = workspace.macros.getMacro(macro.name);
@@ -16767,7 +17632,7 @@ function validateMacros(macros, workspace, diagnostics) {
16767
17632
  ));
16768
17633
  }
16769
17634
  if (info?.children && info.children.length > 0 && macro.open && macro.pair !== -1) {
16770
- validateChildren(macros, curIndex, macro, info.children, workspace, diagnostics);
17635
+ validateChildren(macros, curIndex, macro, info.children, elements, workspace, diagnostics);
16771
17636
  }
16772
17637
  } else if (!macro.open) {
16773
17638
  diagnostics.push(makeDiag(
@@ -16777,8 +17642,20 @@ function validateMacros(macros, workspace, diagnostics) {
16777
17642
  ));
16778
17643
  }
16779
17644
  if (info?.parents && info.parents.length > 0 && macro.open) {
16780
- if (!isInsideParent(macros, curIndex, info.parents, workspace)) {
16781
- const parentList = info.parents.join(", ");
17645
+ const parentList = info.parents.join(", ");
17646
+ if (DIRECT_CHILD_MACROS.has(macro.name.toLowerCase())) {
17647
+ const element = elements.get(curIndex);
17648
+ const enclosing = element === void 0 ? innermostContainer(macros, curIndex, workspace) : void 0;
17649
+ const parentSet = new Set(info.parents.map((p) => p.toLowerCase()));
17650
+ if (element !== void 0 || !enclosing || !parentSet.has(enclosing.name.toLowerCase())) {
17651
+ const where = element !== void 0 ? `, not inside <${element}>` : enclosing ? `, not inside {${enclosing.name}}` : "";
17652
+ diagnostics.push(makeDiag(
17653
+ macro.range,
17654
+ DiagnosticCode.InvalidChildren,
17655
+ `Invalid: {${macro.name}} can only be directly inside {${parentList}}${where}`
17656
+ ));
17657
+ }
17658
+ } else if (!isInsideParent(macros, curIndex, info.parents, workspace)) {
16782
17659
  diagnostics.push(makeDiag(
16783
17660
  macro.range,
16784
17661
  DiagnosticCode.InvalidChildren,
@@ -16788,6 +17665,112 @@ function validateMacros(macros, workspace, diagnostics) {
16788
17665
  }
16789
17666
  }
16790
17667
  }
17668
+ function innermostContainer(macros, index, workspace) {
17669
+ for (let i = index - 1; i >= 0; i--) {
17670
+ const candidate = macros[i];
17671
+ if (!candidate.open || candidate.pair <= index) continue;
17672
+ if (workspace.isContainer(candidate.name)) return candidate;
17673
+ }
17674
+ return void 0;
17675
+ }
17676
+ function isMarkupPassage(passage) {
17677
+ return !NON_MARKUP_PASSAGES.has(passage.name) && !isScriptOrStylesheetPassage(passage);
17678
+ }
17679
+ function replayElements(text, macros, passages, workspace) {
17680
+ const replay = { elements: /* @__PURE__ */ new Map(), errors: [] };
17681
+ if (!text.includes("<")) return replay;
17682
+ const lineStarts = buildLineStarts(text);
17683
+ const lineOffset = (line) => lineStarts[line] ?? text.length;
17684
+ const ordered = [...passages].sort((a, b) => a.range.start.line - b.range.start.line);
17685
+ let m = 0;
17686
+ for (const passage of ordered) {
17687
+ const contentStart = lineOffset(passage.range.start.line + 1);
17688
+ const contentEnd = lineOffset(passage.range.end.line + 1);
17689
+ while (m < macros.length && positionToOffset(macros[m].range.start, lineStarts) < contentStart) m++;
17690
+ const first = m;
17691
+ while (m < macros.length && positionToOffset(macros[m].range.start, lineStarts) < contentEnd) m++;
17692
+ const content = text.slice(contentStart, contentEnd);
17693
+ if (!content.includes("<") || !isMarkupPassage(passage)) continue;
17694
+ replayPassage(macros, first, m, scanHtmlTags(content), contentStart, lineStarts, workspace, replay);
17695
+ }
17696
+ return replay;
17697
+ }
17698
+ function replayPassage(macros, from, to, scan, base, lineStarts, workspace, replay) {
17699
+ const { tags } = scan;
17700
+ const macroStart = (k) => positionToOffset(macros[k].range.start, lineStarts) - base;
17701
+ let certainUntil = scan.stoppedAt === -1 ? Infinity : scan.stoppedAt;
17702
+ const parsed = /* @__PURE__ */ new Set();
17703
+ for (let k = from; k < to; k++) parsed.add(macroStart(k));
17704
+ const unparsed = scan.macros.find((at) => !parsed.has(at));
17705
+ if (unparsed !== void 0) certainUntil = Math.min(certainUntil, unparsed);
17706
+ const stack = [];
17707
+ let t = 0;
17708
+ let s = 0;
17709
+ let lastMacroEnd = -1;
17710
+ const report2 = (range2, message) => {
17711
+ replay.errors.push(makeDiag(range2, DiagnosticCode.MalformedElement, `Malformed element: ${message}`));
17712
+ };
17713
+ const tagRange = (tag) => ({
17714
+ start: offsetToPosition(base + tag.start, lineStarts),
17715
+ end: offsetToPosition(base + tag.end, lineStarts)
17716
+ });
17717
+ const applyTags = (until) => {
17718
+ for (; t < tags.length; t++) {
17719
+ const tag = tags[t];
17720
+ const at = tag.kind === "close" ? tag.start : tag.end;
17721
+ if (at > until) break;
17722
+ if (tag.start >= certainUntil) return false;
17723
+ if (tag.start < lastMacroEnd) return false;
17724
+ if (tag.kind === "open") {
17725
+ stack.push({ element: tag });
17726
+ } else if (tag.kind === "close") {
17727
+ const top = stack[stack.length - 1];
17728
+ const found = `</${tag.name}>`;
17729
+ if (!top) {
17730
+ report2(tagRange(tag), `unexpected closing ${found}`);
17731
+ return false;
17732
+ }
17733
+ if ("macro" in top) {
17734
+ report2(tagRange(tag), `expected {/${macros[top.macro].name}} but found ${found}`);
17735
+ return false;
17736
+ }
17737
+ if (top.element.name.toLowerCase() !== tag.name.toLowerCase()) {
17738
+ report2(tagRange(tag), `expected </${top.element.name}> but found ${found}`);
17739
+ return false;
17740
+ }
17741
+ stack.pop();
17742
+ }
17743
+ }
17744
+ return true;
17745
+ };
17746
+ for (let k = from; k < to; k++) {
17747
+ const macro = macros[k];
17748
+ const start = macroStart(k);
17749
+ if (!applyTags(start)) return;
17750
+ if (start >= certainUntil) return;
17751
+ while (s < scan.macros.length && scan.macros[s] < start) s++;
17752
+ if (scan.macros[s] !== start) return;
17753
+ lastMacroEnd = positionToOffset(macro.range.end, lineStarts) - base;
17754
+ const top = stack[stack.length - 1];
17755
+ if (top && "element" in top) replay.elements.set(k, top.element.name);
17756
+ if (!workspace.isContainer(macro.name)) continue;
17757
+ if (macro.pair === -1) return;
17758
+ if (macro.open) {
17759
+ stack.push({ macro: k });
17760
+ continue;
17761
+ }
17762
+ const opener = stack.pop();
17763
+ if (opener && "element" in opener) {
17764
+ report2(macro.range, `expected </${opener.element.name}> but found {/${macro.name}}`);
17765
+ return;
17766
+ }
17767
+ if (!opener || opener.macro !== macro.pair) return;
17768
+ }
17769
+ if (!applyTags(Infinity) || certainUntil !== Infinity) return;
17770
+ for (const entry of stack) {
17771
+ if ("element" in entry) report2(tagRange(entry.element), `unclosed <${entry.element.name}>`);
17772
+ }
17773
+ }
16791
17774
  function isInsideParent(macros, index, parents, workspace) {
16792
17775
  const parentSet = new Set(parents.map((p) => p.toLowerCase()));
16793
17776
  for (let i = index - 1; i >= 0; i--) {
@@ -16803,18 +17786,18 @@ function isInsideParent(macros, index, parents, workspace) {
16803
17786
  }
16804
17787
  return false;
16805
17788
  }
16806
- function validateChildren(macros, curIndex, parentMacro, childConstraints, workspace, diagnostics) {
17789
+ function validateChildren(macros, curIndex, parentMacro, childConstraints, elements, workspace, diagnostics) {
16807
17790
  const children = /* @__PURE__ */ Object.create(null);
16808
17791
  const startIndex = curIndex + 1;
16809
17792
  const endIndex = parentMacro.pair;
16810
17793
  for (let i = startIndex; i < endIndex; i++) {
16811
17794
  const child = macros[i];
16812
- const childInfo = workspace.macros.getMacro(child.name);
16813
- if (!childInfo) continue;
16814
- if (childInfo.block && child.open && child.pair !== -1) {
17795
+ if (child.open && child.pair !== -1 && workspace.isContainer(child.name)) {
16815
17796
  i = child.pair;
16816
17797
  continue;
16817
17798
  }
17799
+ if (elements.has(i)) continue;
17800
+ if (!workspace.macros.getMacro(child.name)) continue;
16818
17801
  const childKey = child.name.toLowerCase();
16819
17802
  if (childConstraints.some((c3) => c3.name.toLowerCase() === childKey)) {
16820
17803
  children[childKey] = (children[childKey] ?? 0) + 1;
@@ -16929,15 +17912,25 @@ function validateVariables(uri, workspace, diagnostics) {
16929
17912
  `Variable '$${u.name}' is not declared in StoryVariables`
16930
17913
  ));
16931
17914
  }
17915
+ for (const a of workspace.variables.getPrimitiveFieldAccesses(uri)) {
17916
+ diagnostics.push(makeDiag(
17917
+ a.range,
17918
+ DiagnosticCode.PrimitiveFieldAccess,
17919
+ `Cannot access field "${a.field}" on ${a.path} (type: ${a.type}). Spindle checks field access against the StoryVariables defaults and will not start the story.`
17920
+ ));
17921
+ }
16932
17922
  const storyVars = workspace.passages.getStoryVariables();
16933
17923
  if (storyVars && storyVars.uri === uri) {
16934
17924
  for (const nd of workspace.variables.getNullDeclarations()) {
16935
17925
  diagnostics.push(makeDiag(
16936
17926
  nd.range,
16937
17927
  DiagnosticCode.NullVariableValue,
16938
- `Variable '$${nd.name}' is set to null. Spindle does not support null \u2014 use a valid default (number, string, boolean, array, or object).`
17928
+ `${nullSubject(nd)} is set to null. Spindle does not support null \u2014 use a valid default (number, string, boolean, array, or object).`
16939
17929
  ));
16940
17930
  }
17931
+ for (const d of workspace.variables.getInvalidDeclarations()) {
17932
+ diagnostics.push(makeDiag(d.range, DiagnosticCode.InvalidDeclaration, `${d.message} ${WILL_NOT_START}`));
17933
+ }
16941
17934
  }
16942
17935
  }
16943
17936
  if (workspace.variables.hasStoryTransients()) {
@@ -16955,12 +17948,20 @@ function validateVariables(uri, workspace, diagnostics) {
16955
17948
  diagnostics.push(makeDiag(
16956
17949
  nd.range,
16957
17950
  DiagnosticCode.NullVariableValue,
16958
- `Transient variable '%${nd.name}' is set to null. Spindle does not support null \u2014 use a valid default (number, string, boolean, array, or object).`
17951
+ `${nullSubject(nd)} is set to null. Spindle does not support null \u2014 use a valid default (number, string, boolean, array, or object).`
16959
17952
  ));
16960
17953
  }
17954
+ for (const d of workspace.variables.getInvalidTransientDeclarations()) {
17955
+ diagnostics.push(makeDiag(d.range, DiagnosticCode.InvalidDeclaration, `${d.message} ${WILL_NOT_START}`));
17956
+ }
16961
17957
  }
16962
17958
  }
16963
17959
  }
17960
+ function nullSubject(nd) {
17961
+ const variable = nd.sigil === "%" ? "Transient variable" : "Variable";
17962
+ if (nd.field?.length) return `Field '${nd.sigil}${[nd.name, ...nd.field].join(".")}'`;
17963
+ return `${variable} '${nd.sigil}${nd.name}'`;
17964
+ }
16964
17965
  function validateLinks(text, passages, passageNames, diagnostics) {
16965
17966
  const links = parseLinks(text);
16966
17967
  for (const link of links) {
@@ -17177,7 +18178,7 @@ function widgetDefinitionNames(content) {
17177
18178
  while ((m = re.exec(content)) !== null) names.push(m[2]);
17178
18179
  return names;
17179
18180
  }
17180
- function passageContent(passage, workspace) {
18181
+ function passageContent2(passage, workspace) {
17181
18182
  const text = workspace.documents.getText(passage.uri) ?? "";
17182
18183
  return text.split("\n").slice(passage.range.start.line + 1, passage.range.end.line + 1).join("\n");
17183
18184
  }
@@ -17190,7 +18191,7 @@ function validateWidgetIncludes(text, macros, workspace, diagnostics) {
17190
18191
  if (target === null) continue;
17191
18192
  const passage = workspace.passages.getPassage(target);
17192
18193
  if (!passage?.tags?.includes("widget")) continue;
17193
- const widgets = widgetDefinitionNames(passageContent(passage, workspace));
18194
+ const widgets = widgetDefinitionNames(passageContent2(passage, workspace));
17194
18195
  if (widgets.length === 0) continue;
17195
18196
  const list = widgets.map((w) => `{${w}}`).join(", ");
17196
18197
  diagnostics.push(makeDiag(
@@ -17263,18 +18264,28 @@ function toLspDiagnostic(d) {
17263
18264
  message: d.message
17264
18265
  };
17265
18266
  }
17266
- var DEFERRED_CONTAINERS, EXPRESSION_BUILTINS2, diagnosticsPlugin;
18267
+ var DIRECT_CHILD_MACROS, NON_MARKUP_PASSAGES, WILL_NOT_START, DEFERRED_CONTAINERS, EXPRESSION_BUILTINS2, diagnosticsPlugin;
17267
18268
  var init_diagnostics = __esm({
17268
18269
  "src/plugins/diagnostics.ts"() {
17269
18270
  "use strict";
17270
18271
  init_diagnostic_codes();
17271
18272
  init_macro_parser();
17272
18273
  init_argument_lexer();
18274
+ init_html_scanner();
17273
18275
  init_widget_arguments();
17274
18276
  init_parameter_validator();
17275
18277
  init_link_parser();
17276
18278
  init_passage_parser();
17277
18279
  init_macro_sources();
18280
+ DIRECT_CHILD_MACROS = /* @__PURE__ */ new Set(["elseif", "else", "case", "default", "next", "option"]);
18281
+ NON_MARKUP_PASSAGES = /* @__PURE__ */ new Set([
18282
+ "StoryTitle",
18283
+ "StoryData",
18284
+ "StoryVariables",
18285
+ "StoryTransients",
18286
+ "SaveTitle"
18287
+ ]);
18288
+ WILL_NOT_START = "Spindle will not start the story.";
17278
18289
  DEFERRED_CONTAINERS = /* @__PURE__ */ new Set(["link", "button"]);
17279
18290
  EXPRESSION_BUILTINS2 = /* @__PURE__ */ new Set([
17280
18291
  "currentPassage",
@@ -17302,12 +18313,14 @@ var init_diagnostics = __esm({
17302
18313
  diagnostics: diags.filter(isEnabled).map((d) => toLspDiagnostic(d))
17303
18314
  });
17304
18315
  };
17305
- ctx.workspace.on("modelReady", () => {
18316
+ const publishAll = () => {
17306
18317
  for (const uri of ctx.workspace.documents.getUris()) {
17307
18318
  if (isMacroSource(uri)) continue;
17308
18319
  publishFor(uri);
17309
18320
  }
17310
- });
18321
+ };
18322
+ ctx.workspace.on("modelReady", publishAll);
18323
+ ctx.workspace.on("storyFormatChanged", publishAll);
17311
18324
  ctx.workspace.on("documentChanged", publishFor);
17312
18325
  ctx.workspace.on("documentClosed", (uri) => {
17313
18326
  ctx.connection.sendDiagnostics({ uri, diagnostics: [] });
@@ -17484,7 +18497,7 @@ var check_exports = {};
17484
18497
  __export(check_exports, {
17485
18498
  runCheck: () => runCheck
17486
18499
  });
17487
- import { readFileSync as readFileSync5 } from "node:fs";
18500
+ import { readFileSync as readFileSync6 } from "node:fs";
17488
18501
  import { resolve as resolve2 } from "node:path";
17489
18502
  import { pathToFileURL as pathToFileURL3 } from "node:url";
17490
18503
  function parseArgs(args2) {
@@ -17605,16 +18618,22 @@ async function runCheck(args2) {
17605
18618
  }
17606
18619
  projectConfig = loadConfigFromDisk(configRoot);
17607
18620
  }
17608
- const workspace = new WorkspaceModel({ workspaceRoot: commonDir });
17609
18621
  const fileContents = /* @__PURE__ */ new Map();
17610
18622
  for (const filePath of uniqueFiles) {
17611
18623
  try {
17612
- const text = readFileSync5(filePath, "utf-8");
18624
+ const text = readFileSync6(filePath, "utf-8");
17613
18625
  const uri = pathToFileURL3(filePath).toString();
17614
18626
  fileContents.set(uri, text);
17615
18627
  } catch {
17616
18628
  }
17617
18629
  }
18630
+ const storyFormat = await findStoryFormat(fileContents.values(), commonDir);
18631
+ if (!storyFormat.isSpindle) {
18632
+ console.error(skippedFormatNote(storyFormat));
18633
+ report(options.format, []);
18634
+ return 0;
18635
+ }
18636
+ const workspace = new WorkspaceModel({ workspaceRoot: commonDir });
17618
18637
  const workspaceContents = new Map(fileContents);
17619
18638
  await addProjectMacroSources(workspaceContents, commonDir);
17620
18639
  workspace.initialize(workspaceContents);
@@ -17639,19 +18658,22 @@ async function runCheck(args2) {
17639
18658
  }
17640
18659
  }
17641
18660
  }
17642
- switch (options.format) {
18661
+ report(options.format, allResults);
18662
+ workspace.dispose();
18663
+ return hasErrors ? 1 : 0;
18664
+ }
18665
+ function report(format, results) {
18666
+ switch (format) {
17643
18667
  case "pretty":
17644
- console.log(formatPretty(allResults));
18668
+ console.log(formatPretty(results));
17645
18669
  break;
17646
18670
  case "json":
17647
- console.log(formatJson(allResults));
18671
+ console.log(formatJson(results));
17648
18672
  break;
17649
18673
  case "sarif":
17650
- console.log(formatSarif(allResults));
18674
+ console.log(formatSarif(results));
17651
18675
  break;
17652
18676
  }
17653
- workspace.dispose();
17654
- return hasErrors ? 1 : 0;
17655
18677
  }
17656
18678
  var SEVERITY_ORDER;
17657
18679
  var init_check = __esm({
@@ -17662,6 +18684,7 @@ var init_check = __esm({
17662
18684
  init_diagnostics();
17663
18685
  init_config_loader();
17664
18686
  init_macro_sources();
18687
+ init_story_format();
17665
18688
  init_pretty();
17666
18689
  init_json();
17667
18690
  init_sarif();
@@ -17705,13 +18728,17 @@ function splitPassages(text) {
17705
18728
  return passages;
17706
18729
  }
17707
18730
  function classifyPassage(header) {
17708
- const tagMatch = header.match(/\[([^\]]*)\]/g);
17709
- if (!tagMatch) return "normal";
17710
- const tags = tagMatch.flatMap((t) => t.slice(1, -1).toLowerCase().split(/\s+/));
18731
+ const tags = headerTags(header).map((t) => t.toLowerCase());
17711
18732
  if (tags.includes("script")) return "script";
17712
18733
  if (tags.includes("stylesheet")) return "stylesheet";
17713
18734
  return "normal";
17714
18735
  }
18736
+ function headerTags(header) {
18737
+ const parsed = parsePassageHeader(header, 0);
18738
+ if (parsed) return parsed.tags;
18739
+ const unescaped = header.replace(/\\./g, "ec");
18740
+ return (unescaped.match(/\[[^\]]*\]/g) ?? []).flatMap((t) => t.slice(1, -1).split(/\s+/));
18741
+ }
17715
18742
  function segmentRegions(body) {
17716
18743
  const lines = body.split("\n");
17717
18744
  if (lines.length > 0 && lines[lines.length - 1] === "") {
@@ -17758,7 +18785,7 @@ function segmentRegions(body) {
17758
18785
  if (/^<([a-zA-Z][\w-]*)/.test(trimmed2)) {
17759
18786
  const tagMatch = trimmed2.match(/^<([a-zA-Z][\w-]*)/);
17760
18787
  const tagName = tagMatch[1].toLowerCase();
17761
- if (VOID_ELEMENTS.has(tagName) && !lines[i + 1]?.trim().startsWith("<")) {
18788
+ if (VOID_ELEMENTS2.has(tagName) && !lines[i + 1]?.trim().startsWith("<")) {
17762
18789
  pushSpindleLine(regions, line);
17763
18790
  i++;
17764
18791
  continue;
@@ -17795,12 +18822,13 @@ function pushSpindleLine(regions, line) {
17795
18822
  regions.push({ type: "spindle", lines: [line] });
17796
18823
  }
17797
18824
  }
17798
- var PASSAGE_HEADER_REGEX, VOID_ELEMENTS;
18825
+ var PASSAGE_HEADER_REGEX, VOID_ELEMENTS2;
17799
18826
  var init_segment = __esm({
17800
18827
  "src/plugins/format/segment.ts"() {
17801
18828
  "use strict";
18829
+ init_passage_parser();
17802
18830
  PASSAGE_HEADER_REGEX = /^::\s+/;
17803
- VOID_ELEMENTS = /* @__PURE__ */ new Set([
18831
+ VOID_ELEMENTS2 = /* @__PURE__ */ new Set([
17804
18832
  "area",
17805
18833
  "base",
17806
18834
  "br",
@@ -18287,11 +19315,12 @@ var init_format = __esm({
18287
19315
  "src/plugins/format.ts"() {
18288
19316
  "use strict";
18289
19317
  init_macro_supplements();
19318
+ init_macro_parser();
18290
19319
  init_segment();
18291
19320
  init_prettier_bridge();
18292
19321
  init_placeholders();
18293
19322
  PASSAGE_HEADER_REGEX2 = /^(::)\s+/;
18294
- MACRO_OPEN_REGEX = /^\{(?:[#.][a-zA-Z][\w-]*\s*)*([A-Za-z][\w-]*)\b/;
19323
+ MACRO_OPEN_REGEX = new RegExp(String.raw`^\{(?:${SELECTOR_PATTERN} )?([A-Za-z][\w-]*)\b`);
18295
19324
  MACRO_CLOSE_REGEX = /^\{\/([A-Za-z][\w-]*)\b/;
18296
19325
  DEFAULT_DEDENTING = /* @__PURE__ */ new Set(["else", "elseif", "next", "case", "default"]);
18297
19326
  LIST_ITEM_REGEX = /^(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)/;
@@ -18304,7 +19333,8 @@ var init_format = __esm({
18304
19333
  },
18305
19334
  initialize(ctx) {
18306
19335
  const formatOpts = {
18307
- isBlock: (name) => ctx.workspace.macros.isBlock(name),
19336
+ // Block widgets (whose body renders {@children}) are containers too
19337
+ isBlock: (name) => ctx.workspace.isContainer(name),
18308
19338
  isDedentingSubMacro: (name) => DEFAULT_DEDENTING.has(name.toLowerCase())
18309
19339
  };
18310
19340
  ctx.connection.onDocumentFormatting(async (params) => {
@@ -18349,7 +19379,7 @@ var format_exports = {};
18349
19379
  __export(format_exports, {
18350
19380
  runFormat: () => runFormat
18351
19381
  });
18352
- import { readFileSync as readFileSync6, writeFileSync } from "node:fs";
19382
+ import { readFileSync as readFileSync7, writeFileSync } from "node:fs";
18353
19383
  function parseArgs2(args2) {
18354
19384
  const options = {
18355
19385
  check: false,
@@ -18399,14 +19429,25 @@ async function runFormat(args2) {
18399
19429
  console.log("No files found");
18400
19430
  return 0;
18401
19431
  }
19432
+ const texts = /* @__PURE__ */ new Map();
19433
+ for (const filePath of uniqueFiles) {
19434
+ try {
19435
+ texts.set(filePath, readFileSync7(filePath, "utf-8"));
19436
+ } catch {
19437
+ }
19438
+ }
19439
+ const storyFormat = await findStoryFormat(texts.values(), commonDirectory(uniqueFiles));
19440
+ if (!storyFormat.isSpindle) {
19441
+ console.error(skippedFormatNote(storyFormat));
19442
+ return 0;
19443
+ }
18402
19444
  const formatOpts = {};
18403
19445
  if (options.maxLineLength !== null) {
18404
19446
  formatOpts.maxLineLength = options.maxLineLength;
18405
19447
  }
18406
19448
  const unformatted = [];
18407
- for (const filePath of uniqueFiles) {
19449
+ for (const [filePath, text] of texts) {
18408
19450
  try {
18409
- const text = readFileSync6(filePath, "utf-8");
18410
19451
  const formatted = await formatDocument(text, formatOpts);
18411
19452
  if (formatted !== text) {
18412
19453
  if (options.check) {
@@ -18437,6 +19478,8 @@ var init_format2 = __esm({
18437
19478
  "use strict";
18438
19479
  init_esm6();
18439
19480
  init_format();
19481
+ init_macro_sources();
19482
+ init_story_format();
18440
19483
  }
18441
19484
  });
18442
19485
 
@@ -49584,9 +50627,12 @@ var init_stdio2 = __esm({
49584
50627
  var server_exports = {};
49585
50628
  __export(server_exports, {
49586
50629
  checkFiles: () => checkFiles,
50630
+ checkFormatting: () => checkFormatting,
50631
+ checkProject: () => checkProject,
50632
+ formatFiles: () => formatFiles,
49587
50633
  startMcpServer: () => startMcpServer
49588
50634
  });
49589
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
50635
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "node:fs";
49590
50636
  import { resolve as resolve3 } from "node:path";
49591
50637
  import { pathToFileURL as pathToFileURL4 } from "node:url";
49592
50638
  function filterBySeverity2(diagnostics, minSeverity) {
@@ -49618,20 +50664,28 @@ function findConfigRoot(commonDir) {
49618
50664
  }
49619
50665
  return configRoot;
49620
50666
  }
49621
- async function createWorkspace(files) {
49622
- const commonDir = commonDirectory(files);
49623
- const configRoot = findConfigRoot(commonDir);
49624
- const projectConfig = loadConfigFromDisk(configRoot);
49625
- const workspace = new WorkspaceModel({ workspaceRoot: configRoot });
50667
+ function readFiles(files) {
49626
50668
  const fileContents = /* @__PURE__ */ new Map();
49627
50669
  for (const filePath of files) {
49628
50670
  try {
49629
- const text = readFileSync7(filePath, "utf-8");
50671
+ const text = readFileSync8(filePath, "utf-8");
49630
50672
  const uri = pathToFileURL4(filePath).toString();
49631
50673
  fileContents.set(uri, text);
49632
50674
  } catch {
49633
50675
  }
49634
50676
  }
50677
+ return fileContents;
50678
+ }
50679
+ async function skippedReason(fileContents, files) {
50680
+ const format = await findStoryFormat(fileContents.values(), commonDirectory(files));
50681
+ return format.isSpindle ? void 0 : skippedFormatNote(format);
50682
+ }
50683
+ async function createWorkspace(files, fileContents) {
50684
+ const commonDir = commonDirectory(files);
50685
+ const configRoot = findConfigRoot(commonDir);
50686
+ const projectConfig = loadConfigFromDisk(configRoot);
50687
+ const workspace = new WorkspaceModel({ workspaceRoot: configRoot });
50688
+ fileContents = new Map(fileContents);
49635
50689
  await addProjectMacroSources(fileContents, commonDir);
49636
50690
  workspace.initialize(fileContents);
49637
50691
  if (Object.keys(projectConfig.macros).length > 0) {
@@ -49640,9 +50694,15 @@ async function createWorkspace(files) {
49640
50694
  return workspace;
49641
50695
  }
49642
50696
  async function checkFiles(pattern, severity, cwd = process.cwd()) {
50697
+ return (await checkProject(pattern, severity, cwd)).results;
50698
+ }
50699
+ async function checkProject(pattern, severity, cwd = process.cwd()) {
49643
50700
  const files = await resolveFiles(pattern, cwd);
49644
- if (files.length === 0) return [];
49645
- const workspace = await createWorkspace(files);
50701
+ if (files.length === 0) return { results: [] };
50702
+ const fileContents = readFiles(files);
50703
+ const skipped = await skippedReason(fileContents, files);
50704
+ if (skipped) return { results: [], skipped };
50705
+ const workspace = await createWorkspace(files, fileContents);
49646
50706
  try {
49647
50707
  const results = [];
49648
50708
  for (const filePath of files) {
@@ -49664,11 +50724,57 @@ async function checkFiles(pattern, severity, cwd = process.cwd()) {
49664
50724
  });
49665
50725
  }
49666
50726
  }
49667
- return results;
50727
+ return { results };
49668
50728
  } finally {
49669
50729
  workspace.dispose();
49670
50730
  }
49671
50731
  }
50732
+ function relativeTo(cwd, filePath) {
50733
+ return filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
50734
+ }
50735
+ async function formatFiles(pattern, cwd = process.cwd()) {
50736
+ const files = await resolveFiles(pattern, cwd);
50737
+ const contents = readFiles(files);
50738
+ const skipped = files.length > 0 ? await skippedReason(contents, files) : void 0;
50739
+ if (skipped) return { formatted: 0, unchanged: 0, files: [], skipped };
50740
+ let formatted = 0;
50741
+ let unchanged = 0;
50742
+ const changedFiles = [];
50743
+ for (const filePath of files) {
50744
+ const text = contents.get(pathToFileURL4(filePath).toString());
50745
+ if (text === void 0) continue;
50746
+ try {
50747
+ const result = await formatDocument(text);
50748
+ if (result !== text) {
50749
+ writeFileSync2(filePath, result, "utf-8");
50750
+ formatted++;
50751
+ changedFiles.push(relativeTo(cwd, filePath));
50752
+ } else {
50753
+ unchanged++;
50754
+ }
50755
+ } catch {
50756
+ }
50757
+ }
50758
+ return { formatted, unchanged, files: changedFiles };
50759
+ }
50760
+ async function checkFormatting(pattern, cwd = process.cwd()) {
50761
+ const files = await resolveFiles(pattern, cwd);
50762
+ const contents = readFiles(files);
50763
+ const skipped = files.length > 0 ? await skippedReason(contents, files) : void 0;
50764
+ if (skipped) return { needsFormatting: [], alreadyFormatted: [], skipped };
50765
+ const needsFormatting = [];
50766
+ const alreadyFormatted = [];
50767
+ for (const filePath of files) {
50768
+ const text = contents.get(pathToFileURL4(filePath).toString());
50769
+ if (text === void 0) continue;
50770
+ try {
50771
+ const result = await formatDocument(text);
50772
+ (result !== text ? needsFormatting : alreadyFormatted).push(relativeTo(cwd, filePath));
50773
+ } catch {
50774
+ }
50775
+ }
50776
+ return { needsFormatting, alreadyFormatted };
50777
+ }
49672
50778
  async function startMcpServer() {
49673
50779
  const server = new McpServer({
49674
50780
  name: "spindle-lsp",
@@ -49682,9 +50788,12 @@ async function startMcpServer() {
49682
50788
  severity: external_exports3.enum(["error", "warning", "info", "hint"]).optional().describe("Minimum severity to include")
49683
50789
  },
49684
50790
  async (args2) => {
49685
- const results = await checkFiles(args2.path, args2.severity);
50791
+ const { results, skipped } = await checkProject(args2.path, args2.severity);
49686
50792
  return {
49687
- content: [{ type: "text", text: JSON.stringify(results, null, 2) }]
50793
+ content: [
50794
+ { type: "text", text: JSON.stringify(results, null, 2) },
50795
+ ...skipped ? [{ type: "text", text: skipped }] : []
50796
+ ]
49688
50797
  };
49689
50798
  }
49690
50799
  );
@@ -49695,27 +50804,7 @@ async function startMcpServer() {
49695
50804
  path: external_exports3.string().default("**/*.{tw,twee}").describe("Glob pattern or directory to format")
49696
50805
  },
49697
50806
  async (args2) => {
49698
- const cwd = process.cwd();
49699
- const files = await resolveFiles(args2.path, cwd);
49700
- let formatted = 0;
49701
- let unchanged = 0;
49702
- const changedFiles = [];
49703
- for (const filePath of files) {
49704
- try {
49705
- const text = readFileSync7(filePath, "utf-8");
49706
- const result = await formatDocument(text);
49707
- if (result !== text) {
49708
- writeFileSync2(filePath, result, "utf-8");
49709
- formatted++;
49710
- const relativePath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
49711
- changedFiles.push(relativePath);
49712
- } else {
49713
- unchanged++;
49714
- }
49715
- } catch {
49716
- }
49717
- }
49718
- const output = { formatted, unchanged, files: changedFiles };
50807
+ const output = await formatFiles(args2.path);
49719
50808
  return {
49720
50809
  content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
49721
50810
  };
@@ -49728,24 +50817,7 @@ async function startMcpServer() {
49728
50817
  path: external_exports3.string().default("**/*.{tw,twee}").describe("Glob pattern or directory to check")
49729
50818
  },
49730
50819
  async (args2) => {
49731
- const cwd = process.cwd();
49732
- const files = await resolveFiles(args2.path, cwd);
49733
- const needsFormatting = [];
49734
- const alreadyFormatted = [];
49735
- for (const filePath of files) {
49736
- try {
49737
- const text = readFileSync7(filePath, "utf-8");
49738
- const result = await formatDocument(text);
49739
- const relativePath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
49740
- if (result !== text) {
49741
- needsFormatting.push(relativePath);
49742
- } else {
49743
- alreadyFormatted.push(relativePath);
49744
- }
49745
- } catch {
49746
- }
49747
- }
49748
- const output = { needsFormatting, alreadyFormatted };
50820
+ const output = await checkFormatting(args2.path);
49749
50821
  return {
49750
50822
  content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
49751
50823
  };
@@ -49767,6 +50839,7 @@ var init_server3 = __esm({
49767
50839
  init_macro_sources();
49768
50840
  init_diagnostics();
49769
50841
  init_format();
50842
+ init_story_format();
49770
50843
  SEVERITY_ORDER2 = ["hint", "info", "warning", "error"];
49771
50844
  }
49772
50845
  });
@@ -58894,6 +59967,62 @@ var init_capabilities = __esm({
58894
59967
  }
58895
59968
  });
58896
59969
 
59970
+ // src/server/story-format-gate.ts
59971
+ function gateRegistrations(target, methods, enabled, nested = {}) {
59972
+ return new Proxy(target, {
59973
+ get(obj, prop, receiver) {
59974
+ const value = Reflect.get(obj, prop, receiver);
59975
+ if (typeof prop !== "string") return value;
59976
+ if (Object.hasOwn(nested, prop) && value !== null && (typeof value === "object" || typeof value === "function")) {
59977
+ return nested[prop](value);
59978
+ }
59979
+ if (methods.includes(prop) && typeof value === "function") {
59980
+ return (handler, ...rest) => Reflect.apply(value, obj, [
59981
+ (...args2) => enabled() ? handler(...args2) : null,
59982
+ ...rest
59983
+ ]);
59984
+ }
59985
+ return value;
59986
+ }
59987
+ });
59988
+ }
59989
+ function gateConnection(connection, enabled) {
59990
+ const languageFeatures = {};
59991
+ for (const [feature, methods] of Object.entries(GATED_LANGUAGE_FEATURES)) {
59992
+ languageFeatures[feature] = (value) => gateRegistrations(value, methods, enabled);
59993
+ }
59994
+ return gateRegistrations(connection, GATED_REQUESTS, enabled, {
59995
+ languages: (value) => gateRegistrations(value, [], enabled, languageFeatures)
59996
+ });
59997
+ }
59998
+ var GATED_REQUESTS, GATED_LANGUAGE_FEATURES;
59999
+ var init_story_format_gate = __esm({
60000
+ "src/server/story-format-gate.ts"() {
60001
+ "use strict";
60002
+ GATED_REQUESTS = [
60003
+ "onCompletion",
60004
+ "onHover",
60005
+ "onSignatureHelp",
60006
+ "onDefinition",
60007
+ "onReferences",
60008
+ "onPrepareRename",
60009
+ "onRenameRequest",
60010
+ "onCodeLens",
60011
+ "onCodeAction",
60012
+ "onDocumentFormatting",
60013
+ "onDocumentRangeFormatting",
60014
+ "onDocumentSymbol",
60015
+ "onWorkspaceSymbol",
60016
+ "onFoldingRanges",
60017
+ "onDocumentLinks"
60018
+ ];
60019
+ GATED_LANGUAGE_FEATURES = {
60020
+ semanticTokens: ["on"],
60021
+ inlayHint: ["on"]
60022
+ };
60023
+ }
60024
+ });
60025
+
58897
60026
  // src/plugins/completions.ts
58898
60027
  function getCompletions(uri, position, triggerChar, workspace) {
58899
60028
  const text = workspace.documents.getText(uri);
@@ -58904,11 +60033,11 @@ function getCompletions(uri, position, triggerChar, workspace) {
58904
60033
  if (/\{\/[A-Za-z\w-]*$/.test(lineText)) {
58905
60034
  return getClosingMacroCompletions(uri, text, position, workspace);
58906
60035
  }
58907
- const transientDotPathMatch = /%([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
60036
+ const transientDotPathMatch = /%([\w$]+)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
58908
60037
  if (transientDotPathMatch) {
58909
60038
  return getTransientDotPathCompletions(transientDotPathMatch[1], workspace);
58910
60039
  }
58911
- const dotPathMatch = /\$([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
60040
+ const dotPathMatch = /\$([\w$]+)\.([A-Za-z_$][\w$]*)?$/.exec(lineText);
58912
60041
  if (dotPathMatch) {
58913
60042
  return getDotPathCompletions(dotPathMatch[1], workspace);
58914
60043
  }
@@ -59122,6 +60251,215 @@ var init_completions = __esm({
59122
60251
  }
59123
60252
  });
59124
60253
 
60254
+ // src/plugins/references.ts
60255
+ function findReferences(uri, position, workspace, includeDeclaration) {
60256
+ const text = workspace.documents.getText(uri);
60257
+ if (text === void 0) return [];
60258
+ const lines = text.split("\n");
60259
+ if (position.line >= lines.length) return [];
60260
+ const line = lines[position.line];
60261
+ const header = parsePassageHeader(line, position.line);
60262
+ if (header) {
60263
+ const { start, end } = header.nameRange;
60264
+ if (position.character >= start.character && position.character <= end.character) {
60265
+ return findPassageReferences(header.name, workspace, includeDeclaration);
60266
+ }
60267
+ }
60268
+ {
60269
+ const varRegex = /\$([\w$]+)/g;
60270
+ let match2;
60271
+ while ((match2 = varRegex.exec(line)) !== null) {
60272
+ const start = match2.index;
60273
+ const end = start + match2[0].length;
60274
+ if (position.character >= start && position.character <= end) {
60275
+ const varName = match2[1];
60276
+ return findVariableReferences(varName, workspace, includeDeclaration);
60277
+ }
60278
+ }
60279
+ }
60280
+ {
60281
+ const transRegex = /(?<!\w)%([\w$]+)/g;
60282
+ let match2;
60283
+ while ((match2 = transRegex.exec(line)) !== null) {
60284
+ const start = match2.index;
60285
+ const end = start + match2[0].length;
60286
+ if (position.character >= start && position.character <= end) {
60287
+ const varName = match2[1];
60288
+ if (!isTransientAt(varName, uri, position.line, start, workspace)) break;
60289
+ return findTransientReferences(varName, workspace, includeDeclaration);
60290
+ }
60291
+ }
60292
+ }
60293
+ {
60294
+ const widgetRegex = /\{\/?([A-Za-z_$][\w$]*)/g;
60295
+ let match2;
60296
+ while ((match2 = widgetRegex.exec(line)) !== null) {
60297
+ const name = match2[1];
60298
+ const nameStart = match2.index + match2[0].length - name.length;
60299
+ const nameEnd = nameStart + name.length;
60300
+ if (position.character >= nameStart && position.character <= nameEnd) {
60301
+ const widget = workspace.widgets.getWidget(name);
60302
+ const isClosing = match2[0][1] === "/";
60303
+ if (!workspace.macros.getMacro(name) && widget && (!isClosing || widget.block)) {
60304
+ return findWidgetReferences(name, workspace, includeDeclaration);
60305
+ }
60306
+ }
60307
+ }
60308
+ }
60309
+ const passageRef = findPassageRefAt(text, position);
60310
+ if (passageRef) {
60311
+ return findPassageReferences(passageRef.name, workspace, includeDeclaration);
60312
+ }
60313
+ {
60314
+ const allPassages = workspace.passages.getAllPassages();
60315
+ const passageNames = new Set(allPassages.map((p) => p.name));
60316
+ const wordRegex = /[A-Za-z_$][\w$\s]*/g;
60317
+ let match2;
60318
+ while ((match2 = wordRegex.exec(line)) !== null) {
60319
+ const start = match2.index;
60320
+ const end = start + match2[0].length;
60321
+ if (position.character >= start && position.character <= end) {
60322
+ const word = match2[0].trim();
60323
+ if (passageNames.has(word)) {
60324
+ return findPassageReferences(word, workspace, includeDeclaration);
60325
+ }
60326
+ }
60327
+ }
60328
+ }
60329
+ return [];
60330
+ }
60331
+ function findPassageReferences(passageName, workspace, includeDeclaration) {
60332
+ const locations = [];
60333
+ if (includeDeclaration) {
60334
+ const passage = workspace.passages.getPassage(passageName);
60335
+ if (passage) {
60336
+ locations.push({
60337
+ uri: passage.uri,
60338
+ range: passage.nameRange
60339
+ });
60340
+ }
60341
+ }
60342
+ for (const docUri of workspace.documents.getUris()) {
60343
+ if (isMacroSource(docUri)) continue;
60344
+ const docText = workspace.documents.getText(docUri);
60345
+ if (!docText) continue;
60346
+ for (const ref of [...parseLinks(docText), ...parseMacroPassageRefs(docText)]) {
60347
+ if (ref.name === passageName) {
60348
+ locations.push({ uri: docUri, range: ref.range });
60349
+ }
60350
+ }
60351
+ }
60352
+ return locations;
60353
+ }
60354
+ function findVariableReferences(varName, workspace, includeDeclaration) {
60355
+ const locations = [];
60356
+ if (includeDeclaration) {
60357
+ const decl = workspace.variables.getDeclared().get(varName);
60358
+ if (decl?.declarationRange && decl.declarationUri) {
60359
+ locations.push({
60360
+ uri: decl.declarationUri,
60361
+ range: decl.declarationRange
60362
+ });
60363
+ }
60364
+ }
60365
+ const usages = workspace.variables.getUsages(varName);
60366
+ for (const u of usages) {
60367
+ locations.push({ uri: u.uri, range: u.range });
60368
+ }
60369
+ return locations;
60370
+ }
60371
+ function isTransientAt(name, uri, line, character, workspace) {
60372
+ if (!/^\d/.test(name)) return true;
60373
+ const at = (u) => u.uri === uri && u.range?.start.line === line && u.range.start.character === character;
60374
+ const decl = workspace.variables.getDeclaredTransient().get(name);
60375
+ if (decl && at({ uri: decl.declarationUri, range: decl.declarationRange })) return true;
60376
+ return workspace.variables.getTransientUsages(name).some(at);
60377
+ }
60378
+ function findTransientReferences(varName, workspace, includeDeclaration) {
60379
+ const results = [];
60380
+ if (includeDeclaration) {
60381
+ const decl = workspace.variables.getDeclaredTransient().get(varName);
60382
+ if (decl?.declarationUri && decl.declarationRange) {
60383
+ results.push({ uri: decl.declarationUri, range: decl.declarationRange });
60384
+ }
60385
+ }
60386
+ const usages = workspace.variables.getTransientUsages(varName);
60387
+ for (const u of usages) {
60388
+ results.push({ uri: u.uri, range: u.range });
60389
+ }
60390
+ return results;
60391
+ }
60392
+ function findWidgetReferences(widgetName, workspace, includeDeclaration) {
60393
+ const locations = [];
60394
+ const widget = workspace.widgets.getWidget(widgetName);
60395
+ if (includeDeclaration && widget) {
60396
+ locations.push({ uri: widget.uri, range: widget.range });
60397
+ }
60398
+ const widgetInvocationRegex = /\{(\/)?([A-Za-z_$][\w$]*)\b/g;
60399
+ const lowerName = widgetName.toLowerCase();
60400
+ const isBlock = widget?.block ?? false;
60401
+ for (const docUri of workspace.documents.getUris()) {
60402
+ if (isMacroSource(docUri)) continue;
60403
+ const docText = workspace.documents.getText(docUri);
60404
+ if (!docText) continue;
60405
+ const lines = docText.split("\n");
60406
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
60407
+ const line = lines[lineNum];
60408
+ widgetInvocationRegex.lastIndex = 0;
60409
+ let match2;
60410
+ while ((match2 = widgetInvocationRegex.exec(line)) !== null) {
60411
+ if (match2[1] && !isBlock) continue;
60412
+ if (match2[2].toLowerCase() === lowerName) {
60413
+ const nameStart = match2.index + 1 + (match2[1] ? 1 : 0);
60414
+ locations.push({
60415
+ uri: docUri,
60416
+ range: {
60417
+ start: { line: lineNum, character: nameStart },
60418
+ end: { line: lineNum, character: nameStart + match2[2].length }
60419
+ }
60420
+ });
60421
+ }
60422
+ }
60423
+ }
60424
+ }
60425
+ return locations;
60426
+ }
60427
+ function toLspRange(r) {
60428
+ return {
60429
+ start: { line: r.start.line, character: r.start.character },
60430
+ end: { line: r.end.line, character: r.end.character }
60431
+ };
60432
+ }
60433
+ var referencesPlugin;
60434
+ var init_references = __esm({
60435
+ "src/plugins/references.ts"() {
60436
+ "use strict";
60437
+ init_link_parser();
60438
+ init_passage_parser();
60439
+ init_macro_sources();
60440
+ referencesPlugin = {
60441
+ id: "references",
60442
+ capabilities: {
60443
+ referencesProvider: true
60444
+ },
60445
+ initialize(ctx) {
60446
+ ctx.connection.onReferences((params) => {
60447
+ const refs = findReferences(
60448
+ params.textDocument.uri,
60449
+ { line: params.position.line, character: params.position.character },
60450
+ ctx.workspace,
60451
+ params.context.includeDeclaration
60452
+ );
60453
+ return refs.map((r) => ({
60454
+ uri: r.uri,
60455
+ range: toLspRange(r.range)
60456
+ }));
60457
+ });
60458
+ }
60459
+ };
60460
+ }
60461
+ });
60462
+
59125
60463
  // src/plugins/hover.ts
59126
60464
  function getHoverInfo(uri, position, workspace) {
59127
60465
  const text = workspace.documents.getText(uri);
@@ -59131,18 +60469,18 @@ function getHoverInfo(uri, position, workspace) {
59131
60469
  const line = lines[position.line];
59132
60470
  const macroResult = getMacroHover(line, position, workspace);
59133
60471
  if (macroResult) return macroResult;
59134
- const varResult = getVariableHover(line, position, workspace);
60472
+ const varResult = getVariableHover(uri, line, position, workspace);
59135
60473
  if (varResult) return varResult;
59136
60474
  const widgetResult = getWidgetHover(line, position, workspace);
59137
60475
  if (widgetResult) return widgetResult;
59138
60476
  return null;
59139
60477
  }
59140
60478
  function getMacroHover(line, position, workspace) {
59141
- const macroRegex = /\{(\/)?(?:(?:[#.][a-zA-Z][\w-]*\s*)*)([A-Za-z][\w-]*)/g;
60479
+ const macroRegex = new RegExp(String.raw`\{(?:(\/)|${SELECTOR_PATTERN} )?([A-Za-z][\w-]*)`, "g");
59142
60480
  let match2;
59143
60481
  while ((match2 = macroRegex.exec(line)) !== null) {
59144
60482
  const name = match2[2];
59145
- const nameStart = match2.index + match2[0].indexOf(name);
60483
+ const nameStart = match2.index + match2[0].length - name.length;
59146
60484
  const nameEnd = nameStart + name.length;
59147
60485
  if (position.character >= nameStart && position.character <= nameEnd) {
59148
60486
  const info = workspace.macros.getMacro(name);
@@ -59171,9 +60509,9 @@ function getMacroHover(line, position, workspace) {
59171
60509
  }
59172
60510
  return null;
59173
60511
  }
59174
- function getVariableHover(line, position, workspace) {
60512
+ function getVariableHover(uri, line, position, workspace) {
59175
60513
  {
59176
- const re = /\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
60514
+ const re = /\$([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
59177
60515
  let match2;
59178
60516
  while ((match2 = re.exec(line)) !== null) {
59179
60517
  const start = match2.index;
@@ -59229,13 +60567,14 @@ Fields: ${decl.fields.map((f) => `\`${f}\``).join(", ")}` : "";
59229
60567
  }
59230
60568
  }
59231
60569
  {
59232
- const re = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
60570
+ const re = /(?<!\w)%([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
59233
60571
  let match2;
59234
60572
  while ((match2 = re.exec(line)) !== null) {
59235
60573
  const start = match2.index;
59236
60574
  const end = start + match2[0].length;
59237
60575
  if (position.character >= start && position.character <= end) {
59238
60576
  const baseName = match2[1].split(".")[0];
60577
+ if (!isTransientAt(baseName, uri, position.line, start, workspace)) break;
59239
60578
  const decl = workspace.variables.getDeclaredTransient().get(baseName);
59240
60579
  const typeInfo = decl?.fields && decl.fields.length > 0 ? `
59241
60580
 
@@ -59282,7 +60621,7 @@ Defined in: \`${widget.uri}\``,
59282
60621
  }
59283
60622
  };
59284
60623
  }
59285
- function toLspRange(r) {
60624
+ function toLspRange2(r) {
59286
60625
  return {
59287
60626
  start: { line: r.start.line, character: r.start.character },
59288
60627
  end: { line: r.end.line, character: r.end.character }
@@ -59292,6 +60631,8 @@ var hoverPlugin;
59292
60631
  var init_hover = __esm({
59293
60632
  "src/plugins/hover.ts"() {
59294
60633
  "use strict";
60634
+ init_macro_parser();
60635
+ init_references();
59295
60636
  hoverPlugin = {
59296
60637
  id: "hover",
59297
60638
  capabilities: {
@@ -59307,7 +60648,7 @@ var init_hover = __esm({
59307
60648
  if (!result) return null;
59308
60649
  return {
59309
60650
  contents: { kind: "markdown", value: result.contents },
59310
- range: toLspRange(result.range)
60651
+ range: toLspRange2(result.range)
59311
60652
  };
59312
60653
  });
59313
60654
  }
@@ -59374,7 +60715,7 @@ var init_signature = __esm({
59374
60715
  init_argument_lexer();
59375
60716
  init_widget_arguments();
59376
60717
  init_macro_parser();
59377
- macroHeadRegex2 = /(?<!\\)\{(?:[#.][a-zA-Z][\w-]*\s*)*([A-Za-z][\w-]*)\s+/g;
60718
+ macroHeadRegex2 = new RegExp(String.raw`(?<!\\)\{(?:${SELECTOR_PATTERN} )?([A-Za-z][\w-]*)\s+`, "g");
59378
60719
  signaturePlugin = {
59379
60720
  id: "signature",
59380
60721
  capabilities: {
@@ -59449,7 +60790,7 @@ function getWidgetDefinition(line, position, workspace) {
59449
60790
  }
59450
60791
  return null;
59451
60792
  }
59452
- function toLspRange2(r) {
60793
+ function toLspRange3(r) {
59453
60794
  return {
59454
60795
  start: { line: r.start.line, character: r.start.character },
59455
60796
  end: { line: r.end.line, character: r.end.character }
@@ -59475,7 +60816,7 @@ var init_definition = __esm({
59475
60816
  if (!result) return null;
59476
60817
  return {
59477
60818
  uri: result.uri,
59478
- range: toLspRange2(result.range)
60819
+ range: toLspRange3(result.range)
59479
60820
  };
59480
60821
  });
59481
60822
  }
@@ -59483,211 +60824,6 @@ var init_definition = __esm({
59483
60824
  }
59484
60825
  });
59485
60826
 
59486
- // src/plugins/references.ts
59487
- function findReferences(uri, position, workspace, includeDeclaration) {
59488
- const text = workspace.documents.getText(uri);
59489
- if (text === void 0) return [];
59490
- const lines = text.split("\n");
59491
- if (position.line >= lines.length) return [];
59492
- const line = lines[position.line];
59493
- const passageHeaderRegex2 = /^::\s*(\S.*?)(?:\s*\[|\s*\{|\s*$)/;
59494
- if (line.trimStart().startsWith("::")) {
59495
- const headerMatch = passageHeaderRegex2.exec(line);
59496
- if (headerMatch) {
59497
- const passageName = headerMatch[1].trim();
59498
- const nameStart = line.indexOf(passageName);
59499
- const nameEnd = nameStart + passageName.length;
59500
- if (position.character >= nameStart && position.character <= nameEnd) {
59501
- return findPassageReferences(passageName, workspace, includeDeclaration);
59502
- }
59503
- }
59504
- }
59505
- {
59506
- const varRegex = /\$([A-Za-z_$][\w$]*)/g;
59507
- let match2;
59508
- while ((match2 = varRegex.exec(line)) !== null) {
59509
- const start = match2.index;
59510
- const end = start + match2[0].length;
59511
- if (position.character >= start && position.character <= end) {
59512
- const varName = match2[1];
59513
- return findVariableReferences(varName, workspace, includeDeclaration);
59514
- }
59515
- }
59516
- }
59517
- {
59518
- const transRegex = /(?<!\w)%([A-Za-z_$][\w$]*)/g;
59519
- let match2;
59520
- while ((match2 = transRegex.exec(line)) !== null) {
59521
- const start = match2.index;
59522
- const end = start + match2[0].length;
59523
- if (position.character >= start && position.character <= end) {
59524
- const varName = match2[1];
59525
- return findTransientReferences(varName, workspace, includeDeclaration);
59526
- }
59527
- }
59528
- }
59529
- {
59530
- const widgetRegex = /\{\/?([A-Za-z_$][\w$]*)/g;
59531
- let match2;
59532
- while ((match2 = widgetRegex.exec(line)) !== null) {
59533
- const name = match2[1];
59534
- const nameStart = match2.index + match2[0].length - name.length;
59535
- const nameEnd = nameStart + name.length;
59536
- if (position.character >= nameStart && position.character <= nameEnd) {
59537
- const widget = workspace.widgets.getWidget(name);
59538
- const isClosing = match2[0][1] === "/";
59539
- if (!workspace.macros.getMacro(name) && widget && (!isClosing || widget.block)) {
59540
- return findWidgetReferences(name, workspace, includeDeclaration);
59541
- }
59542
- }
59543
- }
59544
- }
59545
- const passageRef = findPassageRefAt(text, position);
59546
- if (passageRef) {
59547
- return findPassageReferences(passageRef.name, workspace, includeDeclaration);
59548
- }
59549
- {
59550
- const allPassages = workspace.passages.getAllPassages();
59551
- const passageNames = new Set(allPassages.map((p) => p.name));
59552
- const wordRegex = /[A-Za-z_$][\w$\s]*/g;
59553
- let match2;
59554
- while ((match2 = wordRegex.exec(line)) !== null) {
59555
- const start = match2.index;
59556
- const end = start + match2[0].length;
59557
- if (position.character >= start && position.character <= end) {
59558
- const word = match2[0].trim();
59559
- if (passageNames.has(word)) {
59560
- return findPassageReferences(word, workspace, includeDeclaration);
59561
- }
59562
- }
59563
- }
59564
- }
59565
- return [];
59566
- }
59567
- function findPassageReferences(passageName, workspace, includeDeclaration) {
59568
- const locations = [];
59569
- if (includeDeclaration) {
59570
- const passage = workspace.passages.getPassage(passageName);
59571
- if (passage) {
59572
- locations.push({
59573
- uri: passage.uri,
59574
- range: passage.nameRange
59575
- });
59576
- }
59577
- }
59578
- for (const docUri of workspace.documents.getUris()) {
59579
- if (isMacroSource(docUri)) continue;
59580
- const docText = workspace.documents.getText(docUri);
59581
- if (!docText) continue;
59582
- for (const ref of [...parseLinks(docText), ...parseMacroPassageRefs(docText)]) {
59583
- if (ref.name === passageName) {
59584
- locations.push({ uri: docUri, range: ref.range });
59585
- }
59586
- }
59587
- }
59588
- return locations;
59589
- }
59590
- function findVariableReferences(varName, workspace, includeDeclaration) {
59591
- const locations = [];
59592
- if (includeDeclaration) {
59593
- const decl = workspace.variables.getDeclared().get(varName);
59594
- if (decl?.declarationRange && decl.declarationUri) {
59595
- locations.push({
59596
- uri: decl.declarationUri,
59597
- range: decl.declarationRange
59598
- });
59599
- }
59600
- }
59601
- const usages = workspace.variables.getUsages(varName);
59602
- for (const u of usages) {
59603
- locations.push({ uri: u.uri, range: u.range });
59604
- }
59605
- return locations;
59606
- }
59607
- function findTransientReferences(varName, workspace, includeDeclaration) {
59608
- const results = [];
59609
- if (includeDeclaration) {
59610
- const decl = workspace.variables.getDeclaredTransient().get(varName);
59611
- if (decl?.declarationUri && decl.declarationRange) {
59612
- results.push({ uri: decl.declarationUri, range: decl.declarationRange });
59613
- }
59614
- }
59615
- const usages = workspace.variables.getTransientUsages(varName);
59616
- for (const u of usages) {
59617
- results.push({ uri: u.uri, range: u.range });
59618
- }
59619
- return results;
59620
- }
59621
- function findWidgetReferences(widgetName, workspace, includeDeclaration) {
59622
- const locations = [];
59623
- const widget = workspace.widgets.getWidget(widgetName);
59624
- if (includeDeclaration && widget) {
59625
- locations.push({ uri: widget.uri, range: widget.range });
59626
- }
59627
- const widgetInvocationRegex = /\{(\/)?([A-Za-z_$][\w$]*)\b/g;
59628
- const lowerName = widgetName.toLowerCase();
59629
- const isBlock = widget?.block ?? false;
59630
- for (const docUri of workspace.documents.getUris()) {
59631
- if (isMacroSource(docUri)) continue;
59632
- const docText = workspace.documents.getText(docUri);
59633
- if (!docText) continue;
59634
- const lines = docText.split("\n");
59635
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
59636
- const line = lines[lineNum];
59637
- widgetInvocationRegex.lastIndex = 0;
59638
- let match2;
59639
- while ((match2 = widgetInvocationRegex.exec(line)) !== null) {
59640
- if (match2[1] && !isBlock) continue;
59641
- if (match2[2].toLowerCase() === lowerName) {
59642
- const nameStart = match2.index + 1 + (match2[1] ? 1 : 0);
59643
- locations.push({
59644
- uri: docUri,
59645
- range: {
59646
- start: { line: lineNum, character: nameStart },
59647
- end: { line: lineNum, character: nameStart + match2[2].length }
59648
- }
59649
- });
59650
- }
59651
- }
59652
- }
59653
- }
59654
- return locations;
59655
- }
59656
- function toLspRange3(r) {
59657
- return {
59658
- start: { line: r.start.line, character: r.start.character },
59659
- end: { line: r.end.line, character: r.end.character }
59660
- };
59661
- }
59662
- var referencesPlugin;
59663
- var init_references = __esm({
59664
- "src/plugins/references.ts"() {
59665
- "use strict";
59666
- init_link_parser();
59667
- init_macro_sources();
59668
- referencesPlugin = {
59669
- id: "references",
59670
- capabilities: {
59671
- referencesProvider: true
59672
- },
59673
- initialize(ctx) {
59674
- ctx.connection.onReferences((params) => {
59675
- const refs = findReferences(
59676
- params.textDocument.uri,
59677
- { line: params.position.line, character: params.position.character },
59678
- ctx.workspace,
59679
- params.context.includeDeclaration
59680
- );
59681
- return refs.map((r) => ({
59682
- uri: r.uri,
59683
- range: toLspRange3(r.range)
59684
- }));
59685
- });
59686
- }
59687
- };
59688
- }
59689
- });
59690
-
59691
60827
  // src/plugins/rename.ts
59692
60828
  function prepareRename(uri, position, workspace) {
59693
60829
  const symbol2 = resolveSymbolAtCursor(uri, position, workspace);
@@ -59705,8 +60841,11 @@ function computeRename(uri, position, newName, workspace) {
59705
60841
  }
59706
60842
  switch (symbol2.kind) {
59707
60843
  case "passage": {
59708
- const refs = findPassageReferences(symbol2.name, workspace, true);
59709
- for (const ref of refs) {
60844
+ const declaration = workspace.passages.getPassage(symbol2.name);
60845
+ if (declaration) {
60846
+ addEdit(declaration.uri, declaration.nameRange, escapePassageName(newName));
60847
+ }
60848
+ for (const ref of findPassageReferences(symbol2.name, workspace, false)) {
59710
60849
  addEdit(ref.uri, ref.range, newName);
59711
60850
  }
59712
60851
  break;
@@ -59737,33 +60876,24 @@ function computeRename(uri, position, newName, workspace) {
59737
60876
  }
59738
60877
  return edits;
59739
60878
  }
60879
+ function escapePassageName(name) {
60880
+ return name.replace(/[[\]{}\\]/g, "\\$&");
60881
+ }
59740
60882
  function resolveSymbolAtCursor(uri, position, workspace) {
59741
60883
  const text = workspace.documents.getText(uri);
59742
60884
  if (text === void 0) return null;
59743
60885
  const lines = text.split("\n");
59744
60886
  if (position.line >= lines.length) return null;
59745
60887
  const line = lines[position.line];
59746
- const passageHeaderRegex2 = /^::\s*(\S.*?)(?:\s*\[|\s*\{|\s*$)/;
59747
- if (line.trimStart().startsWith("::")) {
59748
- const headerMatch = passageHeaderRegex2.exec(line);
59749
- if (headerMatch) {
59750
- const passageName = headerMatch[1].trim();
59751
- const nameStart = line.indexOf(passageName);
59752
- const nameEnd = nameStart + passageName.length;
59753
- if (position.character >= nameStart && position.character <= nameEnd) {
59754
- return {
59755
- kind: "passage",
59756
- name: passageName,
59757
- range: {
59758
- start: { line: position.line, character: nameStart },
59759
- end: { line: position.line, character: nameEnd }
59760
- }
59761
- };
59762
- }
60888
+ const header = parsePassageHeader(line, position.line);
60889
+ if (header) {
60890
+ const { start, end } = header.nameRange;
60891
+ if (position.character >= start.character && position.character <= end.character) {
60892
+ return { kind: "passage", name: header.name, range: header.nameRange };
59763
60893
  }
59764
60894
  }
59765
60895
  {
59766
- const varRegex = /\$([A-Za-z_$][\w$]*)/g;
60896
+ const varRegex = /\$([\w$]+)/g;
59767
60897
  let match2;
59768
60898
  while ((match2 = varRegex.exec(line)) !== null) {
59769
60899
  const start = match2.index;
@@ -59782,12 +60912,13 @@ function resolveSymbolAtCursor(uri, position, workspace) {
59782
60912
  }
59783
60913
  }
59784
60914
  {
59785
- const transRegex = /(?<!\w)%([A-Za-z_$][\w$]*)/g;
60915
+ const transRegex = /(?<!\w)%([\w$]+)/g;
59786
60916
  let match2;
59787
60917
  while ((match2 = transRegex.exec(line)) !== null) {
59788
60918
  const start = match2.index;
59789
60919
  const end = start + match2[0].length;
59790
60920
  if (position.character >= start && position.character <= end) {
60921
+ if (!isTransientAt(match2[1], uri, position.line, start, workspace)) break;
59791
60922
  return {
59792
60923
  kind: "variable",
59793
60924
  name: match2[1],
@@ -59846,6 +60977,7 @@ var init_rename = __esm({
59846
60977
  "src/plugins/rename.ts"() {
59847
60978
  "use strict";
59848
60979
  init_link_parser();
60980
+ init_passage_parser();
59849
60981
  init_references();
59850
60982
  renamePlugin = {
59851
60983
  id: "rename",
@@ -59898,9 +61030,9 @@ function computeCodeLenses(uri, workspace) {
59898
61030
  const isStoryVarsFile = storyVarsPassage && storyVarsPassage.uri === uri;
59899
61031
  for (let lineNum = 0; lineNum < lines.length; lineNum++) {
59900
61032
  const line = lines[lineNum];
59901
- const passageMatch = line.match(/^::\s*(\S.*?)(?:\s*\[|\s*\{|\s*$)/);
59902
- if (passageMatch) {
59903
- const passageName = passageMatch[1].trim();
61033
+ const header = parsePassageHeader(line, lineNum);
61034
+ if (header) {
61035
+ const passageName = header.name;
59904
61036
  if (passageName === "StoryData") continue;
59905
61037
  const refs = findPassageReferences(passageName, workspace, true);
59906
61038
  const refCount = Math.max(0, refs.length - 1);
@@ -59931,7 +61063,7 @@ function computeCodeLenses(uri, workspace) {
59931
61063
  });
59932
61064
  }
59933
61065
  if (isStoryVarsFile) {
59934
- const varDeclMatch = line.match(/^\$([A-Za-z_$][\w$]*)\s*=/);
61066
+ const varDeclMatch = line.match(/^\$(\w+)\s*=/);
59935
61067
  if (varDeclMatch) {
59936
61068
  const varName = varDeclMatch[1];
59937
61069
  const refs = findVariableReferences(varName, workspace, true);
@@ -59960,6 +61092,7 @@ var codeLensPlugin;
59960
61092
  var init_code_lens = __esm({
59961
61093
  "src/plugins/code-lens.ts"() {
59962
61094
  "use strict";
61095
+ init_passage_parser();
59963
61096
  init_references();
59964
61097
  codeLensPlugin = {
59965
61098
  id: "code-lens",
@@ -60025,7 +61158,7 @@ function addVariableTypeHints(uri, text, range2, workspace, hints) {
60025
61158
  const endLine = Math.min(range2.end.line, lines.length - 1);
60026
61159
  for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
60027
61160
  const line = lines[lineNum];
60028
- const declMatch = line.match(/^\$([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
61161
+ const declMatch = line.match(/^\$(\w+)\s*=\s*(.+)$/);
60029
61162
  if (!declMatch) continue;
60030
61163
  const valueStr = declMatch[2].trim();
60031
61164
  const inferredType = inferType(valueStr);
@@ -60047,7 +61180,7 @@ function addTransientTypeHints(uri, text, range2, workspace, hints) {
60047
61180
  const endLine = Math.min(range2.end.line, lines.length - 1);
60048
61181
  for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
60049
61182
  const line = lines[lineNum];
60050
- const declMatch = line.match(/^%([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
61183
+ const declMatch = line.match(/^%(\w+)\s*=\s*(.+)$/);
60051
61184
  if (!declMatch) continue;
60052
61185
  const valueStr = declMatch[2].trim();
60053
61186
  const inferredType = inferType(valueStr);
@@ -60123,7 +61256,6 @@ function computeSemanticTokensAbsolute(uri, workspace) {
60123
61256
  for (const passage of passages) {
60124
61257
  const headerLine = passage.headerEnd.start.line;
60125
61258
  headerLines.add(headerLine);
60126
- const rawLine = lines[headerLine] ?? "";
60127
61259
  tokens.push({
60128
61260
  line: headerLine,
60129
61261
  startChar: 0,
@@ -60131,18 +61263,14 @@ function computeSemanticTokensAbsolute(uri, workspace) {
60131
61263
  tokenType: encodeType("namespace"),
60132
61264
  tokenModifiers: 0
60133
61265
  });
60134
- const nameMatch = rawLine.match(/^::\s*(\S.*?)(?:\s*\[|\s*\{|\s*$)/);
60135
- if (nameMatch) {
60136
- const name = nameMatch[1].trim();
60137
- const nameStart = rawLine.indexOf(name);
60138
- tokens.push({
60139
- line: headerLine,
60140
- startChar: nameStart,
60141
- length: name.length,
60142
- tokenType: encodeType("namespace"),
60143
- tokenModifiers: encodeModifiers(["declaration"])
60144
- });
60145
- }
61266
+ const { start, end } = passage.nameRange;
61267
+ tokens.push({
61268
+ line: headerLine,
61269
+ startChar: start.character,
61270
+ length: end.character - start.character,
61271
+ tokenType: encodeType("namespace"),
61272
+ tokenModifiers: encodeModifiers(["declaration"])
61273
+ });
60146
61274
  }
60147
61275
  const macros = parseMacros(text);
60148
61276
  for (const macro of macros) {
@@ -60161,10 +61289,10 @@ function computeSemanticTokensAbsolute(uri, workspace) {
60161
61289
  tokenModifiers: encodeModifiers(isDefined ? ["defaultLibrary"] : [])
60162
61290
  });
60163
61291
  }
60164
- const storyVarRegex = /(?<!\w)\$([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
61292
+ const storyVarRegex = /(?<!\w)\$([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
60165
61293
  const tempVarRegex = /(?<!\w)_([A-Za-z_$][\w$]*)/g;
60166
61294
  const localVarRegex = /(?<!\w)@([A-Za-z_$][\w$]*)/g;
60167
- const transientVarRegex = /(?<!\w)%([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/g;
61295
+ const transientVarRegex = /(?<!\w)%([\w$]+(?:\.[A-Za-z_$][\w$]*)*)/g;
60168
61296
  const sugarKeywordRegex = /\b(to|is|isnot|eq|neq|gt|gte|lt|lte|and|or|not|def|ndef)\b/g;
60169
61297
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
60170
61298
  if (headerLines.has(lineIndex)) continue;
@@ -60202,6 +61330,7 @@ function computeSemanticTokensAbsolute(uri, workspace) {
60202
61330
  }
60203
61331
  transientVarRegex.lastIndex = 0;
60204
61332
  while ((m = transientVarRegex.exec(line)) !== null) {
61333
+ if (!isTransientAt(m[1].split(".")[0], uri, lineIndex, m.index, workspace)) continue;
60205
61334
  tokens.push({
60206
61335
  line: lineIndex,
60207
61336
  startChar: m.index,
@@ -60246,6 +61375,7 @@ var init_semantic_tokens = __esm({
60246
61375
  "src/plugins/semantic-tokens.ts"() {
60247
61376
  "use strict";
60248
61377
  init_macro_parser();
61378
+ init_references();
60249
61379
  tokenTypesLegend = [
60250
61380
  "function",
60251
61381
  "variable",
@@ -60295,7 +61425,7 @@ var init_semantic_tokens = __esm({
60295
61425
  });
60296
61426
 
60297
61427
  // src/plugins/code-actions.ts
60298
- import { readFileSync as readFileSync8 } from "node:fs";
61428
+ import { readFileSync as readFileSync9 } from "node:fs";
60299
61429
  import { basename, dirname as dirname3, join as join4 } from "node:path";
60300
61430
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL5 } from "node:url";
60301
61431
  function computeCodeActions(uri, diagnostics, workspace, options = {}) {
@@ -60360,7 +61490,7 @@ function fixUndefinedMacro(uri, diag, workspace, options) {
60360
61490
  let text = workspace.documents.getText(configUri);
60361
61491
  if (text === void 0) {
60362
61492
  try {
60363
- text = readFileSync8(existingPath, "utf-8");
61493
+ text = readFileSync9(existingPath, "utf-8");
60364
61494
  } catch {
60365
61495
  return null;
60366
61496
  }
@@ -60691,7 +61821,7 @@ function computeDocumentSymbols(uri, workspace) {
60691
61821
  const contentStart = passage.range.start.line + 1;
60692
61822
  const contentEnd = passage.range.end.line + 1;
60693
61823
  for (let i = contentStart; i < contentEnd && i < lines.length; i++) {
60694
- const varMatch = lines[i].match(/^\$([A-Za-z_$][\w$]*)\s*=/);
61824
+ const varMatch = lines[i].match(/^\$(\w+)\s*=/);
60695
61825
  if (varMatch) {
60696
61826
  const charEnd = varMatch[0].length;
60697
61827
  passageSymbol.children.push({
@@ -60990,7 +62120,7 @@ __export(server_exports2, {
60990
62120
  startServer: () => startServer
60991
62121
  });
60992
62122
  import { fileURLToPath as fileURLToPath6, pathToFileURL as pathToFileURL6 } from "node:url";
60993
- import { readFileSync as readFileSync9 } from "node:fs";
62123
+ import { readFileSync as readFileSync10 } from "node:fs";
60994
62124
  import { basename as basename2 } from "node:path";
60995
62125
  function uriToFsPath(uri) {
60996
62126
  try {
@@ -61028,9 +62158,27 @@ function startServer(_args) {
61028
62158
  for (const warning of workspace.macros.warnings) console.error("[spindle-lsp]", warning);
61029
62159
  activePlugins = loadPlugins(allPlugins, config2);
61030
62160
  console.error("[spindle-lsp] plugins loaded:", activePlugins.map((p) => p.id).join(", "));
62161
+ const model = workspace;
62162
+ const pluginConnection = gateConnection(connection, () => model.isSpindleProject());
61031
62163
  for (const plugin of activePlugins) {
61032
- plugin.initialize({ connection, workspace, config: config2 });
61033
- }
62164
+ plugin.initialize({ connection: pluginConnection, workspace, config: config2 });
62165
+ }
62166
+ const refresh = params.capabilities.workspace;
62167
+ let spindle = workspace.isSpindleProject();
62168
+ workspace.on("storyFormatChanged", (format) => {
62169
+ if (format.isSpindle === spindle) return;
62170
+ spindle = format.isSpindle;
62171
+ console.error(format.isSpindle ? "[spindle-lsp] story format is Spindle; language features enabled" : `[spindle-lsp] story format "${format.name}" is not Spindle; language features disabled`);
62172
+ const requests = [
62173
+ refresh?.semanticTokens?.refreshSupport && "workspace/semanticTokens/refresh",
62174
+ refresh?.inlayHint?.refreshSupport && "workspace/inlayHint/refresh",
62175
+ refresh?.codeLens?.refreshSupport && "workspace/codeLens/refresh"
62176
+ ];
62177
+ for (const method of requests) {
62178
+ if (method) connection.sendRequest(method).catch(() => {
62179
+ });
62180
+ }
62181
+ });
61034
62182
  if (Object.keys(projectConfig.macros).length > 0) {
61035
62183
  workspace.macros.loadConfig(projectConfig.macros);
61036
62184
  console.error("[spindle-lsp] loaded config macros:", Object.keys(projectConfig.macros).length);
@@ -61097,7 +62245,7 @@ function startServer(_args) {
61097
62245
  if (workspace) {
61098
62246
  try {
61099
62247
  const fsPath = uriToFsPath(textDocument.uri);
61100
- const text = readFileSync9(fsPath, "utf-8");
62248
+ const text = readFileSync10(fsPath, "utf-8");
61101
62249
  workspace.documents.update(textDocument.uri, text);
61102
62250
  } catch {
61103
62251
  workspace.documents.close(textDocument.uri);
@@ -61133,7 +62281,7 @@ function startServer(_args) {
61133
62281
  } else {
61134
62282
  try {
61135
62283
  const fsPath = uriToFsPath(change.uri);
61136
- const text = readFileSync9(fsPath, "utf-8");
62284
+ const text = readFileSync10(fsPath, "utf-8");
61137
62285
  if (workspace.documents.has(change.uri)) {
61138
62286
  workspace.documents.update(change.uri, text);
61139
62287
  } else {
@@ -61169,7 +62317,7 @@ async function scanWorkspaceFiles(root) {
61169
62317
  ];
61170
62318
  for (const filePath of files) {
61171
62319
  try {
61172
- const text = readFileSync9(filePath, "utf-8");
62320
+ const text = readFileSync10(filePath, "utf-8");
61173
62321
  const uri = fsPathToUri(filePath);
61174
62322
  contents.set(uri, text);
61175
62323
  } catch {
@@ -61189,6 +62337,7 @@ var init_server4 = __esm({
61189
62337
  init_workspace_model();
61190
62338
  init_plugin_loader();
61191
62339
  init_capabilities();
62340
+ init_story_format_gate();
61192
62341
  init_plugins();
61193
62342
  init_config_loader();
61194
62343
  init_macro_sources();
@@ -61197,7 +62346,7 @@ var init_server4 = __esm({
61197
62346
 
61198
62347
  // src/bin.ts
61199
62348
  var args = process.argv.slice(2);
61200
- var VERSION = "0.9.3";
62349
+ var VERSION = "0.10.0";
61201
62350
  var HELP = `spindle-lsp v${VERSION}
61202
62351
  Language Server Protocol server for Spindle story format
61203
62352