storm-lua-minify 0.1.2 → 0.2.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 (81) hide show
  1. package/.github/workflows/ci.yml +39 -0
  2. package/.github/workflows/publish.yml +39 -0
  3. package/.prettierignore +3 -0
  4. package/.prettierrc.json +1 -0
  5. package/LICENSE +21 -21
  6. package/README.md +37 -15
  7. package/dist/ast2lua.js +188 -85
  8. package/dist/cli.js +17 -7
  9. package/dist/index.js +3 -19
  10. package/dist/linker.js +96 -0
  11. package/dist/minifier.js +176 -51
  12. package/dist/output.js +33 -0
  13. package/dist/renamer.js +87 -0
  14. package/dist/resolver.js +303 -0
  15. package/eslint.config.js +29 -0
  16. package/package.json +33 -27
  17. package/src/ast2lua.ts +940 -812
  18. package/src/cli.ts +86 -56
  19. package/src/linker.ts +108 -0
  20. package/src/minifier.ts +284 -114
  21. package/src/output.ts +71 -0
  22. package/src/renamer.ts +134 -0
  23. package/src/resolver.ts +378 -0
  24. package/test/circular-require.test.ts +19 -0
  25. package/test/fixtures/bare-require/main.lua +2 -0
  26. package/test/fixtures/bare-require/mod.lua +1 -0
  27. package/test/fixtures/bitwise-precedence/main.lua +10 -0
  28. package/test/fixtures/circular-require/a.lua +2 -0
  29. package/test/fixtures/circular-require/b.lua +2 -0
  30. package/test/fixtures/circular-require/main.lua +2 -0
  31. package/test/fixtures/dofile/greet.lua +1 -0
  32. package/test/fixtures/dofile/main.lua +2 -0
  33. package/test/fixtures/entry-scope-many-requires/dep_alpha.lua +2 -0
  34. package/test/fixtures/entry-scope-many-requires/dep_bravo.lua +2 -0
  35. package/test/fixtures/entry-scope-many-requires/dep_charlie.lua +2 -0
  36. package/test/fixtures/entry-scope-many-requires/dep_delta.lua +2 -0
  37. package/test/fixtures/entry-scope-many-requires/dep_echo.lua +2 -0
  38. package/test/fixtures/entry-scope-many-requires/dep_foxtrot.lua +2 -0
  39. package/test/fixtures/entry-scope-many-requires/dep_golf.lua +2 -0
  40. package/test/fixtures/entry-scope-many-requires/dep_hotel.lua +2 -0
  41. package/test/fixtures/entry-scope-many-requires/dep_india.lua +2 -0
  42. package/test/fixtures/entry-scope-many-requires/dep_juliet.lua +2 -0
  43. package/test/fixtures/entry-scope-many-requires/dep_kilo.lua +2 -0
  44. package/test/fixtures/entry-scope-many-requires/main.lua +25 -0
  45. package/test/fixtures/multi-require/common.lua +1 -0
  46. package/test/fixtures/multi-require/main.lua +3 -0
  47. package/test/fixtures/nested-module/main.lua +2 -0
  48. package/test/fixtures/nested-module/sub/deep.lua +1 -0
  49. package/test/fixtures/require-call/main.lua +2 -0
  50. package/test/fixtures/require-call/mod.lua +5 -0
  51. package/test/fixtures/require-in-expression/main.lua +1 -0
  52. package/test/fixtures/require-in-expression/mod.lua +1 -0
  53. package/test/fixtures/require-string-call/main.lua +2 -0
  54. package/test/fixtures/require-string-call/mod.lua +5 -0
  55. package/test/fixtures/single-file/main.lua +15 -0
  56. package/test/identifier-collision.test.ts +28 -0
  57. package/test/lib/collision.ts +131 -0
  58. package/test/lib/helpers.ts +124 -0
  59. package/test/no-rename.test.ts +19 -0
  60. package/test/output.test.ts +95 -0
  61. package/test/precedence.test.ts +28 -0
  62. package/test/renamer.test.ts +130 -0
  63. package/test/resolver.test.ts +206 -0
  64. package/test/roundtrip.test.ts +26 -0
  65. package/test/snapshot.test.ts +27 -0
  66. package/test/snapshots/bare-require.sl.lua +1 -0
  67. package/test/snapshots/bitwise-precedence.sl.lua +2 -0
  68. package/test/snapshots/dofile.sl.lua +1 -0
  69. package/test/snapshots/entry-scope-many-requires.m.lua +14 -0
  70. package/test/snapshots/multi-require.m.lua +4 -0
  71. package/test/snapshots/multi-require.sl.lua +1 -0
  72. package/test/snapshots/nested-module.m.lua +4 -0
  73. package/test/snapshots/require-call.m.lua +5 -0
  74. package/test/snapshots/require-call.sl.lua +1 -0
  75. package/test/snapshots/require-in-expression.sl.lua +1 -0
  76. package/test/snapshots/require-string-call.m.lua +5 -0
  77. package/test/snapshots/single-file.sl.lua +6 -0
  78. package/test/sourcemap.test.ts +105 -0
  79. package/tsconfig.eslint.json +8 -0
  80. package/tsconfig.json +111 -109
  81. package/.eslintrc.json +0 -20
