mt-lang 0.3.17 → 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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/lib/milk_tea/base.rb +1 -1
  3. data/lib/milk_tea/core/c_backend/expressions.rb +34 -23
  4. data/lib/milk_tea/core/c_backend/feature_detection.rb +25 -45
  5. data/lib/milk_tea/core/c_backend/type_collectors.rb +18 -30
  6. data/lib/milk_tea/core/c_backend/type_declaration.rb +0 -6
  7. data/lib/milk_tea/core/c_backend.rb +49 -39
  8. data/lib/milk_tea/core/compile_time.rb +98 -72
  9. data/lib/milk_tea/core/intrinsics.rb +7 -0
  10. data/lib/milk_tea/core/lexer.rb +46 -35
  11. data/lib/milk_tea/core/lowering/block.rb +9 -0
  12. data/lib/milk_tea/core/lowering/calls.rb +2 -0
  13. data/lib/milk_tea/core/lowering/declarations.rb +1 -1
  14. data/lib/milk_tea/core/lowering/functions.rb +11 -8
  15. data/lib/milk_tea/core/lowering/resolve.rb +10 -3
  16. data/lib/milk_tea/core/lowering/scans.rb +13 -18
  17. data/lib/milk_tea/core/module_binder.rb +9 -10
  18. data/lib/milk_tea/core/module_loader.rb +38 -42
  19. data/lib/milk_tea/core/module_path_resolver.rb +1 -4
  20. data/lib/milk_tea/core/parser/declarations.rb +43 -19
  21. data/lib/milk_tea/core/parser/expressions.rb +4 -7
  22. data/lib/milk_tea/core/parser/statements.rb +5 -5
  23. data/lib/milk_tea/core/parser.rb +26 -0
  24. data/lib/milk_tea/core/semantic_analyzer/calls.rb +24 -31
  25. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +20 -23
  26. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +117 -85
  27. data/lib/milk_tea/core/semantic_analyzer/statements.rb +19 -41
  28. data/lib/milk_tea/core/semantic_analyzer.rb +56 -37
  29. data/lib/milk_tea/lsp/server/semantic_tokens.rb +6 -0
  30. data/lib/milk_tea/tooling/cli/commands/bindgen.rb +11 -0
  31. data/lib/milk_tea/tooling/cli/commands/build.rb +37 -0
  32. data/lib/milk_tea/tooling/cli/commands/cache.rb +46 -0
  33. data/lib/milk_tea/tooling/cli/commands/check.rb +116 -0
  34. data/lib/milk_tea/tooling/cli/commands/command_base.rb +8 -0
  35. data/lib/milk_tea/tooling/cli/commands/completions.rb +48 -0
  36. data/lib/milk_tea/tooling/cli/commands/dap.rb +58 -0
  37. data/lib/milk_tea/tooling/cli/commands/debug.rb +77 -0
  38. data/lib/milk_tea/tooling/cli/commands/deps.rb +17 -0
  39. data/lib/milk_tea/tooling/cli/commands/docs.rb +58 -0
  40. data/lib/milk_tea/tooling/cli/commands/emit_c.rb +64 -0
  41. data/lib/milk_tea/tooling/cli/commands/format.rb +199 -0
  42. data/lib/milk_tea/tooling/cli/commands/lex.rb +46 -0
  43. data/lib/milk_tea/tooling/cli/commands/lint.rb +248 -0
  44. data/lib/milk_tea/tooling/cli/commands/lower.rb +50 -0
  45. data/lib/milk_tea/tooling/cli/commands/lsp.rb +43 -0
  46. data/lib/milk_tea/tooling/cli/commands/new.rb +26 -0
  47. data/lib/milk_tea/tooling/cli/commands/parse.rb +51 -0
  48. data/lib/milk_tea/tooling/cli/commands/run.rb +99 -0
  49. data/lib/milk_tea/tooling/cli/commands/snapshot.rb +117 -0
  50. data/lib/milk_tea/tooling/cli/commands/test.rb +557 -0
  51. data/lib/milk_tea/tooling/cli/commands/toolchain.rb +16 -0
  52. data/lib/milk_tea/tooling/cli.rb +101 -1893
  53. metadata +24 -2
@@ -556,9 +556,15 @@ module MilkTea
556
556
  next_tok&.type == :colon
557
557
  end
558
558
 
559
+ # Keywords whose standalone syntax is "keyword:" — they form blocks or
560
+ # expressions directly with a colon and no intervening token, so they are
561
+ # not field declarations when followed by ":".
562
+ STANDS_ALONE_WITH_COLON = %i[unsafe else defer parallel].to_set.freeze
563
+
559
564
  def keyword_field_declaration_token?(tokens, index)
