nene-lang 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ style
46
46
  | 層 | 中身 | パッケージ |
47
47
  | --- | --- | --- |
48
48
  | 言語 | コンパイラ、ランタイム、`nene` コマンド、標準ライブラリ(ルーティング・HTTP・JSON など) | `nene-lang` |
49
- | 部品集 | ボタン・入力欄・ダイアログなど 16 の部品 | [`nene-lang-ui`](ui/README.md) |
49
+ | 部品集 | ボタン・入力欄・ダイアログ・メニューなど 20 の部品 | [`nene-lang-ui`](ui/README.md) |
50
50
 
51
51
  標準ライブラリは差し替えられない(ルーティングも HTTP も一通りだけ)。部品集は別のパッケージで、使わなくてもよい。出力には、アプリが使っている標準モジュールとランタイムの部分だけが入る。
52
52
 
@@ -100,6 +100,7 @@ my-app/
100
100
  | `npm run build`(`nene build`) | `dist/` に出力する。そのまま静的ホスティングに置ける。`--base /repo/` でサブディレクトリにも置ける |
101
101
  | `npm run preview`(`nene preview`) | `dist/` を手元で動かして確かめる |
102
102
  | `npm run check`(`nene check`) | 型検査だけ。エラーは該当する行つきで出る |
103
+ | `npm run fmt`(`nene fmt`) | 整形する。`--check` は直さずに確かめるだけ(CI 向け) |
103
104
 
104
105
  `dev`・`build`・`preview` は内部で Vite を使う。複数ページなど特別なことをしたいときだけ、普通の `vite.config.ts` を置けば読み込まれる(例: `examples/vite.config.ts`)。
105
106
 
@@ -123,7 +124,7 @@ npm run dev
123
124
 
124
125
  ### 公開する
125
126
 
126
- `nene build` は、`/todos/3` のような URL を直接開いても表示されるように、`404.html`(GitHub Pages など)と `_redirects`(Netlify・Cloudflare Pages)も出力する。ホスティングごとの手順は、作ったプロジェクトの `README.md` に書いてある。
127
+ `nene build` は、リンクでたどれるページを HTML にしておく(静的生成)。`/todos/3` のような、たどれない URL を直接開いても表示されるように、`404.html`(GitHub Pages など)と `_redirects`(Netlify・Cloudflare Pages)も出力する。ホスティングごとの手順は、作ったプロジェクトの `README.md` に書いてある。
127
128
 
128
129
  ### パッケージを作る
129
130
 
@@ -157,9 +158,22 @@ vim.opt.rtp:append("/path/to/nene/editors/nvim")
157
158
  require("nene").setup()
