gem_docs 0.3.1 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e1d29bbce836d7be31883f9748134af47f3c2d6e160b35639f7195cf3ab23d6
4
- data.tar.gz: f01a2d4a987020c7dad7f18a3d4ebd3145c2326d069ad9b3b5e20348e6b56d4e
3
+ metadata.gz: 23f41751e4958ef349292a6620097b7319493abce0ee282d7a0bf8d7aa064195
4
+ data.tar.gz: 944ffcfa8220c6991ff91282b61bf5926027d47da432c2087aec34be1a2c40ab
5
5
  SHA512:
6
- metadata.gz: 2163336ebab1ea2d2e7eed04b98aa8f2bb6da856a3184342fcb6e6d675e73a131fe6eeaf5707ddae1e35681c873d31044f85cf9c0c90f9ccb8d94972f32adbfa
7
- data.tar.gz: 41f5ca7c522e9fd18d462e1b5f9a5a020e6a0ee4a5cbeab645b93c76b8342c37cf2064acf27eae6d657497485a036ca9a8502cf9b314bf13e3dc3a32764737a7
6
+ metadata.gz: 5666427b3018cbb1f6a923e211cbdae7a6b7c86d63e264fca9d24672e005f65f7565716c4a8cd1746d1c52b7a8f46cbaba11b4cfa1df61f1e073888c8ec05c76
7
+ data.tar.gz: 8924cc0a2db42fe04cf7ce40837803aa768a9814a45a8db498fe790eeb1ca3ab15180c316669adfc71c9d2f3ed950bf1812a55dc2ee6e3b6e9f649f82fe63303
@@ -11,15 +11,17 @@ module GemDocs
11
11
  attr_accessor :repo_workflow_dir
12
12
  attr_accessor :repo_workflow_name
13
13
  attr_accessor :badge
14
+ attr_accessor :emacs_timeout
14
15
 
15
16
  def initialize
17
+ @emacs_timeout = 120
16
18
  # Default: support org comment markers
17
19
  @overview_headings = ["Introduction"]
18
20
  @headers =
19
21
  <<~HEADER
20
22
  #+PROPERTY: header-args:ruby :results value :colnames no :hlines yes :exports both :dir "./"
21
- #+PROPERTY: header-args:ruby+ :wrap example :session %n_session :eval yes
22
- #+PROPERTY: header-args:ruby+ :prologue "$:.unshift('./lib') unless $:.first == './lib'; require '%n'"
23
+ #+PROPERTY: header-args:ruby+ :wrap example :session %n_session :eval never-export
24
+ #+PROPERTY: header-args:ruby+ :prologue "$:.unshift('./lib') unless $:.first == './lib'; require '%n'; nil"
23
25
  #+PROPERTY: header-args:ruby+ :ruby "bundle exec irb"
24
26
  #+PROPERTY: header-args:sh :exports code :eval no
