docscribe 1.5.2 → 1.6.1

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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docscribe
4
+ module Infer
5
+ # Analyzes method behavior (predicates, side effects, mutating calls).
6
+ module Behavior
7
+ MUTATING_METHODS = %i[<< push pop delete merge update write save create destroy
8
+ insert update_all delete_all].freeze
9
+
10
+ class << self
11
+ # @param [Object] body
12
+ # @param [Object] method_name
13
+ # @return [Hash<Symbol, Object>]
14
+ def analyze(body, method_name)
15
+ result = default_result(method_name)
16
+
17
+ return result unless body.is_a?(Parser::AST::Node)
18
+
19
+ analyze_body(body, result)
20
+ result
21
+ end
22
+
23
+ # @param [Object] method_name
24
+ # @return [Hash<Symbol, Object>]
25
+ def default_result(method_name)
26
+ {
27
+ predicate: method_name&.to_s&.end_with?('?') || false,
28
+ bang: method_name&.to_s&.end_with?('!') || false,
29
+ has_side_effects: false,
30
+ delegates: nil,
31
+ returns_self: false,
32
+ returns_boolean: false
33
+ }
34
+ end
35
+
36
+ # @param [Hash<Symbol, Object>] analysis
37
+ # @param [Object] _method_name
38
+ # @return [String?]
39
+ def infer_description(analysis, _method_name)
40
+ return nil unless analysis[:has_side_effects] || analysis[:predicate]
41
+
42
+ if analysis[:predicate]
43
+ 'Returns true if the condition is met, false otherwise'
44
+ elsif analysis[:returns_self]
45
+ 'Returns self to allow method chaining'
46
+ elsif analysis[:has_side_effects]
47
+ nil
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # @private
54
+ # @param [Object] node
55
+ # @param [Hash<Symbol, Object>] result
56
+ # @return [void]
57
+ def analyze_body(node, result)
58
+ case node.type
59
+ when :ivasgn, :ivar
60
+ result[:has_side_effects] = true
61
+ when :send
62
+ analyze_send(node, result)
63
+ when :self
64
+ result[:returns_self] = true if result[:returns_self]
65
+ end
66
+
67
+ recurse_children(node, result)
68
+ end
69
+
70
+ # @private
71
+ # @param [Object] node
72
+ # @param [Hash<Symbol, Object>] result
73
+ # @return [void]
74
+ def recurse_children(node, result)
75
+ node.children.each do |child|
76
+ analyze_body(child, result) if child.is_a?(Parser::AST::Node)
77
+ end
78
+ end
79
+
80
+ # @private
81
+ # @param [Object] node
82
+ # @param [Hash<Symbol, Object>] result
83
+ # @return [void]
84
+ def analyze_send(node, result)
85
+ _receiver, method_name = *node
86
+ method_sym = method_name.is_a?(Symbol) ? method_name : nil
87
+ return unless method_sym
88
+
89
+ return unless MUTATING_METHODS.include?(method_sym)
90
+
91
+ result[:has_side_effects] = true
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -48,9 +48,7 @@ module Docscribe
48
48
  # @param [Boolean] treat_options_keyword_as_hash whether to treat 'options:' as Hash
49
49
  # @return [String]
50
50
  def inferred_param_type(name, default_str, fallback_type, treat_options_keyword_as_hash:)
51
- if name.end_with?(':') && default_str.nil?
52
- return options_keyword_type(name, treat_options_keyword_as_hash, fallback_type)
53
- end
51
+ return options_keyword_type(name, treat_options_keyword_as_hash, fallback_type) if name.end_with?(':') && default_str.nil?
54
52
 
55
53
  node = parse_expr(default_str)
56
54
  ty = Literals.type_from_literal(node, fallback_type: fallback_type)
@@ -16,6 +16,7 @@ require_relative 'infer/literals'
16
16
  require_relative 'infer/params'
17
17
  require_relative 'infer/returns'
18
18
  require_relative 'infer/raises'
19
+ require_relative 'infer/behavior'
19
20
 
20
21
  module Docscribe
21
22
  # Best-effort inference utilities used to generate YARD tags.
@@ -37,6 +38,40 @@ module Docscribe
37
38
  Raises.infer_raises_from_node(node)
38
39
  end
39
40
 
