cf-mcp 0.20.0 → 0.21.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: aa1835b909429755f2b06a50b6db8d973c68445f879413974b7b28863b6bce06
4
- data.tar.gz: 6a110d101e27e125763506715bed2a92b973e9cecd709bd17a69c7088fb5e55f
3
+ metadata.gz: 74d1434cafd05f9f937dffdf7ae0f332ddb30816b9584bd20a5cf7b0a964914a
4
+ data.tar.gz: c513c509f8cc74e414312be4caf351ecb36b12c95ac23a98f43e4fa68721f29d
5
5
  SHA512:
6
- metadata.gz: e450154e426288754d99ee28a63e4b3fa99d2394600ab8519df3f0d584844b2428f64a1c62d106053f3f3826ace1d4e4beeadd6d9f45e0100b2d7bcc3717db69
7
- data.tar.gz: 7fd889d1f4ae36abed48cb84048efa8debf22a4c97d198c2d12745ad095589a3ab02feeb5c2c1d4d3221f33a38069c363214f3142b7c56bcbf0c30e04d790f54
6
+ metadata.gz: 9f037629edf72ea2a7a9b221d51a7108376e8136c7fa4f749fb10088c96a134b6ab1d62e77297da5167189ce940c22f5519d6303112a2c0d76b5b8b16ddf543c
7
+ data.tar.gz: 465e434412b383fa0f99d2210e86131cf320bb59f2dccd2d8781960b000a7a7f1a77155951ced1d191c5f5923527453f33f7c31d67d105a1e2de308c2ebeef52
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - Every tool is now a command (`cf-mcp search sprite --type function`, `cf-mcp get_details CF_Sprite`, and so on) with arguments and `--help` generated from the tool's input schema
15
+ - `cf-mcp index` caches the parsed index in `~/.cache/cf-mcp` (`CF_MCP_CACHE_DIR` and `XDG_CACHE_HOME` are honoured), one cache per `--root` so projects on different Cute Framework versions do not evict each other; tool commands read it and rebuild it when a header or topic changes. A cache directory that belongs to another user or that everyone can write to is refused, since cache files are read back with `Marshal`
16
+ - RBS signatures for the whole library, checked in CI with Steep (strict, and every method must have a signature) and by running the test suite under RBS's runtime type checker; run them with `rake rbs`. They are development-only and not packaged in the gem.
17
+
18
+ ### Changed
19
+
20
+ - `CLI#run` returns the exit status instead of exiting, and an unknown command or option now fails with a message (previously an unknown command printed the usage and exited 0)
21
+ - `search` and `list_category` list the index's categories when their schema is asked for instead of when the class loads, so tools can be loaded before the index is filled; the `category` property has no `enum` while the index is empty
22
+ - `name:` is now a required keyword on `DocItem` and its subclasses (`FunctionDoc`, `StructDoc`, `EnumDoc`, `TopicDoc`); previously a nameless item could be built and indexed
23
+ - `Index#by_type` now returns an `Index::ByType` (`functions`, `structs`, `enums`, `topics`) instead of a Hash keyed by type symbol, and `Index#add` files items by class rather than by their `type`
24
+ - Small internal refactors so the code type checks; no behavior change for valid input
25
+
26
+ ### Removed
27
+
28
+ - Dropped the unused `listen` runtime dependency
29
+
10
30
  ## [0.20.0] - 2026-09-19
11
31
 
12
32
  ### Added
@@ -14,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
34
  - `CF_MCP_ALLOWED_HOSTS` environment variable (comma-separated) to allow extra `Host` header values on the HTTP endpoint; set to `cf-mcp.fly.dev` in `fly.toml`
15
35
  - Support MCP protocol version 2026-07-28: stateless requests carrying their version in `_meta`, and the `server/discover` method