25
27
  #+PROPERTY: header-args:bash :exports code :eval no
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module GemDocs
6
+ # Advisory static checks; never evaluates examples or starts Emacs.
7
+ class Doctor
8
+ def self.run
9
+ new.run
10
+ end
11
+
12
+ def initialize
13
+ @root = GemDocs.project_root
14
+ @suggestions = []
15
+ end
16
+
17
+ def run
18
+ readme = path(README_ORG)
19
+ if File.file?(readme)
20
+ text = File.read(readme)
21
+ outline, ruby_blocks = scan_org(text)
22
+ check_overview(outline)
23
+ check_headers(outline) unless ruby_blocks.empty?
24
+ check_version(outline)
25
+ check_export(readme)
26
+ else
27
+ suggest(README_ORG, "File is missing. Run bundle exec rake docs:skeleton:readme to create it.")
28
+ check_export(readme) if File.file?(path(README_MD))
29
+ end
30
+ check_yard
31
+ puts "GemDocs doctor: #{@root}"
32
+ puts "Advisory static checks; Org's inherited and per-block settings may intentionally differ."
33
+ if @suggestions.empty?
34
+ puts "No suggestions from these checks."
35
+ else
36
+ @suggestions.each { |message| puts "- #{message}" }
37
+ end
38
+ @suggestions
39
+ end
40
+
41
+ private
42
+
43
+ def path(name)
44
+ File.join(@root, name)
45
+ end
46
+
47
+ def suggest(file, message)
48
+ @suggestions << "#{file}: #{message}"
49
+ end
50
+
51
+ # Exclude examples and export blocks so quoted headers/headings do not
52
+ # masquerade as document configuration.
53
+ def scan_org(text)
54
+ outline = +''
55
+ ruby_blocks = []
56
+ block = nil
57
+ text.each_line do |line|
58
+ if block
59
+ block = nil if line.match?(/^\s*#\+end_#{Regexp.escape(block)}\b/i)
60
+ elsif (match = line.match(/^\s*#\+begin_(\w+)\b(.*)/i))
61
+ block = match[1]
62
+ ruby_blocks << line if block.casecmp?('src') && match[2].match?(/^\s+ruby\b/i)
63
+ else
64
+ outline << line
65
+ end
66
+ end
67
+ [outline, ruby_blocks]
68
+ end
69
+
70
+ def check_overview(outline)
71
+ heads = Array(GemDocs.config.overview_headings)
72
+ return if heads.empty?
73
+
74
+ sections = outline.scan(/^\* +([^\n]+)\n(.*?)(?=^\* |\z)/m).to_h
75
+ missing = heads.reject { |head| sections.key?(head) }
76
+ unless missing.empty?
77
+ suggest(README_ORG, "No top-level overview section for #{missing.map(&:inspect).join(', ')}. " \
78
+ "Add prose under that heading, or set c.overview_headings to existing top-level headings " \
79
+ "(for example, ['Overview']) in GemDocs.configure.")
80
+ end
81
+ (heads - missing).each do |head|
82
+ next unless sections[head].strip.empty?
83
+
84
+ suggest(README_ORG, "The #{head.inspect} overview section is empty. Add a short description for YARD/ri.")
85
+ end
86
+ end
87
+
88
+ def check_headers(outline)
89
+ headers = outline.lines.grep(/^#\+PROPERTY:\s+header-args:ruby\+?\s/i).join
90
+ if headers.empty?
91
+ suggest(README_ORG, "Ruby examples have no file-level Ruby headers. Run bundle exec rake docs:headers " \
92
+ "or review local/inherited settings before adding the defaults.")
93
+ return
94
+ end
95
+ unless headers.match?(/:eval\s+never-export\b/)
96
+ suggest(README_ORG, "Ruby headers do not specify :eval never-export. Consider it to execute examples with " \
97
+ "docs:tangle and reuse results during export; docs:all then runs examples once.")
98
+ end
99
+ if headers.match?(/:eval\s+yes\b/)
100
+ suggest(README_ORG, "Ruby headers contain :eval yes, which allows execution during export. " \
101
+ "Remove or replace conflicting :eval settings if adopting never-export.")
102
+ end
103
+ unless headers.match?(/:session\s+(?!none\b)\S+/)
104
+ suggest(README_ORG, "No shared Ruby :session is declared. Add one if examples depend on earlier variables.")
105
+ end
106
+ unless headers.match?(/:exports\s+both\b/)
107
+ suggest(README_ORG, "Consider :exports both for Ruby examples so exported docs show code and saved results.")
108
+ end
109
+ unless headers.match?(/:wrap\s+example\b/)
110
+ suggest(
111
+ README_ORG,
112
+ "Consider :wrap example to preserve saved Ruby output as literal text during Markdown export.",
113
+ )
114
+ end
115
+ unless headers.match?(/:prologue\b/)
116
+ suggest(README_ORG, "No Ruby :prologue is declared. Ensure examples load the local gem before using it.")
117
+ end
118
+ end
119
+
120
+ def check_version(outline)
121
+ blocks = outline.scan(Skeleton::VERSION_BLOCK_RE)
122
+ if blocks.size != 1
123
+ suggest(README_ORG, "Expected one managed README version block, found #{blocks.size}. " \
124
+ "Remove duplicates if needed, then run bundle exec rake docs:skeleton:readme.")
125
+ return
126
+ end
127
+ specs = Dir.glob(path('*.gemspec'))
128
+ return unless specs.one?
129
+
130
+ spec = Gem::Specification.load(specs.first)
131
+ return unless spec
132
+ return if blocks.first.lines.any? { |line| line.strip == "Version: #{spec.version}" }
133
+
134
+ suggest(
135
+ README_ORG,
136
+ "README version differs from gemspec #{spec.version}. Run bundle exec rake docs:skeleton:readme.",
137
+ )
138
+ end
139
+
140
+ def check_export(readme)
141
+ markdown = path(README_MD)
142
+ if !File.file?(markdown)
143
+ suggest(README_MD, "File is missing. Run bundle exec rake docs:export after refreshing example results.")
144
+ elsif File.open(markdown, &:gets).to_s.chomp != Emacs::README_MARKER
145
+ suggest(README_MD, "Protected from export: missing first-line #{Emacs::README_MARKER}. " \
146
+ "Keep handwritten Markdown as-is; review/back up older generated files before adding the marker to opt in.")
147
+ elsif File.file?(readme) && File.mtime(readme) > File.mtime(markdown)
148
+ suggest(README_MD, "README.org is newer; export may be stale. " \
149
+ "Run docs:all after example edits, or docs:export if saved results are current.")
150
+ end
151
+ end
152
+
153
+ def check_yard
154
+ unless File.file?(path('.yardopts'))
155
+ suggest(".yardopts", "File is missing. docs:yard creates defaults; review and commit that configuration.")
156
+ end
157
+ %w[.yardoc doc].each do |name|
158
+ next unless File.directory?(path(name))
159
+
160
+ tracked, status = Open3.capture2('git', '-C', @root, 'ls-files', '--', name, err: File::NULL)
161
+ next unless status.success?
162
+
163
+ if !tracked.empty?
164
+ suggest("#{name}/", "Contains tracked generated YARD files. Consider untracking and ignoring that directory.")
165
+ else
166
+ _, ignored = Open3.capture2('git', '-C', @root, 'check-ignore', '-q', '--', "#{name}/", err: File::NULL)
167
+ suggest(".gitignore", "Add /#{name}/ to exclude generated YARD output.") unless ignored.success?
168
+ end
169
+ rescue Errno::ENOENT
170
+ # Git is optional; the remaining static checks still apply.
171
+ next
172
+ end
173
+ end
174
+ end
175
+ end
@@ -1,57 +1,149 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tempfile'
4
+
3
5
  module GemDocs
4
6
  module Emacs
7
+ class Error < StandardError; end
8
+ README_MARKER = '<!-- Generated by gem_docs -->'
9
+
5
10
  def self.tangle
11
+ path = File.expand_path(README_ORG, GemDocs.project_root)
12
+ # A conservative fast path: leave header inheritance and eligibility to Org.
13
+ unless File.read(path).match?(/#\+begin_src\b|#\+call:|\bsrc_\w+|\bcall_\w+/i)
14
+ puts "Skipped: no executable code blocks"
15
+ return
16
+ end
17
+
6
18
  expr = <<~ELISP
7
- (save-window-excursion
8
- (if (get-buffer "#{session_name}")
9
- (kill-buffer "#{session_name}"))
10
- (with-current-buffer (find-file-noselect "#{README_ORG}")
11
- (save-buffer)
12
- (require 'ob-ruby)
13
- (org-babel-execute-buffer)
14
- (save-buffer)
15
- "OK"))
19
+ (with-current-buffer (find-file-noselect #{path.dump})
20
+ (save-buffer)
21
+ (require 'ob-ruby)
22
+ (let ((eligible nil))
23
+ (let ((org-confirm-babel-evaluate nil))
24
+ (org-babel-map-executables nil
25
+ (let ((info (org-babel-get-src-block-info 'light)))
26
+ (when (or (null info) (org-babel-check-confirm-evaluate info))
27
+ (setq eligible t)))))
28
+ (if (not eligible)
29
+ "Skipped: no evaluable code blocks"
30
+ (let ((session (get-buffer #{session_name.dump})))
31
+ (when session
32
+ (let ((process (get-buffer-process session)))
33
+ (when process (set-process-query-on-exit-flag process nil)))
34
+ (kill-buffer session)))
35
+ (org-babel-execute-buffer)
36
+ (save-buffer)
37
+ "Babel execution complete")))
16
38
  ELISP
17
39
 
18
- unless system("emacsclient", "--quiet", "--eval", expr)
19
- abort "Babel execution failed"
40
+ run(expr, "Babel execution")
41
+ end
42
+
43
+ def self.export_readme # rubocop:disable Naming/PredicateMethod -- public command, not a predicate
44
+ check_readme_destination!
45
+ root = GemDocs.project_root
46
+ destination = File.join(root, README_MD)
47
+ Tempfile.create(['.gem_docs-readme-', '.md'], root) do |temporary|
48
+ export(README_ORG, destination: temporary.path)
49
+ generated = File.read(temporary.path)
50
+ check_readme_destination!
51
+ temporary.rewind
52
+ temporary.write("#{README_MARKER}\n\n#{generated}")
53
+ temporary.truncate(temporary.pos)
54
+ temporary.flush
55
+ temporary.chmod(File.exist?(destination) ? File.stat(destination).mode & 0o777 : 0o644)
56
+ File.rename(temporary.path, destination)
20
57
  end
58
+ true
21
59
  end
22
60
 
23
- def self.export_readme
24
- expr = <<~ELISP
25
- (save-window-excursion
26
- (with-current-buffer (find-file-noselect "#{README_ORG}")
27
- (save-buffer)
28
- (require 'ox-gfm)
29
- (org-gfm-export-to-markdown))
30
- (with-current-buffer (find-file-noselect "#{CHANGELOG_ORG}")
31
- (save-buffer)
32
- (require 'ox-gfm)
33
- (org-gfm-export-to-markdown))
34
- "Export README complete")
35
- ELISP
61
+ def self.check_readme_destination!
62
+ destination = File.join(GemDocs.project_root, README_MD)
63
+ return unless File.exist?(destination) || File.symlink?(destination)
64
+ return if File.file?(destination) && File.open(destination, &:gets).to_s.chomp == README_MARKER
36
65
 
37
- system("emacsclient", "--quiet", "--eval", expr)
66
+ raise Error, "Refusing to overwrite #{destination}: missing first-line #{README_MARKER}. " \
67
+ "Keep handwritten Markdown unchanged. To opt into generation, review/back up the file " \
68
+ "and add that marker as its first line."
38
69
  end
39
70
 
40
71
  def self.export_changelog
41
- expr = <<~ELISP
42
- (save-window-excursion
43
- (with-current-buffer (find-file-noselect "#{CHANGELOG_ORG}")
44
- (save-buffer)
45
- (require 'ox-gfm)
46
- (org-gfm-export-to-markdown))
47
- "Export CHANGELOG complete")
48
- ELISP
49
-
50
- system("emacsclient", "--quiet", "--eval", expr)
72
+ export(CHANGELOG_ORG)
51
73
  end
52
74
 
53
75
  def self.session_name
54
76
  "*#{Repo.from_gemspec.name}_session*"
55
77
  end
78
+
79
+ class << self
80
+ private
81
+
82
+ def export(filename, destination: nil)
83
+ path = File.expand_path(filename, GemDocs.project_root)
84
+ raise Error, "Cannot export missing file: #{path}" unless File.file?(path)
85
+
86
+ expr = <<~ELISP
87
+ (with-current-buffer (find-file-noselect #{path.dump})
88
+ (save-buffer)
89
+ (require 'ox-gfm)
90
+ #{destination ? "(org-export-to-file 'gfm #{destination.dump})" : '(org-gfm-export-to-markdown)'}
91
+ #{"Export #{filename} complete".dump})
92
+ ELISP
93
+ run(expr, "Export #{filename}")
94
+ end
95
+
96
+ def run(expr, operation)
97
+ seconds = Float(GemDocs.config.emacs_timeout)
98
+ raise ArgumentError,
99
+ "emacs_timeout must be a positive, finite number" unless seconds.positive? && seconds.finite?
100
+
101
+ # Fail on prompts rather than leaving emacsclient waiting in the background.
102
+ wrapped = <<~ELISP
103
+ (progn
104
+ (require 'cl-lib)
105
+ (save-window-excursion
106
+ (cl-letf (((symbol-function 'yes-or-no-p)
107
+ (lambda (&rest args)
108
+ (error "GemDocs requires interactive confirmation: %s. Run this operation in Emacs first" (car args))))
109
+ ((symbol-function 'y-or-n-p)
110
+ (symbol-function 'yes-or-no-p)))
111
+ (with-timeout (#{seconds} (error "GemDocs timed out: #{operation}"))
112
+ #{expr}))))
113
+ ELISP
114
+ pid = Process.spawn("emacsclient", "--quiet", "--eval", wrapped)
115
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
116
+ loop do
117
+ result = Process.waitpid2(pid, Process::WNOHANG)
118
+ if result
119
+ pid = nil
120
+ unless result.last.success?
121
+ raise Error,
122
+ "#{operation} failed (emacsclient exit #{result.last.exitstatus.inspect}); see Emacs output above"
123
+ end
124
+
125
+ return true
126
+ end
127
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
128
+ raise Error,
129
+ "#{operation} timed out after #{seconds}s. " \
130
+ "The Emacs server may still be executing; inspect it before retrying."
131
+ end
132
+
133
+ sleep 0.05
134
+ end
135
+ rescue Errno::ENOENT
136
+ raise Error, "#{operation} failed: emacsclient was not found"
137
+ ensure
138
+ if pid
139
+ begin
140
+ Process.kill("KILL", pid)
141
+ rescue Errno::ESRCH
142
+ # The client exited between the timeout and cleanup.
143
+ end
144
+ Process.waitpid(pid)
145
+ end
146
+ end
147
+ end
56
148
  end
57
149
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'strscan'
4
- require 'debug'
5
4
 
6
5
  module GemDocs
7
6
  module Overview
@@ -26,7 +25,7 @@ module GemDocs
26
25
  #
27
26
  #{overview.lines.map { |l| l.match?(/\A\s*\z/) ? '#' : "# #{l.rstrip}" }.join("\n")}
28
27
  RUBY
29
- return false if old_comment == new_comment
28
+ return false if old_comment.rstrip == new_comment
30
29
 
31
30
  new_lib =
32
31
  if old_match
@@ -35,10 +34,10 @@ module GemDocs
35
34
  old_lib.sub(old_comment, new_comment)
36
35
  else
37
36
  scanner = StringScanner.new(old_lib)
38
- if scanner.scan_until(/^module #{repo.module_name}/)
37
+ if scanner.scan_until(/^(?:class|module) #{Regexp.escape(repo.module_name)}\b/)
39
38
  scanner.pre_match + "\n" + new_comment + "\n" + scanner.matched + scanner.rest
40
39
  else
41
- # No `module GemName` in the file. Add empty one at the end
40
+ # No matching class or module in the file. Add an empty module at the end.
42
41
  old_lib + "\n" + new_comment + "\nmodule #{repo.module_name}\nend"
43
42
  end
44
43
  end
@@ -46,18 +45,15 @@ module GemDocs
46
45
  end
47
46
 
48
47
  # Return a Regexp that capture any GemDocs-generated overview comment in
49
- # the main module library file. The Regex requires the comment to come
50
- # immediately before the `module <GemName>` line of the libary file, or it
51
- # will not match.
48
+ # the main library file. The Regex requires the comment to come immediately
49
+ # before the `class <GemName>` or `module <GemName>` line, or it will not match.
52
50
  def self.overview_re
53
- heads = GemDocs.config.overview_headings
54
- re_str = "#\\s*" + Regexp.quote(BANNER) + ".*"
55
- heads.each do |h|
56
- re_str << "\\*\\s+#{Regexp.escape(h)}.*"
57
- end
58
51
  repo = Repo.from_gemspec
59
- re_str += "(?=\\n\\s*module\\s+#{repo.module_name})"
60
- Regexp.new(re_str, Regexp::MULTILINE | Regexp::IGNORECASE)
52
+ # Match only comment/blank lines, never Ruby code between the banner and
53
+ # declaration. This also recognizes legacy empty or duplicated banners.
54
+ %r{^\#[ ]#{Regexp.escape(BANNER)}[^\n]*
55
+ (?:\n(?:[ \t]*\#[^\n]*|[ \t]*))*
56
+ (?=\n(?:class|module)\s+#{Regexp.escape(repo.module_name)}\b)}ix
61
57
  end
62
58
 
63
59
  def self.present?
@@ -82,7 +78,7 @@ module GemDocs
82
78
  result = +''
83
79
  scanner = StringScanner.new(text)
84
80
  heads.each do |h|
85
- if scanner.scan_until(/\n(?<head>\s*\*\s+#{Regexp.escape(h)})[^\n]*\n/)
81
+ if scanner.scan_until(/^(?<head>\* +#{Regexp.escape(h)})(?=[ \t\n]|\z)[^\n]*\n/)
86
82
  this_head = scanner.named_captures['head'] + "\n"
87
83
  body_start = scanner.pos
88
84
  body_end =
@@ -95,6 +91,8 @@ module GemDocs
95
91
  result << this_head + scanner.string[body_start..body_end]
96
92
  end
97
93
  end
94
+ return if result.empty?
95
+
98
96
  result.sub(/\A\n*/, '')
99
97
  end
100
98
  end
data/lib/gem_docs/repo.rb CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  module GemDocs
4
4
  class Repo
5
- attr_accessor :root, :host, :user, :name, :module_name
5
+ attr_accessor :root, :host, :user, :name, :module_name, :version
6
6
  attr_accessor :branch, :workflow_dir, :workflow_name
7
7
 
8
8
  def initialize(root: nil, host: nil,
9
9
  user: nil,
10
10
  name: nil,
11
11
  module_name: nil,
12
+ version: nil,
12
13
  branch: 'master',
13
14
  workflow_dir: nil,
14
15
  workflow_name: nil)
@@ -16,6 +17,7 @@ module GemDocs
16
17
  @host = host
17
18
  @user = user
18
19
  @module_name = module_name
20
+ @version = version
19
21
  @name = name
20
22
  @branch = branch
21
23
  @workflow_dir = workflow_dir
@@ -59,6 +61,7 @@ module GemDocs
59
61
  user: user,
60
62
  name: name,
61
63
  module_name: mname,
64
+ version: spec.version.to_s,
62
65
  branch: branch,
63
66
  workflow_dir: wdir,
64
67
  workflow_name: wname,
@@ -72,7 +75,7 @@ module GemDocs
72
75
  GemDocs.config.repo_workflow_dir,
73
76
  GemDocs.config.repo_workflow_name,
74
77
  )
75
- return [File.dirname(workflow_file), File.dirname(workflow_file)] if File.readable?(workflow_file)
78
+ return [File.dirname(workflow_file), File.basename(workflow_file)] if File.readable?(workflow_file)
76
79
  end
77
80
 
78
81
  dir = File.join(GemDocs.project_root, ".github/workflows")
@@ -149,7 +152,7 @@ module GemDocs
149
152
  abort "No gemspec found" if candidates.empty?
150
153
  abort "Multiple gemspecs found: #{candidates.join(', ')}" if candidates.size > 1
151
154
  end
152
- candidates.first
155
+ File.join(GemDocs.project_root, candidates.first)
153
156
  end
154
157
 
155
158
  def load_gemspec(path)
@@ -2,13 +2,17 @@
2
2
 
3
3
  module GemDocs
4
4
  module Skeleton
5
- # @return String The overview from README per config
6
- def self.make_readme?
7
- return false if readme_present?
5
+ VERSION_BLOCK_RE = /^# gem_docs:version begin\n.*?^# gem_docs:version end\n?/m
8
6
 
7
+ # Create the README or refresh its managed gemspec version block.
8
+ # @return [Boolean] whether the README changed
9
+ def self.make_readme?
9
10
  repo = Repo.from_gemspec
11
+ return write_version?(File.read(README_ORG), repo.version) if readme_present?
12
+
10
13
  content = <<~SKEL
11
14
  #+TITLE: #{repo.module_name} Guide
15
+ #{Header.org_headers.strip}
12
16
 
13
17
  * Introduction
14
18
 
@@ -42,8 +46,28 @@ module GemDocs
42
46
  The gem is available as open source under the terms of the [MIT
43
47
  License](https://opensource.org/licenses/MIT).
44
48
  SKEL
45
- File.write(README_ORG, content) > 0
49
+ write_version?(content, repo.version)
50
+ end
51
+
52
+ def self.write_version?(content, version)
53
+ block = <<~ORG
54
+ # gem_docs:version begin
55
+ Version: #{version}
56
+ # gem_docs:version end
57
+ ORG
58
+ updated = if content.match?(VERSION_BLOCK_RE)
59
+ content.sub(VERSION_BLOCK_RE, block)
60
+ else
61
+ # Keep the version below the initial Org metadata and near the CI badge.
62
+ header = content[/\A(?:#\+[^\n]*\n)*/]
63
+ header + "\n" + block + "\n" + content.delete_prefix(header)
64
+ end
65
+ return false if File.exist?(README_ORG) && updated == content
66
+
67
+ File.write(README_ORG, updated)
68
+ true
46
69
  end
70
+ private_class_method :write_version?
47
71
 
48
72
  def self.readme_present?
49
73
  File.exist?(README_ORG)
@@ -15,6 +15,15 @@ module GemDocs
15
15
  end
16
16
 
17
17
  namespace :docs do
18
+ task :check_readme do
19
+ GemDocs::Emacs.check_readme_destination!
20
+ end
21
+
22
+ desc "Suggest documentation improvements without changing files or running examples"
23
+ task :doctor do
24
+ GemDocs::Doctor.run
25
+ end
26
+
18
27
  desc "Evaluate code blocks in README.org"
19
28
  task :tangle => ["docs:skeleton:readme"] do
20
29
  print "Executing code blocks in #{README_ORG} ... "
@@ -22,7 +31,7 @@ module GemDocs
22
31
  end
23
32
 
24
33
  desc "Export README.org → README.md"
25
- task :export => [:badge, README_MD, CHANGELOG_MD]
34
+ task :export => [:check_readme, :badge, README_MD, CHANGELOG_MD]
26
35
 
27
36
  desc "Extract overview from README.org and embed in lib/<gem>.rb for ri/yard"
28
37
  task :overview => ["docs:skeleton:readme", README_ORG] do
@@ -30,17 +39,17 @@ module GemDocs
30
39
  if GemDocs::Overview.write_overview?
31
40
  puts "added"
32
41
  else
33
- puts "already present"
42
+ puts "unchanged (already current or no matching overview)"
34
43
  end
35
44
  end
36
45
 
37
46
  namespace :skeleton do
38
- desc "Create skeleton README.org if one does not exist"
47
+ desc "Create README.org if missing and refresh its gem version"
39
48
  task :readme do
40
49
  if GemDocs::Skeleton.make_readme?
41
- puts "README.org added"
50
+ puts "README.org created or version updated"
42
51
  else
43
- puts "README.org already present"
52
+ puts "README.org already up to date"
44
53
  end
45
54
  end
46
55
 
@@ -55,7 +64,7 @@ module GemDocs
55
64
  end
56
65
 
57
66
  desc "Insert #+PROPERTY headers at top of README.org for code blocks"
58
- task :header => "docs:skeleton:readme" do
67
+ task :headers => "docs:skeleton:readme" do
59
68
  print "Inserting headers ... "
60
69
  if GemDocs::Header.write_header?
61
70
  puts "added"
@@ -64,6 +73,8 @@ module GemDocs
64
73
  end
65
74
  end
66
75
 
76
+ task :header => :headers
77
+
67
78
  desc "Generate YARD HTML documentation"
68
79
  task :yard => [:overview] do
69
80
  puts "Generating YARD documentation ... "
@@ -81,7 +92,16 @@ module GemDocs
81
92
  end
82
93
 
83
94
  desc "Run all documentation tasks (examples, readme, overview, yard, ri)"
84
- task :all => ["docs:skeleton:readme", "docs:skeleton:changelog", :header, :tangle, :export, :overview, :yard]
95
+ task :all => [
96
+ :check_readme,
97
+ "docs:skeleton:readme",
98
+ "docs:skeleton:changelog",
99
+ :headers,
100
+ :tangle,
101
+ :export,
102
+ :overview,
103
+ :yard
104
+ ]
85
105
  end
86
106
  end
87
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GemDocs
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/gem_docs/yard.rb CHANGED
@@ -31,11 +31,11 @@ module GemDocs
31
31
  YOPTS
32
32
  end
33
33
 
34
- # Write .yardopts only if needed
34
+ # Create defaults only when the project has no .yardopts file.
35
35
  def write_yardopts
36
- if !File.exist?(yardopts_path) || File.read(yardopts_path) != yardopts_contents
37
- File.write(yardopts_path, yardopts_contents)
38
- end
36
+ return if File.exist?(yardopts_path)
37
+
38
+ File.write(yardopts_path, yardopts_contents)
39
39
  end
40
40
  end
41
41
  end
data/lib/gem_docs.rb CHANGED
@@ -285,6 +285,7 @@ module GemDocs
285
285
  require_relative "gem_docs/badge"
286
286
  require_relative "gem_docs/header"
287
287
  require_relative "gem_docs/skeleton"
288
+ require_relative "gem_docs/doctor"
288
289
  require_relative "gem_docs/tasks"
289
290
 
290
291
  README_ORG = "README.org"
@@ -295,7 +296,13 @@ module GemDocs
295
296
  # Auto-detect project root (handles being run from subdirs)
296
297
  def self.project_root
297
298
  here = Dir.pwd
298
- here = File.dirname(here) until !Dir['*.gemspec', 'Gemfile'].empty? || here == "/"
299
- here
299
+ loop do
300
+ return here unless Dir.glob(['*.gemspec', 'Gemfile'], base: here).empty?
301
+
302
+ parent = File.dirname(here)
303
+ return here if parent == here
304
+
305
+ here = parent
306
+ end
300
307
  end
301
308
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gem_docs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel E. Doherty
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 0.9.28
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 0.9.28
40
40
  description: Shared tasks for README.org code block execution, markdown export, YARD
41
41
  integration, etc.
42
42
  email:
@@ -48,6 +48,7 @@ files:
48
48
  - lib/gem_docs.rb
49
49
  - lib/gem_docs/badge.rb
50
50
  - lib/gem_docs/config.rb
51
+ - lib/gem_docs/doctor.rb
51
52
  - lib/gem_docs/emacs.rb
52
53
  - lib/gem_docs/header.rb
53
54
  - lib/gem_docs/overview.rb
@@ -76,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
77
  - !ruby/object:Gem::Version
77
78
  version: '0'
78
79
  requirements: []
79
- rubygems_version: 4.0.3
80
+ rubygems_version: 3.6.2
80
81
  specification_version: 4
81
82
  summary: Documentation automation for Ruby gems
82
83
  test_files: []