41
+ # Analyze method behavior from AST node and method name.
42
+ #
43
+ # @param [Object] node def or defs node
44
+ # @param [Object] method_name
45
+ # @return [Hash<Symbol, Object>]
46
+ def analyze_behavior(node, method_name)
47
+ body = extract_body(node)
48
+ Behavior.analyze(body, method_name)
49
+ end
50
+
51
+ # Get behavioral description for a method.
52
+ #
53
+ # @param [Object] node def or defs node
54
+ # @param [Object] method_name
55
+ # @return [String?]
56
+ def infer_behavior_description(node, method_name)
57
+ body = extract_body(node)
58
+ return nil unless body
59
+
60
+ analysis = Behavior.analyze(body, method_name)
61
+ Behavior.infer_description(analysis, method_name)
62
+ end
63
+
64
+ # Extract method body from def/defs node.
65
+ #
66
+ # @param [Object] node def or defs node
67
+ # @return [Object?]
68
+ def extract_body(node)
69
+ return nil unless node.is_a?(Parser::AST::Node)
70
+
71
+ body_idx = node.type == :def ? 2 : 3
72
+ node.children[body_idx]
73
+ end
74
+
40
75
  # Infer a parameter type from its internal name form and optional default
41
76
  # expression.
42
77
  #
@@ -186,7 +186,7 @@ module Docscribe
186
186
  # @param [Hash<Symbol, Object>] opts additional options including
187
187
  # @return [Hash<Symbol, Object>]
188
188
  def resolve_doc_setup!(setup, node, name, config, opts)
189
- external_sig = resolve_external_sig(setup[:container], setup[:scope], name, opts[:signature_provider])
189
+ external_sig = resolve_external_sig(setup[:container], setup[:scope], name, opts[:signature_provider], node)
190
190
  returns_spec = compute_returns_spec(node, config, opts[:param_types], opts[:core_rbs_provider],
191
191
  signature_provider: opts[:signature_provider],
192
192
  container: setup[:container])
@@ -219,9 +219,26 @@ module Docscribe
219
219
  # @param [Symbol] scope method scope symbol
220
220
  # @param [Symbol] name the method name string
221
221
  # @param [Docscribe::Types::ProviderChain, nil] signature_provider external sig provider
222
+ # @param [Parser::AST::Node] node
222
223
  # @return [Docscribe::Types::MethodSignature, nil]
223
- def resolve_external_sig(container, scope, name, signature_provider)
224
- signature_provider&.signature_for(container: container, scope: scope, name: name)
224
+ def resolve_external_sig(container, scope, name, signature_provider, node = nil)
225
+ param_count, param_names = extract_sig_param_info(node)
226
+ signature_provider&.signature_for(container: container, scope: scope, name: name,
227
+ param_count: param_count, param_names: param_names)
228
+ end
229
+
230
+ # @note module_function: defines #extract_sig_param_info (visibility: private)
231
+ # @param [Parser::AST::Node?] node
232
+ # @return [(Integer?, Array<String>)]
233
+ def extract_sig_param_info(node)
234
+ empty = [] #: Array[String]
235
+ return [nil, empty] unless node
236
+
237
+ args = extract_args_from_node(node)
238
+ empty = [] #: Array[String]
239
+ return [nil, empty] unless args
240
+
241
+ [args.children.length, args.children.map { |a| a.children.first.to_s if a.respond_to?(:children) }.compact]
225
242
  end
226
243
 
227
244
  # Compute returns spec
@@ -832,9 +849,7 @@ module Docscribe
832
849
  # @param [Hash<Symbol, Object>] info parse info hash to update with visibility flags
833
850
  # @return [Array<String>]
834
851
  def merge_module_function_note_lines(indent, insertion, name, info)
835
- unless insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
836
- return []
837
- end
852
+ return [] unless insertion.respond_to?(:module_function) && insertion.module_function && !info[:has_module_function_note]
838
853
 
839
854
  included_vis = insertion.included_instance_visibility || :private
840
855
  ["#{indent}# @note module_function: defines ##{name} (visibility: #{included_vis})"]
@@ -1228,9 +1243,7 @@ module Docscribe
1228
1243
  end
1229
1244
 
1230
1245
  method_name = :"build_#{arg_node.type}_lines"
1231
- if respond_to?(method_name, true)
1232
- return send(method_name, arg_node, indent, external_sig, param_types_override, **opts)
1233
- end
1246
+ return send(method_name, arg_node, indent, external_sig, param_types_override, **opts) if respond_to?(method_name, true)
1234
1247
 