16
36
  - Allow the `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers in CORS preflight responses
37
+ - Server `description` in `serverInfo`
17
38
 
18
39
  ### Changed
19
40
 
@@ -21,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
21
42
  - Updated `mcp` from 0.25.0 to 1.5.1
22
43
  - Updated `puma` from 7.2.1 to 8.0.2 (the gemspec constraint is now `~> 8.0`) and `rubyzip` from 3.6.0 to 3.7.0
23
44
  - Removed the `2025-03-26` protocol pin; clients using the `initialize` handshake now negotiate up to 2025-11-25
45
+ - Restored the server `title`, `website_url` and tool titles removed in 0.17.2 for that pin; the SDK still omits the server fields for clients that negotiate an older version
24
46
  - The web UI now calls tools through the stateless 2026-07-28 protocol
25
47
  - The web UI shows both the modern and the legacy handshake protocol versions
26
48
  - Bump Ruby to 4.0.7 in `.ruby-version`, the Dockerfile, and the CI matrix
@@ -332,7 +354,8 @@ This version was prepared but never released. Changes rolled into 0.17.2.
332
354
  - `cf_list_category` - List items by category
333
355
  - `cf_get_details` - Get full documentation by name
334
356
 
335
- [Unreleased]: https://github.com/pusewicz/cf-mcp/compare/v0.20.0...HEAD
357
+ [Unreleased]: https://github.com/pusewicz/cf-mcp/compare/v0.21.0...HEAD
358
+ [0.21.0]: https://github.com/pusewicz/cf-mcp/compare/v0.20.0...v0.21.0
336
359
  [0.20.0]: https://github.com/pusewicz/cf-mcp/compare/v0.19.0...v0.20.0
337
360
  [0.19.0]: https://github.com/pusewicz/cf-mcp/compare/v0.18.0...v0.19.0
338
361
  [0.17.2]: https://github.com/pusewicz/cf-mcp/compare/v0.17.0...v0.17.2
data/Manifest.txt CHANGED
@@ -12,6 +12,7 @@ lib/cf/mcp/downloader.rb
12
12
  lib/cf/mcp/github_client.rb
13
13
  lib/cf/mcp/index.rb
14
14
  lib/cf/mcp/index_builder.rb
15
+ lib/cf/mcp/index_cache.rb
15
16
  lib/cf/mcp/models/doc_item.rb
16
17
  lib/cf/mcp/models/enum_doc.rb
17
18
  lib/cf/mcp/models/function_doc.rb
@@ -29,6 +30,8 @@ lib/cf/mcp/server.rb
29
30
  lib/cf/mcp/templates/index.erb
30
31
  lib/cf/mcp/templates/script.js
31
32
  lib/cf/mcp/templates/style.css
33
+ lib/cf/mcp/tool_command.rb
34
+ lib/cf/mcp/tools/category_enum.rb
32
35
  lib/cf/mcp/tools/find_related.rb
33
36
  lib/cf/mcp/tools/get_details.rb
34
37
  lib/cf/mcp/tools/get_topic.rb
@@ -41,4 +44,3 @@ lib/cf/mcp/tools/search_result_formatter.rb
41
44
  lib/cf/mcp/tools/search_tool.rb
42
45
  lib/cf/mcp/topic_parser.rb
43
46
  lib/cf/mcp/version.rb
44
- sig/cf/mcp.rbs
data/README.md CHANGED
@@ -35,6 +35,21 @@ cf-mcp stdio --root /path/to/cute_framework_project # STDIO mode
35
35
  cf-mcp http --root /path/to/cute_framework_project # HTTP mode with web UI
36
36
  ```
37
37
 
38
+ ## Command line
39
+
40
+ Every tool is also a command, so no MCP client is needed. Index the docs once, then look things up:
41
+
42
+ ```bash
43
+ cf-mcp index --root /path/to/cute_framework # re-run after updating Cute Framework
44
+ cf-mcp search sprite --type function --limit 5
45
+ cf-mcp get_details CF_Sprite
46
+ cf-mcp list_category sprite
47
+ ```
48
+
49
+ The commands are `search`, `get_details`, `find_related`, `parameter_search`, `member_search`, `list_category`, `list_topics` and `get_topic`. Run `cf-mcp <command> --help` for a command's arguments.
50
+
51
+ The index is cached in `~/.cache/cf-mcp` (or `$XDG_CACHE_HOME/cf-mcp`, or `$CF_MCP_CACHE_DIR`) and rebuilt automatically when a header changes. Each `--root` keeps its own cache, so projects on different Cute Framework versions can take turns without reindexing: pass `--root` (or set `CF_HEADERS_PATH`) to say which one a command should use. Without either, a command uses the last one it was given. Cache files are read back with `Marshal`, so a cache directory that belongs to another user or that everyone can write to is refused.
52
+
38
53
  ## Inspecting with MCP Inspector