158
159
  ```
159
160
 
160
- - `.nn` のハイライト。言語サーバーが動いているときは、その解析結果で色を分ける(部品と HTML 要素、コンストラクタ、フィールド、引数など)
161
- - 入力中のエラー表示(1 ファイルのエラーをまとめて表示)
162
- - `K` で型を表示、`gd` で定義へ移動(`<Button>` から `Button.nn` へも飛べる)
161
+ キーはどれも nvim の標準のもの。
162
+
163
+ | | |
164
+ | --- | --- |
165
+ | 色分け | `.nn` のハイライト。言語サーバーが動いているときは、その解析結果で色を分ける(部品と HTML 要素、コンストラクタ、フィールド、引数など)。`style` ブロックは CSS として |
166
+ | エラー | 入力中に表示する(1 ファイルのエラーをまとめて) |
167
+ | `K` / `gd` | 型を表示 / 定義へ移動(`<Button>` から `Button.nn` へも飛べる) |
168
+ | `grr` | 参照一覧(プロジェクトのすべてのファイルから) |
169
+ | `grn` | 名前の一括変更(ファイルをまたいで。部品の関数を変えるとタグも変わる) |
170
+ | `gra` | コードアクション(部品の import を足す、JSX の `{}` の式を `where` に取り出す) |
171
+ | `gO` | アウトライン(関数・型・コンストラクタ・フィールド・`where` の中の定義) |
172
+ | 挿入モードの `<C-s>` | 引数のヒント(関数の型と、いま書いている引数) |
173
+ | `gq` | 整形(`nene fmt` と同じ)。`setup({ format_on_save = true })` で保存のたびに |
174
+ | 折りたたみ | 宣言・`where`・JSX の要素・import・コメント(`zc` `zo` など) |
175
+ | `van` / `vin` | 構造に沿った選択(名前 → JSX の要素 → 宣言 と広げる。nvim 0.12 以上) |
176
+
163
177
  - 補完
164
178
  - `<But` → `<Button onClick={…}>…</Button>`(必須の属性と閉じタグまで入る)
165
179
  - タグの中で属性名(まだ書いていないもの。必須のものが先)
@@ -201,12 +215,15 @@ npm pack # 配布するパッケージを手元で作る
201
215
  | `src/codegen.ts` | JavaScript の生成 |
202
216
  | `src/compiler.ts` | モジュールを依存順に読み込み、1 つの JavaScript にまとめる(使っている標準モジュールだけを入れる) |
203
217
  | `src/packages.ts` | `node_modules` から Nene のパッケージを探す |
218
+ | `src/prerender.ts` | 静的生成。ビルドのあとでアプリを Node の中で動かし、リンクをたどってページの HTML を作る |
204
219
  | `src/lib/*.nn` | 標準ライブラリ(Nene で書かれている) |
205
220
  | `runtime/runtime.js` | 仮想 DOM、レイアウトタグ、`style` ブロックの CSS の読み込み、URL、The Elm Architecture の実行 |
206
221
  | `runtime/http.js` | JSON との変換と HTTP(使うアプリにだけ入る) |
207
222
  | `ui/` | 公式の部品集 `nene-lang-ui` |
208
223
  | `src/css.ts` | `style` ブロックのクラス名を取り出し、ファイルごとに区別できる名前に書き換える |
209
224
  | `src/cli.ts` / `src/vite.ts` / `src/lsp.ts` | `nene` コマンド、Vite プラグイン、言語サーバー |
225
+ | `src/format.ts` | 整形(`nene fmt` と言語サーバー) |
226
+ | `src/outline.ts` / `src/references.ts` / `src/actions.ts` | アウトライン・折りたたみ・選択範囲、参照と名前の変更、コードアクション |
210
227
  | `src/report.ts` | コンパイルエラーを、該当する行と印つきで見せる |
211
228
  | `template/` | `nene new` で作るプロジェクトのひな形 |
212
229
  | `bin/nene.js` | コマンドの入口。手元のリポジトリでは `src/` を、npm から入れたときは `build/` を使う |
@@ -214,7 +231,6 @@ npm pack # 配布するパッケージを手元で作る
214
231
 
215
232
  ## まだないもの
216
233
 
217
- - 部品の中だけで完結する状態(React の useState に相当するもの)
218
- - サーバー側での描画(SSR)と、ビルド時に HTML を作る静的生成
234
+ - リクエストごとにサーバーで描く SSR(ビルド時の静的生成はある)
219
235
  - VS Code の拡張
220
- - Tree-sitter の文法(今は Vim の syntax ファイルと、言語サーバーによる色分け)
236
+ - Tree-sitter の文法(色分け・折りたたみ・構造に沿った選択は、言語サーバーで代わりにできる)
@@ -0,0 +1,22 @@
1
+ type Json = Record<string, any>;
2
+ interface Diagnostic {
3
+ range: {
4
+ start: {
5
+ line: number;
6
+ character: number;
7
+ };
8
+ end: {
9
+ line: number;
10
+ character: number;
11
+ };
12
+ };
13
+ message: string;
14
+ }
15
+ export interface ActionContext {
16
+ uri: string;
17
+ text: string;
18
+ diagnostics: Diagnostic[];
19
+ modules: string[];
20
+ }
21
+ export declare function codeActions(ctx: ActionContext): Json[];
22
+ export {};
@@ -0,0 +1,124 @@
1
+ // コードアクション(エラーの直し方をその場で当てる)
2
+ //
3
+ // - 部品 <Button> が見つからない → import Ui.Button を足す
4
+ // - モジュール Icon が見つからない(Icon.close など) → import qualified Ui.Icon as Icon を足す
5
+ // - JSX の {} に式を書いた → where に名前をつけて取り出す(JSX には名前だけを書く)
6
+ export function codeActions(ctx) {
7
+ const out = [];
8
+ for (const d of ctx.diagnostics) {
9
+ const tag = /部品 <([A-Z]\w*)> が見つかりません/.exec(d.message)?.[1];
10
+ if (tag) {
11
+ for (const m of ctx.modules.filter((name) => name.split(".").pop() === tag)) {
12
+ out.push(fix(ctx, d, `import ${m} を足す`, addImport(ctx.text, `import ${m}`)));
13
+ }
14
+ }
15
+ const alias = /モジュール ([A-Z][\w.]*) が見つかりません(import していますか?)/.exec(d.message)?.[1];
16
+ if (alias) {
17
+ for (const m of ctx.modules.filter((name) => name === alias || name.endsWith(`.${alias}`))) {
18
+ const line = m === alias ? `import qualified ${m}` : `import qualified ${m} as ${alias}`;
19
+ out.push(fix(ctx, d, `${line} を足す`, addImport(ctx.text, line)));
20
+ }
21
+ }
22
+ if (d.message.startsWith("JSX の {} には")) {
23
+ const edits = extractToWhere(ctx.text, d.range.start.line, d.range.start.character);
24
+ if (edits)
25
+ out.push({ title: "where に名前をつけて取り出す", kind: "refactor.extract", diagnostics: [d], edit: { changes: { [ctx.uri]: edits } } });
26
+ }
27
+ }
28
+ return out;
29
+ }
30
+ function fix(ctx, d, title, edit) {
31
+ return { title, kind: "quickfix", diagnostics: [d], isPreferred: true, edit: { changes: { [ctx.uri]: [edit] } } };
32
+ }
33
+ // 最後の import の次の行に足す。import がなければ module の行の後に、空行を挟んで足す
34
+ function addImport(text, line) {
35
+ const lines = text.split("\n");
36
+ const last = lines.reduce((found, l, i) => (l.startsWith("import ") ? i : found), -1);
37
+ if (last >= 0)
38
+ return insertAt(last + 1, `${line}\n`);
39
+ const moduleLine = lines.findIndex((l) => l.startsWith("module "));
40
+ return insertAt(moduleLine + 1, `\n${line}\n`);
41
+ }
42
+ const insertAt = (line, newText) => ({ range: { start: { line, character: 0 }, end: { line, character: 0 } }, newText });
43
+ // ---- where に取り出す ----
44
+ const indentOf = (line) => line.length - line.trimStart().length;
45
+ function extractToWhere(text, line, character) {
46
+ const lines = text.split("\n");
47
+ const offset = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0) + character;
48
+ const open = text.lastIndexOf("{", offset);
49
+ const close = matchingBrace(text, open);
50
+ if (open < 0 || close < 0 || close < offset)
51
+ return null;
52
+ const expr = text.slice(open + 1, close).trim().replace(/\s*\n\s*/g, " ");
53
+ const name = freshName(text);
54
+ // 式を囲む、いちばん内側の等式を探す(字下げがより浅く、名前で始まる行)
55
+ const head = equationAbove(lines, line);
56
+ if (head < 0)
57
+ return null;
58
+ const base = indentOf(lines[head]);
59
+ let end = head;
60
+ for (let i = head + 1; i < lines.length; i++) {
61
+ if (lines[i].trim() === "")
62
+ continue;
63
+ if (indentOf(lines[i]) <= base)
64
+ break;
65
+ end = i;
66
+ }
67
+ const where = lines.slice(head + 1, end + 1).findIndex((l) => l.trim() === "where" && indentOf(l) === base + 2);
68
+ const binding = `${" ".repeat(base + 4)}${name} = ${expr}`;
69
+ const insert = where >= 0 ? `\n${binding}` : `\n${" ".repeat(base + 2)}where\n${binding}`;
70
+ const startPos = positionOf(lines, open + 1);
71
+ const endPos = positionOf(lines, close);
72
+ return [
73
+ { range: { start: startPos, end: endPos }, newText: name },
74
+ { range: { start: { line: end, character: lines[end].length }, end: { line: end, character: lines[end].length } }, newText: insert },
75
+ ];
76
+ }
77
+ function matchingBrace(text, open) {
78
+ let depth = 0;
79
+ for (let i = open; i < text.length; i++) {
80
+ if (text[i] === '"') {
81
+ i = text.indexOf('"', i + 1);
82
+ if (i < 0)
83
+ return -1;
84
+ }
85
+ else if (text[i] === "{")
86
+ depth++;
87
+ else if (text[i] === "}" && --depth === 0)
88
+ return i;
89
+ }
90
+ return -1;
91
+ }
92
+ function equationAbove(lines, from) {
93
+ let limit = indentOf(lines[from]);
94
+ for (let i = from; i >= 0; i--) {
95
+ const l = lines[i];
96
+ if (l.trim() === "" || l.trimStart().startsWith("--"))
97
+ continue;
98
+ const indent = indentOf(l);
99
+ if (indent >= limit && i !== from)
100
+ continue;
101
+ limit = Math.min(limit, indent);
102
+ if (!/^\s*[a-z_][\w']*/.test(l) || /^\s*where\b/.test(l))
103
+ continue;
104
+ const next = lines.slice(i + 1).find((x) => x.trim() !== "") ?? "";
105
+ if (/\s=(\s|$)/.test(l) || next.trimStart().startsWith("|"))
106
+ return i;
107
+ }
108
+ return -1;
109
+ }
110
+ function freshName(text) {
111
+ for (let n = 1;; n++) {
112
+ const name = n === 1 ? "value" : `value${n}`;
113
+ if (!new RegExp(`\\b${name}\\b`).test(text))
114
+ return name;
115
+ }
116
+ }
117
+ function positionOf(lines, offset) {
118
+ let line = 0;
119
+ while (line < lines.length && offset > lines[line].length) {
120
+ offset -= lines[line].length + 1;
121
+ line++;
122
+ }
123
+ return { line, character: offset };
124
+ }
package/build/checker.js CHANGED
@@ -19,12 +19,12 @@ export const HTML_ELEMENTS = new Set([
19
19
  const STRING_ATTRS = [
20
20
  "key", "id", "title", "role", "lang", "dir", "src", "srcset", "sizes", "alt", "href", "target", "rel", "type",
21
21
  "value", "name", "placeholder", "for", "form", "list", "min", "max", "step", "pattern", "accept", "autocomplete",
22
- "width", "height", "loading", "poster", "datetime", "headers", "scope",
22
+ "width", "height", "loading", "poster", "datetime", "headers", "scope", "popovertarget", "popovertargetaction",
23
23
  ];
24
24
  const INT_ATTRS = ["tabindex", "rows", "cols", "colspan", "rowspan", "maxlength", "minlength", "span", "start"];
25
25
  const BOOL_ATTRS = [
26
26
  "disabled", "checked", "selected", "readonly", "required", "multiple", "hidden", "autofocus", "open", "controls",
27
- "autoplay", "loop", "muted", "playsinline", "reversed", "novalidate",
27
+ "autoplay", "loop", "muted", "playsinline", "reversed", "novalidate", "popover",
28
28
  ];
29
29
  const MSG_EVENTS = ["onClick", "onDoubleClick", "onSubmit", "onFocus", "onBlur", "onMouseEnter", "onMouseLeave"];
30
30
  const STRING_EVENTS = ["onInput", "onChange"];
package/build/cli.js CHANGED
@@ -4,6 +4,8 @@ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpat
4
4
  import { basename, dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { compileProgram } from "./compiler.js";
7
+ import { format } from "./format.js";
8
+ import { prerenderSite } from "./prerender.js";
7
9
  import { displayWidth, formatError } from "./report.js";
8
10
  import { CompileError } from "./syntax.js";
9
11
  const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -31,14 +33,17 @@ const commands = {
31
33
  usage: "build [--base /repo/] [--out dist]",
32
34
  summary: "公開用のファイルを出力する",
33
35
  run: async (args) => {
34
- const { build } = await import("vite");
36
+ const { build, loadEnv } = await import("vite");
35
37
  const root = projectDir();
38
+ const [entry] = entries(root);
36
39
  checkAll(entries(root));
37
40
  const outDir = resolve(root, option(args, "--out") ?? "dist");
38
41
  const base = normalizeBase(option(args, "--base") ?? "/");
39
42
  await build({ ...(await viteOptions(root)), base, build: { outDir, emptyOutDir: true } });
40
43
  writeFallbacks(outDir, base);
41
- console.log(`\n${paint("32", "✓")} ${relative(process.cwd(), outDir) || "."}/ に出力しました(置き場所: ${base})`);
44
+ const pages = prerenderSite({ outDir, entry, base, env: loadEnv("production", root, "NENE_") });
45
+ const where = relative(process.cwd(), outDir) || ".";
46
+ console.log(`\n${paint("32", "✓")} ${where}/ に出力しました(置き場所: ${base}、HTML にしたページ: ${pages.length})`);
42
47
  },
43
48
  },
44
49
  preview: {
@@ -66,6 +71,30 @@ const commands = {
66
71
  console.log(`${paint("32", "✓")} エラーはありません`);
67
72
  },
68
73
  },
74
+ fmt: {
75
+ usage: "fmt [Path...] [--check]",
76
+ summary: "整形する(フォルダならその中の .nn すべて。省略するとこのフォルダ)。--check は確かめるだけ",
77
+ run: (args) => {
78
+ const check = args.includes("--check");
79
+ const given = args.filter((a) => a !== "--check");
80
+ const files = (given.length > 0 ? given : ["."]).flatMap((f) => (statSync(f).isDirectory() ? nnFiles(resolve(f)) : [resolve(f)]));
81
+ const changed = files.filter((file) => {
82
+ const src = readFileSync(file, "utf8");
83
+ const out = format(src);
84
+ if (out === src)
85
+ return false;
86
+ if (!check)
87
+ writeFileSync(file, out);
88
+ return true;
89
+ });
90
+ for (const file of changed)
91
+ console.log(`${check ? "整形が必要" : "整形した"}: ${relative(process.cwd(), file)}`);
92
+ if (check && changed.length > 0)
93
+ process.exit(1);
94
+ if (changed.length === 0)
95
+ console.log(`${paint("32", "✓")} 整形済みです(${files.length} ファイル)`);
96
+ },
97
+ },
69
98
  lsp: {
70
99
  usage: "lsp",
71
100
  summary: "言語サーバーを起動する(エディタから使う)",
@@ -140,17 +169,28 @@ async function viteOptions(root) {
140
169
  const { default: nene } = await import("./vite.js");
141
170
  return { root, plugins: [nene()], logLevel: "info" };
142
171
  }
143
- // URL を直接開いたときやリロードしたときも、アプリが表示されるようにする。
144
- // 404.html は GitHub Pages など、_redirects は Netlify や Cloudflare Pages が読む
172
+ // HTML にしていない URL を直接開いたときも、アプリが表示されるようにする(中身の空の枠を返す)。
173
+ // 404.html は GitHub Pages など、_redirects は Netlify や Cloudflare Pages が読む。静的生成より先に写す
145
174
  function writeFallbacks(outDir, base) {
146
175
  copyFileSync(join(outDir, "index.html"), join(outDir, "404.html"));
147
- writeFileSync(join(outDir, "_redirects"), `${base}* ${base}index.html 200\n`);
176
+ writeFileSync(join(outDir, "_redirects"), `${base}* ${base}404.html 200\n`);
148
177
  }
149
178
  // build で出力した index.html から、置き場所を読み取る(<script src="/repo/assets/...">)
150
179
  function builtBase(outDir) {
151
180
  const html = readFileSync(join(outDir, "index.html"), "utf8");
152
181
  return html.match(/<script[^>]*src="([^"]*\/)assets\//)?.[1] ?? "/";
153
182
  }
183
+ // フォルダの中の .nn(node_modules と dist は除く)
184
+ function nnFiles(dir) {
185
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
186
+ if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist")
187
+ return [];
188
+ const path = join(dir, e.name);
189
+ if (e.isDirectory())
190
+ return nnFiles(path);
191
+ return e.name.endsWith(".nn") ? [path] : [];
192
+ });
193
+ }
154
194
  // ---- nene new ----
155
195
  function createProject(name) {
156
196
  const dir = resolve(name);
@@ -16,6 +16,7 @@ export interface CompileOptions {
16
16
  }
17
17
  export declare function projectRoot(file: string, moduleName: string): string;
18
18
  export declare function listModules(root: string): string[];
19
+ export declare function projectFiles(root: string): string[];
19
20
  export interface ProgramResult {
20
21
  entry: CompiledModule;
21
22
  modules: CompiledModule[];
@@ -26,5 +27,6 @@ export interface BundleOptions {
26
27
  mount?: string;
27
28
  base?: string;
28
29
  env?: string;
30
+ prerender?: boolean;
29
31
  }
30
32
  export declare function bundle(result: ProgramResult, options?: BundleOptions): string;
package/build/compiler.js CHANGED
@@ -47,6 +47,10 @@ export function listModules(root) {
47
47
  const names = [root, ...findPackages(root).map((p) => p.root)].flatMap(modulesIn);
48
48
  return [...new Set(names)].sort();
49
49
  }
50
+ // プロジェクトの .nn ファイル(パッケージのものは含めない)
51
+ export function projectFiles(root) {
52
+ return modulesIn(root).map((name) => join(root, ...name.split(".")) + ".nn");
53
+ }
50
54
  function modulesIn(root) {
51
55
  if (!existsSync(root))
52
56
  return [];
@@ -156,7 +160,9 @@ export function bundle(result, options = {}) {
156
160
  `$rt.configure({ base: ${options.base ?? '"/"'}, env: ${options.env ?? "{}"} });`,
157
161
  ...lib,
158
162
  ...user,
159
- `$rt.run(${result.entry.iface.js}.main(), document.getElementById(${mount}) ?? document.body);`,
163
+ options.prerender
164
+ ? `({ rt: $rt, program: ${result.entry.iface.js}.main() });`
165
+ : `$rt.run(${result.entry.iface.js}.main(), document.getElementById(${mount}) ?? document.body);`,
160
166
  ].join("\n\n") + "\n";
161
167
  }
162
168
  // コードが $M_名前 で参照している標準モジュールを、たどれるだけたどって集める(読み込む順は LIB_ORDER のまま)
@@ -0,0 +1 @@
1
+ export declare function format(source: string): string;
@@ -0,0 +1,120 @@
1
+ // .nn の整形。書いた形はなるべく保ち、次のことだけをする(何度かけても同じ結果になる)
2
+ //
3
+ // - 行末の空白を消し、行頭のタブを 2 つの空白にする
4
+ // - 3 行以上続く空行を 2 行にする。ファイルの最後は改行 1 つにする
5
+ // - 続けて書いた 1 行の等式(同じ名前のもの)、ガード、レコード型のフィールドの = と :: の位置をそろえる
6
+ //
7
+ // update Increment n = n + 1 update Increment n = n + 1
8
+ // update Reset _ = 0 → update Reset _ = 0
9
+ //
10
+ // style ブロックの CSS は、行末の空白を消すだけにする。
11
+ import { displayWidth } from "./report.js";
12
+ export function format(source) {
13
+ const lines = source
14
+ .replace(/\r\n?/g, "\n")
15
+ .split("\n")
16
+ .map((l) => l.replace(/^\t+/, (tabs) => " ".repeat(tabs.length)).replace(/\s+$/, ""));
17
+ const styleAt = lines.findIndex((l) => l === "style");
18
+ const code = styleAt < 0 ? lines : lines.slice(0, styleAt);
19
+ const rest = styleAt < 0 ? [] : lines.slice(styleAt);
20
+ alignEquations(code);
21
+ alignGuards(code);
22
+ alignFields(code);
23
+ const out = collapseBlankLines([...code, ...rest]);
24
+ while (out.length > 0 && out[out.length - 1] === "")
25
+ out.pop();
26
+ return out.join("\n") + "\n";
27
+ }
28
+ function collapseBlankLines(lines) {
29
+ const out = [];
30
+ let blanks = 0;
31
+ for (const l of lines) {
32
+ blanks = l === "" ? blanks + 1 : 0;
33
+ if (blanks <= 2)
34
+ out.push(l);
35
+ }
36
+ return out;
37
+ }
38
+ // ---- そろえる ----
39
+ const MAX_PAD = 24;
40
+ const KEYWORDS = new Set(["module", "import", "foreign", "data", "type", "where", "style"]);
41
+ const indentOf = (line) => line.length - line.trimStart().length;
42
+ // 続く行のまとまりごとに、印(= や ::)の手前の幅をそろえる。
43
+ // split は、その行がまとまりに入るなら [印の手前, 印から後ろ] を返す
44
+ function alignRuns(lines, key, split) {
45
+ let i = 0;
46
+ while (i < lines.length) {
47
+ const k = key(lines[i]);
48
+ if (k === null || split(lines[i]) === null) {
49
+ i++;
50
+ continue;
51
+ }
52
+ let j = i + 1;
53
+ while (j < lines.length && key(lines[j]) === k && split(lines[j]) !== null)
54
+ j++;
55
+ const parts = lines.slice(i, j).map((l) => split(l));
56
+ const widths = parts.map(([left]) => displayWidth(left));
57
+ const width = Math.max(...widths);
58
+ // 長さが大きく違うものをそろえると、空白ばかりになるのでそのままにする
59
+ if (j - i > 1 && width - Math.min(...widths) <= MAX_PAD) {
60
+ parts.forEach(([left, right], n) => {
61
+ lines[i + n] = left + " ".repeat(width - displayWidth(left)) + right;
62
+ });
63
+ }
64
+ i = j;
65
+ }
66
+ }
67
+ // 同じ名前の 1 行の等式: name args = body
68
+ function alignEquations(lines) {
69
+ const head = (line) => /^(\s*)([a-z_][\w']*)\s/.exec(line);
70
+ alignRuns(lines, (line) => {
71
+ const m = head(line);
72
+ return m && !KEYWORDS.has(m[2]) ? `${m[1].length}:${m[2]}` : null;
73
+ }, (line) => {
74
+ const parts = splitAt(line, "=");
75
+ return parts && parts[1].trim() !== "=" ? parts : null; // 本体が次の行に続く等式はそろえない
76
+ });
77
+ }
78
+ // ガード: | 条件 = 値
79
+ function alignGuards(lines) {
80
+ alignRuns(lines, (line) => (line.trimStart().startsWith("| ") ? String(indentOf(line)) : null), (line) => splitAt(line, "="));
81
+ }
82
+ // レコード型のフィールド: { name :: Type = default } と , name :: Type
83
+ function alignFields(lines) {
84
+ const isField = (line) => /^\s*[{,]\s*[a-z_][\w']*\s*::/.test(line);
85
+ const key = (line) => (isField(line) ? String(indentOf(line)) : null);
86
+ // :: をそろえてから、デフォルト値の = をそろえる(= のない行は、そのまま)
87
+ const after = (line) => /^\s*[{,]/.exec(line)[0].length; // 先頭の { や , の次から見る
88
+ alignRuns(lines, key, (line) => splitAt(line, "::", after(line)));
89
+ const withDefault = (line) => (key(line) !== null && splitAt(line, "=", after(line)) ? key(line) : null);
90
+ alignRuns(lines, withDefault, (line) => splitAt(line, "=", after(line)));
91
+ }
92
+ // from から後ろの、括弧と文字列の外にある、前後に空白のある mark で分ける。[mark の手前(末尾の空白を除く), " mark 以降"]
93
+ function splitAt(line, mark, from = 0) {
94
+ let depth = 0;
95
+ for (let i = from; i < line.length; i++) {
96
+ const c = line[i];
97
+ if (c === '"') {
98
+ i++;
99
+ while (i < line.length && line[i] !== '"')
100
+ i += line[i] === "\\" ? 2 : 1;
101
+ continue;
102
+ }
103
+ if (c === "-" && line[i + 1] === "-")
104
+ return null; // コメントの中は見ない
105
+ if (c === "(" || c === "[" || c === "{")
106
+ depth++;
107
+ else if (c === ")" || c === "]" || c === "}")
108
+ depth--;
109
+ else if (depth === 0 &&
110
+ line.startsWith(mark, i) &&
111
+ line[i - 1] === " " &&
112
+ (line[i + mark.length] === " " || i + mark.length === line.length)) {
113
+ const left = line.slice(0, i).replace(/\s+$/, "");
114
+ if (left.trim() === "")
115
+ return null;
116
+ return [left, " " + line.slice(i)];
117
+ }
118
+ }
119
+ return null;
120
+ }