package/src/cli.ts CHANGED
@@ -1,56 +1,86 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from "fs";
4
- import path from "path";
5
- import { Command } from "commander";
6
- import { Options } from "luaparse";
7
- import { Minifier, MinifierMode } from "./minifier";
8
-
9
- const program = new Command();
10
-
11
- program
12
- .version("0.1.2")
13
- .description("A Lua minifier also outputs source map")
14
- .option(
15
- "-m, --module-like-lua",
16
- "require・dofileの動作を実際のLuaに近づけます"
17
- );
18
-
19
- program.parse(process.argv);
20
-
21
- const luaFiles = program.args;
22
-
23
- const luaparseSetting: Partial<Options> = {
24
- locations: true,
25
- luaVersion: "5.3",
26
- ranges: true,
27
- scope: true,
28
- };
29
-
30
- const mode: MinifierMode = program.opts();
31
-
32
- luaFiles.forEach((fileName) => {
33
- const parsedFileName = path.parse(fileName);
34
-
35
- if (fs.existsSync(fileName)) {
36
- const map = new Minifier(fileName, luaparseSetting, mode).parse();
37
- const minFileName = path.format({
38
- dir: parsedFileName.dir,
39
- name: parsedFileName.name + ".min",
40
- ext: ".lua",
41
- });
42
- const mapFileName = path.format({
43
- dir: parsedFileName.dir,
44
- name: parsedFileName.name,
45
- ext: parsedFileName.ext + ".map",
46
- });
47
- map.add("\n--[[\n//# sourceMappingURL=" + mapFileName + "\n]]");
48
-
49
- const sourceAndMap = map.toStringWithSourceMap();
50
-
51
- fs.writeFileSync(minFileName, sourceAndMap.code);
52
- fs.writeFileSync(mapFileName, JSON.stringify(sourceAndMap.map));
53
- } else {
54
- console.error("No such file: " + fileName);
55
- }
56
- });
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { Command } from "commander";
6
+ import { Options } from "luaparse";
7
+ import { Minifier, MinifierMode } from "./minifier";
8
+ import { buildMinifiedOutput, SourceMappingUrlStyle } from "./output";
9
+
10
+ const program = new Command();
11
+
12
+ program
13
+ .version("0.1.3")
14
+ .description("A Lua minifier also outputs source map")
15
+ .option(
16
+ "-m, --module-like-lua",
17
+ "require・dofileの動作を実際のLuaに近づけます",
18
+ )
19
+ .option("--no-rename", "識別子の短縮(リネーム)を無効にします(デバッグ用途)")
20
+ .option(
21
+ "--single-line-source-mapping-url",
22
+ "sourceMappingURLアノテーションを単一行の--コメントで出力します(Source Map仕様の「最終行」ルールに従いますが、既定の複数行ブロックコメント形式を前提とするツールとは組み合わせられません)",
23
+ )
24
+ .option(
25
+ "--strict-source-mapping-url",
26
+ "sourceMappingURLアノテーションをLuaコメントで一切包まず、Source Map仕様のマーカー文字列(//# sourceMappingURL=...)そのままを出力します。Luaの文法上この形式と有効なLuaコードは両立できないため、出力ファイルの最終行は有効なLua文ではなくなります",
27
+ );
28
+
29
+ program.parse(process.argv);
30
+
31
+ const luaFiles = program.args;
32
+
33
+ const luaparseSetting: Partial<Options> = {
34
+ locations: true,
35
+ luaVersion: "5.3",
36
+ ranges: true,
37
+ scope: true,
38
+ };
39
+
40
+ interface CliOptions extends MinifierMode {
41
+ singleLineSourceMappingUrl?: boolean;
42
+ strictSourceMappingUrl?: boolean;
43
+ }
44
+
45
+ const {
46
+ singleLineSourceMappingUrl,
47
+ strictSourceMappingUrl,
48
+ ...mode
49
+ }: CliOptions = program.opts();
50
+
51
+ // 既定は旧バージョンと互換の複数行ブロックコメント("legacy")
52
+ // --strict-source-mapping-url > --single-line-source-mapping-url の優先順で上書きする。
53
+ const sourceMappingUrlStyle: SourceMappingUrlStyle = strictSourceMappingUrl
54
+ ? "strict"
55
+ : singleLineSourceMappingUrl
56
+ ? "line"
57
+ : "legacy";
58
+
59
+ luaFiles.forEach((fileName) => {
60
+ const parsedFileName = path.parse(fileName);
61
+
62
+ if (fs.existsSync(fileName)) {
63
+ const map = new Minifier(fileName, luaparseSetting, mode).parse();
64
+ const minFileName = path.format({
65
+ dir: parsedFileName.dir,
66
+ name: parsedFileName.name + ".min",
67
+ ext: ".lua",
68
+ });
69
+ const mapFileName = path.format({
70
+ dir: parsedFileName.dir,
71
+ name: parsedFileName.name,
72
+ ext: parsedFileName.ext + ".map",
73
+ });
74
+ const { code, map: mapJson } = buildMinifiedOutput(
75
+ map,
76
+ minFileName,
77
+ mapFileName,
78
+ { sourceMappingUrlStyle },
79
+ );
80
+
81
+ fs.writeFileSync(minFileName, code);
82
+ fs.writeFileSync(mapFileName, mapJson);
83
+ } else {
84
+ console.error("No such file: " + fileName);
85
+ }
86
+ });
package/src/linker.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { Chunk } from "./ast2lua";
2
+
3
+ export interface ModuleReference {
4
+ kind: "require" | "dofile";
5
+ moduleName: string;
6
+ }
7
+
8
+ /**
9
+ * Chunk配下を型を問わず再帰的に走査するジェネリックウォーカー。
10
+ * printerとは独立に、AST全体からrequire/dofile呼び出しを見つけ出すために使う(#18)。
11
+ */
12
+ function walk(node: unknown, visit: (n: Record<string, unknown>) => void) {
13
+ if (node === null || typeof node !== "object") {
14
+ return;
15
+ }
16
+ if (Array.isArray(node)) {
17
+ node.forEach((child) => {
18
+ walk(child, visit);
19
+ });
20
+ return;
21
+ }
22
+ const obj = node as Record<string, unknown>;
23
+ if (typeof obj.type === "string") {
24
+ visit(obj);
25
+ }
26
+ for (const key of Object.keys(obj)) {
27
+ if (key === "loc" || key === "range") {
28
+ continue;
29
+ }
30
+ walk(obj[key], visit);
31
+ }
32
+ }
33
+
34
+ // luaparseはデフォルト設定(encodingMode: "none")ではStringLiteral.valueを
35
+ // 常にnullにする(discardStrings)ため、rawから引用符を取り除いて文字列値を得る。
36
+ // require/dofileのモジュール名として使う簡単な文字列リテラルのみを想定しており、
37
+ // エスケープシーケンスの解釈までは行わない。
38
+ function unquoteRaw(raw: string): string {
39
+ if (raw.length >= 2) {
40
+ const first = raw.charAt(0);
41
+ const last = raw.charAt(raw.length - 1);
42
+ if ((first === '"' || first === "'") && first === last) {
43
+ return raw.slice(1, -1);
44
+ }
45
+ }
46
+ return raw;
47
+ }
48
+
49
+ export function staticStringArgument(node: unknown): string | undefined {
50
+ if (
51
+ node !== null &&
52
+ typeof node === "object" &&
53
+ (node as Record<string, unknown>).type === "StringLiteral" &&
54
+ typeof (node as Record<string, unknown>).raw === "string"
55
+ ) {
56
+ return unquoteRaw((node as Record<string, unknown>).raw as string);
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function calleeName(node: unknown): string | undefined {
62
+ if (
63
+ node !== null &&
64
+ typeof node === "object" &&
65
+ (node as Record<string, unknown>).type === "Identifier" &&
66
+ typeof (node as Record<string, unknown>).name === "string"
67
+ ) {
68
+ return (node as Record<string, unknown>).name as string;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * ASTを走査してrequire/dofile呼び出し(CallExpression / StringCallExpression の両構文)を
75
+ * 静的な文字列引数付きのものに限って列挙する。同一モジュールへの参照は重複したまま返す
76
+ * (呼び出し側で重複排除する)。
77
+ */
78
+ export function findModuleReferences(ast: Chunk): ModuleReference[] {
79
+ const refs: ModuleReference[] = [];
80
+
81
+ walk(ast, (node) => {
82
+ if (node.type === "CallExpression") {
83
+ const name = calleeName(node.base);
84
+ if (name !== "require" && name !== "dofile") {
85
+ return;
86
+ }
87
+ const args = node.arguments;
88
+ if (!Array.isArray(args) || args.length === 0) {
89
+ return;
90
+ }
91
+ const moduleName = staticStringArgument(args[0]);
92
+ if (moduleName !== undefined) {
93
+ refs.push({ kind: name, moduleName });
94
+ }
95
+ } else if (node.type === "StringCallExpression") {
96
+ const name = calleeName(node.base);
97
+ if (name !== "require" && name !== "dofile") {
98
+ return;
99
+ }
100
+ const moduleName = staticStringArgument(node.argument);
101
+ if (moduleName !== undefined) {
102
+ refs.push({ kind: name, moduleName });
103
+ }
104
+ }
105
+ });
106
+
107
+ return refs;
108
+ }
package/src/minifier.ts CHANGED
@@ -1,114 +1,284 @@
1
- import Parser, { Comment, Options } from "luaparse";
2
- import path from "path";
3
- import fs from "fs";
4
- import { SourceNode } from "source-map";
5
- import { Chunk, MinifyFile } from "./ast2lua";
6
-
7
- export interface MinifierMode {
8
- moduleLikeLua: boolean;
9
- }
10
-
11
- export class Minifier {
12
- readonly identifierMap: Map<string, string>;
13
- readonly identifiersInUse: Set<string>;
14
- readonly moduleSourceText: Map<string, string>;
15
- readonly moduleSourceNode: Map<string, SourceNode>;
16
- readonly moduleAST: Map<string, Chunk>;
17
- readonly dir: string;
18
- readonly entryModule: string;
19
- readonly mode: MinifierMode;
20
- readonly luaParseSettings: Partial<Options>;
21
-
22
- constructor(
23
- entryFilePath: string,
24
- luaParseSettings: Partial<Options>,
25
- mode: MinifierMode
26
- ) {
27
- this.identifierMap = new Map<string, string>();
28
- this.identifiersInUse = new Set<string>();
29
- this.moduleSourceText = new Map<string, string>();
30
- this.moduleSourceNode = new Map<string, SourceNode>();
31
- this.moduleAST = new Map<string, Chunk>();
32
- this.luaParseSettings = luaParseSettings;
33
- this.mode = mode;
34
- const pn = path.parse(entryFilePath);
35
- this.dir = pn.dir;
36
- this.entryModule = pn.name;
37
- }
38
-
39
- parse() {
40
- const sn = this.parseModule(this.entryModule);
41
-
42
- if (this.mode.moduleLikeLua) {
43
- // require関数を作成し、流し込む
44
- /*
45
- function require(m,r)
46
- package=package or {loaded={}}
47
- if package.loaded[m] then return package.loaded[m] end
48
- if m=="<MODULE_NAME>" then r=(function()[[MODULE]]end)() end
49
- package.loaded[m]=package.loaded[m] or r or true;return package.loaded[m]
50
- end
51
- */
52
- sn.prepend("package.loaded[m]=package.loaded[m]or r or true;return package.loaded[m]end\n");
53
- this.moduleSourceNode.forEach((v, k) => {
54
- if (k !== this.entryModule) {
55
- sn.prepend(["if m==\"", k, "\"then r=(function() ", v ," end)()end\n"]);
56
- }
57
- });
58
- sn.prepend("function require(m,r)package=package or{loaded={}};if package.loaded[m]then return package.loaded[m]end\n");
59
- }
60
-
61
- // コメントの流し込み
62
- const comments = this.moduleAST.get(this.entryModule)?.comments as
63
- | Comment[]
64
- | undefined;
65
- if (comments) {
66
- comments
67
- .reverse()
68
- .filter((v) => v.raw.includes("--#") || v.raw.includes("[[#"))
69
- .forEach((comment) => {
70
- sn.prepend([
71
- new SourceNode(
72
- comment.loc?.start.line || null,
73
- comment.loc?.start.column || null,
74
- this.entryModule, // 本当に自分のファイル名でよいかは要検討
75
- comment.raw
76
- )
77
- ,"\n"]);
78
- });
79
- }
80
-
81
- return sn;
82
- }
83
-
84
- parseModule(moduleName: string): SourceNode {
85
- const resolvePath = moduleName.replaceAll(".", path.sep) + ".lua";
86
- const fullResolvePath = path.join(this.dir, resolvePath);
87
-
88
- if (this.moduleSourceNode.has(moduleName)) {
89
- const res = this.moduleSourceNode.get(moduleName);
90
- if (res) {
91
- return res;
92
- }
93
- } else if (fs.existsSync(fullResolvePath)) {
94
- const code = fs.readFileSync(fullResolvePath).toString();
95
- const ast = Parser.parse(code, this.luaParseSettings) as Chunk;
96
- if ("globals" in ast) {
97
- ast.globals?.map((v) => this.identifiersInUse.add(v.name));
98
-
99
- const sourceNode = new MinifyFile(
100
- resolvePath,
101
- ast,
102
- this,
103
- this.mode
104
- ).parse(resolvePath === this.entryModule);
105
-
106
- this.moduleSourceText.set(moduleName, code);
107
- this.moduleAST.set(moduleName, ast);
108
- this.moduleSourceNode.set(moduleName, sourceNode);
109
- return sourceNode;
110
- }
111
- }
112
- throw new Error(moduleName + " is not found");
113
- }
114
- }
1
+ import Parser, { Options } from "luaparse";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { SourceNode } from "source-map";
5
+ import { Chunk, MinifyFile } from "./ast2lua";
6
+ import { findModuleReferences } from "./linker";
7
+ import { resolveScopes, ResolveResult } from "./resolver";
8
+ import { assignRenames, RenameResult } from "./renamer";
9
+
10
+ export interface MinifierMode {
11
+ moduleLikeLua: boolean;
12
+ // 識別子の短縮(リネーム)を行うかどうか。デバッグ用途でfalseにできる。省略時はtrue扱い。
13
+ rename?: boolean;
14
+ }
15
+
16
+ const NO_RENAME: RenameResult = {
17
+ nameOf: () => undefined,
18
+ usedNames: new Set(),
19
+ };
20
+
21
+ export class Minifier {
22
+ readonly identifiersInUse: Set<string>;
23
+ readonly moduleSourceText: Map<string, string>;
24
+ readonly moduleAST: Map<string, Chunk>;
25
+ readonly moduleNameAndFileName: Map<string, string>;
26
+ readonly dir: string;
27
+ readonly entryModule: string;
28
+ readonly mode: MinifierMode;
29
+ readonly luaParseSettings: Partial<Options>;
30
+
31
+ // Linkパスで解決されたモジュール名を、依存されている側が先に来る順序で並べたもの
32
+ private readonly linkOrder: string[] = [];
33
+ // モジュールごとのResolveパスの結果(Linkパスで一度だけ計算し使い回す)
34
+ private readonly moduleResolve = new Map<string, ResolveResult>();
35
+ // モジュールごとのRenameパスの結果(初回アクセス時に計算しキャッシュする)
36
+ private readonly renameCache = new Map<string, RenameResult>();
37
+
38
+ constructor(
39
+ entryFilePath: string,
40
+ luaParseSettings: Partial<Options>,
41
+ mode: MinifierMode,
42
+ ) {
43
+ this.identifiersInUse = new Set<string>();
44
+ this.moduleSourceText = new Map<string, string>();
45
+ this.moduleAST = new Map<string, Chunk>();
46
+ this.moduleNameAndFileName = new Map<string, string>();
47
+ this.luaParseSettings = luaParseSettings;
48
+ this.mode = mode;
49
+ const pn = path.parse(entryFilePath);
50
+ this.dir = pn.dir;
51
+ this.entryModule = pn.name;
52
+ }
53
+
54
+ parse(): SourceNode {
55
+ this.link();
56
+ this.renameAll();
57
+
58
+ const parts: (SourceNode | string)[] = [];
59
+
60
+ const entryComments = this.moduleAST.get(this.entryModule)?.comments;
61
+ if (entryComments) {
62
+ entryComments
63
+ .filter((v) => v.raw.includes("--#") || v.raw.includes("[[#"))
64
+ .forEach((comment) => {
65
+ parts.push(
66
+ new SourceNode(
67
+ comment.loc?.start.line ?? null,
68
+ comment.loc?.start.column ?? null,
69
+ this.moduleNameAndFileName.get(this.entryModule) ?? null,
70
+ comment.raw,
71
+ ),
72
+ "\n",
73
+ );
74
+ });
75
+ }
76
+
77
+ if (this.mode.moduleLikeLua) {
78
+ parts.push(this.buildRequireWrapper());
79
+ }
80
+
81
+ parts.push(this.printModule(this.entryModule));
82
+
83
+ const result = new SourceNode(null, null, null, parts);
84
+
85
+ this.moduleSourceText.forEach((v, k) => {
86
+ const fileName = this.moduleNameAndFileName.get(k);
87
+ if (fileName) {
88
+ result.setSourceContent(fileName, v);
89
+ }
90
+ });
91
+
92
+ return result;
93
+ }
94
+
95
+ /**
96
+ * dofileの呼び出し箇所ごとに、キャッシュ済みASTから新規にSourceNodeを作り直す。
97
+ * 同じSourceNodeインスタンスを複数箇所へ挿入すると壊れるため、常に作り直す(#18)。
98
+ */
99
+ printModuleInline(moduleName: string): SourceNode {
100
+ return this.printModule(moduleName);
101
+ }
102
+
103
+ /**
104
+ * requireを式(IIFE)ではなく文として展開できる場合に使う。モジュール本体が
105
+ * 「単一の式を返すreturn文」で終わっている場合のみ結果を返す。それ以外は
106
+ * undefinedを返すので、呼び出し側は従来のIIFE方式にフォールバックする(#29)。
107
+ */
108
+ splitModuleForStatementSplice(
109
+ moduleName: string,
110
+ ): { statements: SourceNode; finalExpression: SourceNode } | undefined {
111
+ const ast = this.moduleAST.get(moduleName);
112
+ const fileName = this.moduleNameAndFileName.get(moduleName);
113
+ if (!ast || !fileName) {
114
+ throw new Error(moduleName + " is not found");
115
+ }
116
+ return new MinifyFile(
117
+ fileName,
118
+ moduleName,
119
+ ast,
120
+ this,
121
+ this.mode,
122
+ ).parseAsStatementsAndFinalExpression(moduleName === this.entryModule);
123
+ }
124
+
125
+ /**
126
+ * 指定モジュールのRenameパス結果を返す。`renameAll`で事前に計算済みの
127
+ * ものをそのまま返すだけの参照用アクセサ。
128
+ */
129
+ getRenameResult(moduleName: string): RenameResult {
130
+ if (this.mode.rename === false) {
131
+ return NO_RENAME;
132
+ }
133
+ const cached = this.renameCache.get(moduleName);
134
+ if (!cached) {
135
+ throw new Error(moduleName + " is not found");
136
+ }
137
+ return cached;
138
+ }
139
+
140
+ /**
141
+ * Renameパス(#20): linkOrder(依存されている側が先)の順にモジュールごとの
142
+ * 短縮名を割り当てる。
143
+ *
144
+ * dofileやSLモードのrequireその場展開は、呼び出し元と同じLuaスコープに
145
+ * 関数で包まずに直接展開されるため、モジュールをまたいで同じ短縮名を
146
+ * 再利用すると本来無関係な変数同士が衝突しうる(#12)。これを安全に防ぐため、
147
+ * あるモジュールが実際に使った短縮名は、後続モジュールを処理する前に
148
+ * `identifiersInUse`(予約名の集合)へ積み増す。これにより短縮名は
149
+ * プログラム全体で重複しなくなる(モジュール間での再利用による圧縮は
150
+ * 犠牲になるが、モジュール内でのスコープに基づく再利用は維持される)。
151
+ */
152
+ private renameAll() {
153
+ if (this.mode.rename === false) {
154
+ return;
155
+ }
156
+ this.linkOrder.forEach((moduleName) => {
157
+ const resolved = this.moduleResolve.get(moduleName);
158
+ if (!resolved) {
159
+ throw new Error(moduleName + " is not found");
160
+ }
161
+ const result = assignRenames(resolved, this.identifiersInUse);
162
+ this.renameCache.set(moduleName, result);
163
+ result.usedNames.forEach((name) => this.identifiersInUse.add(name));
164
+ });
165
+ }
166
+
167
+ private printModule(moduleName: string): SourceNode {
168
+ const ast = this.moduleAST.get(moduleName);
169
+ const fileName = this.moduleNameAndFileName.get(moduleName);
170
+ if (!ast || !fileName) {
171
+ throw new Error(moduleName + " is not found");
172
+ }
173
+ return new MinifyFile(fileName, moduleName, ast, this, this.mode).parse(
174
+ moduleName === this.entryModule,
175
+ );
176
+ }
177
+
178
+ /**
179
+ * エントリファイルから到達可能な全モジュールをASTレベルで解決するLinkパス(#18)。
180
+ * - ファイルごとのパースは一度だけ行う(同一モジュールの多重require/dofileの重複排除)
181
+ * - require/dofileの参照グラフに循環があればエラーを投げる
182
+ * - 出力(Print)を開始する前に、必要なモジュール解決をすべて完了させる
183
+ */
184
+ private link() {
185
+ const visiting = new Set<string>();
186
+ const stack: string[] = [];
187
+
188
+ const visit = (moduleName: string) => {
189
+ if (visiting.has(moduleName)) {
190
+ const cycleStart = stack.indexOf(moduleName);
191
+ const cycle = [...stack.slice(cycleStart), moduleName];
192
+ throw new Error(
193
+ "Circular require/dofile detected: " + cycle.join(" -> "),
194
+ );
195
+ }
196
+ if (this.moduleAST.has(moduleName)) {
197
+ // 解決済み(このモジュールは複数箇所から参照されていても一度しかパースしない)
198
+ return;
199
+ }
200
+
201
+ visiting.add(moduleName);
202
+ stack.push(moduleName);
203
+
204
+ const fullResolvePath =
205
+ path.join(this.dir, ...moduleName.split(".")) + ".lua";
206
+ if (!fs.existsSync(fullResolvePath)) {
207
+ throw new Error(moduleName + " is not found");
208
+ }
209
+ const code = fs.readFileSync(fullResolvePath).toString();
210
+ const ast = Parser.parse(code, this.luaParseSettings) as Chunk;
211
+
212
+ // Resolveパス(#19): このモジュールのスコープ/シンボルを解析し、Renameパスの
213
+ // 入力として使い回せるようキャッシュする。グローバル参照はプログラム全体で
214
+ // 予約すべき名前(identifiersInUse)としてここで集計する。
215
+ const resolved = resolveScopes(ast);
216
+ this.moduleResolve.set(moduleName, resolved);
217
+ resolved.globals.forEach((binding) =>
218
+ this.identifiersInUse.add(binding.name),
219
+ );
220
+
221
+ this.moduleSourceText.set(moduleName, code);
222
+ this.moduleAST.set(moduleName, ast);
223
+ // Source Mapの`sources`はURLとして解釈されるため、OS依存のpath.sepではなく
224
+ // 常に"/"区切りで保持する(Windows上でのビルドでも壊れないように)。
225
+ this.moduleNameAndFileName.set(
226
+ moduleName,
227
+ moduleName.replaceAll(".", "/") + ".lua",
228
+ );
229
+
230
+ findModuleReferences(ast).forEach((ref) => {
231
+ visit(ref.moduleName);
232
+ });
233
+
234
+ visiting.delete(moduleName);
235
+ stack.pop();
236
+ this.linkOrder.push(moduleName);
237
+ };
238
+
239
+ visit(this.entryModule);
240
+ }
241
+
242
+ /**
243
+ * require()(dofileは除く)で参照されているモジュール名の集合を求める。
244
+ * dofileは呼び出しごとに毎回展開しなおすため、キャッシュ/ホイストの対象にしない。
245
+ */
246
+ private collectRequireTargets(): Set<string> {
247
+ const targets = new Set<string>();
248
+ this.linkOrder.forEach((moduleName) => {
249
+ const ast = this.moduleAST.get(moduleName);
250
+ if (!ast) {
251
+ return;
252
+ }
253
+ findModuleReferences(ast).forEach((ref) => {
254
+ if (ref.kind === "require") {
255
+ targets.add(ref.moduleName);
256
+ }
257
+ });
258
+ });
259
+ return targets;
260
+ }
261
+
262
+ private buildRequireWrapper(): SourceNode {
263
+ const targets = this.collectRequireTargets();
264
+ const parts: (SourceNode | string)[] = [
265
+ "function require(m,r)package=package or{loaded={}};if package.loaded[m]then return package.loaded[m]end\n",
266
+ ];
267
+ this.linkOrder.forEach((moduleName) => {
268
+ if (moduleName === this.entryModule || !targets.has(moduleName)) {
269
+ return;
270
+ }
271
+ parts.push(
272
+ 'if m=="',
273
+ moduleName,
274
+ '"then r=(function() ',
275
+ this.printModule(moduleName),
276
+ " end)()end\n",
277
+ );
278
+ });
279
+ parts.push(
280
+ "package.loaded[m]=package.loaded[m]or r or true;return package.loaded[m]end\n",
281
+ );
282
+ return new SourceNode(null, null, null, parts);
283
+ }
284
+ }