39
54
 
40
55
  You can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to debug and test the server.
data/Rakefile CHANGED
@@ -9,7 +9,7 @@ require "standard/rake"
9
9
 
10
10
  desc "Generate Manifest.txt from git ls-files"
11
11
  task :manifest do
12
- ignore_patterns = %w[bin/ Gemfile .gitignore test/ .github/ .standard.yml cf-mcp.gemspec .ruby-version CLAUDE.md AGENTS.md fly.toml Procfile Dockerfile .dockerignore .claude/ .mcp.json .serena/]
12
+ ignore_patterns = %w[bin/ Gemfile .gitignore test/ .github/ .standard.yml cf-mcp.gemspec .ruby-version CLAUDE.md AGENTS.md fly.toml Procfile Dockerfile .dockerignore .claude/ .mcp.json .serena/ sig/ sig-stubs/ Steepfile rbs_collection.yaml rbs_collection.lock.yaml]
13
13
 
14
14
  files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
15
15
  ls.readlines("\x0", chomp: true)
@@ -23,11 +23,35 @@ task :manifest do
23
23
  puts "Generated Manifest.txt with #{files.size} files"
24
24
  end
25
25
 
26
- desc "Validate RBS type signatures"
27
- task :rbs do
28
- sh "rbs", "-I", "sig", "validate"
26
+ namespace :rbs do
27
+ desc "Validate RBS type signatures"
28
+ task :validate do
29
+ sh "rbs", "--collection", "rbs_collection.yaml", "-I", "sig", "-I", "sig-stubs", "validate"
30
+ end
31
+
32
+ desc "Type check lib/ against the RBS signatures with Steep"
33
+ task :steep do
34
+ sh "steep", "check"
35
+ end
36
+
37
+ desc "Run the test suite under the RBS runtime type checker"
38
+ task :test do
39
+ env = {
40
+ "RBS_TEST_TARGET" => "CF::MCP::*",
41
+ "RBS_TEST_OPT" => "-I sig -I sig-stubs --collection rbs_collection.yaml",
42
+ "RBS_TEST_DOUBLE_SUITE" => "minitest",
43
+ "RBS_TEST_LOGLEVEL" => "warn",
44
+ # Append rather than replace: under `bundle exec`, RUBYOPT already carries
45
+ # -rbundler/setup, and dropping it would make the child resolve system gems.
46
+ "RUBYOPT" => "#{ENV["RUBYOPT"]} -rrbs/test/setup".strip
47
+ }
48
+ sh env, "bundle", "exec", "rake", "test"
49
+ end
29
50
  end
30
51
 
52
+ desc "Validate signatures, type check with Steep, and run the tests under the runtime type checker"
53
+ task rbs: %w[rbs:validate rbs:steep rbs:test]
54
+
31
55
  task default: %i[test standard rbs manifest]
32
56
 
33
57
  desc "Deploy to Fly.io (runs tests and linting first)"
data/exe/cf-mcp CHANGED
@@ -3,4 +3,4 @@
3
3
 
4
4
  require "cf/mcp"
5
5
 
6
- CF::MCP::CLI.new(ARGV).run
6
+ exit CF::MCP::CLI.new(ARGV).run
data/lib/cf/mcp/cli.rb CHANGED
@@ -5,92 +5,117 @@ require "optparse"
5
5
  module CF
6
6
  module MCP
7
7
  class CLI