1235
1248
  []
1236
1249
  end
@@ -158,7 +158,8 @@ module Docscribe
158
158
  # @param [String] code the Ruby source code string to parse and rewrite
159
159
  # @param [Hash<Symbol, Object>] options hash containing :config, :file, and :core_rbs_provider
160
160
  # @raise [Docscribe::ParseError]
161
- # @return [Hash<Symbol, Docscribe::Config, String, Parser::Source::Buffer, Parser::AST::Node, Docscribe::Types::ProviderChain, nil, Object, nil>]
161
+ # @return [Hash<Symbol, Docscribe::Config, String, Parser::Source::Buffer, Parser::AST::Node, Docscribe::Types::ProviderChain, nil, Object, nil>] rewrite environment with keys
162
+ # :config, :file, :buffer, :ast, :core_rbs_provider
162
163
  def setup_rewrite_env(code, options)
163
164
  config = options[:config] || Docscribe::Config.load
164
165
  file = (options[:file] || '(inline)').to_s
@@ -454,7 +455,8 @@ module Docscribe
454
455
  # Plugin insertion priority
455
456
  #
456
457
  # @private
457
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
458
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
459
+ # method insertion (plugin hash or collected insertion object)
458
460
  # @raise [StandardError]
459
461
  # @return [Integer]
460
462
  # @return [Integer] if StandardError
@@ -469,7 +471,8 @@ module Docscribe
469
471
  # Plugin insertion label
470
472
  #
471
473
  # @private
472
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
474
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
475
+ # method insertion (plugin hash or collected insertion object)
473
476
  # @raise [StandardError]
474
477
  # @return [String]
475
478
  # @return [String] if StandardError
@@ -485,7 +488,8 @@ module Docscribe
485
488
  # Plugin insertion line
486
489
  #
487
490
  # @private
488
- # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected method insertion
491
+ # @param [Hash<Symbol, Object>, Docscribe::InlineRewriter::Collector::Insertion, Docscribe::InlineRewriter::Collector::AttrInsertion] insertion the collected
492
+ # method insertion (plugin hash or collected insertion object)
489
493
  # @raise [StandardError]
490
494
  # @return [Integer, nil]
491
495
  # @return [nil] if StandardError
@@ -630,9 +634,7 @@ module Docscribe
630
634
  def normalize_plugin_doc(doc, indent, config:, anchor_node:)
631
635
  doc = normalize_plugin_doc_indent(doc, indent)
632
636
  doc = trim_trailing_blank_lines(doc)
633
- if anchor_node && %i[def defs].include?(anchor_node.type) && config.include_default_message?
634
- doc = prepend_default_message_if_no_prose(doc, anchor_node, indent, config)
635
- end
637
+ doc = prepend_default_message_if_no_prose(doc, anchor_node, indent, config) if anchor_node && %i[def defs].include?(anchor_node.type) && config.include_default_message?
636
638
  doc
637
639
  end
638
640
 
@@ -794,9 +796,7 @@ module Docscribe
794
796
  # @return [void]
795
797
  def merge_existing_descriptions!(params, parsed)
796
798
  params[:param_descriptions] = parsed[:param_descriptions] if parsed[:param_descriptions].any?
797
- if parsed[:return_description] && !parsed[:return_description].start_with?('if ')
798
- params[:return_description] = parsed[:return_description]
799
- end
799
+ params[:return_description] = parsed[:return_description] if parsed[:return_description] && !parsed[:return_description].start_with?('if ')
800
800
  params[:description] = parsed[:description] if parsed[:description].any?
801
801
  end
802
802
 
@@ -841,13 +841,27 @@ module Docscribe
841
841
  # @return [Docscribe::Types::MethodSignature, nil]
842
842
  def resolve_external_signature(insertion, signature_provider)
843
843
  node_name = SourceHelpers.node_name(insertion.node) #: Symbol
844
+ param_count, param_names = extract_param_info(insertion)
844
845
  signature_provider&.signature_for(
845
846
  container: insertion.container,
846
847
  scope: insertion.scope,
847
- name: node_name
848
+ name: node_name,
849
+ param_count: param_count,
850
+ param_names: param_names
848
851
  )
849
852
  end
850
853
 
