mt-lang 0.3.16 → 0.3.20

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/docs/language-design.md +3 -4
  3. data/docs/language-manual.md +3 -3
  4. data/lib/milk_tea/base.rb +1 -1
  5. data/lib/milk_tea/core/{binding_types.rb → bindings.rb} +1 -1
  6. data/lib/milk_tea/core/c_backend/expressions.rb +34 -23
  7. data/lib/milk_tea/core/c_backend/feature_detection.rb +25 -45
  8. data/lib/milk_tea/core/c_backend/type_collectors.rb +18 -30
  9. data/lib/milk_tea/core/c_backend/type_declaration.rb +0 -6
  10. data/lib/milk_tea/core/c_backend.rb +63 -54
  11. data/lib/milk_tea/core/compile_time.rb +279 -74
  12. data/lib/milk_tea/core/control_flow/constant_propagation.rb +1 -1
  13. data/lib/milk_tea/core/{compatibility_helpers.rb → intrinsics.rb} +9 -2
  14. data/lib/milk_tea/core/lexer.rb +55 -48
  15. data/lib/milk_tea/core/lowering/block.rb +9 -0
  16. data/lib/milk_tea/core/lowering/calls.rb +2 -0
  17. data/lib/milk_tea/core/lowering/declarations.rb +1 -1
  18. data/lib/milk_tea/core/lowering/functions.rb +11 -8
  19. data/lib/milk_tea/core/lowering/resolve.rb +10 -3
  20. data/lib/milk_tea/core/lowering/scans.rb +13 -18
  21. data/lib/milk_tea/core/lowering.rb +18 -18
  22. data/lib/milk_tea/core/module_binder.rb +9 -10
  23. data/lib/milk_tea/core/module_loader.rb +64 -57
  24. data/lib/milk_tea/core/module_path_resolver.rb +1 -4
  25. data/lib/milk_tea/core/parser/declarations.rb +43 -19
  26. data/lib/milk_tea/core/parser/expressions.rb +4 -7
  27. data/lib/milk_tea/core/parser/statements.rb +5 -5
  28. data/lib/milk_tea/core/parser.rb +43 -17
  29. data/lib/milk_tea/core/semantic_analyzer/calls.rb +24 -31
  30. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +20 -23
  31. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +117 -85
  32. data/lib/milk_tea/core/semantic_analyzer/statements.rb +19 -41
  33. data/lib/milk_tea/core/semantic_analyzer.rb +57 -38
  34. data/lib/milk_tea/core.rb +6 -2
  35. data/lib/milk_tea/dap/lldb_dap_backend.rb +153 -0
  36. data/lib/milk_tea/dap/server/lldb_backend.rb +1 -1
  37. data/lib/milk_tea/dap.rb +1 -1
  38. data/lib/milk_tea/lsp/server/semantic_tokens.rb +6 -0
  39. data/lib/milk_tea/lsp.rb +1 -7
  40. data/lib/milk_tea/{bindings/cli.rb → tooling/bindgen_cli.rb} +1 -1
  41. data/lib/milk_tea/tooling/cli/commands/bindgen.rb +11 -0
  42. data/lib/milk_tea/tooling/cli/commands/build.rb +37 -0
  43. data/lib/milk_tea/tooling/cli/commands/cache.rb +46 -0
  44. data/lib/milk_tea/tooling/cli/commands/check.rb +116 -0
  45. data/lib/milk_tea/tooling/cli/commands/command_base.rb +8 -0
  46. data/lib/milk_tea/tooling/cli/commands/completions.rb +48 -0
  47. data/lib/milk_tea/tooling/cli/commands/dap.rb +58 -0
  48. data/lib/milk_tea/tooling/cli/commands/debug.rb +77 -0
  49. data/lib/milk_tea/tooling/cli/commands/deps.rb +17 -0
  50. data/lib/milk_tea/tooling/cli/commands/docs.rb +58 -0
  51. data/lib/milk_tea/tooling/cli/commands/emit_c.rb +64 -0
  52. data/lib/milk_tea/tooling/cli/commands/format.rb +199 -0
  53. data/lib/milk_tea/tooling/cli/commands/lex.rb +46 -0
  54. data/lib/milk_tea/tooling/cli/commands/lint.rb +248 -0
  55. data/lib/milk_tea/tooling/cli/commands/lower.rb +50 -0
  56. data/lib/milk_tea/tooling/cli/commands/lsp.rb +43 -0
  57. data/lib/milk_tea/tooling/cli/commands/new.rb +26 -0
  58. data/lib/milk_tea/tooling/cli/commands/parse.rb +51 -0
  59. data/lib/milk_tea/tooling/cli/commands/run.rb +99 -0
  60. data/lib/milk_tea/tooling/cli/commands/snapshot.rb +117 -0
  61. data/lib/milk_tea/tooling/cli/commands/test.rb +557 -0
  62. data/lib/milk_tea/tooling/cli/commands/toolchain.rb +16 -0
  63. data/lib/milk_tea/tooling/cli.rb +101 -1893
  64. data/lib/milk_tea/tooling.rb +1 -1
  65. metadata +29 -9
  66. data/lib/milk_tea/core/compile_time/const_eval.rb +0 -182
  67. data/lib/milk_tea/core/module_loader/errors.rb +0 -23
  68. data/lib/milk_tea/dap/backends/lldb_dap.rb +0 -156
  69. /data/lib/milk_tea/core/lowering/{async.rb → async/frame_builder.rb} +0 -0
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandLint
6
+ def lint_command
7
+ resolution = { locked: false, frozen: false }
8
+ select = nil
9
+ ignore = nil
10
+ fix = false
11
+ init = false
12
+ ignore_generated = false
13
+ profile = false
14
+ input_paths = []
15
+ until @argv.empty?
16
+ arg = @argv.shift
17
+ unless arg.start_with?("--")
18
+ input_paths << arg
19
+ next
20
+ end
21
+
22
+ flag = arg
23
+ case flag
24
+ when "--select"
25
+ arg = @argv.shift
26
+ unless arg
27
+ @err.puts("--select requires a comma-separated list of rule codes")
28
+ return 1
29
+ end
30
+ select = arg.split(",").map(&:strip).to_set
31
+ when "--ignore"
32
+ arg = @argv.shift
33
+ unless arg
34
+ @err.puts("--ignore requires a comma-separated list of rule codes")
35
+ return 1
36
+ end
37
+ ignore = arg.split(",").map(&:strip).to_set
38
+ when "--fix"
39
+ fix = true
40
+ when "--init"
41
+ init = true
42
+ when "--locked"
43
+ resolution[:locked] = true
44
+ when "--frozen"
45
+ resolution[:locked] = true
46
+ resolution[:frozen] = true
47
+ when "--ignore-generated"
48
+ ignore_generated = true
49
+ when "--timings"
50
+ profile = true
51
+ when "--"
52
+ input_paths.concat(@argv)
53
+ @argv.clear
54
+ else
55
+ @err.puts("unknown lint flag: #{flag}")
56
+ return 1
57
+ end
58
+ end
59
+
60
+ if init
61
+ if input_paths.empty? && !select && !ignore && !fix && !resolution[:locked] && !resolution[:frozen]
62
+ return init_lint_config
63
+ end
64
+
65
+ @err.puts("--init does not accept source paths or lint options")
66
+ return 1
67
+ end
68
+
69
+ if input_paths.empty?
70
+ @err.puts("missing source file path")
71
+ print_usage(@err)
72
+ return 1
73
+ end
74
+
75
+ paths = input_paths.flat_map do |path|
76
+ if File.directory?(path)
77
+ Dir.glob(File.join(path, "**/*.mt")).sort
78
+ else
79
+ [path]
80
+ end
81
+ end.uniq
82
+
83
+ if paths.empty?
84
+ label = input_paths.length == 1 ? input_paths.first : input_paths.join(", ")
85
+ @out.puts("no .mt files found in #{label}")
86
+ return 0
87
+ end
88
+
89
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
90
+
91
+ if fix
92
+ lint_profiles = []
93
+ paths.each do |p|
94
+ announce_file_action(p, "lint-fix")
95
+ source = read_source_file(p)
96
+ if ignore_generated && generated_source?(source)
97
+ @out.puts("ignored generated #{p}")
98
+ next
99
+ end
100
+
101
+ facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
102
+ prof = profile ? Linter::Profile.new : nil
103
+ start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
104
+
105
+ fixed = Linter.fix_source(
106
+ source,
107
+ path: p,
108
+ sema_facts: facts,
109
+ select:,
110
+ ignore:,
111
+ profile: prof,
112
+ )
113
+ total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
114
+ lint_profiles << { path: p, profile: prof, mode: :pre_fix_scan, total_ms: } if prof
115
+ if fixed != source
116
+ File.write(p, fixed)
117
+ @out.puts("fixed #{p}")
118
+ end
119
+ end
120
+ print_lint_rule_profiles(lint_profiles) if profile
121
+ print_lint_file_profiles(lint_profiles) if profile
122
+ return 0
123
+ end
124
+
125
+ lint_profiles = []
126
+ all_warnings = paths.flat_map do |p|
127
+ announce_file_action(p, "lint")
128
+ source = read_source_file(p)
129
+ next [] if ignore_generated && generated_source?(source)
130
+
131
+ facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
132
+ prof = profile ? Linter::Profile.new : nil
133
+ start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
134
+ warnings = Linter.lint_source(source, path: p, select:, ignore:, sema_facts: facts, profile: prof)
135
+ total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
136
+ lint_profiles << { path: p, profile: prof, mode: :lint, total_ms: } if prof
137
+ warnings
138
+ end
139
+
140
+ if all_warnings.empty?
141
+ if input_paths.length == 1
142
+ info("clean #{input_paths.first}")
143
+ else
144
+ info("clean #{paths.size} file(s)")
145
+ end
146
+ print_lint_file_profiles(lint_profiles) if profile
147
+ return 0
148
+ end
149
+
150
+ all_warnings.each do |warning|
151
+ @out.puts("#{warning.path}:#{warning.line}: #{warning.code}: #{warning.message}")
152
+ end
153
+
154
+ print_lint_rule_profiles(lint_profiles) if profile
155
+ print_lint_file_profiles(lint_profiles) if profile
156
+
157
+ file_count = all_warnings.map(&:path).uniq.size
158
+ noun = all_warnings.size == 1 ? "warning" : "warnings"
159
+ files_str = file_count == 1 ? "1 file" : "#{file_count} files"
160
+ @out.puts("Found #{all_warnings.size} #{noun} in #{files_str}.")
161
+ 1
162
+ end
163
+
164
+ def init_lint_config
165
+ path = File.join(Dir.pwd, Linter::DEFAULT_CONFIG_FILE_NAME)
166
+ if File.exist?(path)
167
+ @err.puts("lint config already exists at #{path}")
168
+ return 1
169
+ end
170
+
171
+ File.write(path, Linter.default_config_source)
172
+ info("created #{path}")
173
+ 0
174
+ end
175
+
176
+ def print_lint_rule_profiles(lint_profiles, limit: 12)
177
+ lint_profiles.each do |entry|
178
+ profile = entry[:profile]
179
+ next unless profile
180
+
181
+ rows = profile.rule_breakdown(limit:, min_ms: 0.0)
182
+ rule_total = profile.total_time_ms(prefix: "rule.")
183
+ overall_total = profile.total_time_ms
184
+ mode_label = entry[:mode] == :pre_fix_scan ? "pre-fix scan" : "lint scan"
185
+ @out.puts("lint profile #{entry[:path]} (#{mode_label}): rules=#{format('%.1f', rule_total)}ms total=#{format('%.1f', overall_total)}ms")
186
+
187
+ if rows.empty?
188
+ @out.puts(" no rule timing data captured")
189
+ next
190
+ end
191
+
192
+ rows.each do |row|
193
+ share = rule_total.positive? ? ((row[:total_ms] / rule_total) * 100.0) : 0.0
194
+ @out.puts(
195
+ " #{row[:code]}: #{row[:count]}x total=#{format('%.1f', row[:total_ms])}ms avg=#{format('%.2f', row[:avg_ms])}ms share=#{format('%.1f', share)}%"
196
+ )
197
+ end
198
+
199
+ non_rule_rows = profile.timings_ms
200
+ .filter_map do |name, total_ms|
201
+ next if name.start_with?("rule.")
202
+ next if total_ms < 1.0
203
+
204
+ [name, total_ms]
205
+ end
206
+ .sort_by { |_name, total_ms| -total_ms }
207
+ .first(5)
208
+ .map do |name, total_ms|
209
+ count = profile.counts[name]
210
+ "#{name}:#{count}x/#{format('%.1f', total_ms)}ms"
211
+ end
212
+
213
+ @out.puts(" non-rule hot phases: #{non_rule_rows.join(', ')}") unless non_rule_rows.empty?
214
+ end
215
+ end
216
+
217
+ def print_lint_file_profiles(lint_profiles)
218
+ file_entries = lint_profiles.filter_map do |entry|
219
+ total = entry[:total_ms]
220
+ next unless total
221
+
222
+ phases = entry[:profile]&.timings_ms&.reject { |name, _| name.start_with?("rule.") }&.sort_by { |_, ms| -ms }
223
+ { path: entry[:path], total_ms: total, phases: }
224
+ end
225
+ return if file_entries.empty?
226
+
227
+ sorted = file_entries.sort_by { |e| -e[:total_ms] }
228
+ @out.puts
229
+ @out.puts("Profile (lint): #{sorted.size} file(s)")
230
+ sorted.each do |entry|
231
+ phase_str = entry[:phases]&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 1.0 }&.join(", ")
232
+ detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
233
+ @out.puts(" #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
234
+ end
235
+ total = sorted.sum { |e| e[:total_ms] }
236
+ @out.puts("Total: #{format('%.1f', total)}ms")
237
+ end
238
+
239
+ def lint_sema_facts_for(source, path, locked: false)
240
+ ast = Parser.parse(source, path: path)
241
+ imported_modules = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform).imported_modules_for_ast(ast, importer_path: path)
242
+ SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: path).facts
243
+ rescue MilkTea::LexError, MilkTea::ParseError, SemanticError, ModuleLoadError
244
+ nil
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandLower
6
+ def lower_command
7
+ sexpr = false
8
+ args = @argv.dup
9
+ @argv = []
10
+ until args.empty?
11
+ arg = args.shift
12
+ if arg == "--sexpr"
13
+ sexpr = true
14
+ next
15
+ end
16
+ @argv << arg
17
+ end
18
+
19
+ unless @argv.any?
20
+ @err.puts("missing source file path")
21
+ print_usage(@err)
22
+ return 1
23
+ end
24
+
25
+ resolution = extract_resolution_flags!
26
+ input_paths = @argv.dup
27
+ return 1 unless ensure_known_source_operands!("lower", input_paths)
28
+
29
+ paths = expand_source_paths(input_paths)
30
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
31
+
32
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
33
+
34
+ multiple = paths.length > 1
35
+ paths.each_with_index do |path, index|
36
+ program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
37
+ if multiple
38
+ @out.puts("# --- #{path} ---") unless sexpr
39
+ end
40
+ if sexpr
41
+ @out.puts(SexprDumper.dump_ir(Lowering.lower(program)))
42
+ else
43
+ @out.write(PrettyPrinter.format_ir(Lowering.lower(program)))
44
+ end
45
+ end
46
+ 0
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandLsp
6
+ def lsp_command
7
+ log_level = nil
8
+
9
+ until @argv.empty?
10
+ arg = @argv.shift
11
+ case arg
12
+ when "--log-level"
13
+ log_level = @argv.shift&.downcase
14
+ unless log_level && %w[trace debug info warn error].include?(log_level)
15
+ @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
16
+ return 1
17
+ end
18
+ when /\A--log-level=(.+)\z/
19
+ log_level = ::Regexp.last_match(1).downcase
20
+ unless %w[trace debug info warn error].include?(log_level)
21
+ @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
22
+ return 1
23
+ end
24
+ when "--stdio"
25
+ # stdio is the only transport; accept the flag as a no-op
26
+ else
27
+ if arg.start_with?("-")
28
+ @err.puts("lsp: unknown option #{arg}")
29
+ return 1
30
+ end
31
+ @err.puts("lsp: unexpected argument #{arg}")
32
+ return 1
33
+ end
34
+ end
35
+
36
+ require "milk_tea/lsp/server" unless defined?(MilkTea::LSP::Server)
37
+ server = MilkTea::LSP::Server.new
38
+ server.run
39
+ 0
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandNew
6
+ def new_command
7
+ name = @argv.shift
8
+ unless name
9
+ @err.puts("missing project name")
10
+ print_usage(@err)
11
+ return 1
12
+ end
13
+
14
+ if @argv.any?
15
+ @err.puts("unknown new option #{@argv.first}")
16
+ print_usage(@err)
17
+ return 1
18
+ end
19
+
20
+ result = ProjectScaffold.create(name)
21
+ info("created #{result.root_path}")
22
+ 0
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandParse
6
+ def parse_command
7
+ sexpr = false
8
+ args = @argv.dup
9
+ @argv = []
10
+ until args.empty?
11
+ arg = args.shift
12
+ if arg == "--sexpr"
13
+ sexpr = true
14
+ next
15
+ end
16
+ @argv << arg
17
+ end
18
+
19
+ unless @argv.any?
20
+ @err.puts("missing source file path")
21
+ print_usage(@err)
22
+ return 1
23
+ end
24
+
25
+ resolution = extract_resolution_flags!
26
+ input_paths = @argv.dup
27
+ return 1 unless ensure_known_source_operands!("parse", input_paths)
28
+
29
+ paths = expand_source_paths(input_paths)
30
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
31
+
32
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
33
+
34
+ multiple = paths.length > 1
35
+ paths.each_with_index do |path, index|
36
+ ast = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).load_file(path)
37
+ if multiple
38
+ @out.puts("# --- #{path} ---") unless sexpr
39
+ end
40
+ if sexpr
41
+ @out.puts(SexprDumper.dump_ast(ast))
42
+ else
43
+ @out.write(PrettyPrinter.format_ast(ast))
44
+ end
45
+ @out.puts if multiple && index < paths.length - 1
46
+ end
47
+ 0
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandRun
6
+ def run_command
7
+ path, options = extract_path_and_options
8
+ return 1 unless path
9
+
10
+ run_and_print_result(path, options)
11
+ end
12
+
13
+ def app_command
14
+ options = parse_build_options
15
+ return 1 unless options
16
+
17
+ module_name = @argv.shift
18
+ unless module_name
19
+ @err.puts("missing module name")
20
+ print_usage(@err)
21
+ return 1
22
+ end
23
+
24
+ path = resolve_app_module(module_name)
25
+ unless path
26
+ @err.puts("run-module module not found: #{module_name}")
27
+ return 1
28
+ end
29
+
30
+ frozen = options.delete(:frozen)
31
+ ensure_current_lockfile!(path) if frozen
32
+
33
+ run_and_print_result(path, options)
34
+ end
35
+
36
+ def extract_path_and_options(allow_clean: false)
37
+ options = parse_build_options(allow_clean:)
38
+ return nil unless options
39
+
40
+ path = @argv.shift
41
+ unless path
42
+ if File.file?(File.join(Dir.pwd, "package.toml"))
43
+ path = Dir.pwd
44
+ else
45
+ @err.puts("missing source file path")
46
+ print_usage(@err)
47
+ return nil
48
+ end
49
+ end
50
+
51
+ [path, options]
52
+ end
53
+
54
+ def run_and_print_result(path, options)
55
+ frozen = options.delete(:frozen)
56
+ ensure_current_lockfile!(path) if frozen
57
+ locked = options.delete(:locked)
58
+ package_graph = package_graph_for(path, locked:)
59
+ preview_notice_emitted = false
60
+ preview_started = lambda do |message|
61
+ preview_notice_emitted = true
62
+ @out.write(message)
63
+ @out.flush if @out.respond_to?(:flush)
64
+ end
65
+
66
+ result = Run.run(
67
+ path,
68
+ module_roots: module_roots_for(path, locked:),
69
+ package_graph:,
70
+ frontend: @build_frontend,
71
+ preview_started:,
72
+ argv: @argv.dup,
73
+ **options.except(:timings)
74
+ )
75
+ unless @out.equal?($stdout) || preview_notice_emitted
76
+ @out.write(result.stdout)
77
+ end
78
+ @out.flush if @out.respond_to?(:flush)
79
+ @err.write(result.stderr) unless @err.equal?($stderr)
80
+ info("[cached]") if result.cached
81
+ result.exit_status
82
+ end
83
+
84
+ def resolve_app_module(name)
85
+ relative = name.tr(".", "/").sub(%r{^/}, "") + ".mt"
86
+
87
+ module_roots_for(Dir.pwd).each do |root|
88
+ candidate = File.join(root, "std", relative)
89
+ return File.expand_path(candidate) if File.file?(candidate)
90
+
91
+ candidate = File.join(root, relative)
92
+ return File.expand_path(candidate) if File.file?(candidate)
93
+ end
94
+
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandSnapshot
6
+ def snapshot_command
7
+ input_path = nil
8
+ theme_path = nil
9
+ output_path = nil
10
+ textmate_only = false
11
+
12
+ until @argv.empty?
13
+ arg = @argv.first
14
+ case arg
15
+ when "--theme", "-t"
16
+ @argv.shift
17
+ theme_path = @argv.shift
18
+ unless theme_path
19
+ @err.puts("snapshot: missing value for --theme")
20
+ return 1
21
+ end
22
+ when "--output", "-o"
23
+ @argv.shift
24
+ output_path = @argv.shift
25
+ unless output_path
26
+ @err.puts("snapshot: missing value for --output")
27
+ return 1
28
+ end
29
+ when "--textmate-only"
30
+ @argv.shift
31
+ textmate_only = true
32
+ else
33
+ if arg.start_with?("-")
34
+ @err.puts("snapshot: unknown option #{arg}")
35
+ return 1
36
+ end
37
+ unless input_path
38
+ input_path = @argv.shift
39
+ else
40
+ @err.puts("snapshot: unexpected argument #{@argv.shift}")
41
+ return 1
42
+ end
43
+ end
44
+ end
45
+
46
+ unless input_path
47
+ @err.puts("snapshot: missing source file path")
48
+ print_usage(@err)
49
+ return 1
50
+ end
51
+
52
+ unless File.file?(input_path)
53
+ @err.puts("snapshot: source file not found: #{input_path}")
54
+ return 1
55
+ end
56
+
57
+ input_path = File.expand_path(input_path)
58
+ theme_path = File.expand_path(theme_path) if theme_path
59
+ output_path = File.expand_path(output_path) if output_path
60
+
61
+ if theme_path && !File.file?(theme_path)
62
+ @err.puts("snapshot: theme file not found: #{theme_path}")
63
+ return 1
64
+ end
65
+
66
+ snapshot_script = MilkTea.root.join("bindings/vscode/scripts/snapshot.js").to_s
67
+ unless File.file?(snapshot_script)
68
+ @err.puts("snapshot: internal script not found at #{snapshot_script}")
69
+ return 1
70
+ end
71
+
72
+ args = ["node", snapshot_script, input_path]
73
+ args.push("-t", theme_path) if theme_path
74
+ args.push("-o", output_path) if output_path
75
+
76
+ semantic_result = nil
77
+ unless textmate_only
78
+ begin
79
+ semantic_result = MilkTea::LSP::Server.semantic_tokens_for_path(input_path)
80
+ rescue => e
81
+ @err.puts("snapshot: semantic analysis skipped: #{e.message}")
82
+ end
83
+ end
84
+
85
+ if semantic_result && semantic_result[:entries] && !semantic_result[:entries].empty?
86
+ source = File.read(input_path)
87
+ lines = source.split("\n", -1)
88
+ semantic_result[:entries].each do |entry|
89
+ byte_start = entry[:startChar]
90
+ byte_len = entry[:length]
91
+ line_text = lines[entry[:line]]
92
+ unless line_text && byte_start && byte_len
93
+ entry[:startChar] = 0
94
+ entry[:length] = 0
95
+ next
96
+ end
97
+ char_start = line_text.byteslice(0, byte_start).length
98
+ char_length = line_text.byteslice(byte_start, byte_len).length
99
+ char_length = line_text.length - char_start if char_start + char_length > line_text.length
100
+ entry[:startChar] = char_start
101
+ entry[:length] = char_length
102
+ end
103
+
104
+ temp = Tempfile.new(["mt_semantic", ".json"])
105
+ temp.write(JSON.generate(semantic_result[:entries]))
106
+ temp.close
107
+ args.push("-s", temp.path)
108
+ system(*args)
109
+ temp.unlink
110
+ else
111
+ system(*args)
112
+ end
113
+ $?.success? ? 0 : 1
114
+ end
115
+ end
116
+ end
117
+ end