8
+ USAGE = "Usage: cf-mcp [options] <command> [options]"
9
+
10
+ COMMANDS = {
11
+ "stdio" => "Run in STDIO mode (for CLI integration)",
12
+ "http" => "Run as HTTP server with web interface",
13
+ "index" => "Index the documentation into a cache for fast lookups"
14
+ }.freeze
15
+
8
16
  def initialize(args)
9
17
  @args = args
10
- @options = parse_args
18
+ @options = {
19
+ port: nil,
20
+ host: "0.0.0.0",
21
+ root: nil,
22
+ download: false,
23
+ help: false,
24
+ version: false
25
+ } #: options
26
+ @option_parser = build_option_parser
11
27
  end
12
28
 
29
+ # Returns the process exit status.
13
30
  def run
14
- case @options[:command]
15
- when :stdio
16
- run_server(:stdio)
17
- when :http
18
- run_http_server
19
- when :help
20
- puts @option_parser
21
- else
22
- warn "Unknown command. Use --help for usage information."
23
- exit 1
31
+ @option_parser.order!(@args)
32
+ command = @args.shift&.tr("-", "_")
33
+ tool = find_tool(command)
34
+ # A tool's flags are checked against the index, so the options that choose it come out first.
35
+ tool ? take_source_options : @option_parser.parse!(@args)
36
+
37
+ return print_version if @options[:version]
38
+ return print_usage if @options[:help] || [nil, "help"].include?(command)
39
+ return run_tool(tool) if tool
40
+ return fail_with("Unexpected arguments: #{@args.join(" ")}") unless @args.empty?
41
+
42
+ case command
43
+ when "stdio" then run_stdio
44
+ when "http" then run_http
45
+ when "index" then run_index
46
+ else fail_with("Unknown command '#{command}'. Use --help for usage information.")
24
47
  end
48
+ rescue OptionParser::ParseError, Error => e
49
+ fail_with(e.message)
25
50
  end
26
51
 
27
52
  private
28
53
 
29
- def parse_args
30
- options = {
31
- command: nil,
32
- port: nil,
33
- host: "0.0.0.0",
34
- root: nil,
35
- download: false
36
- }
37
-
38
- @option_parser = OptionParser.new do |opts|
39
- opts.banner = "Usage: cf-mcp <command> [options]"
40
- opts.separator ""
41
- opts.separator "Commands:"
42
- opts.separator " stdio Run in STDIO mode (for CLI integration)"
43
- opts.separator " http Run as HTTP server with web interface"
54
+ def build_option_parser
55
+ OptionParser.new do |opts|
56
+ opts.banner = USAGE
44
57
  opts.separator ""
45
58
  opts.separator "Options:"
46
59
 
47
- opts.on("-r", "--root PATH", "Path to Cute Framework headers directory") do |path|
48
- options[:root] = path
60
+ opts.on("-r", "--root PATH", "Path to a Cute Framework checkout, or its include directory") do |path|
61
+ @options[:root] = path
49
62
  end
50
63
 
51
64
  opts.on("-p", "--port PORT", Integer, "Port for HTTP server (default: 9292)") do |port|
52
- options[:port] = port
65
+ @options[:port] = port
53
66
  end
54
67
 
55
68
  opts.on("-H", "--host HOST", "Host to bind to (default: 0.0.0.0)") do |host|
56
- options[:host] = host
69
+ @options[:host] = host
57
70
  end
58
71
 
59
72
  opts.on("-d", "--download", "Download Cute Framework headers from GitHub") do
60
- options[:download] = true
73
+ @options[:download] = true
61
74
  end
62
75
 
63
76
  opts.on("-h", "--help", "Show this help message") do
64
- options[:command] = :help
77
+ @options[:help] = true
65
78
  end
66
79
 
67
80
  opts.on("-v", "--version", "Show version") do
68
- puts "cf-mcp #{CF::MCP::VERSION}"
69
- exit 0
81
+ @options[:version] = true
70
82
  end
71
83
  end
84
+ end
72
85
 
73
- @option_parser.parse!(@args)
86
+ def print_version
87
+ puts "cf-mcp #{VERSION}"
88
+ 0
89
+ end
74
90
 