854
+ # @private
855
+ # @param [Object] insertion
856
+ # @return [(Integer?, Array<Object>)]
857
+ def extract_param_info(insertion)
858
+ args = DocBuilder.extract_args_from_node(insertion.node)
859
+ empty = [] #: Array[untyped]
860
+ return [nil, empty] unless args
861
+
862
+ [args.children.length, args.children.map { |a| a.children.first.to_s if a.respond_to?(:children) }.compact]
863
+ end
864
+
851
865
  # Resolve param types
852
866
  #
853
867
  # @private
@@ -1079,7 +1093,8 @@ module Docscribe
1079
1093
  # @param [Docscribe::Config] config the active Docscribe::Config
1080
1094
  # @param [Docscribe::Types::ProviderChain, nil] signature_provider external RBS signature provider
1081
1095
  # @param [Parser::Source::Range] bol_range the beginning-of-line range for the attribute node
1082
- # @return [Hash<Symbol, Docscribe::InlineRewriter::Collector::AttrInsertion, Docscribe::Config, Docscribe::Types::ProviderChain, nil, Parser::Source::Range>]
1096
+ # @return [Hash<Symbol, Docscribe::InlineRewriter::Collector::AttrInsertion, Docscribe::Config, Docscribe::Types::ProviderChain, nil, Parser::Source::Range>] attr insertion params with keys
1097
+ # :insertion, :config, :signature_provider, :bol_range
1083
1098
  def attr_insertion_params(insertion, config, signature_provider, bol_range)
1084
1099
  {
1085
1100
  insertion: insertion, config: config,
@@ -1237,19 +1252,52 @@ module Docscribe
1237
1252
  # @return [String, nil]
1238
1253
  # @return [nil] if StandardError
1239
1254
  def build_attr_merge_additions(ins:, existing_lines:, config:, signature_provider:)
1240
- missing = missing_attr_names(ins, existing_lines)
1241
- return '' if missing.empty?
1242
-
1243
1255
  indent = SourceHelpers.line_indent(ins.node)
1244
- lines = [] #: Array[String]
1245
- lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1246
- lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1247
- signature_provider: signature_provider, names: missing))
1248
- lines.map { |l| "#{l}\n" }.join
1256
+ lines = build_attr_merge_missing_lines(ins, existing_lines, indent, config, signature_provider)
1257
+ lines.concat(build_attr_merge_tag_lines(ins, existing_lines, indent, config, signature_provider))
1258
+
1259
+ lines.empty? ? '' : lines.map { |l| "#{l}\n" }.join
1249
1260
  rescue StandardError
1250
1261
  nil
1251
1262
  end
1252
1263
 
1264
+ # @private
1265
+ # @param [Object] ins
1266
+ # @param [Array<String>] existing_lines
1267
+ # @param [String] indent
1268
+ # @param [Docscribe::Config] config
1269
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider
1270
+ # @return [Array<String>]
1271
+ def build_attr_merge_missing_lines(ins, existing_lines, indent, config, signature_provider)
1272
+ missing = missing_attr_names(ins, existing_lines)
1273
+ lines = [] #: Array[String]
1274
+
1275
+ unless missing.empty?
1276
+ lines << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1277
+ lines.concat(build_attr_doc_lines(ins, indent: indent, config: config,
1278
+ signature_provider: signature_provider, names: missing))
1279
+ end
1280
+
1281
+ lines
1282
+ end
1283
+
1284
+ # @private
1285
+ # @param [Object] ins
1286
+ # @param [Array<String>] existing_lines
1287
+ # @param [String] indent
1288
+ # @param [Docscribe::Config] config
1289
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider
1290
+ # @return [Array<String>]
1291
+ def build_attr_merge_tag_lines(ins, existing_lines, indent, config, signature_provider)
1292
+ tag_additions = build_missing_tag_additions(ins, existing_lines, indent, config, signature_provider)
1293
+ return [] if tag_additions.empty?
1294
+
1295
+ result = [] #: Array[String]
1296
+ result << "#{indent}#" if existing_lines.any? && existing_lines.last.strip != '#'
1297
+ result.concat(tag_additions)
1298
+ result
1299
+ end
1300
+
1253
1301
  # Missing attr names
1254
1302
  #
1255
1303
  # @private
@@ -1278,6 +1326,80 @@ module Docscribe
1278
1326
  names
