@rohal12/spindle-lsp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1011,6 +1011,12 @@ var init_macro_supplements = __esm({
1011
1011
  name: "unwatch",
1012
1012
  description: "Stops watching a variable.\n\nUsage: `{unwatch $var}`",
1013
1013
  parameters: ["...text"]
1014
+ },
1015
+ unless: {
1016
+ name: "unless",
1017
+ container: true,
1018
+ description: "Conditionally displays its contents when the expression is falsy. The inverse of `{if}`.\n\nUsage:\n\n```\n{unless condition}\n ...\n{/unless}\n```",
1019
+ parameters: ["...text"]
1014
1020
  }
1015
1021
  };
1016
1022
  }
@@ -2787,51 +2793,369 @@ var init_check = __esm({
2787
2793
  }
2788
2794
  });
2789
2795
 
2790
- // src/plugins/format.ts
2791
- function formatDocument(text) {
2796
+ // src/plugins/format/segment.ts
2797
+ function splitPassages(text) {
2792
2798
  const lines = text.split("\n");
2793
- const result = [];
2794
- let indentLevel = 0;
2799
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
2800
+ lines.pop();
2801
+ }
2802
+ const passages = [];
2803
+ let currentHeader = "";
2804
+ let bodyLines = [];
2805
+ let startLine = 0;
2806
+ function pushPassage() {
2807
+ if (currentHeader || bodyLines.length > 0) {
2808
+ while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1].trim() === "") {
2809
+ bodyLines.pop();
2810
+ }
2811
+ passages.push({
2812
+ header: currentHeader,
2813
+ body: bodyLines.join("\n") + (bodyLines.length > 0 ? "\n" : ""),
2814
+ startLine
2815
+ });
2816
+ }
2817
+ }
2795
2818
  for (let i = 0; i < lines.length; i++) {
2796
- let line = lines[i];
2797
- line = line.replace(/\s+$/, "");
2798
- if (PASSAGE_HEADER_REGEX.test(line)) {
2799
- line = normalizePassageHeader(line);
2800
- indentLevel = 0;
2801
- result.push(line);
2819
+ if (PASSAGE_HEADER_REGEX.test(lines[i])) {
2820
+ pushPassage();
2821
+ currentHeader = lines[i];
2822
+ bodyLines = [];
2823
+ startLine = i;
2824
+ } else {
2825
+ bodyLines.push(lines[i]);
2826
+ }
2827
+ }
2828
+ pushPassage();
2829
+ return passages;
2830
+ }
2831
+ function classifyPassage(header) {
2832
+ const tagMatch = header.match(/\[([^\]]*)\]/g);
2833
+ if (!tagMatch) return "normal";
2834
+ const tags = tagMatch.map((t) => t.slice(1, -1).trim().toLowerCase());
2835
+ if (tags.includes("script")) return "script";
2836
+ if (tags.includes("stylesheet")) return "stylesheet";
2837
+ return "normal";
2838
+ }
2839
+ function segmentRegions(body) {
2840
+ const lines = body.split("\n");
2841
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
2842
+ lines.pop();
2843
+ }
2844
+ if (lines.length === 0) return [];
2845
+ const regions = [];
2846
+ let i = 0;
2847
+ while (i < lines.length) {
2848
+ const line = lines[i];
2849
+ const trimmed = line.trim();
2850
+ if (/^<script(\s|>)/i.test(trimmed)) {
2851
+ const scriptLines = [line];
2852
+ i++;
2853
+ while (i < lines.length && !/<\/script>/i.test(lines[i])) {
2854
+ scriptLines.push(lines[i]);
2855
+ i++;
2856
+ }
2857
+ if (i < lines.length) {
2858
+ scriptLines.push(lines[i]);
2859
+ i++;
2860
+ }
2861
+ regions.push({ type: "script", lines: scriptLines });
2862
+ continue;
2863
+ }
2864
+ if (/^<([a-zA-Z][\w-]*)/.test(trimmed)) {
2865
+ const tagMatch = trimmed.match(/^<([a-zA-Z][\w-]*)/);
2866
+ const tagName = tagMatch[1].toLowerCase();
2867
+ if (VOID_ELEMENTS.has(tagName) && !lines[i + 1]?.trim().startsWith("<")) {
2868
+ pushSpindleLine(regions, line);
2869
+ i++;
2870
+ continue;
2871
+ }
2872
+ const selfClosing = new RegExp(`</${tagName}\\s*>`, "i");
2873
+ if (selfClosing.test(trimmed)) {
2874
+ pushSpindleLine(regions, line);
2875
+ i++;
2876
+ continue;
2877
+ }
2878
+ const htmlLines = [line];
2879
+ let depth = 1;
2880
+ i++;
2881
+ while (i < lines.length && depth > 0) {
2882
+ htmlLines.push(lines[i]);
2883
+ const opens = (lines[i].match(new RegExp(`<${tagName}[\\s>]`, "gi")) ?? []).length;
2884
+ const closes = (lines[i].match(new RegExp(`</${tagName}\\s*>`, "gi")) ?? []).length;
2885
+ depth += opens - closes;
2886
+ i++;
2887
+ }
2888
+ regions.push({ type: "html", lines: htmlLines });
2889
+ continue;
2890
+ }
2891
+ pushSpindleLine(regions, line);
2892
+ i++;
2893
+ }
2894
+ return regions;
2895
+ }
2896
+ function pushSpindleLine(regions, line) {
2897
+ const last = regions[regions.length - 1];
2898
+ if (last?.type === "spindle") {
2899
+ last.lines.push(line);
2900
+ } else {
2901
+ regions.push({ type: "spindle", lines: [line] });
2902
+ }
2903
+ }
2904
+ var PASSAGE_HEADER_REGEX, VOID_ELEMENTS;
2905
+ var init_segment = __esm({
2906
+ "src/plugins/format/segment.ts"() {
2907
+ "use strict";
2908
+ PASSAGE_HEADER_REGEX = /^::\s+/;
2909
+ VOID_ELEMENTS = /* @__PURE__ */ new Set([
2910
+ "area",
2911
+ "base",
2912
+ "br",
2913
+ "col",
2914
+ "embed",
2915
+ "hr",
2916
+ "img",
2917
+ "input",
2918
+ "link",
2919
+ "meta",
2920
+ "param",
2921
+ "source",
2922
+ "track",
2923
+ "wbr"
2924
+ ]);
2925
+ }
2926
+ });
2927
+
2928
+ // src/plugins/format/prettier-bridge.ts
2929
+ async function loadPrettier() {
2930
+ if (prettierLoaded) return prettier;
2931
+ prettierLoaded = true;
2932
+ try {
2933
+ prettier = await import("prettier");
2934
+ } catch {
2935
+ prettier = null;
2936
+ }
2937
+ return prettier;
2938
+ }
2939
+ async function getBaseConfig() {
2940
+ const p = await loadPrettier();
2941
+ if (!p) return {};
2942
+ try {
2943
+ const resolved = await p.resolveConfig(process.cwd());
2944
+ return { tabWidth: 2, ...resolved ?? {} };
2945
+ } catch {
2946
+ return { tabWidth: 2 };
2947
+ }
2948
+ }
2949
+ async function formatJS(code) {
2950
+ const p = await loadPrettier();
2951
+ if (!p) return code;
2952
+ try {
2953
+ const config = await getBaseConfig();
2954
+ return await p.format(code, { ...config, parser: "babel" });
2955
+ } catch {
2956
+ return code;
2957
+ }
2958
+ }
2959
+ async function formatCSS(code) {
2960
+ const p = await loadPrettier();
2961
+ if (!p) return code;
2962
+ try {
2963
+ const config = await getBaseConfig();
2964
+ return await p.format(code, { ...config, parser: "css" });
2965
+ } catch {
2966
+ return code;
2967
+ }
2968
+ }
2969
+ async function formatHTML(code) {
2970
+ const p = await loadPrettier();
2971
+ if (!p) return code;
2972
+ try {
2973
+ const config = await getBaseConfig();
2974
+ return await p.format(code, {
2975
+ ...config,
2976
+ parser: "html",
2977
+ htmlWhitespaceSensitivity: "ignore"
2978
+ });
2979
+ } catch {
2980
+ return code;
2981
+ }
2982
+ }
2983
+ var prettier, prettierLoaded;
2984
+ var init_prettier_bridge = __esm({
2985
+ "src/plugins/format/prettier-bridge.ts"() {
2986
+ "use strict";
2987
+ prettier = null;
2988
+ prettierLoaded = false;
2989
+ }
2990
+ });
2991
+
2992
+ // src/plugins/format/placeholders.ts
2993
+ function isInsideAttribute(text, pos) {
2994
+ let inQuote = false;
2995
+ let quoteChar = "";
2996
+ for (let i = 0; i < pos; i++) {
2997
+ const ch = text[i];
2998
+ if (!inQuote && (ch === '"' || ch === "'") && i > 0 && text[i - 1] === "=") {
2999
+ inQuote = true;
3000
+ quoteChar = ch;
3001
+ } else if (inQuote && ch === quoteChar) {
3002
+ inQuote = false;
3003
+ }
3004
+ }
3005
+ return inQuote;
3006
+ }
3007
+ function replaceSpindleTokens(html) {
3008
+ const tokens = [];
3009
+ const text = html.replace(SPINDLE_TOKEN_REGEX, (match, ...args2) => {
3010
+ const offset = args2[args2.length - 2];
3011
+ const idx = tokens.length;
3012
+ tokens.push(match);
3013
+ if (isInsideAttribute(html, offset)) {
3014
+ return `__SP${idx}__`;
3015
+ }
3016
+ return `<!--SP:${idx}-->`;
3017
+ });
3018
+ return { text, tokens };
3019
+ }
3020
+ function restoreSpindleTokens(text, tokens) {
3021
+ let result = text;
3022
+ for (let i = 0; i < tokens.length; i++) {
3023
+ result = result.replace(`<!--SP:${i}-->`, tokens[i]);
3024
+ result = result.replace(`__SP${i}__`, tokens[i]);
3025
+ }
3026
+ return result;
3027
+ }
3028
+ var SPINDLE_TOKEN_REGEX;
3029
+ var init_placeholders = __esm({
3030
+ "src/plugins/format/placeholders.ts"() {
3031
+ "use strict";
3032
+ SPINDLE_TOKEN_REGEX = /(?:\{\/[A-Za-z][\w-]*\s*\}|\{(?:[#.][a-zA-Z][\w-]*\s*)*[A-Za-z][\w-]*(?:\s+(?:[^}]|\}(?=[^}]))*?)?\}|\{[$_@][a-zA-Z][\w.]*\}|\[\[(?:[^\]]*)\]\])/g;
3033
+ }
3034
+ });
3035
+
3036
+ // src/plugins/format.ts
3037
+ function getSupplementContainers() {
3038
+ const containers = /* @__PURE__ */ new Set();
3039
+ for (const [key, entry] of Object.entries(macro_supplements_default)) {
3040
+ if (entry.container) {
3041
+ containers.add(key.toLowerCase());
3042
+ }
3043
+ }
3044
+ return containers;
3045
+ }
3046
+ function detectContainersFromText(text) {
3047
+ const found = /* @__PURE__ */ new Set();
3048
+ const re = /\{\/([A-Za-z][\w-]*)\s*\}/g;
3049
+ let m;
3050
+ while ((m = re.exec(text)) !== null) {
3051
+ found.add(m[1].toLowerCase());
3052
+ }
3053
+ return found;
3054
+ }
3055
+ function buildDefaultIsBlock(text) {
3056
+ const containers = getSupplementContainers();
3057
+ for (const name of detectContainersFromText(text)) {
3058
+ containers.add(name);
3059
+ }
3060
+ return (name) => containers.has(name.toLowerCase());
3061
+ }
3062
+ async function formatDocument(text, options) {
3063
+ const isBlock = options?.isBlock ?? buildDefaultIsBlock(text);
3064
+ const isDedenting = options?.isDedentingSubMacro ?? ((name) => DEFAULT_DEDENTING.has(name.toLowerCase()));
3065
+ const passages = splitPassages(text);
3066
+ const resultLines = [];
3067
+ for (let pi = 0; pi < passages.length; pi++) {
3068
+ const passage = passages[pi];
3069
+ if (pi > 0 && passage.header) {
3070
+ const prev = passages[pi - 1];
3071
+ const prevBodyLineCount = prev.body ? prev.body.split("\n").length - 1 : 0;
3072
+ const prevEndLine = prev.startLine + (prev.header ? 1 : 0) + prevBodyLineCount;
3073
+ if (passage.startLine > prevEndLine) {
3074
+ resultLines.push("");
3075
+ }
3076
+ }
3077
+ if (passage.header) {
3078
+ const header = PASSAGE_HEADER_REGEX2.test(passage.header) ? normalizePassageHeader(passage.header) : passage.header;
3079
+ resultLines.push(header);
3080
+ }
3081
+ const kind = classifyPassage(passage.header);
3082
+ if (kind === "script") {
3083
+ const formatted = await formatJS(passage.body.trim());
3084
+ resultLines.push(formatted.trim());
2802
3085
  continue;
2803
3086
  }
3087
+ if (kind === "stylesheet") {
3088
+ const formatted = await formatCSS(passage.body.trim());
3089
+ resultLines.push(formatted.trim());
3090
+ continue;
3091
+ }
3092
+ const regions = segmentRegions(passage.body);
3093
+ for (const region of regions) {
3094
+ if (region.type === "script") {
3095
+ const firstLine = region.lines[0];
3096
+ const lastLine = region.lines[region.lines.length - 1];
3097
+ const innerLines = region.lines.slice(1, region.lines.length - 1);
3098
+ const innerCode = innerLines.join("\n");
3099
+ const formatted = await formatJS(innerCode.trim());
3100
+ resultLines.push(firstLine);
3101
+ if (formatted.trim()) {
3102
+ for (const fLine of formatted.trim().split("\n")) {
3103
+ resultLines.push(" " + fLine);
3104
+ }
3105
+ }
3106
+ resultLines.push(lastLine);
3107
+ continue;
3108
+ }
3109
+ if (region.type === "html") {
3110
+ const htmlText = region.lines.join("\n");
3111
+ const { text: placeholdered, tokens } = replaceSpindleTokens(htmlText);
3112
+ const formatted = await formatHTML(placeholdered);
3113
+ const restored = restoreSpindleTokens(formatted.trim(), tokens);
3114
+ for (const fLine of restored.split("\n")) {
3115
+ resultLines.push(fLine);
3116
+ }
3117
+ continue;
3118
+ }
3119
+ const indented = indentMacros(region.lines, isBlock, isDedenting);
3120
+ resultLines.push(...indented);
3121
+ }
3122
+ }
3123
+ let output = resultLines.map((l) => l.replace(/\s+$/, "")).join("\n");
3124
+ output = output.replace(/\n*$/, "\n");
3125
+ return output;
3126
+ }
3127
+ function indentMacros(lines, isBlock, isDedenting) {
3128
+ const result = [];
3129
+ let indentLevel = 0;
3130
+ for (const rawLine of lines) {
3131
+ const line = rawLine.replace(/\s+$/, "");
2804
3132
  const trimmed = line.trim();
2805
3133
  if (trimmed === "") {
2806
3134
  result.push("");
2807
3135
  continue;
2808
3136
  }
2809
- if (BLOCK_CLOSE_REGEX.test(trimmed)) {
3137
+ const dedentMatch = trimmed.match(MACRO_OPEN_REGEX);
3138
+ if (dedentMatch && isDedenting(dedentMatch[1])) {
2810
3139
  indentLevel = Math.max(0, indentLevel - 1);
3140
+ result.push(indentLevel > 0 ? " ".repeat(indentLevel) + trimmed : trimmed);
3141
+ indentLevel++;
3142
+ continue;
2811
3143
  }
2812
- const indented = indentLevel > 0 ? " ".repeat(indentLevel) + trimmed : trimmed;
2813
- result.push(indented);
2814
- if (BLOCK_OPEN_REGEX.test(trimmed)) {
3144
+ const closeMatch = trimmed.match(MACRO_CLOSE_REGEX);
3145
+ if (closeMatch) {
3146
+ indentLevel = Math.max(0, indentLevel - 1);
3147
+ }
3148
+ result.push(indentLevel > 0 ? " ".repeat(indentLevel) + trimmed : trimmed);
3149
+ const openMatch = trimmed.match(MACRO_OPEN_REGEX);
3150
+ if (openMatch && isBlock(openMatch[1])) {
2815
3151
  indentLevel++;
2816
3152
  }
2817
3153
  }
2818
- let output = result.join("\n");
2819
- output = output.replace(/\n*$/, "\n");
2820
- return output;
2821
- }
2822
- function formatRange(text, range) {
2823
- const lines = text.split("\n");
2824
- const startLine = Math.max(0, range.start.line);
2825
- const endLine = Math.min(lines.length - 1, range.end.line);
2826
- const rangeLines = lines.slice(startLine, endLine + 1);
2827
- const rangeText = rangeLines.join("\n");
2828
- const formatted = formatDocument(rangeText);
2829
- const formattedLines = formatted.replace(/\n$/, "").split("\n");
2830
- const before = lines.slice(0, startLine);
2831
- const after = lines.slice(endLine + 1);
2832
- const result = [...before, ...formattedLines, ...after].join("\n");
2833
3154
  return result;
2834
3155
  }
3156
+ async function formatRange(text, _range, options) {
3157
+ return formatDocument(text, options);
3158
+ }
2835
3159
  function normalizePassageHeader(line) {
2836
3160
  const headerMatch = line.match(/^::\s+(.*)/);
2837
3161
  if (!headerMatch) return line;
@@ -2851,13 +3175,18 @@ function normalizePassageHeader(line) {
2851
3175
  }
2852
3176
  return `:: ${name}${suffix}`;
2853
3177
  }
2854
- var BLOCK_OPEN_REGEX, BLOCK_CLOSE_REGEX, PASSAGE_HEADER_REGEX, formatPlugin;
3178
+ var PASSAGE_HEADER_REGEX2, MACRO_OPEN_REGEX, MACRO_CLOSE_REGEX, DEFAULT_DEDENTING, formatPlugin;
2855
3179
  var init_format = __esm({
2856
3180
  "src/plugins/format.ts"() {
2857
3181
  "use strict";
2858
- BLOCK_OPEN_REGEX = /^\s*\{(if|for|switch|unless|widget)\b/i;
2859
- BLOCK_CLOSE_REGEX = /^\s*\{\/(if|for|switch|unless|widget)\b/i;
2860
- PASSAGE_HEADER_REGEX = /^(::)\s+/;
3182
+ init_macro_supplements();
3183
+ init_segment();
3184
+ init_prettier_bridge();
3185
+ init_placeholders();
3186
+ PASSAGE_HEADER_REGEX2 = /^(::)\s+/;
3187
+ MACRO_OPEN_REGEX = /^\{(?:[#.][a-zA-Z][\w-]*\s*)*([A-Za-z][\w-]*)\b/;
3188
+ MACRO_CLOSE_REGEX = /^\{\/([A-Za-z][\w-]*)\b/;
3189
+ DEFAULT_DEDENTING = /* @__PURE__ */ new Set(["else", "elseif", "next", "case", "default"]);
2861
3190
  formatPlugin = {
2862
3191
  id: "format",
2863
3192
  capabilities: {
@@ -2865,10 +3194,14 @@ var init_format = __esm({
2865
3194
  documentRangeFormattingProvider: true
2866
3195
  },
2867
3196
  initialize(ctx) {
2868
- ctx.connection.onDocumentFormatting((params) => {
3197
+ const formatOpts = {
3198
+ isBlock: (name) => ctx.workspace.macros.isBlock(name),
3199
+ isDedentingSubMacro: (name) => DEFAULT_DEDENTING.has(name.toLowerCase())
3200
+ };
3201
+ ctx.connection.onDocumentFormatting(async (params) => {
2869
3202
  const text = ctx.workspace.documents.getText(params.textDocument.uri);
2870
3203
  if (text === void 0) return [];
2871
- const formatted = formatDocument(text);
3204
+ const formatted = await formatDocument(text, formatOpts);
2872
3205
  if (formatted === text) return [];
2873
3206
  const lines = text.split("\n");
2874
3207
  return [{
@@ -2879,14 +3212,14 @@ var init_format = __esm({
2879
3212
  newText: formatted
2880
3213
  }];
2881
3214
  });
2882
- ctx.connection.onDocumentRangeFormatting((params) => {
3215
+ ctx.connection.onDocumentRangeFormatting(async (params) => {
2883
3216
  const text = ctx.workspace.documents.getText(params.textDocument.uri);
2884
3217
  if (text === void 0) return [];
2885
3218
  const range = {
2886
3219
  start: { line: params.range.start.line, character: params.range.start.character },
2887
3220
  end: { line: params.range.end.line, character: params.range.end.character }
2888
3221
  };
2889
- const formatted = formatRange(text, range);
3222
+ const formatted = await formatRange(text, range, formatOpts);
2890
3223
  if (formatted === text) return [];
2891
3224
  const lines = text.split("\n");
2892
3225
  return [{
@@ -2947,7 +3280,7 @@ async function runFormat(args2) {
2947
3280
  for (const filePath of uniqueFiles) {
2948
3281
  try {
2949
3282
  const text = readFileSync5(filePath, "utf-8");
2950
- const formatted = formatDocument(text);
3283
+ const formatted = await formatDocument(text);
2951
3284
  if (formatted !== text) {
2952
3285
  if (options.check) {
2953
3286
  const relative = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
@@ -3107,7 +3440,7 @@ async function startMcpServer() {
3107
3440
  for (const filePath of files) {
3108
3441
  try {
3109
3442
  const text = readFileSync6(filePath, "utf-8");
3110
- const result = formatDocument(text);
3443
+ const result = await formatDocument(text);
3111
3444
  if (result !== text) {
3112
3445
  writeFileSync2(filePath, result, "utf-8");
3113
3446
  formatted++;
@@ -3139,7 +3472,7 @@ async function startMcpServer() {
3139
3472
  for (const filePath of files) {
3140
3473
  try {
3141
3474
  const text = readFileSync6(filePath, "utf-8");
3142
- const result = formatDocument(text);
3475
+ const result = await formatDocument(text);
3143
3476
  const relativePath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath;
3144
3477
  if (result !== text) {
3145
3478
  needsFormatting.push(relativePath);