75
- # Parse command from remaining args
76
- if options[:command].nil? && !@args.empty?
77
- command = @args.shift.to_sym
78
- options[:command] = command if [:stdio, :http].include?(command)
79
- end
91
+ # The commands go in the banner because the tools are only loaded when asked for.
92
+ def print_usage
93
+ @option_parser.banner = [
94
+ USAGE, "",
95
+ "Commands:", *command_lines(COMMANDS), "",
96
+ "Documentation commands (hyphens work too, as in get-details; see `cf-mcp <command> --help`):",
97
+ *command_lines(tool_descriptions)
98
+ ].join("\n")
99
+ puts @option_parser
100
+ 0
101
+ end
80
102
 
81
- options[:command] ||= :help
82
- options
103
+ def command_lines(descriptions)
104
+ descriptions.map { |name, description| " #{name.ljust(18)}#{description}" }
83
105
  end
84
106
 
85
- def run_server(mode)
86
- builder = IndexBuilder.new(root: @options[:root], download: @options[:download])
107
+ def tool_descriptions
108
+ Tools.all.to_h { |tool| [tool.tool_name.to_s, tool.description.to_s] }
109
+ end
87
110
 
88
- unless builder.valid?
89
- warn "Error: Headers directory not found: #{builder.headers_path}"
90
- warn "Use --root to specify the path to Cute Framework headers"
91
- warn "Or use --download to fetch headers from GitHub"
92
- exit 1
93
- end
111
+ def fail_with(message)
112
+ warn "Error: #{message}"
113
+ 1
114
+ end
115
+
116
+ def run_stdio
117
+ builder = IndexBuilder.new(root: @options[:root], download: @options[:download])
118
+ return fail_with(missing_headers_message(builder)) unless builder.valid?
94
119
 
95
120
  warn "Parsing headers from: #{builder.headers_path}"
96
121
  index = builder.build do |event, path, count|
@@ -99,11 +124,11 @@ module CF
99
124
  warn "Indexed #{index.stats[:total]} items (#{index.stats[:functions]} functions, #{index.stats[:structs]} structs, #{index.stats[:enums]} enums)"
100
125
  warn "Cute Framework revision: #{builder.revision}" if builder.revision
101
126
 
102
- server = Server.new(index, revision: builder.revision)
103
- server.run_stdio
127
+ Server.new(index, revision: builder.revision).run_stdio
128
+ 0
104
129
  end
105
130
 
106
- def run_http_server
131
+ def run_http
107
132
  require "rackup"
108
133
 
109
134
  port = @options[:port] || 9292
@@ -118,6 +143,56 @@ module CF
118
143
  warn "Web interface available at http://localhost:#{port}/"
119
144
  warn "MCP endpoint available at http://localhost:#{port}/http"
120
145
  Rackup::Server.start(app: app, Host: host, Port: port, Logger: $stderr)