560
565
  return false if match_arm_binding_token?(tokens, index)
561
566
  return false if destructure_let_binding?(tokens, index)
567
+ return false if STANDS_ALONE_WITH_COLON.include?(tokens[index].type)
562
568
 
563
569
  next_tok = next_non_trivia_token(tokens, index + 1)
564
570
  next_tok&.type == :colon
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandBindgen
6
+ def bindgen_command
7
+ BindgenCLI.start(@argv, out: @out, err: @err, help_printer: method(:print_bindgen_help))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandBuild
6
+ def build_command
7
+ path, options = extract_path_and_options(allow_clean: true)
8
+ return 1 unless path
9
+
10
+ if options.delete(:clean)
11
+ cleaned_path = Build.clean(path, output_path: options[:output_path], profile: options[:profile], platform: options[:platform], bundle: options[:bundle], archive: options[:archive])
12
+ info("cleaned #{cleaned_path}")
13
+ return 0
14
+ end
15
+
16
+ frozen = options.delete(:frozen)
17
+ ensure_current_lockfile!(path) if frozen
18
+ locked = options.delete(:locked)
19
+ bundle = options[:bundle]
20
+ package_graph = package_graph_for(path, locked:)
21
+ result = Build.build(path, module_roots: module_roots_for(path, locked:), package_graph:, frontend: @build_frontend, **options.except(:timings))
22
+ if bundle
23
+ info("built #{path} -> #{File.dirname(result.output_path)}")
24
+ info("entry executable #{result.output_path}")
25
+ info(" [cached]") if result.cached
26
+ info("archive #{result.archive_path}") if result.archive_path
27
+ elsif result.cached
28
+ info("built #{path} -> #{result.output_path} [cached]")
29
+ else
30
+ info("built #{path} -> #{result.output_path}")
31
+ end
32
+ info("saved C to #{result.c_path}") if result.c_path
33
+ 0
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandCache
6
+ def cache_command
7
+ subcommand = @argv.shift
8
+ unless subcommand
9
+ @err.puts("missing cache subcommand")
10
+ print_command_help("cache", @err)
11
+ return 1
12
+ end
13
+
14
+ cache_root = MilkTea.data_root.join("tmp", "mtc-cache")
15
+
16
+ case subcommand
17
+ when "purge"
18
+ if File.directory?(cache_root)
19
+ FileUtils.rm_rf(cache_root)
20
+ @out.puts("purged #{cache_root}")
21
+ else
22
+ @out.puts("cache is already empty")
23
+ end
24
+ 0
25
+ when "status"
26
+ unless File.directory?(cache_root)
27
+ @out.puts("cache directory does not exist: #{cache_root}")
28
+ return 0
29
+ end
30
+ program_dirs = Dir.glob(File.join(cache_root, "programs", "*", "*")).select { |d| File.directory?(d) }
31
+ binary_files = Dir.glob(File.join(cache_root, "binaries", "*", "*", "binary")).select { |f| File.file?(f) }
32
+ total_size = (program_dirs + binary_files).sum { |p|
33
+ File.file?(p) ? File.size(p) : Dir.glob(File.join(p, "**", "*")).sum { |f| File.file?(f) ? File.size(f) : 0 }
34
+ }
35
+ @out.puts("cache #{program_dirs.length} programs, #{binary_files.length} binaries (#{format_size(total_size)})")
36
+ @out.puts(" root #{cache_root}")
37
+ 0
38
+ else
39
+ @err.puts("unknown cache subcommand #{subcommand}")
40
+ print_command_help("cache", @err)
41
+ 1
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandCheck
6
+ def check_command
7
+ args = @argv.dup
8
+ @argv = []
9
+ until args.empty?
10
+ arg = args.shift
11
+ @argv << arg
12
+ end
13
+
14
+ unless @argv.any?
15
+ @err.puts("missing source file path")
16
+ print_usage(@err)
17
+ return 1
18
+ end
19
+
20
+ resolution = extract_resolution_flags!
21
+ input_paths = @argv.dup
22
+ return 1 unless ensure_known_source_operands!("check", input_paths)
23
+
24
+ paths = expand_source_paths(input_paths)
25
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
26
+
27
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
28
+
29
+ all_diagnostics = []
30
+ paths.each do |path|
31
+ diagnostics, module_name, closure_errors = check_single_reporting_all(path, locked: resolution[:locked])
32
+ closure_errors = [] if paths.length > 1
33
+ diagnostics = sort_by_location(diagnostics)
34
+
35
+ if diagnostics.any? || closure_errors.any?
36
+ main_source = read_source_file(path)
37
+ main_abs = File.expand_path(path)
38
+ diagnostics.each do |d|
39
+ same_file = !d.respond_to?(:path) || d.path.nil? || File.expand_path(d.path) == main_abs
40
+ source = same_file ? main_source : nil
41
+ @err.puts(ErrorFormatter.format(d, source:, color: error_color?(@err)))
42
+ end
43
+ closure_errors.each do |d|
44
+ same_file = !d.respond_to?(:path) || d.path.nil? || File.expand_path(d.path) == main_abs
45
+ source = same_file ? main_source : nil
46
+ @err.puts(ErrorFormatter.format(d, source:, color: error_color?(@err)))
47
+ end
48
+ all_diagnostics.concat(diagnostics)
49
+ all_diagnostics.concat(closure_errors)
50
+ elsif module_name
51
+ info("checked #{path} as #{module_name}")
52
+ end
53
+ end
54
+
55
+ return 0 if all_diagnostics.empty?
56
+
57
+ error_count = all_diagnostics.count { |d| !d.respond_to?(:severity) || d.severity == :error }
58
+ warning_count = all_diagnostics.count { |d| d.respond_to?(:severity) && d.severity == :warning }
59
+ info_count = all_diagnostics.count { |d| d.respond_to?(:severity) && (d.severity == :info || d.severity == :hint) }
60
+
61
+ @err.puts
62
+ parts = []
63
+ parts << "#{error_count} #{error_count == 1 ? 'error' : 'errors'}" if error_count > 0
64
+ parts << "#{warning_count} #{warning_count == 1 ? 'warning' : 'warnings'}" if warning_count > 0
65
+ parts << "#{info_count} #{info_count == 1 ? 'note' : 'notes'}" if info_count > 0
66
+ body = parts.join("; ")
67
+ if error_count > 0
68
+ @err.puts("#{body} found")
69
+ elsif warning_count > 0
70
+ @err.puts("#{body}")
71
+ end
72
+ final_error_count = error_count + (resolution[:warnings_as_errors] ? warning_count : 0)
73
+ final_error_count > 0 ? 1 : 0
74
+ end
75
+
76
+ def check_single_reporting_all(path, locked: false)
77
+ loader = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform)
78
+ resolved_path = File.expand_path(path)
79
+ ast = loader.load_file(resolved_path)
80
+ module_name = ast.module_name.to_s
81
+
82
+ import_result = loader.imported_modules_for_ast_collecting_errors(ast, importer_path: resolved_path)
83
+ errors = import_result.errors.dup
84
+
85
+ analysis = nil
86
+ begin
87
+ result = SemanticAnalyzer.check_collecting_errors(ast, imported_modules: import_result.modules, path: resolved_path)
88
+ errors.concat(result[:errors])
89
+ analysis = result[:analysis]
90
+ rescue SemanticError => e
91
+ errors << e
92
+ end
93
+
94
+ if analysis && errors.empty?
95
+ source = read_source_file(path)
96
+ warnings = Linter.lint_source(source, path: resolved_path, sema_facts: analysis, lint_tier: :full)
97
+ errors.concat(warnings)
98
+ end
99
+
100
+ closure_errors = loader.collecting_path_errors.values.flatten.compact
101
+ [errors, module_name, closure_errors]
102
+ rescue ModuleLoadError, PackageLockError, SemanticError => e
103
+ [[e], nil, []]
104
+ end
105
+
106
+ def sort_by_location(errors)
107
+ errors.sort_by do |e|
108
+ actual = e.respond_to?(:error) ? e.error : e
109
+ line = actual.respond_to?(:line) ? actual.line.to_i : 0
110
+ column = actual.respond_to?(:column) ? actual.column.to_i : 0
111
+ [line, column]
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandBase
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandCompletions
6
+ def completions_command
7
+ shell = @argv.shift
8
+ unless %w[bash zsh fish].include?(shell)
9
+ @err.puts("completions: shell must be bash, zsh, or fish")
10
+ print_command_help("completions", @err)
11
+ return 1
12
+ end
13
+
14
+ @out.puts(completion_script(shell))
15
+ 0
16
+ end
17
+
18
+ def completion_script(shell)
19
+ names = COMMANDS.map(&:first)
20
+ case shell
21
+ when "bash"
22
+ [
23
+ "# mtc bash completion. Source this file or install it into your bash",
24
+ "# completion directory (e.g. /etc/bash_completion.d/mtc).",
25
+ "_mtc() {",
26
+ %( local cur="${COMP_WORDS[COMP_CWORD]}"),
27
+ %( if [ "${COMP_CWORD}" -eq 1 ]; then),
28
+ %( COMPREPLY=( $(compgen -W "#{(names + %w[help version]).join(' ')}" -- "${cur}") )),
29
+ " fi",
30
+ "}",
31
+ "complete -F _mtc mtc",
32
+ ].join("\n")
33
+ when "zsh"
34
+ lines = ["#compdef mtc", "# mtc zsh completion. Install onto your $fpath as _mtc.", "_mtc() {", " local -a commands", " commands=("]
35
+ COMMANDS.each { |name, summary| lines << " '#{name}:#{summary}'" }
36
+ lines.concat([" )", " if (( CURRENT == 2 )); then", " _describe 'mtc command' commands", " fi", "}", %(_mtc "$@")])
37
+ lines.join("\n")
38
+ when "fish"
39
+ lines = ["# mtc fish completion. Install into ~/.config/fish/completions/mtc.fish."]
40
+ COMMANDS.each do |name, summary|
41
+ lines << "complete -c mtc -f -n '__fish_use_subcommand' -a #{name} -d '#{summary}'"
42
+ end
43
+ lines.join("\n")
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandDap
6
+ def dap_command
7
+ preferred_backend_kind = "process"
8
+ adapter_command = nil
9
+
10
+ until @argv.empty?
11
+ arg = @argv.shift
12
+ case arg
13
+ when "--log-level"
14
+ @argv.shift
15
+ when /\A--log-level=(.+)\z/
16
+ # accept and ignore
17
+ when "--backend"
18
+ preferred_backend_kind = @argv.shift&.downcase
19
+ when /\A--backend=(.+)\z/
20
+ preferred_backend_kind = ::Regexp.last_match(1).downcase
21
+ when "--adapter-path"
22
+ adapter_path = @argv.shift
23
+ adapter_command = resolve_adapter_path(adapter_path)
24
+ return 1 unless adapter_command
25
+ when /\A--adapter-path=(.+)\z/
26
+ adapter_path = ::Regexp.last_match(1)
27
+ adapter_command = resolve_adapter_path(adapter_path)
28
+ return 1 unless adapter_command
29
+ else
30
+ if arg.start_with?("-")
31
+ @err.puts("dap: unknown option #{arg}")
32
+ return 1
33
+ end
34
+ @err.puts("dap: unexpected argument #{arg}")
35
+ return 1
36
+ end
37
+ end
38
+
39
+ require "milk_tea/dap/server" unless defined?(MilkTea::DAP::Server)
40
+ server = MilkTea::DAP::Server.new(
41
+ preferred_backend_kind:,
42
+ adapter_command:,
43
+ )
44
+ server.run
45
+ 0
46
+ end
47
+
48
+ def resolve_adapter_path(adapter_path)
49
+ unless adapter_path && File.file?(adapter_path)
50
+ @err.puts("dap: adapter path not found: #{adapter_path}")
51
+ return nil
52
+ end
53
+ expanded = File.expand_path(adapter_path)
54
+ adapter_path.end_with?(".rb") ? [RbConfig.ruby, expanded] : [expanded]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandDebug
6
+ def debug_command
7
+ unless @argv.any?
8
+ @err.puts("missing source file path")
9
+ print_usage(@err)
10
+ return 1
11
+ end
12
+
13
+ resolution = extract_resolution_flags!
14
+ input_paths = @argv.dup
15
+ return 1 unless ensure_known_source_operands!("debug", input_paths)
16
+
17
+ path = expand_source_paths(input_paths).first
18
+ unless path
19
+ @err.puts("no .mt files found in #{input_paths.join(', ')}")
20
+ return 1
21
+ end
22
+
23
+ ensure_current_lockfile!(path) if resolution[:frozen]
24
+
25
+ source = read_source_file(path)
26
+ resolved_path = File.expand_path(path)
27
+
28
+ tokens = MilkTea::Lexer.lex(source, path: resolved_path)
29
+
30
+ parse_result = MilkTea::Parser.parse_collecting_errors(source, path: resolved_path)
31
+ ast = parse_result.ast
32
+ parse_errors = parse_result.errors.dup
33
+
34
+ facts = nil
35
+ snapshot = nil
36
+ loader_ast = ast
37
+
38
+ if ast && parse_errors.empty?
39
+ begin
40
+ loader = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform)
41
+ loader_ast = loader.load_file(resolved_path)
42
+
43
+ import_result = loader.send(:imported_modules_for_ast_collecting_errors, loader_ast, importer_path: resolved_path)
44
+ import_errors = import_result.respond_to?(:errors) ? import_result.errors : []
45
+ parse_errors.concat(import_errors) unless import_errors.empty?
46
+
47
+ snapshot = MilkTea::SemanticAnalyzer.tooling_snapshot(
48
+ loader_ast,
49
+ imported_modules: import_result.modules,
50
+ allow_missing_imports: true,
51
+ path: resolved_path,
52
+ )
53
+ facts = snapshot&.facts
54
+ rescue MilkTea::LexError, MilkTea::ParseError, ModuleLoadError, SemanticError => e
55
+ parse_errors << e
56
+ end
57
+ end
58
+
59
+ text = DebugInfoFormatter.format_all(
60
+ content: source,
61
+ tokens: tokens,
62
+ ast: loader_ast,
63
+ parse_errors: parse_errors,
64
+ facts: facts,
65
+ snapshot: snapshot,
66
+ path: resolved_path,
67
+ )
68
+
69
+ @out.puts(text)
70
+ 0
71
+ rescue MilkTea::LexError => e
72
+ @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
73
+ 1
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandDeps
6
+ def deps_command
7
+ PackageManagerCLI.start(
8
+ @argv,
9
+ out: @out,
10
+ err: @err,
11
+ help_printer: method(:print_deps_help),
12
+ services: package_services,
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandDocs
6
+ def docs_command
7
+ port = nil
8
+ open_flag = false
9
+
10
+ while (arg = @argv.first)
11
+ case arg
12
+ when "--port", "-p"
13
+ @argv.shift
14
+ port = @argv.shift.to_i
15
+ port = nil if port <= 0 || port > 65535
16
+ when "--open", "-o"
17
+ open_flag = true
18
+ @argv.shift
19
+ else
20
+ break
21
+ end
22
+ end
23
+
24
+ port = resolve_docs_port(port)
25
+
26
+ DocsApp.set :port, port
27
+ DocsApp.set :bind, "127.0.0.1"
28
+ DocsApp.set :environment, :production
29
+ DocsApp.set :server, :puma
30
+
31
+ url = "http://127.0.0.1:#{port}/"
32
+
33
+ @out.puts("Serving Milk Tea docs at #{url}")
34
+ @out.puts("Press Ctrl+C to stop.")
35
+
36
+ if open_flag
37
+ open_browser(url)
38
+ end
39
+
40
+ DocsApp.run!
41
+ 0
42
+ rescue Interrupt
43
+ 0
44
+ end
45
+
46
+ def resolve_docs_port(preferred)
47
+ return preferred if preferred
48
+
49
+ server = TCPServer.new("127.0.0.1", 0)
50
+ port = server.addr[1]
51
+ server.close
52
+ port
53
+ rescue StandardError
54
+ 4567
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandEmitC
6
+ def emit_c_command
7
+ args = @argv.dup
8
+ @argv = []
9
+ until args.empty?
10
+ arg = args.shift
11
+ @argv << arg
12
+ end
13
+
14
+ unless @argv.any?
15
+ @err.puts("missing source file path")
16
+ print_usage(@err)
17
+ return 1
18
+ end
19
+
20
+ resolution = extract_resolution_flags!
21
+ input_paths = @argv.dup
22
+ return 1 unless ensure_known_source_operands!("emit-c", input_paths)
23
+
24
+ program_paths = input_paths.map { |p| resolve_program_path(p) }
25
+ return 1 if program_paths.include?(nil)
26
+
27
+ ensure_current_lockfiles!(input_paths) if resolution[:frozen]
28
+
29
+ multiple = program_paths.length > 1
30
+ program_paths.each_with_index do |path, index|
31
+ program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
32
+ if multiple
33
+ @out.puts("/* --- #{path} --- */")
34
+ end
35
+ @out.write(CBackend.generate_c(Lowering.lower(program), emit_line_directives: false))
36
+ @out.puts if multiple && index < program_paths.length - 1
37
+ end
38
+ 0
39
+ end
40
+
41
+ def resolve_program_path(path)
42
+ return path unless File.directory?(path)
43
+
44
+ manifest_path = File.join(path, "package.toml")
45
+ unless File.file?(manifest_path)
46
+ @err.puts("no package.toml found in #{path}")
47
+ return nil
48
+ end
49
+
50
+ manifest = PackageManifest.load(path)
51
+ entry_path = manifest.source_path
52
+ unless entry_path && File.file?(entry_path)
53
+ @err.puts("no build entry found for #{path}")
54
+ return nil
55
+ end
56
+
57
+ entry_path
58
+ rescue PackageManifestError => e
59
+ @err.puts("failed to load package manifest for #{path}: #{e.message}")
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end