1279
1327
  end
1280
1328
 
1329
+ # Build missing tag additions for existing @!attribute lines
1330
+ #
1331
+ # @private
1332
+ # @param [Docscribe::InlineRewriter::Collector::AttrInsertion] ins the attribute insertion object
1333
+ # @param [Array<String>] existing_lines array of existing doc comment lines
1334
+ # @param [String] indent whitespace indentation prefix
1335
+ # @param [Docscribe::Config] config the active Docscribe::Config
1336
+ # @param [Docscribe::Types::ProviderChain, nil] signature_provider external RBS signature provider
1337
+ # @return [Array<String>]
1338
+ def build_missing_tag_additions(ins, existing_lines, indent, config, signature_provider)
1339
+ ins.names.each_with_object([]) do |name_sym, additions|
1340
+ idx = existing_attr_line_index(existing_lines, name_sym.to_s)
1341
+ next unless idx
1342
+
1343
+ attr_type = attribute_type(ins, name_sym, config, signature_provider: signature_provider)
1344
+ additions << "#{indent}# @return [#{attr_type}]" if attr_return_missing?(ins.access, existing_lines, idx)
1345
+ additions << format_attribute_param_tag(indent, 'value', attr_type, style: config.param_tag_style) if attr_param_missing?(ins.access, existing_lines, idx)
1346
+ end
1347
+ end
1348
+
1349
+ # @private
1350
+ # @param [Symbol] access
1351
+ # @param [Array<String>] existing_lines
1352
+ # @param [Integer] idx
1353
+ # @return [Boolean]
1354
+ def attr_return_missing?(access, existing_lines, idx)
1355
+ %i[r rw].include?(access) && !existing_lines_contain_tag?(existing_lines, idx, 'return')
1356
+ end
1357
+
1358
+ # @private
1359
+ # @param [Symbol] access
1360
+ # @param [Array<String>] existing_lines
1361
+ # @param [Integer] idx
1362
+ # @return [Boolean]
1363
+ def attr_param_missing?(access, existing_lines, idx)
1364
+ %i[w rw].include?(access) && !existing_lines_contain_tag?(existing_lines, idx, 'param')
1365
+ end
1366
+
1367
+ # Find index of line containing @!attribute for a specific name
1368
+ #
1369
+ # @private
1370
+ # @param [Array<String>] lines array of existing doc comment lines
1371
+ # @param [String] name the attribute name to search for
1372
+ # @return [Integer?]
1373
+ def existing_attr_line_index(lines, name)
1374
+ Array(lines).each_with_index do |line, idx|
1375
+ return idx if line.match?(/\A\s*#\s*@!attribute\b(?:\s+\[[^\]]+\])?\s+#{Regexp.escape(name)}\b/)
1376
+ end
1377
+ nil
1378
+ end
1379
+
1380
+ # Check if lines after an @!attribute line contain a specific tag
1381
+ #
1382
+ # Walks forward from attr_line_idx + 1, stopping at the next top-level
1383
+ # directive or non-comment line.
1384
+ #
1385
+ # @private
1386
+ # @param [Array<String>] lines array of existing doc comment lines
1387
+ # @param [Integer] attr_line_idx index of the @!attribute line
1388
+ # @param [String] tag_name the tag name to search for (e.g. "return", "param")
1389
+ # @return [Boolean]
1390
+ def existing_lines_contain_tag?(lines, attr_line_idx, tag_name)
1391
+ i = attr_line_idx + 1
1392
+ while i < lines.length
1393
+ line = lines[i]
1394
+ break unless line.match?(/\A\s*#/) # not a comment line
1395
+ break if line.match?(/\A\s*# @(?!@)/) # next top-level directive (single space)
1396
+ return true if line.match?(/\A\s*#\s{2,}@#{tag_name}\b/)
1397
+
1398
+ i += 1
1399
+ end
1400
+ false
1401
+ end
1402
+
1281
1403
  # Attribute allowed
1282
1404
  #
1283
1405
  # @private
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'fileutils'
5
+ require 'digest/md5'
6
+ require 'tmpdir'
7
+
8
+ module Docscribe
9
+ # Server/daemon mode for persistent multi-request operation.
10
+ #
11
+ # Architecture:
12
+ # - Daemon process loads Ruby runtime once, listens on a Unix socket
13
+ # - Client sends JSON-line requests, receives JSON-line responses
14
+ # - Auto-shutdown after idle timeout
15
+ # - Protocol: JSON-RPC 2.0 over Unix socket
16
+ module Server
17
+ # Unix socket path max is 104 bytes on macOS (the more restrictive).
18
+ # Dir.tmpdir on macOS often returns a long path under /var/folders/.../T
19
+ # that exceeds this limit, so we fall back to /tmp when needed.
20
+ SOCKET_DIR = begin
21
+ tmp = Dir.tmpdir || '/tmp'
22
+ sock_overhead = "/docscribe-#{'a' * 32}.sock".bytesize # 48
23
+ tmp.bytesize <= 104 - sock_overhead ? tmp : '/tmp'
24
+ end
25
+ IDLE_TIMEOUT = 300
26
+
27
+ class << self
28
+ # Start the server daemon if not running.
29
+ #
30
+ # @param [String, nil] config_path optional config file path
31
+ # @param [Boolean] daemonize redirect stdin/stdout/stderr to /dev/null
32
+ # @param [Integer] timeout max seconds to wait for readiness
33
+ # @return [void]
34
+ def ensure_running!(config_path: nil, daemonize: false, timeout: 5)
35
+ return if running?(config_path)
36
+
37
+ check_platform_support!
38
+
39
+ lock_path = "#{socket_path(config_path)}.lock"
40
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
41
+ lock.flock(File::LOCK_EX)
42
+ next if running?(config_path)
43
+
44
+ start_daemon_process(config_path: config_path, daemonize: daemonize)
45
+ end
46
+ wait_for_ready(config_path: config_path, timeout: timeout)
47
+ end
48
+
49
+ # Start the server daemon and wait for it to become ready.
50
+ #
51
+ # @param [String, nil] config_path optional config path for socket/pid lookup
52
+ # @param [Integer] timeout max seconds to wait for readiness
53
+ # @param [Boolean] raise_on_timeout
54
+ # @raise [StandardError]
55
+ # @return [Boolean]
56
+ def wait_for_ready(config_path: nil, timeout: 5, raise_on_timeout: true) # rubocop:disable SortedMethodsByCall/Waterfall
57
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
58
+ loop do
59
+ return true if running?(config_path)
60
+
61
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
62
+ raise('Docscribe: server failed to start') if raise_on_timeout
63
+
64
+ warn('Docscribe server failed to start within timeout')
65
+ return false
66
+ end
67
+
68
+ sleep 0.1
69
+ end
70
+ end
71
+
72
+ # Whether a server process is listening on the socket.
73
+ #
74
+ # On ECONNREFUSED, checks whether the PID process is still alive:
75
+ # if yes, the daemon is still starting up (don't clean up);
76
+ # if no, removes stale socket and pid files.
77
+ #
78
+ # @param [String, nil] config_path optional config path for socket lookup
79
+ # @raise [Errno::ECONNREFUSED]
80
+ # @raise [Errno::ENOENT]
81
+ # @raise [Errno::ENOTSOCK]
82
+ # @raise [StandardError]
83
+ # @return [Boolean]
84
+ # @return [Boolean] if Errno::ECONNREFUSED
85
+ # @return [void, Boolean] if Errno::ENOENT, Errno::ENOTSOCK
86
+ # @return [Boolean] if StandardError
87
+ def running?(config_path = nil)
88
+ return false unless defined?(UNIXSocket)
89
+
90
+ socket = UNIXSocket.new(socket_path(config_path))
91
+ socket.close
92
+ true
93
+ rescue Errno::ECONNREFUSED
94
+ handle_stale_socket?(config_path)
95
+ rescue Errno::ENOENT, Errno::ENOTSOCK
96
+ clean_socket_files(config_path) && false
97
+ rescue StandardError
98
+ false
99
+ end
100
+
101
+ # Handle ECONNREFUSED: check if the pid process is alive.
102
+ # Cleans up only if the process is dead.
103
+ #
104
+ # @param [String, nil] config_path
105
+ # @return [Boolean] false (not running)
106
+ def handle_stale_socket?(config_path)
107
+ pid = read_pid(config_path)
108
+ return false if pid && process_alive?(pid)
109
+
110
+ clean_socket_files(config_path)
111
+ false
112
+ end
113
+
114
+ # @param [Integer] pid
115
+ # @raise [Errno::ESRCH]
116
+ # @return [Boolean]
117
+ # @return [Boolean] if Errno::ESRCH
118
+ def process_alive?(pid)
119
+ Process.kill(0, pid)
120
+ true
121
+ rescue Errno::ESRCH
122
+ false
123
+ end
124
+
125
+ # @param [String, nil] config_path
126
+ # @raise [StandardError]
127
+ # @return [Integer, nil]
128
+ # @return [nil] if StandardError
129
+ def read_pid(config_path = nil)
130
+ File.read(pid_path(config_path)).to_i if File.exist?(pid_path(config_path))
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ # Remove stale socket and pid files.
136
+ #
137
+ # @param [String, nil] config_path
138
+ # @return [void]
139
+ def clean_socket_files(config_path)
140
+ FileUtils.rm_f(socket_path(config_path))
141
+ FileUtils.rm_f(pid_path(config_path))
142
+ end
143
+
144
+ # @param [String, nil] config_path
145
+ # @return [String]
146
+ def pid_path(config_path = nil)
147
+ "#{socket_path(config_path)}.pid"
148
+ end
149
+
150
+ ENV_FILES = %w[Gemfile.lock rbs_collection.lock.yaml].freeze
151
+
152
+ # @param [String] config_path
153
+ # @return [String]
154
+ def config_hash(config_path)
155
+ resolved = File.expand_path(config_path)
156
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
157
+ Digest::MD5.hexdigest("#{resolved}:#{mtime}")
158
+ end
159
+
160
+ # Check platform compatibility before starting server.
161
+ #
162
+ # @raise [StandardError]
163
+ # @return [void]
164
+ def check_platform_support!
165
+ unless defined?(UNIXSocket)
166
+ raise 'Server mode requires Unix domain sockets, which are not available on Windows. ' \
167
+ 'Use docscribe directly without --server flag.'
168
+ end
169
+ return if Process.respond_to?(:fork)
170
+
171
+ raise 'Server mode requires Process.fork, which is not available on JRuby. ' \
172
+ 'Use docscribe directly without --server flag.'
173
+ end
174
+
175
+ # Derive a project-specific socket path from the current working directory.
176
+ # Uses MD5 (deterministic across processes) instead of String#hash
177
+ # (which varies per Ruby process due to random seeding).
178
+ # When a config_path is given, its path + mtime are included in the hash
179
+ # so different configs get different daemons.
180
+ # Environment files (Gemfile.lock, rbs_collection.lock.yaml) are also
181
+ # included so daemon is invalidated when gems or RBS types change.
182
+ #
183
+ # @param [String, nil] config_path optional config path to differentiate
184
+ # @return [String]
185
+ def socket_path(config_path = nil)
186
+ seed = +Dir.pwd
187
+ seed << ":#{env_hash}"
188
+ if config_path
189
+ resolved = File.expand_path(config_path)
190
+ mtime = File.exist?(resolved) ? File.mtime(resolved).to_f : 0.0
191
+ seed << ":#{resolved}:#{mtime}"
192
+ end
193
+ "#{SOCKET_DIR}/docscribe-#{Digest::MD5.hexdigest(seed)}.sock"
194
+ end
195
+
196
+ # Hash of environment files that affect analysis results.
197
+ # When any of these change, the daemon is invalidated (new socket path).
198
+ #
199
+ # @return [String]
200
+ def env_hash
201
+ parts = ENV_FILES.map do |file|
202
+ path = File.join(Dir.pwd, file)
203
+ File.exist?(path) ? File.mtime(path).to_f.to_s : '0'
204
+ end
205
+ Digest::MD5.hexdigest(parts.join(':'))
206
+ end
207
+
208
+ public :read_pid, :pid_path, :socket_path
209
+
210
+ # @param [String, nil] config_path
211
+ # @param [Boolean] daemonize
212
+ # @return [void]
213
+ def start_daemon_process(config_path:, daemonize:)
214
+ warn 'Docscribe: starting server...' if daemonize
215
+ pid = Process.fork do # steep:ignore NoMethod
216
+ [$stdin, $stdout].each { _1.reopen(File::NULL) }
217
+ $stderr.reopen(File::NULL)
218
+ Daemon.new(config_path: config_path).start
219
+ end
220
+ Process.detach(pid)
221
+ end
222
+ end
223
+ end
224
+ end