146
+ 0
147
+ end
148
+
149
+ def run_index
150
+ cache = IndexCache.new
151
+ stats = cache.refresh(index_source) { |source| IndexBuilder.new(**source) }.stats
152
+
153
+ puts "Indexed #{stats[:total]} items (#{stats[:functions]} functions, #{stats[:structs]} structs, #{stats[:enums]} enums, #{stats[:topics]} topics)"
154
+ puts "Cute Framework revision: #{cache.revision}" if cache.revision
155
+ puts "Cached at #{cache.path}"
156
+ 0
157
+ end
158
+
159
+ # Takes --root and --download out of the arguments wherever they stand, leaving the rest for the tool.
160
+ def take_source_options
161
+ rest = [] #: Array[String]
162
+ while (arg = @args.shift)
163
+ case arg
164
+ when "-r", "--root" then @options[:root] = @args.shift || raise(OptionParser::MissingArgument, arg)
165
+ when /\A--root=(.*)\z/ then @options[:root] = $1
166
+ when "-d", "--download" then @options[:download] = true
167
+ else rest << arg
168
+ end
169
+ end
170
+ @args.replace(rest)
171
+ end
172
+
173
+ # The tool named by the command, if it names one. Asking loads the tools, which the
174
+ # other commands have no need of.
175
+ def find_tool(command)
176
+ return if command.nil? || COMMANDS.key?(command)
177
+
178
+ Tools.all.find { |tool| tool.tool_name == command }
179
+ end
180
+
181
+ # Help describes the tool from its schema alone, so it goes without the index.
182
+ def run_tool(tool)
183
+ IndexCache.new.index(index_source) { |source| IndexBuilder.new(**source) } unless @args.intersect?(%w[-h --help])
184
+ ToolCommand.new(tool).run(@args)
185
+ end
186
+
187
+ # What the user asked to index, by flag or CF_HEADERS_PATH; nil leaves the choice to the
188
+ # cache. The root is expanded so every spelling of it names the same index.
189
+ def index_source
190
+ root = @options[:root] || ENV["CF_HEADERS_PATH"]
191
+ {root: root && File.expand_path(root), download: @options[:download]} if root || @options[:download]
192
+ end
193
+
194
+ def missing_headers_message(builder)
195
+ "Headers directory not found: #{builder.headers_path}. Use --root to specify the path to Cute Framework headers, or --download to fetch them from GitHub."
121
196
  end
122
197
  end
123
198
  end
@@ -63,15 +63,19 @@ module CF
63
63
 
64
64
  def download_zip(destination, url = CUTE_FRAMEWORK_ZIP_URL)
65
65
  uri = URI.parse(url)
66
+ host = uri.host || raise(DownloadError, "No host in #{url}")
66
67
 
67
- Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
68
+ Net::HTTP.start(host, uri.port, use_ssl: true) do |http|
68
69
  request = Net::HTTP::Get.new(uri)
70
+ # @type var response: Net::HTTPResponse
69
71
  response = http.request(request)
70
72
 
71
73
  # Handle redirects (GitHub redirects to codeload.github.com)
72
74
  if response.is_a?(Net::HTTPRedirection)
73
- redirect_uri = URI.parse(response["location"])
74
- Net::HTTP.start(redirect_uri.host, redirect_uri.port, use_ssl: true) do |redirect_http|
75
+ location = response["location"] || raise(DownloadError, "Redirect from #{url} has no Location header")
76
+ redirect_uri = URI.parse(location)
77
+ redirect_host = redirect_uri.host || raise(DownloadError, "No host in redirect to #{location}")
78
+ Net::HTTP.start(redirect_host, redirect_uri.port, use_ssl: true) do |redirect_http|
75
79
  redirect_request = Net::HTTP::Get.new(redirect_uri)
76
80
  response = redirect_http.request(redirect_request)
77
81
  end
@@ -92,12 +96,12 @@ module CF
92
96
  Zip::File.open(zip_path) do |zip_file|
93
97
  # The zip contains a top-level directory like "cute_framework-master/"
94
98
  # We want to extract "include/" and "docs/topics/" subdirectories
95
- top_level_prefix = nil
99
+ top_level_prefix = nil #: String?
96
100
 
97
101
  zip_file.each do |entry|
98
102
  # Find the top-level directory prefix (e.g., "cute_framework-master/" or "cute_framework-abc1234/")
99
103
  if top_level_prefix.nil? && entry.name.match?(%r{^cute_framework-[^/]+/include/})
100
- top_level_prefix = entry.name.match(%r{^(cute_framework-[^/]+/)})[1]
104
+ top_level_prefix = entry.name[%r{^(cute_framework-[^/]+/)}, 1]
101
105
  break
102
106
  end
103
107
  end
@@ -25,7 +25,8 @@ module CF
25
25
  request["Authorization"] = "Bearer #{@token}" if @token
26
26
  request["User-Agent"] = "cf-mcp/#{CF::MCP::VERSION}"
27
27
 
28
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
28
+ hostname = uri.hostname || raise(ArgumentError, "No host in #{uri}")
29
+ response = Net::HTTP.start(hostname, uri.port, use_ssl: true) do |http|
29
30
  http.request(request)
30
31
  end
31
32
 
data/lib/cf/mcp/index.rb CHANGED
@@ -7,6 +7,8 @@ module CF
7
7
  class Index
8
8
  include Singleton
9
9
 
10
+ ByType = Data.define(:functions, :structs, :enums, :topics)
11
+
10
12
  attr_reader :items, :by_type, :by_category, :topic_references
11
13
 
12
14
  def initialize
@@ -15,27 +17,28 @@ module CF
15
17
 
16
18
  def reset!
17
19
  @items = {}
18
- @by_type = {
19
- function: [],
20
- struct: [],
21
- enum: [],
22
- topic: []
23
- }
20
+ @by_type = ByType.new(functions: [], structs: [], enums: [], topics: [])
24
21
  @by_category = {}
25
22
  @topic_references = {}
26
23
  end
27
24
 
28
25
  def add(item)
29
26
  @items[item.name] = item
30
- @by_type[item.type] << item if @by_type.key?(item.type)
27
+
28
+ case item
29
+ when Models::FunctionDoc then @by_type.functions << item
30
+ when Models::StructDoc then @by_type.structs << item
31
+ when Models::EnumDoc then @by_type.enums << item
32
+ when Models::TopicDoc
33
+ @by_type.topics << item
34
+ # Build reverse reference index for topics
35
+ build_topic_reverse_index(item)
36
+ end
31
37
 
32
38
  if item.category
33
39
  @by_category[item.category] ||= []
34
40
  @by_category[item.category] << item
35
41
  end
36
-
37
- # Build reverse reference index for topics
38
- build_topic_reverse_index(item) if item.type == :topic
39
42
  end
40
43
 
41
44
  def find(name)
@@ -73,19 +76,19 @@ module CF
73
76
  end
74
77
 
75
78
  def functions
76
- @by_type[:function]
79
+ @by_type.functions
77
80
  end
78
81
 
79
82
  def structs
80
- @by_type[:struct]
83
+ @by_type.structs
81
84
  end
82
85
 
83
86
  def enums
84
- @by_type[:enum]
87
+ @by_type.enums
85
88
  end
86
89
 
87
90
  def topics
88
- @by_type[:topic]
91
+ @by_type.topics
89
92
  end
90
93
 
91
94
  def topics_ordered
@@ -111,10 +114,10 @@ module CF
111
114
  def stats
112
115
  {
113
116
  total: @items.size,
114
- functions: @by_type[:function].size,
115
- structs: @by_type[:struct].size,
116
- enums: @by_type[:enum].size,
117
- topics: @by_type[:topic].size,
117
+ functions: @by_type.functions.size,
118
+ structs: @by_type.structs.size,
119
+ enums: @by_type.enums.size,
120
+ topics: @by_type.topics.size,
118
121
  categories: @by_category.size
119
122
  }
120
123
  end
@@ -23,14 +23,13 @@ module CF
23
23
  end
24
24
 
25
25
  # Parse topics if available
26
- topics_path = find_topics_path(headers_path)
27
- if topics_path && File.directory?(topics_path)
26
+ if (path = topics_path)
28
27
  topic_parser = TopicParser.new
29
- topic_parser.parse_directory(topics_path).each do |topic|
28
+ topic_parser.parse_directory(path).each do |topic|
30
29
  refine_topic_references(topic, index)
31
30
  index.add(topic)
32
31
  end
33
- yield(:topics_indexed, topics_path, index.stats[:topics]) if block_given?
32
+ yield(:topics_indexed, path, index.stats[:topics]) if block_given?
34
33
  end
35
34
 
36
35
  index
@@ -40,6 +39,11 @@ module CF
40
39
  File.directory?(headers_path)
41
40
  end
42
41
 
42
+ # The topic guides directory, when the checkout has one.
43
+ def topics_path
44
+ @topics_path ||= find_topics_path(headers_path)
45
+ end
46
+
43
47
  private
44
48
 
45
49
  def resolve_headers_path(root:, download:)