├── .gitignore ├── config ├── external.yaml └── sus.rb ├── .editorconfig ├── guides ├── links.yaml ├── extract-symbols │ └── extract.rb └── getting-started │ └── readme.md ├── test ├── decode │ ├── language │ │ └── ruby │ │ │ ├── .fixtures │ │ │ ├── block_argument.rb │ │ │ ├── modules.rb │ │ │ ├── nested_modules.rb │ │ │ ├── functions.rb │ │ │ ├── instance_methods.rb │ │ │ ├── class_methods.rb │ │ │ ├── attributes.rb │ │ │ ├── singleton_class.rb │ │ │ ├── test_comments.rb │ │ │ ├── classes.rb │ │ │ ├── constants.rb │ │ │ ├── types.rb │ │ │ ├── unless_else_methods.rb │ │ │ ├── comments.rb │ │ │ ├── private.rb │ │ │ ├── block.rb │ │ │ ├── if_else_methods.rb │ │ │ ├── adjacent_comments.rb │ │ │ ├── aliases.rb │ │ │ ├── inline_visibility.rb │ │ │ └── indented_methods.rb │ │ │ ├── code.rb │ │ │ ├── source.rb │ │ │ ├── aliases.rb │ │ │ ├── comments.rb │ │ │ └── visibility.rb │ ├── comment │ │ ├── .fixtures │ │ │ ├── returns.rb │ │ │ ├── yields.rb │ │ │ ├── pragmas.rb │ │ │ ├── parameters.rb │ │ │ ├── example.rb │ │ │ └── text.rb │ │ ├── returns.rb │ │ ├── tags.rb │ │ ├── parameter.rb │ │ ├── yields.rb │ │ ├── example.rb │ │ └── text.rb │ ├── rbs │ │ ├── .fixtures │ │ │ ├── basic_module.rbs │ │ │ ├── basic_class.rbs │ │ │ ├── super_class.rbs │ │ │ ├── generics.rbs │ │ │ ├── super_class.rb │ │ │ ├── basic_class.rb │ │ │ ├── constant_inference.rbs │ │ │ ├── method_types.rbs │ │ │ ├── basic_module.rb │ │ │ ├── generics.rb │ │ │ ├── attribute_inference.rbs │ │ │ ├── parameter_forwarding.rb │ │ │ ├── method_types.rb │ │ │ ├── attribute_inference.rb │ │ │ └── constant_inference.rb │ │ ├── wrapper.rb │ │ ├── type.rb │ │ ├── generator.rb │ │ └── module.rb │ ├── index.rb │ └── languages.rb └── decode.rb ├── lib ├── decode │ ├── version.rb │ ├── language.rb │ ├── comment │ │ ├── returns.rb │ │ ├── throws.rb │ │ ├── option.rb │ │ ├── raises.rb │ │ ├── text.rb │ │ ├── yields.rb │ │ ├── attribute.rb │ │ ├── constant.rb │ │ ├── pragma.rb │ │ ├── parameter.rb │ │ ├── example.rb │ │ ├── tag.rb │ │ ├── rbs.rb │ │ ├── tags.rb │ │ └── node.rb │ ├── rbs.rb │ ├── language │ │ ├── ruby.rb │ │ ├── ruby │ │ │ ├── function.rb │ │ │ ├── attribute.rb │ │ │ ├── module.rb │ │ │ ├── constant.rb │ │ │ ├── segment.rb │ │ │ ├── alias.rb │ │ │ ├── block.rb │ │ │ ├── call.rb │ │ │ ├── reference.rb │ │ │ ├── generic.rb │ │ │ ├── class.rb │ │ │ ├── method.rb │ │ │ ├── definition.rb │ │ │ └── code.rb │ │ ├── reference.rb │ │ └── generic.rb │ ├── scope.rb │ ├── location.rb │ ├── syntax │ │ ├── link.rb │ │ ├── match.rb │ │ └── rewriter.rb │ ├── segment.rb │ ├── documentation.rb │ ├── rbs │ │ ├── type.rb │ │ ├── wrapper.rb │ │ ├── module.rb │ │ └── class.rb │ ├── source.rb │ ├── languages.rb │ ├── trie.rb │ ├── index.rb │ └── definition.rb └── decode.rb ├── bake.rb ├── .github ├── workflows │ ├── rubocop.yaml │ ├── documentation-coverage.yaml │ ├── test-types.yaml │ ├── test-external.yaml │ ├── test.yaml │ ├── documentation.yaml │ └── test-coverage.yaml └── copilot-instructions.md ├── bake └── decode │ ├── rbs.rb │ └── index.rb ├── .context ├── agent-context │ ├── index.yaml │ └── usage.md └── sus │ ├── index.yaml │ ├── mocking.md │ └── shared.md ├── gems.rb ├── decode.gemspec ├── license.md ├── context ├── index.yaml └── getting-started.md ├── .rubocop.yml ├── release.cert ├── releases.md ├── agent.md └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | /agents.md 2 | /.context 3 | /.bundle 4 | /pkg 5 | /gems.locked 6 | /.covered.db 7 | /external 8 | -------------------------------------------------------------------------------- /config/external.yaml: -------------------------------------------------------------------------------- 1 | utopia-project: 2 | url: https://github.com/socketry/utopia-project 3 | command: bundle exec sus 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /guides/links.yaml: -------------------------------------------------------------------------------- 1 | getting-started: 2 | order: 1 3 | documentation-coverage: 4 | order: 2 5 | extract-symbols: 6 | order: 3 7 | ruby-documentation: 8 | order: 4 9 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/block_argument.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | define_method(:foo, &block) 5 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/modules.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | module X 7 | module Y 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/returns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # @returns [Integer] The number of items. 7 | def size 8 | end 9 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/nested_modules.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | module X::Y 7 | module Z 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/functions.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2023-2024, by Samuel Williams. 3 | 4 | if something 5 | # This is a comment. 6 | # @scope Foo 7 | def Foo.bar(...) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | $LOAD_PATH << ::File.expand_path("../ext", __dir__) 7 | 8 | require "covered/sus" 9 | include Covered::Sus 10 | -------------------------------------------------------------------------------- /lib/decode/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Decode 7 | # @constant [String] The version of the gem. 8 | VERSION = "0.26.0" 9 | end 10 | -------------------------------------------------------------------------------- /lib/decode.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "decode/version" 7 | require_relative "decode/index" 8 | 9 | # @namespace 10 | module Decode 11 | end 12 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/instance_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | def without_arguments 7 | end 8 | 9 | def with_arguments(x = 10) 10 | end 11 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | def self.without_arguments 7 | end 8 | 9 | def self.with_arguments(x = 10) 10 | end 11 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | # The first attribute 7 | attr :a 8 | attr_reader :b 9 | attr_writer :c 10 | attr_accessor :d 11 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/basic_module.rbs: -------------------------------------------------------------------------------- 1 | # A utility module for string operations. 2 | module StringUtils 3 | # Reverse a string. 4 | public def reverse_string: (String str) -> String 5 | 6 | # Check if a string is empty. 7 | public def empty?: (String str) -> bool 8 | end 9 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/yields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # @yields {|item| ...} The items if a block is given. 7 | # @parameter item [Integer] 8 | def each 9 | end 10 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/singleton_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | class Foo 7 | class << self 8 | # Singleton method 9 | def bar 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/test_comments.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | class TestClass 5 | # This is a method comment. 6 | # It should also be clean. 7 | def test_method 8 | puts "test" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/pragmas.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2021-2024, by Samuel Williams. 6 | 7 | # @public 8 | def public_method 9 | end 10 | 11 | # @private 12 | def private_method 13 | end 14 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # @parameter x [Integer] The x co-ordinate. 7 | # @parameter y [Integer] The y co-ordinate. 8 | def add(x, y) 9 | end 10 | -------------------------------------------------------------------------------- /test/decode.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2023-2024, by Samuel Williams. 5 | 6 | require "decode/version" 7 | 8 | describe Decode do 9 | it "has a version number" do 10 | expect(Decode::VERSION).to be =~ /\d+\.\d+\.\d+/ 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/classes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | class Parent 7 | end 8 | 9 | class Child < Parent 10 | end 11 | 12 | class << self 13 | end 14 | 15 | class My::Nested::Child 16 | end 17 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/constants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | SINGLE_LINE_STRING = "Hello World" 7 | 8 | MULTI_LINE_ARRAY = [ 9 | "One", 10 | "Two", 11 | "Three" 12 | ] 13 | 14 | MULTI_LINE_HASH = { 15 | x: 10 16 | } 17 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/basic_class.rbs: -------------------------------------------------------------------------------- 1 | # A basic class for testing RBS generation. 2 | class Animal 3 | # Make the animal speak. 4 | public def speak: () -> untyped 5 | 6 | # Get the animal's name. 7 | public def name: () -> untyped 8 | 9 | # Set the animal's name. 10 | public def name=: (String name) -> untyped 11 | end 12 | -------------------------------------------------------------------------------- /lib/decode/language.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "language/generic" 7 | require_relative "language/ruby" 8 | 9 | module Decode 10 | # Language specific parsers and definitions. 11 | module Language 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | class Tuple 7 | end 8 | 9 | # A sequence of characters. 10 | class String 11 | end 12 | 13 | # A whole number. 14 | class Integer 15 | end 16 | 17 | Tuple(String, Integer) 18 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/unless_else_methods.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | # This file is used to test extraction of definitions from unless/else branches. 5 | 6 | def foo 7 | end 8 | 9 | unless RUBY_VERSION < "3.0" 10 | def bar 11 | end 12 | else 13 | def baz 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/super_class.rbs: -------------------------------------------------------------------------------- 1 | # Base animal class. 2 | class Animal 3 | # Make the animal speak. 4 | public def speak: () -> untyped 5 | end 6 | 7 | # A dog is a type of animal. 8 | class Dog < Animal 9 | # Dogs bark. 10 | public def speak: () -> untyped 11 | 12 | # Dogs can fetch. 13 | public def fetch: () -> untyped 14 | end 15 | -------------------------------------------------------------------------------- /lib/decode/comment/returns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "attribute" 7 | 8 | module Decode 9 | module Comment 10 | # Represents a return value. 11 | # 12 | # Example: `@returns [Integer] The person's age.` 13 | class Returns < Attribute 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/comments.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | # Firstly, we define a method: 8 | def method 9 | # Frobulate the combobulator: 10 | $combobulator.frobulate 11 | end 12 | 13 | # Then we invoke it: 14 | result = self.method 15 | puts result 16 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/example.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2025, by Samuel Williams. 6 | 7 | # @example Create a new thing 8 | # x = Thing.new 9 | # x.do_something 10 | def method_with_example 11 | end 12 | 13 | # @example 14 | # y = Thing.new 15 | # y.process 16 | def method_without_title 17 | end 18 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/generics.rbs: -------------------------------------------------------------------------------- 1 | # A generic container class. 2 | class Container[T] 3 | # Create a new container. 4 | public def initialize: () -> void 5 | 6 | # Add an item to the container. 7 | public def add: (T item) -> self 8 | 9 | # Get the first item. 10 | public def first: () -> T? 11 | 12 | # Check if the container is empty. 13 | public def empty?: () -> bool 14 | end 15 | -------------------------------------------------------------------------------- /bake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # Update the project documentation with the new version number. 7 | # 8 | # @parameter version [String] The new version number. 9 | def after_gem_release_version_increment(version) 10 | context["releases:update"].call(version) 11 | context["utopia:project:update"].call 12 | end 13 | -------------------------------------------------------------------------------- /lib/decode/comment/throws.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "attribute" 7 | 8 | module Decode 9 | module Comment 10 | # Identifies that a method might throw a specific symbol. 11 | # 12 | # - `@throws [:skip] To skip recursion.` 13 | # 14 | class Throws < Attribute 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/decode/comment/option.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024-2025, by Samuel Williams. 5 | 6 | require_relative "parameter" 7 | 8 | module Decode 9 | module Comment 10 | # Describes a method option (keyword argument). 11 | # 12 | # - `@option :cached [bool] Whether to cache the value.` 13 | # 14 | class Option < Parameter 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/decode/comment/raises.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require_relative "attribute" 7 | 8 | module Decode 9 | module Comment 10 | # Identifies that a method might raise an exception. 11 | # 12 | # - `@raises [ArgumentError] If the argument cannot be coerced.` 13 | # 14 | class Raises < Attribute 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/private.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2024, by Samuel Williams. 3 | 4 | class Foo 5 | def self.my_public_class_method 6 | end 7 | 8 | def my_public_method 9 | end 10 | 11 | private 12 | 13 | def my_private_method 14 | end 15 | 16 | class Nested 17 | def whatever 18 | end 19 | end 20 | 21 | private_constant :Nested 22 | 23 | module Nested2 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/decode/rbs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "rbs" 7 | require_relative "rbs/wrapper" 8 | require_relative "rbs/class" 9 | require_relative "rbs/method" 10 | require_relative "rbs/module" 11 | require_relative "rbs/generator" 12 | 13 | module Decode 14 | # RBS generation functionality for Ruby type signatures. 15 | module RBS 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yaml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: ruby 17 | bundler-cache: true 18 | 19 | - name: Run RuboCop 20 | timeout-minutes: 10 21 | run: bundle exec rubocop 22 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # @scope Foo Bar 7 | # @name local 8 | add(:local) do 9 | # The default hostname for the connection. 10 | # @name hostname 11 | # @attribute [String] 12 | hostname "localhost" 13 | 14 | # The default context for managing the connection. 15 | # @attribute [Context] 16 | context {Context.new(hostname)} 17 | end 18 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/if_else_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module IfElseMethods 7 | if true 8 | def method_in_if 9 | end 10 | else 11 | def method_in_else 12 | end 13 | end 14 | 15 | if false 16 | def method_in_if_false 17 | end 18 | elsif true 19 | def method_in_elsif 20 | end 21 | else 22 | def method_in_final_else 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /bake/decode/rbs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | def initialize(...) 7 | super 8 | 9 | require "decode/rbs" 10 | end 11 | 12 | # Generate RBS declarations for the given source root. 13 | # @parameter root [String] The root path to index. 14 | def generate(root) 15 | index = Decode::Index.for(root) 16 | generator = Decode::RBS::Generator.new(include_private: true) 17 | generator.generate(index) 18 | end 19 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/adjacent_comments.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2025, by Samuel Williams. 6 | 7 | # This is a separate comment block 8 | # that should also NOT be included. 9 | 10 | # This is the actual method comment 11 | # that SHOULD be included. 12 | def documented_method 13 | puts "Hello" 14 | end 15 | 16 | # This is another method comment 17 | def another_method 18 | puts "World" 19 | end 20 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/super_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # Base animal class. 7 | class Animal 8 | # Make the animal speak. 9 | def speak 10 | puts "Animal speaks" 11 | end 12 | end 13 | 14 | # A dog is a type of animal. 15 | class Dog < Animal 16 | # Dogs bark. 17 | def speak 18 | puts "Woof!" 19 | end 20 | 21 | # Dogs can fetch. 22 | def fetch 23 | puts "Fetching!" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/aliases.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | class Test 5 | def original_method 6 | puts "original" 7 | end 8 | 9 | alias new_method original_method 10 | alias_method :another_method, :original_method 11 | 12 | private 13 | 14 | def private_original 15 | puts "private original" 16 | end 17 | 18 | private 19 | alias private_alias private_original 20 | alias_method :private_alias_method, :private_original 21 | end 22 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/basic_class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # A basic class for testing RBS generation. 7 | class Animal 8 | # Make the animal speak. 9 | def speak 10 | puts "Animal speaks" 11 | end 12 | 13 | # Get the animal's name. 14 | def name 15 | @name 16 | end 17 | 18 | # Set the animal's name. 19 | # @parameter name [String] The new name. 20 | def name=(name) 21 | @name = name 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/decode/language/ruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "ruby/generic" 7 | 8 | module Decode 9 | module Language 10 | # Represents an interface for extracting information from Ruby source code. 11 | module Ruby 12 | # Create a new Ruby language instance. 13 | # @returns [Ruby::Generic] A configured Ruby language parser. 14 | def self.new 15 | Generic.new("ruby") 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/decode/scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | # An abstract namespace for nesting definitions. 10 | class Scope < Definition 11 | # @returns [String] The name of the scope. 12 | def short_form 13 | name.to_s 14 | end 15 | 16 | # Scopes are always containers. 17 | # @returns [bool] Always `true`. 18 | def container? 19 | true 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/constant_inference.rbs: -------------------------------------------------------------------------------- 1 | module TestModule 2 | # A test class demonstrating constant type inference. 3 | class TestClass 4 | CONFIG_FILE: String 5 | 6 | MAX_RETRIES: Integer 7 | 8 | DEFAULT_CONFIG: Hash[Symbol, String] 9 | 10 | SUPPORTED_FORMATS: Array[String] 11 | 12 | FLEXIBLE_VALUE: String | Integer? 13 | end 14 | 15 | # A test module with constants. 16 | module ConfigModule 17 | DEFAULT_TIMEOUT: Integer 18 | 19 | DEBUG_MODE: bool 20 | 21 | IDENTIFIER_PATTERN: Regexp 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/method_types.rbs: -------------------------------------------------------------------------------- 1 | # A class demonstrating different method signatures. 2 | class Calculator 3 | # Add two numbers. 4 | public def add: (Integer a, Integer b) -> Integer 5 | 6 | # Check if a number is positive. 7 | public def positive?: (Integer num) -> bool 8 | 9 | # Initialize the calculator. 10 | public def initialize: () -> void 11 | 12 | # Clear the history. 13 | public def clear: () -> self 14 | 15 | # Process numbers with a block. 16 | public def process_numbers: (Array[Integer] numbers) -> Array[Integer] 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/documentation-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | COVERAGE: PartialSummary 10 | 11 | jobs: 12 | validate: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ruby 20 | bundler-cache: true 21 | 22 | - name: Validate coverage 23 | timeout-minutes: 5 24 | run: bundle exec bake decode:index:coverage lib 25 | -------------------------------------------------------------------------------- /test/decode/comment/.fixtures/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | # Iterates over all the items. 7 | # @yields {|item| ...} The items if a block is given. 8 | # The items are yielded in reverse order. 9 | # @parameter item [Integer] 10 | # The item will always be negative. 11 | # For more details see {Array}. 12 | def each 13 | end 14 | 15 | # Indented code: 16 | # ``` ruby 17 | # def indentation 18 | # return "Hello World!" 19 | # end 20 | # ``` 21 | def indentation 22 | end 23 | -------------------------------------------------------------------------------- /lib/decode/comment/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "node" 7 | 8 | module Decode 9 | module Comment 10 | # A structured comment. 11 | class Text 12 | # Initialize a new text node. 13 | # @parameter line [String] The text content. 14 | def initialize(line) 15 | @line = line 16 | end 17 | 18 | # @attribute [String] The text content. 19 | attr :line 20 | 21 | # Traverse the text node. 22 | def traverse 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/basic_module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # A utility module for string operations. 7 | module StringUtils 8 | # Reverse a string. 9 | # @parameter str [String] The string to reverse. 10 | # @returns [String] The reversed string. 11 | def reverse_string(str) 12 | str.reverse 13 | end 14 | 15 | # Check if a string is empty. 16 | # @parameter str [String] The string to check. 17 | # @returns [bool] True if empty. 18 | def empty?(str) 19 | str.empty? 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.context/agent-context/index.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | description: Install and manage context files from Ruby gems. 3 | version: 0.1.3 4 | metadata: 5 | documentation_uri: https://ioquatix.github.io/agent-context/ 6 | funding_uri: https://github.com/sponsors/ioquatix/ 7 | source_code_uri: https://github.com/ioquatix/agent-context.git 8 | files: 9 | - path: usage.md 10 | title: Usage Guide 11 | description: "`agent-context` is a tool that helps you discover and install contextual 12 | information from Ruby gems for AI agents. Gems can provide additional documentation, 13 | examples, and guidance in a `context/` ..." 14 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-gem" 12 | gem "bake-modernize" 13 | gem "bake-releases" 14 | 15 | gem "agent-context" 16 | 17 | gem "utopia-project" 18 | end 19 | 20 | group :test do 21 | gem "sus" 22 | gem "covered" 23 | 24 | gem "rubocop" 25 | gem "rubocop-socketry" 26 | 27 | gem "bake-test" 28 | gem "bake-test-external" 29 | 30 | gem "steep" 31 | 32 | gem "build-files" 33 | end 34 | 35 | gem "rubocop-md", "~> 2.0", group: :test 36 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/function.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "method" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific function. 12 | class Function < Method 13 | # Generate a nested name for the function. 14 | def nested_name 15 | ".#{@name}" 16 | end 17 | 18 | # The node which contains the function arguments. 19 | def arguments_node 20 | if node = @node.children[2] 21 | if node.location.expression 22 | return node 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/inline_visibility.rb: -------------------------------------------------------------------------------- 1 | # Released under the MIT License. 2 | # Copyright, 2025, by Samuel Williams. 3 | 4 | class VisibilityTest 5 | def public_method_1 6 | end 7 | 8 | private def private_method_1 9 | end 10 | 11 | def public_method_2 12 | end 13 | 14 | protected def protected_method_1 15 | end 16 | 17 | def public_method_3 18 | end 19 | 20 | public def public_method_4 21 | end 22 | 23 | # Test standalone modifier after inline 24 | private 25 | 26 | def private_method_2 27 | end 28 | 29 | def private_method_3 30 | end 31 | 32 | protected 33 | 34 | def protected_method_2 35 | end 36 | 37 | public 38 | 39 | def public_method_5 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/generics.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # A generic container class. 7 | # @rbs generic T 8 | class Container 9 | # Create a new container. 10 | def initialize 11 | @items = [] 12 | end 13 | 14 | # Add an item to the container. 15 | # @parameter item [T] The item to add. 16 | def add(item) 17 | @items << item 18 | end 19 | 20 | # Get the first item. 21 | # @returns [T?] The first item or nil if empty. 22 | def first 23 | @items.first 24 | end 25 | 26 | # Check if the container is empty. 27 | # @returns [bool] True if empty. 28 | def empty? 29 | @items.empty? 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/workflows/test-types.yaml: -------------------------------------------------------------------------------- 1 | name: Test Types 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | 21 | ruby: 22 | - "3.4" 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{matrix.ruby}} 29 | bundler-cache: true 30 | 31 | - name: Run tests 32 | timeout-minutes: 10 33 | run: bundle exec steep check lib 34 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | name: ${{matrix.ruby}} on ${{matrix.os}} 11 | runs-on: ${{matrix.os}}-latest 12 | 13 | strategy: 14 | matrix: 15 | os: 16 | - ubuntu 17 | - macos 18 | 19 | ruby: 20 | - "3.2" 21 | - "3.3" 22 | - "3.4" 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{matrix.ruby}} 29 | bundler-cache: true 30 | 31 | - name: Run tests 32 | timeout-minutes: 10 33 | run: bundle exec bake test:external 34 | -------------------------------------------------------------------------------- /lib/decode/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2025, by Samuel Williams. 5 | 6 | module Decode 7 | # Represents a location in a source file. 8 | class Location 9 | # Initialize a new location. 10 | # @parameter path [String] The path to the source file. 11 | # @parameter line [Integer] The line number in the source file. 12 | def initialize(path, line) 13 | @path = path 14 | @line = line 15 | end 16 | 17 | # @attribute [String] The path to the source file. 18 | attr :path 19 | 20 | # @attribute [Integer] The line number in the source file. 21 | attr :line 22 | 23 | # Generate a string representation of the location. 24 | def to_s 25 | "#{path}:#{line}" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/attribute_inference.rbs: -------------------------------------------------------------------------------- 1 | module TestModule 2 | # A test class demonstrating attribute type inference. 3 | class TestClass 4 | attr_reader name(@name): String 5 | 6 | attr_reader count(@count): Integer 7 | 8 | attr_reader data(@data): Hash[String, Object] 9 | 10 | attr_reader items(@items): Array[String] 11 | 12 | @name: String 13 | 14 | @count: Integer 15 | 16 | @data: Hash[String, Object] 17 | 18 | @items: Array[String] 19 | 20 | # Initialize a new test instance. 21 | public def initialize: (untyped name, untyped count) -> void 22 | end 23 | 24 | # A test module with attributes. 25 | module AttributeModule 26 | attr_reader settings(@settings): Hash[Symbol, String] 27 | 28 | @settings: Hash[Symbol, String] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/parameter_forwarding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | class Example 7 | # Forward all parameters to another method 8 | # @parameter args [Array] Any arguments 9 | # @parameter kwargs [Hash] Any keyword arguments 10 | def forward(...) 11 | delegate(...) 12 | end 13 | 14 | # Another forwarding example with explicit return type 15 | # @returns [String] The result from the delegated method 16 | def forward_with_return(...) 17 | other_method(...) 18 | end 19 | 20 | private 21 | 22 | def delegate(*args, **kwargs) 23 | "delegated with #{args.length} args and #{kwargs.keys.length} kwargs" 24 | end 25 | 26 | def other_method(*args, **kwargs) 27 | "result" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/decode/comment/returns.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Returns do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:documentation) {source.segments.first.documentation} 13 | 14 | with "nested parameters" do 15 | let(:path) {File.expand_path(".fixtures/returns.rb", __dir__)} 16 | 17 | it "should have returns node" do 18 | expect(documentation.children[0]).to be_a(Decode::Comment::Returns) 19 | expect(documentation.children[0]).to have_attributes( 20 | type: be == "Integer", 21 | text: be == ["The number of items."], 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific attribute. 12 | class Attribute < Definition 13 | # The short form of the attribute. 14 | # e.g. `attr :value`. 15 | def short_form 16 | case @node&.type 17 | when :block_node 18 | "#{@name} { ... }" 19 | else 20 | @node&.location&.slice || @name 21 | end 22 | end 23 | 24 | # Generate a long form representation of the attribute. 25 | def long_form 26 | if @node&.location&.start_line == @node&.location&.end_line 27 | @node.location.slice 28 | else 29 | short_form 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific module. 12 | class Module < Definition 13 | # A module is a container for other definitions. 14 | def container? 15 | true 16 | end 17 | 18 | # The short form of the module. 19 | # e.g. `module Barnyard`. 20 | def short_form 21 | "module #{self.name}" 22 | end 23 | 24 | # Generate a long form representation of the module. 25 | def long_form 26 | qualified_form 27 | end 28 | 29 | # The fully qualified name of the module. 30 | # e.g. `module ::Barnyard::Dog`. 31 | def qualified_form 32 | "module #{self.qualified_name}" 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/decode/comment/tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2024, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Tags do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:segments) {source.segments.to_a} 13 | 14 | with "pragmas" do 15 | let(:path) {File.expand_path(".fixtures/pragmas.rb", __dir__)} 16 | let(:public_method) {segments[0]} 17 | let(:private_method) {segments[1]} 18 | 19 | it "should have public directive" do 20 | pragma = public_method.documentation.children.first 21 | expect(pragma.directive).to be == "public" 22 | end 23 | 24 | it "should have private directive" do 25 | pragma = private_method.documentation.children.first 26 | expect(pragma.directive).to be == "private" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /decode.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/decode/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "decode" 7 | spec.version = Decode::VERSION 8 | 9 | spec.summary = "Code analysis for documentation generation." 10 | spec.authors = ["Samuel Williams"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ["release.cert"] 14 | spec.signing_key = File.expand_path("~/.gem/release.pem") 15 | 16 | spec.homepage = "https://github.com/socketry/decode" 17 | 18 | spec.metadata = { 19 | "documentation_uri" => "https://socketry.github.io/decode/", 20 | "funding_uri" => "https://github.com/sponsors/socketry/", 21 | "source_code_uri" => "https://github.com/socketry/decode.git", 22 | } 23 | 24 | spec.files = Dir.glob(["{bake,context,lib,sig}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) 25 | 26 | spec.required_ruby_version = ">= 3.2" 27 | 28 | spec.add_dependency "prism" 29 | spec.add_dependency "rbs" 30 | end 31 | -------------------------------------------------------------------------------- /test/decode/language/ruby/code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/index" 7 | require "decode/source" 8 | require "decode/language/ruby" 9 | require "decode/syntax/rewriter" 10 | 11 | describe Decode::Language::Ruby do 12 | let(:path) {File.expand_path(".fixtures/types.rb", __dir__)} 13 | let(:language) {subject.new} 14 | let(:source) {Decode::Source.new(path, language)} 15 | let(:index) {Decode::Index.new} 16 | let(:code) {source.code(index)} 17 | 18 | it "can extract some constants" do 19 | index.update([path]) 20 | 21 | matches = code.extract 22 | expect(matches).not.to be(:empty?) 23 | end 24 | 25 | it "can rewrite code" do 26 | index.update([path]) 27 | 28 | rewriter = Decode::Syntax::Rewriter.new(code.text) 29 | 30 | code.extract(rewriter) 31 | 32 | expect(rewriter.apply.join).to be(:include?, "[Tuple]([String], [Integer])") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions 2 | 3 | ## Use Agent Context 4 | 5 | When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. 6 | 7 | If the file does not exist, you will need to install it, by running the following command: 8 | 9 | ```bash 10 | $ bundle install 11 | $ bundle exec bake agent:context:install 12 | ``` 13 | 14 | This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. 15 | 16 | ## Ignoring Files 17 | 18 | The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. 19 | 20 | While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. 21 | -------------------------------------------------------------------------------- /.context/sus/index.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | description: A fast and scalable test runner. 3 | version: 0.33.1 4 | metadata: 5 | documentation_uri: https://socketry.github.io/sus/ 6 | funding_uri: https://github.com/sponsors/ioquatix/ 7 | source_code_uri: https://github.com/socketry/sus.git 8 | files: 9 | - path: usage.md 10 | title: Using Sus Testing Framework 11 | description: Sus is a modern Ruby testing framework that provides a clean, BDD-style 12 | syntax for writing tests. It's designed to be fast, simple, and expressive. 13 | - path: mocking.md 14 | title: Mocking 15 | description: 'There are two types of mocking in sus: `receive` and `mock`. The `receive` 16 | matcher is a subset of full mocking and is used to set expectations on method 17 | calls, while `mock` can be used to replace m...' 18 | - path: shared.md 19 | title: Shared Test Behaviors and Fixtures 20 | description: Sus provides shared test contexts which can be used to define common 21 | behaviours or tests that can be reused across one or more test files. 22 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/constant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific constant. 12 | class Constant < Definition 13 | # The short form of the constant. 14 | # e.g. `NAME`. 15 | def short_form 16 | @node.name.to_s 17 | end 18 | 19 | # Generate a nested name for the constant. 20 | def nested_name 21 | "::#{@name}" 22 | end 23 | 24 | # The long form of the constant. 25 | # e.g. `NAME = "Alice"`. 26 | def long_form 27 | if @node.location.start_line == @node.location.end_line 28 | @node.location.slice 29 | elsif @node.value&.type == :array_node 30 | "#{@node.name} = [...]" 31 | elsif @node.value&.type == :hash_node 32 | "#{@node.name} = {...}" 33 | else 34 | self.short_form 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/decode/language/ruby/.fixtures/indented_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | class MyClass 7 | # This is a simple method with some indentation 8 | # @returns [String] A greeting message 9 | def simple_method 10 | "Hello World" 11 | end 12 | 13 | # This is a more complex method with multiple lines 14 | # @parameter name [String] The name to greet 15 | # @returns [String] A personalized greeting 16 | def complex_method(name) 17 | greeting = "Hello" 18 | message = "#{greeting}, #{name}!" 19 | 20 | # Add some extra processing 21 | if name.length > 5 22 | message += " You have a long name!" 23 | end 24 | 25 | return message 26 | end 27 | 28 | # A method with a block 29 | # @yields [String] Each line of the message 30 | def method_with_block 31 | lines = [ 32 | "First line", 33 | "Second line", 34 | "Third line" 35 | ] 36 | 37 | lines.each do |line| 38 | yield line 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/decode/syntax/link.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "match" 7 | 8 | module Decode 9 | # Provides syntax rewriting and linking functionality. 10 | module Syntax 11 | # Represents a link to a definition in the documentation. 12 | class Link < Match 13 | # Initialize a new link. 14 | # @parameter range [Range] The range of text to link. 15 | # @parameter definition [Definition] The definition to link to. 16 | def initialize(range, definition) 17 | @definition = definition 18 | 19 | super(range) 20 | end 21 | 22 | attr :definition 23 | 24 | # Apply the link to the output. 25 | # @parameter output [String] The output to append to. 26 | # @parameter rewriter [Rewriter] The rewriter instance. 27 | def apply(output, rewriter) 28 | output << rewriter.link_to( 29 | @definition, 30 | rewriter.text_for(@range) 31 | ) 32 | 33 | return self.size 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/decode/comment/parameter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Parameter do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:documentation) {source.segments.first.documentation} 13 | 14 | with "simple parameters" do 15 | let(:path) {File.expand_path(".fixtures/parameters.rb", __dir__)} 16 | 17 | it "should have parameter nodes" do 18 | expect(documentation.children[0]).to be_a(Decode::Comment::Parameter) 19 | expect(documentation.children[0]).to have_attributes( 20 | type: be == "Integer", 21 | text: be == ["The x co-ordinate."], 22 | ) 23 | 24 | expect(documentation.children[1]).to be_a(Decode::Comment::Parameter) 25 | expect(documentation.children[1]).to have_attributes( 26 | type: be == "Integer", 27 | text: be == ["The y co-ordinate."], 28 | ) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/decode/comment/yields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Yields do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:documentation) {source.segments.first.documentation} 13 | 14 | with "nested parameters" do 15 | let(:path) {File.expand_path(".fixtures/yields.rb", __dir__)} 16 | 17 | it "should have yields node with nested parameter nodes" do 18 | expect(documentation.children[0]).to be_a(Decode::Comment::Yields) 19 | expect(documentation.children[0]).to have_attributes( 20 | block: be == "{|item| ...}", 21 | text: be == ["The items if a block is given."], 22 | ) 23 | 24 | parameter = documentation.children[0].children[1] 25 | expect(parameter).to be_a(Decode::Comment::Parameter) 26 | expect(parameter).to have_attributes( 27 | type: be == "Integer", 28 | ) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/method_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | # A class demonstrating different method signatures. 7 | class Calculator 8 | # Add two numbers. 9 | # @parameter a [Integer] The first number. 10 | # @parameter b [Integer] The second number. 11 | # @returns [Integer] The sum. 12 | def add(a, b) 13 | a + b 14 | end 15 | 16 | # Check if a number is positive. 17 | # @parameter num [Integer] The number to check. 18 | # @returns [bool] True if positive. 19 | def positive?(num) 20 | num > 0 21 | end 22 | 23 | # Initialize the calculator. 24 | def initialize 25 | @history = [] 26 | end 27 | 28 | # Clear the history. 29 | def clear 30 | @history.clear 31 | end 32 | 33 | # Process numbers with a block. 34 | # @parameter numbers [Array(Integer)] The numbers to process. 35 | # @yields [Integer] Each number. 36 | # @returns [Array(Integer)] The processed numbers. 37 | def process_numbers(numbers, &block) 38 | numbers.map(&block) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/attribute_inference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module TestModule 7 | # A test class demonstrating attribute type inference. 8 | class TestClass 9 | # Initialize a new test instance. 10 | def initialize(name, count) 11 | @name = name 12 | @count = count 13 | @data = {} 14 | @items = [] 15 | end 16 | 17 | # The name of this instance. 18 | # @attribute [String] The name identifier. 19 | attr :name 20 | 21 | # The count value. 22 | # @attribute [Integer] A numeric counter. 23 | attr :count 24 | 25 | # Complex data storage. 26 | # @attribute [Hash(String, Object)] Mapping from keys to arbitrary values. 27 | attr :data 28 | 29 | # Collection of items. 30 | # @attribute [Array(String)] List of string items. 31 | attr :items 32 | end 33 | 34 | # A test module with attributes. 35 | module AttributeModule 36 | # Configuration settings. 37 | # @attribute [Hash(Symbol, String)] Configuration mapping. 38 | attr :settings 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | name: ${{matrix.ruby}} on ${{matrix.os}} 11 | runs-on: ${{matrix.os}}-latest 12 | continue-on-error: ${{matrix.experimental}} 13 | 14 | strategy: 15 | matrix: 16 | os: 17 | - ubuntu 18 | - macos 19 | 20 | ruby: 21 | - "3.2" 22 | - "3.3" 23 | - "3.4" 24 | 25 | experimental: [false] 26 | 27 | include: 28 | - os: ubuntu 29 | ruby: truffleruby 30 | experimental: true 31 | - os: ubuntu 32 | ruby: jruby 33 | experimental: true 34 | - os: ubuntu 35 | ruby: head 36 | experimental: true 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{matrix.ruby}} 43 | bundler-cache: true 44 | 45 | - name: Run tests 46 | timeout-minutes: 10 47 | run: bundle exec bake test 48 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright, 2020-2025, by Samuel Williams. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /context/index.yaml: -------------------------------------------------------------------------------- 1 | # Automatically generated context index for Utopia::Project guides. 2 | # Do not edit then files in this directory directly, instead edit the guides and then run `bake utopia:project:agent:context:update`. 3 | --- 4 | description: Code analysis for documentation generation. 5 | metadata: 6 | documentation_uri: https://socketry.github.io/decode/ 7 | funding_uri: https://github.com/sponsors/socketry/ 8 | source_code_uri: https://github.com/socketry/decode.git 9 | files: 10 | - path: getting-started.md 11 | title: Getting Started 12 | description: This guide explains how to use `decode` for source code analysis. 13 | - path: documentation-coverage.md 14 | title: Documentation Coverage 15 | description: This guide explains how to test and monitor documentation coverage 16 | in your Ruby projects using the Decode gem's built-in bake tasks. 17 | - path: ruby-documentation.md 18 | title: Ruby Documentation 19 | description: This guide covers documentation practices and pragmas supported by 20 | the Decode gem for documenting Ruby code. These pragmas provide structured documentation 21 | that can be parsed and used to generate API documentation and achieve complete 22 | documentation coverage. 23 | -------------------------------------------------------------------------------- /test/decode/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/index" 7 | require "build/files/glob" 8 | 9 | describe Decode::Index do 10 | let(:index) {subject.new} 11 | let(:languages) {index.languages} 12 | let(:path) {File.expand_path("../../lib", __dir__)} 13 | let(:paths) {Dir.glob(File.join(path, "**/*.rb"))} 14 | 15 | it "can extract declarations" do 16 | index.update(paths) 17 | 18 | expect(index.definitions).to be(:include?, "Decode::Documentation") 19 | expect(index.definitions).to be(:include?, "Decode::Documentation#initialize") 20 | end 21 | 22 | with "#lookup" do 23 | it "can lookup relative references" do 24 | index.update(paths) 25 | 26 | initialize_reference = languages.reference_for("ruby", "Decode::Documentation#initialize") 27 | initialize_definition = index.lookup(initialize_reference) 28 | expect(initialize_definition).not.to be_nil 29 | 30 | source_reference = languages.reference_for("ruby", "Source") 31 | source_definition = index.lookup(source_reference, relative_to: initialize_definition) 32 | expect(source_definition.qualified_name).to be == "Decode::Source" 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/segment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "../../segment" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby specific code segment. 12 | class Segment < Decode::Segment 13 | # Initialize a new Ruby segment. 14 | # @parameter comments [Array(String)] The comments for this segment. 15 | # @parameter language [Generic] The language instance. 16 | # @parameter node [Prism::Node] The syntax tree node. 17 | # @parameter options [Hash] Additional options. 18 | def initialize(comments, language, node, **options) 19 | super(comments, language, **options) 20 | 21 | @node = node 22 | @expression = node.location 23 | end 24 | 25 | # The parser syntax tree node. 26 | attr :node 27 | 28 | # Expand the segment to include another node. 29 | # @parameter node [Prism::Node] The node to include. 30 | def expand(node) 31 | @expression = @expression.join(node.location) 32 | end 33 | 34 | # The source code trailing the comments. 35 | # @returns [String?] 36 | def code 37 | @expression.slice 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /guides/extract-symbols/extract.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Released under the MIT License. 5 | # Copyright, 2020-2024, by Samuel Williams. 6 | 7 | # This example demonstrates how to extract symbols using the index. An instance of {Decode::Index} is used for loading symbols from source code files. These symbols are available as a flat list and as a trie structure. You can look up specific symbols using a reference using {Decode::Index#lookup}. 8 | require_relative "../../lib/decode/index" 9 | 10 | # Firstly, construct the index: 11 | index = Decode::Index.new 12 | 13 | # Then, update the index by loading paths from the file system: 14 | paths = Dir.glob(File.expand_path("../../lib/**/*.rb", __dir__)) 15 | index.update(paths) 16 | 17 | # Finally, you can print out the loaded symbols: 18 | index.definitions.each do |name, symbol| 19 | puts symbol.long_form 20 | end 21 | 22 | # Lookup a specific symbol: 23 | absolute_reference = Decode::Language::Ruby.reference_for("Decode::Index#lookup") 24 | lookup_symbol = index.lookup(absolute_reference).first 25 | puts lookup_symbol.long_form 26 | 27 | # Lookup a method relative to that symbol: 28 | relative_reference = Decode::Language::Ruby.reference_for("trie") 29 | trie_attribute = index.lookup(relative_reference, relative_to: lookup_symbol).first 30 | puts trie_attribute.long_form 31 | -------------------------------------------------------------------------------- /lib/decode/syntax/match.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Decode 7 | module Syntax 8 | # Represents a match in the source text for syntax rewriting. 9 | class Match 10 | # Initialize a new match. 11 | # @parameter range [Range] The range of text this match covers. 12 | def initialize(range) 13 | @range = range 14 | end 15 | 16 | attr :range 17 | 18 | # Apply the match to extract text from source. 19 | # @parameter source [String] The source text. 20 | def apply(source) 21 | return source[range] 22 | end 23 | 24 | # Compare matches by their starting position. 25 | # @parameter other [Match] The other match to compare. 26 | def <=> other 27 | @range.min <=> other.range.min 28 | end 29 | 30 | # Get the starting offset of this match. 31 | def offset 32 | @range.min 33 | end 34 | 35 | # Get the size of this match. 36 | def size 37 | @range.size 38 | end 39 | 40 | # Apply the match to the output. 41 | # @parameter output [String] The output to append to. 42 | # @parameter rewriter [Rewriter] The rewriter instance. 43 | def apply(output, rewriter) 44 | output << rewriter.text_for(@range) 45 | 46 | return self.size 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/decode/comment/yields.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | module Comment 10 | # Describes a block parameter. 11 | # 12 | # - `@yields {|person| ... } If a block is given.` 13 | # 14 | # Should contain nested parameters. 15 | class Yields < Tag 16 | # @constant [Regexp] Pattern for matching yields declarations. 17 | PATTERN = /\A(?{.*?})(\s+(?
.*?))?\Z/ 18 | 19 | # Build a yields tag from a directive and match. 20 | # @parameter directive [String] The directive name. 21 | # @parameter match [MatchData] The regex match data. 22 | def self.build(directive, match) 23 | block = match[:block] or raise "Missing block in yields match!" 24 | 25 | node = self.new(directive, block) 26 | 27 | if details = match[:details] 28 | node.add(Text.new(details)) 29 | end 30 | 31 | return node 32 | end 33 | 34 | # Initialize a new yields tag. 35 | # @parameter directive [String] The directive name. 36 | # @parameter block [String] The block signature. 37 | def initialize(directive, block) 38 | super(directive) 39 | 40 | # @type ivar @block: String? 41 | @block = block 42 | end 43 | 44 | attr :block 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/decode/segment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "documentation" 7 | 8 | module Decode 9 | # A chunk of code with an optional preceeding comment block. 10 | # 11 | # ~~~ ruby 12 | # # Get the first segment from a source file: 13 | # segment = source.segments.first 14 | # ~~~ 15 | # 16 | class Segment 17 | # Initialize a new segment. 18 | # @parameter comments [Array(String)] The preceeding comments. 19 | # @parameter language [Language::Generic] The language of the code. 20 | def initialize(comments, language) 21 | @comments = comments 22 | @language = language 23 | @documentation = nil 24 | end 25 | 26 | # @attribute [Array(String)] The preceeding comments. 27 | attr :comments 28 | 29 | # @attribute [Language::Generic] The language of the code attached to this segment. 30 | attr :language 31 | 32 | # An interface for accsssing the documentation of the definition. 33 | # @returns [Documentation?] A {Documentation} instance if this definition has comments. 34 | def documentation 35 | if @comments&.any? 36 | @documentation ||= Documentation.new(@comments, @language) 37 | end 38 | end 39 | 40 | # The source code trailing the comments. 41 | # @returns [String?] 42 | def code 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | BUNDLE_WITH: maintenance 21 | 22 | jobs: 23 | generate: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ruby 32 | bundler-cache: true 33 | 34 | - name: Installing packages 35 | run: sudo apt-get install wget 36 | 37 | - name: Generate documentation 38 | timeout-minutes: 5 39 | run: bundle exec bake utopia:project:static --force no 40 | 41 | - name: Upload documentation artifact 42 | uses: actions/upload-pages-artifact@v3 43 | with: 44 | path: docs 45 | 46 | deploy: 47 | runs-on: ubuntu-latest 48 | 49 | environment: 50 | name: github-pages 51 | url: ${{steps.deployment.outputs.page_url}} 52 | 53 | needs: generate 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /test/decode/rbs/.fixtures/constant_inference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | module TestModule 7 | # A test class demonstrating constant type inference. 8 | class TestClass 9 | # @constant [String] The default configuration file path. 10 | CONFIG_FILE = "config/settings.yaml" 11 | 12 | # @constant [Integer] The maximum number of retries allowed. 13 | MAX_RETRIES = 3 14 | 15 | # @constant [Hash(Symbol, String)] Default configuration values. 16 | DEFAULT_CONFIG = { 17 | host: "localhost", 18 | port: "3000", 19 | env: "development" 20 | } 21 | 22 | # @constant [Array(String)] List of supported file formats. 23 | SUPPORTED_FORMATS = ["json", "yaml", "toml"] 24 | 25 | # @constant [String | Integer?] A union type constant. 26 | FLEXIBLE_VALUE = nil 27 | 28 | # Regular constant without type annotation - should be ignored 29 | REGULAR_CONSTANT = "no_type_annotation" 30 | end 31 | 32 | # A test module with constants. 33 | module ConfigModule 34 | # @constant [Integer] The default timeout in seconds. 35 | DEFAULT_TIMEOUT = 30 36 | 37 | # @constant [bool] Whether debug mode is enabled by default. 38 | DEBUG_MODE = false 39 | 40 | # @constant [Regexp] Pattern for validating identifiers. 41 | IDENTIFIER_PATTERN = /\A[a-z][a-z0-9_]*\z/i 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | COVERAGE: PartialSummary 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - ruby 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{matrix.ruby}} 30 | bundler-cache: true 31 | 32 | - name: Run tests 33 | timeout-minutes: 5 34 | run: bundle exec bake test 35 | 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | include-hidden-files: true 39 | if-no-files-found: error 40 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 41 | path: .covered.db 42 | 43 | validate: 44 | needs: test 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: ruby/setup-ruby@v1 50 | with: 51 | ruby-version: ruby 52 | bundler-cache: true 53 | 54 | - uses: actions/download-artifact@v4 55 | 56 | - name: Validate coverage 57 | timeout-minutes: 5 58 | run: bundle exec bake covered:validate --paths */.covered.db \; 59 | -------------------------------------------------------------------------------- /lib/decode/comment/attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | # Represents comment parsing and processing functionality. 10 | module Comment 11 | # Describes an attribute type. 12 | # 13 | # - `@attribute [Integer] The person's age.` 14 | # 15 | class Attribute < Tag 16 | # @constant [Regexp] Pattern for matching attribute declarations. 17 | PATTERN = /\A\[#{Tag.bracketed_content(:type)}\](\s+(?
.*?))?\Z/ 18 | 19 | # Build an attribute from a directive and match. 20 | # @parameter directive [String] The original directive text. 21 | # @parameter match [MatchData] The regex match data. 22 | def self.build(directive, match) 23 | type = match[:type] or raise "Missing type in attribute match!" 24 | 25 | node = self.new(directive, type) 26 | 27 | if details = match[:details] 28 | node.add(Text.new(details)) 29 | end 30 | 31 | return node 32 | end 33 | 34 | # Initialize a new attribute. 35 | # @parameter directive [String] The original directive text. 36 | # @parameter type [String] The type of the attribute. 37 | def initialize(directive, type) 38 | super(directive) 39 | 40 | @type = type 41 | end 42 | 43 | # @attribute [String] The type of the attribute. 44 | attr :type 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/decode/comment/constant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | require_relative "text" 8 | 9 | module Decode 10 | module Comment 11 | # Represents a constant type declaration. 12 | # 13 | # - `@constant [Regexp] Pattern for matching parameters.` 14 | # 15 | class Constant < Tag 16 | # @constant [Regexp] Pattern for matching constant declarations. 17 | PATTERN = /\A\[#{Tag.bracketed_content(:type)}\](\s+(?
.*?))?\Z/ 18 | 19 | # Build a constant from a directive and regex match. 20 | # @parameter directive [String] The original directive text. 21 | # @parameter match [MatchData] The regex match data containing type and details. 22 | # @returns [Constant] A new constant object. 23 | def self.build(directive, match) 24 | type = match[:type] or raise "Missing type in constant match!" 25 | 26 | node = self.new(directive, type) 27 | 28 | if details = match[:details] 29 | node.add(Text.new(details)) 30 | end 31 | 32 | return node 33 | end 34 | 35 | # Initialize a new constant. 36 | # @parameter directive [String] The directive that generated the tag. 37 | # @parameter type [String] The type of the constant. 38 | def initialize(directive, type) 39 | super(directive) 40 | @type = type 41 | end 42 | 43 | # @attribute [String] The type of the constant. 44 | attr :type 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/alias.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # Represents an alias statement, e.g., `alias new_name old_name` or `alias_method :new_name, :old_name` 12 | class Alias < Definition 13 | # Initialize a new alias definition. 14 | # @parameter new_name [String] The new name for the alias. 15 | # @parameter old_name [String] The original name being aliased. 16 | # @parameter options [Hash] Additional options for the definition. 17 | def initialize(new_name, old_name, **options) 18 | super(new_name, **options) 19 | @old_name = old_name 20 | end 21 | 22 | attr :old_name 23 | 24 | # Aliases don't require separate documentation as they reference existing methods. 25 | # @returns [bool] Always false for aliases. 26 | def coverage_relevant? 27 | false 28 | end 29 | 30 | # Generate a short form representation of the alias. 31 | def short_form 32 | "alias #{self.name} #{@old_name}" 33 | end 34 | 35 | # Generate a long form representation of the alias. 36 | def long_form 37 | "alias #{self.name} #{@old_name}" 38 | end 39 | 40 | # Generate a string representation of the alias. 41 | def to_s 42 | "#{self.class.name} #{self.name} -> #{@old_name}" 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/decode/languages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/languages" 7 | 8 | describe Decode::Languages do 9 | let(:languages) {subject.all} 10 | 11 | with ".reference" do 12 | with "with language specific reference" do 13 | let(:reference) {languages.parse_reference("ruby Foo::Bar")} 14 | 15 | it "can generate language specific references" do 16 | expect(reference).to be_a Decode::Language::Ruby::Reference 17 | 18 | expect(reference.identifier).to be == "Foo::Bar" 19 | expect(reference.language.name).to be == "ruby" 20 | end 21 | end 22 | 23 | with "with generic reference" do 24 | let(:reference) {languages.parse_reference("generic Foo::Bar")} 25 | 26 | it "can generate language specific references" do 27 | expect(reference).to be_a Decode::Language::Reference 28 | 29 | expect(reference.identifier).to be == "Foo::Bar" 30 | expect(reference.language.name).to be == "generic" 31 | end 32 | end 33 | 34 | with "with default language" do 35 | let(:reference) {languages.parse_reference("Foo::Bar", default_language: Decode::Language::Ruby.new)} 36 | 37 | it "can generate language specific references" do 38 | expect(reference).to be_a Decode::Language::Ruby::Reference 39 | 40 | expect(reference.identifier).to be == "Foo::Bar" 41 | expect(reference.language.name).to be == "ruby" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/decode/comment/pragma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | module Comment 10 | # Asserts a specific property about the method signature. 11 | # 12 | # - `@reentrant This method is thread-safe.` 13 | # - `@deprecated Please use {other_method} instead.` 14 | # - `@blocking This method may block.` 15 | # - `@asynchronous This method may yield.` 16 | # 17 | class Pragma < Tag 18 | # Parse a pragma directive from text. 19 | # @parameter directive [String] The directive name. 20 | # @parameter text [String] The directive text. 21 | # @parameter lines [Array(String)] The remaining lines. 22 | # @parameter tags [Array(Tag)] The collection of tags. 23 | # @parameter level [Integer] The indentation level. 24 | def self.parse(directive, text, lines, tags, level = 0) 25 | self.build(directive, text) 26 | end 27 | 28 | # Build a pragma from a directive and text. 29 | # @parameter directive [String] The directive name. 30 | # @parameter text [String] The directive text. 31 | def self.build(directive, text) 32 | node = self.new(directive) 33 | 34 | if text 35 | node.add(Text.new(text)) 36 | end 37 | 38 | return node 39 | end 40 | 41 | # Initialize a new pragma. 42 | # @parameter directive [String] The directive name. 43 | def initialize(directive) 44 | super(directive) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/decode/documentation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "comment/node" 7 | 8 | require_relative "comment/tags" 9 | require_relative "comment/attribute" 10 | require_relative "comment/constant" 11 | require_relative "comment/parameter" 12 | require_relative "comment/option" 13 | require_relative "comment/pragma" 14 | require_relative "comment/raises" 15 | require_relative "comment/returns" 16 | require_relative "comment/throws" 17 | require_relative "comment/yields" 18 | require_relative "comment/example" 19 | 20 | module Decode 21 | # Structured access to a set of comment lines. 22 | class Documentation < Comment::Node 23 | # Initialize the documentation with an array of comments, within a specific language. 24 | # 25 | # @parameter comments [Array(String)] An array of comment lines. 26 | # @parameter language [Language::Generic?] The language in which the comments were extracted. 27 | def initialize(comments, language) 28 | super(nil) 29 | 30 | @comments = comments 31 | @language = language 32 | 33 | if language 34 | language.tags.parse(@comments.dup) do |node| 35 | self.add(node) 36 | end 37 | end 38 | end 39 | 40 | # @attribute [Array(String)] The underlying comments from which the documentation is extracted. 41 | attr :comments 42 | 43 | # @attribute [Language::Generic?] The language in which the documentation was extracted from. 44 | attr :language 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/decode/language/ruby/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/source" 8 | 9 | describe Decode::Language::Ruby do 10 | let(:language) {Decode::Language::Ruby.new} 11 | 12 | with "source tracking" do 13 | let(:source) {Decode::Source.new("test/decode/language/ruby/.fixtures/classes.rb", language)} 14 | 15 | it "should attach source to all definitions" do 16 | definitions = language.definitions_for(source).to_a 17 | 18 | definitions.each do |definition| 19 | expect(definition.source).to be == source 20 | end 21 | end 22 | 23 | it "should provide correct location information" do 24 | definitions = language.definitions_for(source).to_a 25 | 26 | # Find a class definition 27 | class_def = definitions.find do |definition| 28 | definition.is_a?(Decode::Language::Ruby::Class) 29 | end 30 | expect(class_def).not.to be_nil 31 | expect(class_def.location).not.to be_nil 32 | expect(class_def.location.line).to be > 0 33 | end 34 | 35 | it "should handle nested definitions with correct source" do 36 | # Use the existing nested modules fixture 37 | source = Decode::Source.new("test/decode/language/ruby/.fixtures/nested_modules.rb", language) 38 | definitions = language.definitions_for(source).to_a 39 | 40 | # All definitions should have the same source 41 | definitions.each do |definition| 42 | expect(definition.source).to be == source 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /context/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use `decode` for source code analysis. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add decode 11 | ~~~ 12 | 13 | ## Indexing 14 | 15 | `decode` turns your source code into a kind of database with rich access to definitions, segments and associated comments. Use {ruby Decode::Index} to build an index of your project by loading in source files: 16 | 17 | ~~~ ruby 18 | require 'decode/index' 19 | 20 | index = Decode::Index.new 21 | 22 | # Load all Ruby files into the index: 23 | index.update(Dir['**/*.rb']) 24 | ~~~ 25 | 26 | Once you've done this, you can print out all the definitions from your project: 27 | 28 | ~~~ ruby 29 | index.definitions.each do |name, symbol| 30 | puts symbol.long_form 31 | end 32 | ~~~ 33 | 34 | ## References 35 | 36 | References are strings which can be resolved into definitions. The index allows you to efficiently resolve references. 37 | 38 | ~~~ ruby 39 | # Lookup a specific symbol: 40 | reference = index.languages.parse_reference("ruby Decode::Index#lookup") 41 | definition = index.lookup(reference).first 42 | puts definition.long_form 43 | ~~~ 44 | 45 | ## Documentation 46 | 47 | The {ruby Decode::Documentation} provides rich access to the comments that preceed a definition. This includes metadata including `@parameter`, `@returns` and other tags. 48 | 49 | ~~~ ruby 50 | lines = definition.documentation.text 51 | puts lines 52 | ~~~ 53 | 54 | See {ruby Decode::Comment::Node#traverse} for more details about how to consume this data. 55 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific block which might carry other definitions. 12 | class Block < Definition 13 | # A block can sometimes be a container for other definitions. 14 | def container? 15 | true 16 | end 17 | 18 | # Generate a nested name for the block. 19 | def nested_name 20 | ".#{name}" 21 | end 22 | 23 | # The short form of the block. 24 | # e.g. `foo`. 25 | def short_form 26 | @name.to_s 27 | end 28 | 29 | # The long form of the block. 30 | # e.g. `foo(:bar)`. 31 | def long_form 32 | if @node.location.line == @node.location.last_line 33 | @node.location.expression.source 34 | else 35 | @node.children[0].location.expression.source 36 | end 37 | end 38 | 39 | # The fully qualified name of the block. 40 | # e.g. `::Barnyard::foo`. 41 | def qualified_form 42 | self.qualified_name 43 | end 44 | 45 | # Convert the block to a different kind of definition. 46 | # @parameter kind [Symbol] The kind to convert to. 47 | def convert(kind) 48 | case kind 49 | when :attribute 50 | Attribute.new(@node, @name, 51 | comments: @comments, parent: @parent, language: @language 52 | ) 53 | else 54 | super 55 | end 56 | end 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /guides/getting-started/readme.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide explains how to use `decode` for source code analysis. 4 | 5 | ## Installation 6 | 7 | Add the gem to your project: 8 | 9 | ~~~ bash 10 | $ bundle add decode 11 | ~~~ 12 | 13 | ## Indexing 14 | 15 | `decode` turns your source code into a kind of database with rich access to definitions, segments and associated comments. Use {ruby Decode::Index} to build an index of your project by loading in source files: 16 | 17 | ~~~ ruby 18 | require 'decode/index' 19 | 20 | index = Decode::Index.new 21 | 22 | # Load all Ruby files into the index: 23 | index.update(Dir['**/*.rb']) 24 | ~~~ 25 | 26 | Once you've done this, you can print out all the definitions from your project: 27 | 28 | ~~~ ruby 29 | index.definitions.each do |name, symbol| 30 | puts symbol.long_form 31 | end 32 | ~~~ 33 | 34 | ## References 35 | 36 | References are strings which can be resolved into definitions. The index allows you to efficiently resolve references. 37 | 38 | ~~~ ruby 39 | # Lookup a specific symbol: 40 | reference = index.languages.parse_reference("ruby Decode::Index#lookup") 41 | definition = index.lookup(reference).first 42 | puts definition.long_form 43 | ~~~ 44 | 45 | ## Documentation 46 | 47 | The {ruby Decode::Documentation} provides rich access to the comments that preceed a definition. This includes metadata including `@parameter`, `@returns` and other tags. 48 | 49 | ~~~ ruby 50 | lines = definition.documentation.text 51 | puts lines 52 | ~~~ 53 | 54 | See {ruby Decode::Comment::Node#traverse} for more details about how to consume this data. 55 | -------------------------------------------------------------------------------- /test/decode/rbs/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/rbs/wrapper" 8 | require "decode/definition" 9 | require "decode/documentation" 10 | require "decode/comment/rbs" 11 | 12 | describe Decode::RBS::Wrapper do 13 | let(:language) {Decode::Language::Ruby.new} 14 | let(:comments) {[]} 15 | let(:definition) {Decode::Language::Ruby::Class.new([:TestClass], comments: comments, language: language)} 16 | let(:wrapper) {subject.new(definition)} 17 | 18 | with "#initialize" do 19 | it "initializes with definition" do 20 | expect(wrapper.instance_variable_get(:@definition)).to be == definition 21 | expect(wrapper.instance_variable_get(:@tags)).to be_nil 22 | end 23 | end 24 | 25 | with "#tags" do 26 | with "definition without documentation" do 27 | it "returns empty array when no documentation" do 28 | expect(wrapper.tags).to be == [] 29 | end 30 | end 31 | 32 | with "definition with documentation containing RBS tags" do 33 | let(:comments) {["@rbs generic T"]} 34 | 35 | it "extracts RBS tags from documentation" do 36 | expect(wrapper.tags).to have_value(be_a(Decode::Comment::RBS)) 37 | end 38 | end 39 | 40 | with "definition with documentation containing mixed tags" do 41 | let(:comments) {["Some text", "@rbs generic T"]} 42 | 43 | it "filters only RBS tags" do 44 | wrapper.tags.each do |tag| 45 | expect(tag).to be_a(Decode::Comment::RBS) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-md 3 | - rubocop-socketry 4 | 5 | AllCops: 6 | DisabledByDefault: true 7 | 8 | Layout/ConsistentBlankLineIndentation: 9 | Enabled: true 10 | 11 | Layout/IndentationStyle: 12 | Enabled: true 13 | EnforcedStyle: tabs 14 | 15 | Layout/InitialIndentation: 16 | Enabled: true 17 | 18 | Layout/IndentationWidth: 19 | Enabled: true 20 | Width: 1 21 | 22 | Layout/IndentationConsistency: 23 | Enabled: true 24 | EnforcedStyle: normal 25 | 26 | Layout/BlockAlignment: 27 | Enabled: true 28 | 29 | Layout/EndAlignment: 30 | Enabled: true 31 | EnforcedStyleAlignWith: start_of_line 32 | 33 | Layout/BeginEndAlignment: 34 | Enabled: true 35 | EnforcedStyleAlignWith: start_of_line 36 | 37 | Layout/RescueEnsureAlignment: 38 | Enabled: true 39 | 40 | Layout/ElseAlignment: 41 | Enabled: true 42 | 43 | Layout/DefEndAlignment: 44 | Enabled: true 45 | 46 | Layout/CaseIndentation: 47 | Enabled: true 48 | EnforcedStyle: end 49 | 50 | Layout/CommentIndentation: 51 | Enabled: true 52 | 53 | Layout/FirstHashElementIndentation: 54 | Enabled: true 55 | 56 | Layout/EmptyLinesAroundClassBody: 57 | Enabled: true 58 | 59 | Layout/EmptyLinesAroundModuleBody: 60 | Enabled: true 61 | 62 | Layout/EmptyLineAfterMagicComment: 63 | Enabled: true 64 | 65 | Layout/SpaceInsideBlockBraces: 66 | Enabled: true 67 | EnforcedStyle: no_space 68 | SpaceBeforeBlockParameters: false 69 | 70 | Layout/SpaceAroundBlockParameters: 71 | Enabled: true 72 | EnforcedStyleInsidePipes: no_space 73 | 74 | Style/FrozenStringLiteralComment: 75 | Enabled: true 76 | 77 | Style/StringLiterals: 78 | Enabled: true 79 | EnforcedStyle: double_quotes 80 | -------------------------------------------------------------------------------- /test/decode/comment/example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Example do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:segments) {source.segments.to_a} 13 | 14 | with "example with title" do 15 | let(:path) {File.expand_path(".fixtures/example.rb", __dir__)} 16 | let(:documentation) {segments[0].documentation} 17 | 18 | it "should parse example with title" do 19 | example = documentation.children.first 20 | expect(example).to be_a(Decode::Comment::Example) 21 | expect(example.directive).to be == "example" 22 | expect(example.title).to be == "Create a new thing" 23 | end 24 | 25 | it "should have example code as children" do 26 | example = documentation.children.first 27 | text = example.text 28 | expect(text).to be_a(Array) 29 | expect(text.size).to be > 0 30 | end 31 | end 32 | 33 | with "example without title" do 34 | let(:path) {File.expand_path(".fixtures/example.rb", __dir__)} 35 | let(:documentation) {segments[1].documentation} 36 | 37 | it "should parse example without title" do 38 | example = documentation.children.first 39 | expect(example).to be_a(Decode::Comment::Example) 40 | expect(example.directive).to be == "example" 41 | expect(example.title).to be == nil 42 | end 43 | 44 | it "should have code method that returns joined text" do 45 | example = documentation.children.first 46 | code = example.code 47 | expect(code).to be_a(String) 48 | expect(code.include?("Thing.new")).to be == true 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/decode/comment/parameter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | module Comment 10 | # Represents a named method parameter. 11 | # 12 | # - `@parameter age [Float] The users age.` 13 | # 14 | class Parameter < Tag 15 | # @constant [Regexp] Pattern for matching parameter declarations. 16 | PATTERN = /\A(?.*?)\s+\[#{Tag.bracketed_content(:type)}\](\s+(?
.*?))?\Z/ 17 | 18 | # Build a parameter from a directive and regex match. 19 | # @parameter directive [String] The original directive text. 20 | # @parameter match [MatchData] The regex match data containing name, type, and details. 21 | # @returns [Parameter] A new parameter object. 22 | def self.build(directive, match) 23 | name = match[:name] or raise ArgumentError, "Missing name in parameter match!" 24 | type = match[:type] or raise ArgumentError, "Missing type in parameter match!" 25 | 26 | node = self.new(directive, name, type) 27 | 28 | if details = match[:details] 29 | node.add(Text.new(details)) 30 | end 31 | 32 | return node 33 | end 34 | 35 | # Initialize a new parameter. 36 | # @parameter directive [String] The original directive text. 37 | # @parameter name [String] The name of the parameter. 38 | # @parameter type [String] The type of the parameter. 39 | def initialize(directive, name, type) 40 | super(directive) 41 | 42 | @name = name 43 | @type = type 44 | end 45 | 46 | # @attribute [String] The name of the parameter. 47 | attr :name 48 | 49 | # @attribute [String] The type of the parameter. 50 | attr :type 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific block which might carry other definitions. 12 | class Call < Definition 13 | # A block can sometimes be a container for other definitions. 14 | def container? 15 | case block = @node&.block 16 | when nil 17 | false 18 | when Prism::BlockArgumentNode 19 | false 20 | when Prism::BlockNode 21 | # Technically, all block nodes are containers, but we prefer to be opinionated about when we consider them containers: 22 | block.opening == "do" 23 | else 24 | false 25 | end 26 | end 27 | 28 | # The short form of the class. 29 | # e.g. `foo`. 30 | def short_form 31 | if @node&.block && @node.block.opening == "{" 32 | "#{name} { ... }" 33 | else 34 | name.to_s 35 | end 36 | end 37 | 38 | # The long form of the class. 39 | # e.g. `foo(:bar)`. 40 | def long_form 41 | if @node.location.start_line == @node.location.end_line 42 | @node.location.slice 43 | else 44 | # For multiline calls, use the actual call name with arguments 45 | if @node.arguments && @node.arguments.arguments.any? 46 | argument_text = @node.arguments.arguments.map{|argument| argument.location.slice}.join(", ") 47 | "#{@node.name}(#{argument_text})" 48 | else 49 | @node.name.to_s 50 | end 51 | end 52 | end 53 | 54 | # The fully qualified name of the block. 55 | # e.g. `class ::Barnyard::Dog`. 56 | def qualified_form 57 | self.qualified_name 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/decode/rbs/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "rbs" 7 | require "console" 8 | 9 | module Decode 10 | module RBS 11 | # Utilities for working with RBS types. 12 | module Type 13 | # Check if an RBS type represents a nullable/optional type 14 | # This method recursively traverses the type tree to find nil anywhere 15 | # @parameter rbs_type [untyped] The RBS type to check for nullability. 16 | # @returns [bool] True if the type can be nil, false otherwise. 17 | def self.nullable?(rbs_type) 18 | case rbs_type 19 | when ::RBS::Types::Optional 20 | # Type? form - directly optional 21 | true 22 | when ::RBS::Types::Union 23 | # Type | nil form - recursively check all union members 24 | rbs_type.types.any? {|type| nullable?(type)} 25 | when ::RBS::Types::Tuple 26 | # [Type] form - recursively check all tuple elements 27 | rbs_type.types.any? {|type| nullable?(type)} 28 | when ::RBS::Types::Bases::Nil 29 | # Direct nil type 30 | true 31 | else 32 | false 33 | end 34 | end 35 | 36 | # Parse a type string and convert it to RBS type 37 | # @parameter type_string [String] The type string to parse. 38 | # @returns [untyped] The parsed RBS type object. 39 | def self.parse(type_string) 40 | # This is for backwards compatibility with the old syntax, eventually we will emit warnings for these: 41 | type_string = type_string.tr("()", "[]") 42 | type_string.gsub!(/\s*\| Nil/, "?") 43 | type_string.gsub!("Boolean", "bool") 44 | 45 | return ::RBS::Parser.parse_type(type_string) 46 | rescue => error 47 | warn("Failed to parse type string: #{type_string}") if $DEBUG 48 | return ::RBS::Parser.parse_type("untyped") 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/decode/syntax/rewriter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Decode 7 | module Syntax 8 | # Provides text rewriting functionality with match-based substitutions. 9 | class Rewriter 10 | # Initialize a new rewriter. 11 | # @parameter text [String] The text to rewrite. 12 | def initialize(text) 13 | @text = text 14 | @matches = [] 15 | end 16 | 17 | # @attribute [String] The text to rewrite. 18 | attr :text 19 | 20 | # @attribute [Array[Match]] The matches to apply. 21 | attr :matches 22 | 23 | # Add a match to the rewriter. 24 | # @parameter match [Match] The match to add. 25 | def << match 26 | @matches << match 27 | return self 28 | end 29 | 30 | # Returns a chunk of raw text with no formatting. 31 | def text_for(range) 32 | @text[range] || "" 33 | end 34 | 35 | # Apply all matches to generate the rewritten output. 36 | # @parameter output [Array] The output array to append to. 37 | def apply(output = []) 38 | offset = 0 39 | 40 | @matches.sort.each do |match| 41 | if match.offset > offset 42 | output << text_for(offset...match.offset) 43 | 44 | offset = match.offset 45 | elsif match.offset < offset 46 | # Match intersects last output buffer. 47 | next 48 | end 49 | 50 | offset += match.apply(output, self) 51 | end 52 | 53 | if offset < @text.size 54 | output << text_for(offset...@text.size) 55 | end 56 | 57 | return output 58 | end 59 | 60 | # Generate a link to a definition. 61 | # @parameter definition [Definition] The definition to link to. 62 | # @parameter text [String] The text to display for the link. 63 | def link_to(definition, text) 64 | "[#{text}]" 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /releases.md: -------------------------------------------------------------------------------- 1 | # Releases 2 | 3 | ## v0.26.0 4 | 5 | - Add support for `@example` pragmas in Ruby documentation comments. 6 | 7 | ## v0.25.0 8 | 9 | - Singleton classes are not relevant for coverage, so they are now ignored by the coverage reporter. 10 | 11 | ## v0.24.4 12 | 13 | - Add support for `@constant [Type] Description.` tags. 14 | - Add support for instance variable type inference from `@attribute` tags. 15 | - Add support for method visibility in RBS output. 16 | 17 | ## v0.24.0 18 | 19 | ### Introduce support for RBS signature generation 20 | 21 | Decode now supports generating RBS type signatures from Ruby source code, making it easier to add type annotations to existing Ruby projects. The RBS generator analyzes your Ruby code and documentation to produce type signatures that can be used with tools like Steep, TypeProf, and other RBS-compatible type checkers. 22 | 23 | To generate RBS signatures for your Ruby code, use the provided bake task: 24 | 25 | ``` bash 26 | -- Generate RBS signatures for the current directory 27 | $ bundle exec bake decode:rbs:generate . 28 | 29 | -- Generate RBS signatures for a specific directory 30 | $ bundle exec bake decode:rbs:generate lib/ 31 | ``` 32 | 33 | The generator will output RBS declarations to stdout, which you can redirect to a file: 34 | 35 | ``` bash 36 | -- Save RBS signatures to a file 37 | $ bundle exec bake decode:rbs:generate lib/ > sig/generated.rbs 38 | ``` 39 | 40 | The RBS generator produces type signatures for: 41 | 42 | - **Classes and modules** with their inheritance relationships. 43 | - **Method signatures** with parameter and return types, or explicitly provide `@rbs` method signatures. 44 | - **Generic type parameters** from `@rbs generic` documentation tags. 45 | - **Documentation comments** as RBS comments. 46 | 47 | ## v0.23.5 48 | 49 | - Fix handling of `&block` arguments in call nodes. 50 | 51 | ## v0.23.4 52 | 53 | - Fix handling of definitions nested within `if`/`unless`/`elsif`/`else` blocks. 54 | -------------------------------------------------------------------------------- /lib/decode/comment/example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | module Comment 10 | # Represents a code example with an optional title. 11 | # 12 | # - `@example Title` 13 | # - `@example` 14 | # 15 | # Should contain nested text lines representing the example code. 16 | class Example < Tag 17 | # Parse an example directive from text. 18 | # @parameter directive [String] The directive name. 19 | # @parameter text [String?] The optional title text. 20 | # @parameter lines [Array(String)] The remaining lines. 21 | # @parameter tags [Tags] The tags parser. 22 | # @parameter level [Integer] The indentation level. 23 | def self.parse(directive, text, lines, tags, level = 0) 24 | node = self.new(directive, text) 25 | 26 | tags.parse(lines, level + 1) do |child| 27 | node.add(child) 28 | end 29 | 30 | return node 31 | end 32 | 33 | # Initialize a new example tag. 34 | # @parameter directive [String] The directive name. 35 | # @parameter title [String?] The optional title for the example. 36 | def initialize(directive, title = nil) 37 | super(directive) 38 | 39 | # @type ivar @title: String? 40 | @title = title&.strip unless title&.empty? 41 | end 42 | 43 | # @attribute [String?] The title of the example. 44 | attr :title 45 | 46 | # Get the example code as a single string with leading indentation removed. 47 | # @returns [String?] The example code joined with newlines, or nil if no code. 48 | def code 49 | lines = text 50 | return unless lines 51 | 52 | # Get the indentation from the first line 53 | if indentation = lines.first[/\A\s+/] 54 | # Remove the base indentation from all lines 55 | lines = lines.map{|line| line.sub(indentation, "")} 56 | end 57 | 58 | return lines.join("\n") 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /agent.md: -------------------------------------------------------------------------------- 1 | # Agent 2 | 3 | ## Context 4 | 5 | This section provides links to documentation from installed packages. It is automatically generated and may be updated by running `bake agent:context:install`. 6 | 7 | **Important:** Before performing any code, documentation, or analysis tasks, always read and apply the full content of any relevant documentation referenced in the following sections. These context files contain authoritative standards and best practices for documentation, code style, and project-specific workflows. **Do not proceed with any actions until you have read and incorporated the guidance from relevant context files.** 8 | 9 | ### agent-context 10 | 11 | Install and manage context files from Ruby gems. 12 | 13 | #### [Usage Guide](.context/agent-context/usage.md) 14 | 15 | `agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` ... 16 | 17 | ### sus 18 | 19 | A fast and scalable test runner. 20 | 21 | #### [Using Sus Testing Framework](.context/sus/usage.md) 22 | 23 | Sus is a modern Ruby testing framework that provides a clean, BDD-style syntax for writing tests. It's designed to be fast, simple, and expressive. 24 | 25 | #### [Mocking](.context/sus/mocking.md) 26 | 27 | There are two types of mocking in sus: `receive` and `mock`. The `receive` matcher is a subset of full mocking and is used to set expectations on method calls, while `mock` can be used to replace m... 28 | 29 | #### [Shared Test Behaviors and Fixtures](.context/sus/shared.md) 30 | 31 | Sus provides shared test contexts which can be used to define common behaviours or tests that can be reused across one or more test files. 32 | 33 | ### types 34 | 35 | A simple human-readable and Ruby-parsable type library. 36 | 37 | #### [Usage](.context/types/usage.md) 38 | 39 | The Types gem provides abstract types for the Ruby programming language that can be used for documentation and evaluation purposes. It offers a simple and Ruby-compatible approach to type signature... 40 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/reference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "../reference" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # An Ruby-specific reference which can be resolved to zero or more definitions. 12 | class Reference < Language::Reference 13 | # Create a reference from a constant node. 14 | # @parameter node [Prism::Node] The constant node. 15 | # @parameter language [Language::Generic] The language instance. 16 | # @returns [Reference] A new reference instance. 17 | def self.from_const(node, language) 18 | lexical_path = append_const(node) 19 | 20 | return self.new(node.location.slice, language, lexical_path) 21 | end 22 | 23 | # Append a constant node to the path. 24 | # @parameter node [Prism::Node] The constant node. 25 | # @parameter path [Array] The path to append to. 26 | # @returns [Array] The path with constant information appended. 27 | def self.append_const(node, path = []) 28 | case node.type 29 | when :constant_read_node 30 | path << [nil, node.name.to_s] 31 | when :constant_path_node 32 | if node.parent 33 | append_const(node.parent, path) 34 | path << ["::", node.name.to_s] 35 | else 36 | path << [nil, node.name.to_s] 37 | end 38 | when :call_node 39 | # For call nodes like Tuple(...), treat them as constant references 40 | if node.receiver.nil? 41 | path << [nil, node.name.to_s] 42 | else 43 | append_const(node.receiver, path) 44 | path << [".", node.name.to_s] 45 | end 46 | else 47 | raise ArgumentError, "Could not determine reference for #{node.type}!" 48 | end 49 | 50 | return path 51 | end 52 | 53 | # Split a Ruby identifier into prefix and name components. 54 | # @parameter text [String] The text to split. 55 | # @returns [Array] Array of prefix and name pairs. 56 | def split(text) 57 | text.scan(/(::|\.|#|:)?([^:.#]+)/) 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/decode/source.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "language" 7 | 8 | module Decode 9 | # Represents a source file in a specific language. 10 | class Source 11 | # Initialize a new source file. 12 | # @parameter path [String] The file-system path to the source file. 13 | # @parameter language [Language::Generic] The language parser to use. 14 | def initialize(path, language) 15 | @path = path 16 | @language = language 17 | 18 | @buffer = nil 19 | end 20 | 21 | # The path of the source file. 22 | # @attribute [StringPath] A file-system path to the source file. 23 | attr :path 24 | 25 | # The language of the source file. 26 | # @attribute [Language::Generic] The language parser for this source. 27 | attr :language 28 | 29 | # Read the source file into an internal buffer/cache. 30 | # @returns [String] The contents of the source file. 31 | def read 32 | @buffer ||= File.read(@path).freeze 33 | end 34 | 35 | # Open the source file and read all definitions. 36 | # @yields {|definition| ...} All definitions from the source file. 37 | # @parameter definition [Definition] 38 | # @returns [Enumerator(Definition)] If no block given. 39 | def definitions(&block) 40 | return to_enum(:definitions) unless block_given? 41 | 42 | @language.definitions_for(self, &block) 43 | end 44 | 45 | # Open the source file and read all segments. 46 | # @yields {|segment| ...} All segments from the source file. 47 | # @parameter segment [Segment] 48 | # @returns [Enumerator(Segment)] If no block given. 49 | def segments(&block) 50 | return to_enum(:segments) unless block_given? 51 | 52 | @language.segments_for(self, &block) 53 | end 54 | 55 | # Generate code representation with optional index for link resolution. 56 | # @parameter index [Index?] Optional index for resolving links. 57 | # @parameter relative_to [Definition?] Optional definition to resolve relative references. 58 | # @returns [String] The formatted code representation. 59 | def code(index = nil, relative_to: nil) 60 | @language.code_for(self.read, index, relative_to: relative_to) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2021-2025, by Samuel Williams. 5 | 6 | require_relative "reference" 7 | require_relative "parser" 8 | require_relative "code" 9 | 10 | require_relative "../generic" 11 | require_relative "../../comment/rbs" 12 | 13 | module Decode 14 | module Language 15 | module Ruby 16 | # Represents the Ruby language implementation for parsing and analysis. 17 | class Generic < Language::Generic 18 | EXTENSIONS = [".rb", ".ru"] 19 | 20 | TAGS = Comment::Tags.build do |tags| 21 | tags["attribute"] = Comment::Attribute 22 | tags["constant"] = Comment::Constant 23 | tags["parameter"] = Comment::Parameter 24 | tags["option"] = Comment::Option 25 | tags["yields"] = Comment::Yields 26 | tags["returns"] = Comment::Returns 27 | tags["raises"] = Comment::Raises 28 | tags["throws"] = Comment::Throws 29 | 30 | tags["deprecated"] = Comment::Pragma 31 | 32 | tags["asynchronous"] = Comment::Pragma 33 | 34 | tags["public"] = Comment::Pragma 35 | tags["private"] = Comment::Pragma 36 | 37 | tags["example"] = Comment::Example 38 | 39 | tags["rbs"] = Comment::RBS 40 | end 41 | 42 | # Get the parser for Ruby source code. 43 | # @returns [Language::Ruby::Parser] The Ruby parser instance. 44 | def parser 45 | @parser ||= Parser.new(self) 46 | end 47 | 48 | # Generate a language-specific reference for Ruby. 49 | # @parameter identifier [String] A valid Ruby identifier. 50 | # @returns [Reference] A Ruby-specific reference object. 51 | def reference_for(identifier) 52 | Reference.new(identifier, self) 53 | end 54 | 55 | # Generate a code representation with syntax highlighting and link resolution. 56 | # @parameter text [String] The source code text to format. 57 | # @parameter index [Index] The index for resolving references. 58 | # @parameter relative_to [Definition] The definition to resolve relative references from. 59 | # @returns [Code] A formatted code object with syntax highlighting. 60 | def code_for(text, index, relative_to: nil) 61 | Code.new(text, index, relative_to: relative_to, language: self) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/decode/rbs/wrapper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "rbs" 7 | 8 | module Decode 9 | module RBS 10 | # Base wrapper class for RBS generation from definitions. 11 | class Wrapper 12 | # Initialize the wrapper instance variables. 13 | # @parameter definition [Definition] The definition to wrap. 14 | def initialize(definition) 15 | @definition = definition 16 | @tags = nil 17 | @comment = nil 18 | end 19 | 20 | # Extract RBS tags from the definition's documentation. 21 | # @returns [Array] The RBS tags found in the documentation. 22 | def tags 23 | @tags ||= extract_tags 24 | end 25 | 26 | # Extract comment from the definition's documentation. 27 | # @returns [RBS::AST::Comment?] The RBS comment object. 28 | def comment 29 | @comment ||= extract_comment 30 | end 31 | 32 | private 33 | 34 | # Extract RBS tags from the definition's documentation. 35 | # @returns [Array] The RBS tags found in the documentation. 36 | def extract_tags 37 | @definition.documentation&.children&.select do |child| 38 | child.is_a?(Comment::RBS) 39 | end || [] 40 | end 41 | 42 | # Extract comment from definition documentation. 43 | # @parameter definition [Definition] The definition to extract comment from (defaults to @definition). 44 | # @returns [RBS::AST::Comment?] The extracted comment or nil if no documentation. 45 | def extract_comment(definition = @definition) 46 | documentation = definition.documentation 47 | return nil unless documentation 48 | 49 | # Extract the main description text (non-tag content) 50 | comment_lines = [] #: Array[String] 51 | 52 | documentation.children&.each do |child| 53 | if child.is_a?(Decode::Comment::Text) 54 | comment_lines << child.line.strip 55 | elsif !child.is_a?(Decode::Comment::Tag) 56 | # Handle other text-like nodes 57 | comment_lines << child.to_s.strip if child.respond_to?(:to_s) 58 | end 59 | end 60 | 61 | # Join lines with newlines to preserve markdown formatting 62 | unless comment_lines.empty? 63 | comment_text = comment_lines.join("\n").strip 64 | return ::RBS::AST::Comment.new(string: comment_text, location: nil) unless comment_text.empty? 65 | end 66 | 67 | nil 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/decode/comment/tag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "node" 7 | 8 | module Decode 9 | module Comment 10 | # Represents a documentation tag parsed from a comment directive. 11 | # Subclasses should define a PATTERN constant for matching their specific syntax. 12 | class Tag < Node 13 | # @constant [Regexp] Abstract pattern constant - subclasses must override this. 14 | PATTERN = /(?\A\z)/ 15 | 16 | # Abstract method: Build a tag from directive and match data. 17 | # Subclasses must implement this method. 18 | # @parameter directive [String] The directive that generated the tag. 19 | # @parameter match [MatchData] The regex match data. 20 | # @returns [Tag] A new tag instance. 21 | def self.build(directive, match) 22 | raise NotImplementedError, "Subclasses must implement build method" 23 | end 24 | 25 | # Build a pattern for bracketed content, supporting nested brackets. 26 | # @parameter name [Symbol] The name of the group. 27 | # @returns [String] The pattern. 28 | def self.bracketed_content(name) 29 | "(?<#{name}>(?:[^\\[\\]]+|\\[\\g<#{name}>\\])*)" 30 | end 31 | 32 | # Match text against the tag pattern. 33 | # @parameter text [String] The text to match. 34 | def self.match(text) 35 | self::PATTERN.match(text) 36 | end 37 | 38 | # Parse a tag from a directive and text. 39 | # @parameter directive [String] The directive name. 40 | # @parameter text [String] The directive text. 41 | # @parameter lines [Array(String)] The remaining lines. 42 | # @parameter tags [Tags] The tags parser. 43 | # @parameter level [Integer] The indentation level. 44 | def self.parse(directive, text, lines, tags, level = 0) 45 | if match = self.match(text) 46 | node = self.build(directive, match) 47 | 48 | tags.parse(lines, level + 1) do |child| 49 | node.add(child) 50 | end 51 | 52 | return node 53 | else 54 | # Consume all nested nodes: 55 | tags.ignore(lines, level + 1) 56 | end 57 | end 58 | 59 | # Initialize a new tag. 60 | # @parameter directive [String] The directive that generated the tag. 61 | def initialize(directive) 62 | @directive = directive 63 | end 64 | 65 | # @attribute [String] The directive that generated the tag. 66 | attr :directive 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific class. 12 | class Class < Definition 13 | # Initialize a new class definition. 14 | # @parameter arguments [Array] The definition arguments. 15 | # @parameter super_class [String] The super class name. 16 | # @parameter options [Hash] Additional options. 17 | def initialize(*arguments, super_class: nil, **options) 18 | super(*arguments, **options) 19 | 20 | @super_class = super_class 21 | end 22 | 23 | attr :super_class 24 | 25 | # A class is a container for other definitions. 26 | def container? 27 | true 28 | end 29 | 30 | # The short form of the class. 31 | # e.g. `class Animal`. 32 | def short_form 33 | "class #{self.name}" 34 | end 35 | 36 | # The long form of the class. 37 | # e.g. `class Dog < Animal`. 38 | def long_form 39 | if super_class = self.super_class 40 | "#{qualified_form} < #{super_class}" 41 | else 42 | qualified_form 43 | end 44 | end 45 | 46 | # The fully qualified name of the class. 47 | # e.g. `class ::Barnyard::Dog`. 48 | def qualified_form 49 | "class #{self.qualified_name}" 50 | end 51 | end 52 | 53 | # A Ruby-specific singleton class. 54 | class Singleton < Definition 55 | # Generate a nested name for the singleton class. 56 | def nested_name 57 | "class" 58 | end 59 | 60 | # A singleton class is a container for other definitions. 61 | # @returns [bool] 62 | def container? 63 | true 64 | end 65 | 66 | # Typically, a singleton class does not contain other definitions. 67 | # @returns [bool] 68 | def nested? 69 | false 70 | end 71 | 72 | # The short form of the class. 73 | # e.g. `class << self`. 74 | def short_form 75 | "class << #{self.name}" 76 | end 77 | 78 | # The long form is the same as the short form. 79 | alias long_form short_form 80 | 81 | # Coverage is not relevant for singleton classes. 82 | def coverage_relevant? 83 | false 84 | end 85 | 86 | private 87 | 88 | def absolute_path 89 | if @parent 90 | @parent.path 91 | else 92 | @name.to_s.split("::").map(&:to_sym) 93 | end 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/decode/language/ruby/aliases.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/source" 8 | 9 | describe Decode::Language::Ruby do 10 | let(:language) {Decode::Language::Ruby.new} 11 | 12 | with "alias definitions" do 13 | let(:source) {Decode::Source.new("test/decode/language/ruby/.fixtures/aliases.rb", language)} 14 | 15 | it "should extract alias definitions" do 16 | definitions = language.definitions_for(source).to_a 17 | 18 | aliases = definitions.select do |definition| 19 | definition.is_a?(Decode::Language::Ruby::Alias) 20 | end 21 | expect(aliases.size).to be == 4 22 | 23 | # Check regular alias 24 | new_method_alias = aliases.find do |alias_definition| 25 | alias_definition.name == :new_method 26 | end 27 | expect(new_method_alias).to be_a(Decode::Language::Ruby::Alias) 28 | expect(new_method_alias.old_name).to be == :original_method 29 | expect(new_method_alias.visibility).to be == :public 30 | 31 | # Check alias_method 32 | another_method_alias = aliases.find do |alias_definition| 33 | alias_definition.name == :another_method 34 | end 35 | expect(another_method_alias).to be_a(Decode::Language::Ruby::Alias) 36 | expect(another_method_alias.old_name).to be == :original_method 37 | expect(another_method_alias.visibility).to be == :public 38 | 39 | # Check private aliases 40 | private_alias = aliases.find do |alias_definition| 41 | alias_definition.name == :private_alias 42 | end 43 | expect(private_alias).to be_a(Decode::Language::Ruby::Alias) 44 | expect(private_alias.old_name).to be == :private_original 45 | expect(private_alias.visibility).to be == :private 46 | 47 | private_alias_method = aliases.find do |alias_definition| 48 | alias_definition.name == :private_alias_method 49 | end 50 | expect(private_alias_method).to be_a(Decode::Language::Ruby::Alias) 51 | expect(private_alias_method.old_name).to be == :private_original 52 | expect(private_alias_method.visibility).to be == :private 53 | end 54 | 55 | it "should have correct short and long forms" do 56 | definitions = language.definitions_for(source).to_a 57 | alias_def = definitions.find do |definition| 58 | definition.is_a?(Decode::Language::Ruby::Alias) && definition.name == :new_method 59 | end 60 | 61 | expect(alias_def.short_form).to be == "alias new_method original_method" 62 | expect(alias_def.long_form).to be == "alias new_method original_method" 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # A Ruby-specific method. 12 | class Method < Definition 13 | # Initialize a new method definition. 14 | # @parameter arguments [Array] The definition arguments. 15 | # @parameter receiver [String] The method receiver (for class methods). 16 | # @parameter options [Hash] Additional options. 17 | def initialize(*arguments, receiver: nil, **options) 18 | super(*arguments, **options) 19 | @receiver = receiver 20 | end 21 | 22 | attr :receiver 23 | 24 | # Generate a nested name for the method. 25 | def nested_name 26 | if @receiver 27 | ".#{self.name}" 28 | else 29 | "##{self.name}" 30 | end 31 | end 32 | 33 | # The short form of the method. 34 | # e.g. `def puts` or `def self.puts`. 35 | def short_form 36 | if @receiver 37 | "def #{@receiver}.#{@node.name}" 38 | else 39 | "def #{@node.name}" 40 | end 41 | end 42 | 43 | # The node which contains the function arguments. 44 | def arguments_node 45 | @node.parameters 46 | end 47 | 48 | # The long form of the method. 49 | # e.g. `def puts(*lines, separator: "\n")` or `def self.puts(*lines, separator: "\n")`. 50 | def long_form 51 | if arguments_node = self.arguments_node 52 | if @receiver 53 | "def #{@receiver}.#{@node.name}(#{arguments_node.location.slice})" 54 | else 55 | "def #{@node.name}(#{arguments_node.location.slice})" 56 | end 57 | else 58 | self.short_form 59 | end 60 | end 61 | 62 | # The fully qualified name of the block. 63 | # e.g. `::Barnyard#foo`. 64 | def qualified_form 65 | self.qualified_name 66 | end 67 | 68 | # Override the qualified_name method to handle method name joining correctly 69 | def qualified_name 70 | @qualified_name ||= begin 71 | if @parent 72 | [@parent.qualified_name, self.nested_name].join("") 73 | else 74 | self.nested_name 75 | end 76 | end 77 | end 78 | 79 | # Convert the method to a different kind of definition. 80 | # @parameter kind [Symbol] The kind to convert to. 81 | def convert(kind) 82 | case kind 83 | when :attribute 84 | Attribute.new(@node, @name, 85 | comments: @comments, parent: @parent, language: @language 86 | ) 87 | else 88 | super 89 | end 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/decode/comment/rbs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require_relative "tag" 7 | 8 | module Decode 9 | module Comment 10 | # Represents an RBS type annotation following rbs-inline syntax. 11 | # 12 | # Examples: 13 | # - `@rbs generic T` - Declares a generic type parameter for a class 14 | # - `@rbs [T] () { () -> T } -> Task[T]` - Complete method type signature 15 | # 16 | class RBS < Tag 17 | # Parse an RBS pragma from text. 18 | # @parameter directive [String] The directive name (should be "rbs"). 19 | # @parameter text [String] The RBS type annotation text. 20 | # @parameter lines [Array(String)] The remaining lines (not used for RBS). 21 | # @parameter tags [Array(Tag)] The collection of tags. 22 | # @parameter level [Integer] The indentation level. 23 | def self.parse(directive, text, lines, tags, level = 0) 24 | self.build(directive, text) 25 | end 26 | 27 | # Build an RBS pragma from a directive and text. 28 | # @parameter directive [String] The directive name. 29 | # @parameter text [String] The RBS type annotation text. 30 | def self.build(directive, text) 31 | node = self.new(directive, text) 32 | return node 33 | end 34 | 35 | # Initialize a new RBS pragma. 36 | # @parameter directive [String] The directive name. 37 | # @parameter text [String?] The RBS type annotation text. 38 | def initialize(directive, text) 39 | super(directive) 40 | @text = text&.strip || "" 41 | end 42 | 43 | # The RBS type annotation text. 44 | # @attribute [String] The raw RBS text. 45 | attr :text 46 | 47 | # Check if this is a generic type declaration. 48 | # @returns [bool] True if this is a generic declaration. 49 | def generic? 50 | @text.start_with?("generic ") 51 | end 52 | 53 | # Extract the generic type parameter name. 54 | # @returns [String?] The generic type parameter name, or nil if not a generic. 55 | def generic_parameter 56 | if generic? 57 | # Extract the parameter name from "generic T" or "generic T, U" 58 | match = @text.match(/^generic\s+([A-Z][A-Za-z0-9_]*(?:\s*,\s*[A-Z][A-Za-z0-9_]*)*)/) 59 | return match[1] if match 60 | end 61 | end 62 | 63 | # Check if this is a method type signature. 64 | # @returns [bool] True if this is a method signature. 65 | def method_signature? 66 | @text && !generic? 67 | end 68 | 69 | # Get the method type signature text. 70 | # @returns [String?] The method signature text, or nil if not a method signature. 71 | def method_signature 72 | method_signature? ? @text : nil 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /bake/decode/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | def initialize(...) 7 | super 8 | 9 | require "decode/index" 10 | require "set" 11 | end 12 | 13 | # Process the given source root and report on comment coverage. 14 | # @parameter root [String] The root path to index. 15 | def coverage(root) 16 | index = Decode::Index.for(root) 17 | 18 | documented = Set.new 19 | missing = {} 20 | 21 | index.trie.traverse do |path, node, descend| 22 | public_definition = node.values.nil? 23 | 24 | node.values&.each do |definition| 25 | if definition.coverage_relevant? 26 | level = path.size 27 | 28 | if definition.documented? 29 | documented << definition.qualified_name 30 | else 31 | missing[definition.qualified_name] ||= definition 32 | end 33 | 34 | public_definition = true 35 | end 36 | end 37 | 38 | # Don't descend into non-public definitions: 39 | if public_definition 40 | descend.call 41 | end 42 | end 43 | 44 | # Since there can be multiple definitions for a given symbol, we can ignore any missing definitions that have been documented elsewhere: 45 | documented.each do |qualified_name| 46 | missing.delete(qualified_name) 47 | end 48 | 49 | documented_count = documented.size 50 | public_count = documented_count + missing.size 51 | $stderr.puts "#{documented_count} definitions have documentation, out of #{public_count} public definitions." 52 | 53 | if documented_count < public_count 54 | $stderr.puts nil, "Missing documentation for:" 55 | missing.each do |qualified_name, definition| 56 | location = definition.location 57 | if location 58 | $stderr.puts "- #{qualified_name} (#{location.path}:#{location.line})" 59 | else 60 | $stderr.puts "- #{qualified_name}" 61 | end 62 | end 63 | 64 | raise RuntimeError, "Insufficient documentation!" 65 | end 66 | end 67 | 68 | # Process the given source root and report on symbols. 69 | # @parameter root [String] The root path to index. 70 | def symbols(root) 71 | index = Decode::Index.for(root) 72 | 73 | index.trie.traverse do |path, node, descend| 74 | level = path.size 75 | puts "#{" " * level}#{path.inspect} -> #{node.values.inspect}" 76 | 77 | if path.any? 78 | puts "#{" " * level}#{path.join("::")}" 79 | end 80 | 81 | descend.call 82 | end 83 | end 84 | 85 | # Print documentation for all definitions. 86 | # @parameter root [String] The root path to index. 87 | def documentation(root) 88 | index = Decode::Index.for(root) 89 | 90 | index.definitions.each do |name, definition| 91 | comments = definition.comments 92 | 93 | if comments 94 | puts "## `#{name}`" 95 | puts 96 | puts comments 97 | puts 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "../../definition" 7 | 8 | module Decode 9 | module Language 10 | module Ruby 11 | # Represents a Ruby-specific definition extracted from source code. 12 | class Definition < Decode::Definition 13 | # Initialize the definition from the syntax tree node. 14 | # @parameter arguments [Array] Arguments passed to the parent class. 15 | # @parameter visibility [Symbol] The visibility of the definition (:public, :private, :protected). 16 | # @parameter node [Parser::AST::Node] The syntax tree node representing this definition. 17 | # @parameter options [Hash] Additional options passed to the parent class. 18 | def initialize(*arguments, visibility: nil, node: nil, **options) 19 | super(*arguments, **options) 20 | 21 | @visibility = visibility 22 | @node = node 23 | end 24 | 25 | # The parser syntax tree node. 26 | # @attribute [Parser::AST::Node] The AST node representing this definition. 27 | attr :node 28 | 29 | # The visibility of the definition. 30 | # @attribute [Symbol] The visibility level (:public, :private, or :protected). 31 | attr_accessor :visibility 32 | 33 | # Check if this definition is public. 34 | # @returns [bool] True if the definition is public. 35 | def public? 36 | @visibility == :public 37 | end 38 | 39 | # Check if this definition is protected. 40 | # @returns [bool] True if the definition is protected. 41 | def protected? 42 | @visibility == :protected 43 | end 44 | 45 | # Check if this definition is private. 46 | # @returns [bool] True if the definition is private. 47 | def private? 48 | @visibility == :private 49 | end 50 | 51 | # Check if this definition spans multiple lines. 52 | # @returns [bool] True if the definition spans multiple lines. 53 | def multiline? 54 | @node.location.start_line != @node.location.end_line 55 | end 56 | 57 | # The source code associated with the definition. 58 | # @returns [String] 59 | def text 60 | location = @node.location 61 | source_text = location.slice_lines 62 | lines = source_text.split("\n") 63 | 64 | if lines.count == 1 65 | return lines.first 66 | else 67 | # Get the indentation from the first line of the node in the original source 68 | if indentation = source_text[/\A\s+/] 69 | # Remove the base indentation from all lines 70 | lines.each{|line| line.sub!(indentation, "")} 71 | end 72 | 73 | return lines.join("\n") 74 | end 75 | end 76 | 77 | # Get the location of this definition. 78 | # @returns [Location?] The location object if source is available. 79 | def location 80 | if @source and location = @node&.location 81 | Location.new(@source.path, location.start_line) 82 | end 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/decode/comment/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2024, by Samuel Williams. 5 | 6 | require "decode/source" 7 | require "decode/language/ruby" 8 | 9 | describe Decode::Comment::Text do 10 | let(:language) {Decode::Language::Ruby.new} 11 | let(:source) {Decode::Source.new(path, language)} 12 | let(:documentation) {source.segments.first.documentation} 13 | 14 | with "nested text nodes" do 15 | let(:path) {File.expand_path(".fixtures/text.rb", __dir__)} 16 | 17 | it "should have text nodes" do 18 | expect(documentation.children[0]).to be_a(Decode::Comment::Text) 19 | expect(documentation.children[0]).to have_attributes( 20 | line: be == "Iterates over all the items." 21 | ) 22 | 23 | yields = documentation.children[1] 24 | expect(yields).to be_a(Decode::Comment::Yields) 25 | expect(yields.children[0]).to be_a(Decode::Comment::Text) 26 | expect(yields.children[0]).to have_attributes( 27 | line: be == "The items if a block is given." 28 | ) 29 | 30 | parameter = yields.children[2] 31 | expect(parameter).to be_a(Decode::Comment::Parameter) 32 | 33 | expect(parameter.children[0]).to be_a(Decode::Comment::Text) 34 | expect(parameter.children[0]).to have_attributes( 35 | line: be == "\t\tThe item will always be negative." 36 | ) 37 | end 38 | 39 | it "can extract top level text" do 40 | expect(documentation.text).to be == [ 41 | "Iterates over all the items.", 42 | "For more details see {Array}.", 43 | ] 44 | end 45 | end 46 | 47 | with "indented code" do 48 | let(:path) {File.expand_path(".fixtures/text.rb", __dir__)} 49 | let(:documentation) {source.segments.to_a[1].documentation} 50 | 51 | it "should have text nodes" do 52 | expect(documentation.children[0]).to be_a(Decode::Comment::Text) 53 | expect(documentation.children[0]).to have_attributes( 54 | line: be == "Indented code:" 55 | ) 56 | 57 | expect(documentation.children[1]).to be_a(Decode::Comment::Text) 58 | expect(documentation.children[1]).to have_attributes( 59 | line: be == "``` ruby" 60 | ) 61 | 62 | expect(documentation.children[2]).to be_a(Decode::Comment::Text) 63 | expect(documentation.children[2]).to have_attributes( 64 | line: be == "def indentation" 65 | ) 66 | 67 | expect(documentation.children[3]).to be_a(Decode::Comment::Text) 68 | expect(documentation.children[3]).to have_attributes( 69 | line: be == "\treturn \"Hello World!\"" 70 | ) 71 | 72 | expect(documentation.children[4]).to be_a(Decode::Comment::Text) 73 | expect(documentation.children[4]).to have_attributes( 74 | line: be == "end" 75 | ) 76 | 77 | expect(documentation.children[5]).to be_a(Decode::Comment::Text) 78 | expect(documentation.children[5]).to have_attributes( 79 | line: be == "```" 80 | ) 81 | end 82 | 83 | it "can extract top level text" do 84 | expect(documentation.text).to be == [ 85 | "Indented code:", 86 | "``` ruby", 87 | "def indentation", 88 | "\treturn \"Hello World!\"", 89 | "end", 90 | "```", 91 | ] 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/decode/comment/tags.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "text" 7 | 8 | module Decode 9 | module Comment 10 | # Represents a collection of documentation tags and their parsing logic. 11 | class Tags 12 | # Build a tags parser with directive mappings. 13 | # @yields {|directives| directives['directive'] = Class} 14 | # @parameter directives [Hash(String, _Directive)] The directive mappings hash to configure. 15 | # @returns [Tags] A new tags parser with the configured directives. 16 | # @rbs () { (Hash[String, Class]) -> void } -> Tags 17 | def self.build 18 | directives = Hash.new 19 | 20 | yield directives 21 | 22 | return self.new(directives) 23 | end 24 | 25 | # Initialize a new tags parser. 26 | # @parameter directives [Hash(String, _Directive)] The directive mappings. 27 | def initialize(directives) 28 | @directives = directives 29 | end 30 | 31 | # Check if a line has valid indentation for the given level. 32 | # @parameter line [String] The line to check. 33 | # @parameter level [Integer] The expected indentation level. 34 | def valid_indentation?(line, level) 35 | line.start_with?(" " * level) || line.start_with?("\t" * level) 36 | end 37 | 38 | # @constant [Regexp] Pattern for matching tag directives in comment lines. 39 | PATTERN = /\A\s*@(?.*?)(\s+(?.*?))?\Z/ 40 | 41 | # Parse documentation tags from lines. 42 | # @parameter lines [Array(String)] The lines to parse. 43 | # @parameter level [Integer] The indentation level. 44 | # @yields {|node| process parsed node} 45 | # @parameter node [Node | Text] The parsed node (either a structured tag or plain text). 46 | # @returns [void] Parses tags from lines and yields them to the block. 47 | # @rbs (Array[String] lines, ?Integer level) { (Node | Text) -> void } -> void 48 | def parse(lines, level = 0, &block) 49 | while line = lines.first 50 | # Is it at the right indentation level: 51 | return unless valid_indentation?(line, level) 52 | 53 | # We are going to consume the line: 54 | lines.shift 55 | 56 | # Match it against a tag: 57 | if match = PATTERN.match(line) 58 | directive = match[:directive] #: String 59 | remainder = match[:remainder] #: String 60 | 61 | # @type var klass: _Directive? 62 | if klass = @directives[directive] 63 | yield klass.parse( 64 | directive, remainder, 65 | lines, self, level 66 | ) 67 | else 68 | # Ignore unknown directive. 69 | end 70 | 71 | # Or it's just text: 72 | else 73 | yield Text.new(line) 74 | end 75 | end 76 | end 77 | 78 | # Ignore lines at the specified indentation level. 79 | # @parameter lines [Array(String)] The lines to ignore. 80 | # @parameter level [Integer] The indentation level. 81 | def ignore(lines, level = 0) 82 | if line = lines.first 83 | # Is it at the right indentation level: 84 | return unless valid_indentation?(line, level) 85 | 86 | lines.shift 87 | end 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /test/decode/language/ruby/comments.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/source" 8 | require "decode/documentation" 9 | 10 | describe Decode::Language::Ruby do 11 | let(:language) {Decode::Language::Ruby.new} 12 | let(:source) {Decode::Source.new(path, language)} 13 | let(:definitions) {language.definitions_for(source).to_a} 14 | 15 | with "comment extraction" do 16 | let(:path) {File.expand_path(".fixtures/comments.rb", __dir__)} 17 | 18 | it "should preserve comment indentation" do 19 | # Should have definitions with comments 20 | definitions_with_comments = definitions.select do |definition| 21 | definition.documented? 22 | end 23 | expect(definitions_with_comments.size).to be > 0 24 | end 25 | 26 | it "should handle comments correctly" do 27 | # Should extract comments properly 28 | definitions.each do |definition| 29 | if definition.documented? 30 | expect(definition.comments).to be_a(Array) 31 | expect(definition.comments.first).to be_a(String) 32 | end 33 | end 34 | end 35 | 36 | it "should extract clean comments without hash symbols" do # Find the method definition 37 | method_definition = definitions.find {|definition| definition.name == :method} 38 | expect(method_definition).not.to be_nil 39 | expect(method_definition.documented?).to be_truthy 40 | 41 | # Check the raw comments array - should be clean without leading `#`: 42 | expect(method_definition.comments.size).to be > 0 43 | 44 | # Check that comments don't start with "# " or "#": 45 | method_definition.comments.each do |comment| 46 | expect(comment.start_with?("# ")).to be == false 47 | expect(comment.start_with?("#")).to be == false 48 | end 49 | 50 | # Test Documentation class: 51 | documentation = method_definition.documentation 52 | expect(documentation).not.to be_nil 53 | 54 | # Documentation should also have clean comments: 55 | expect(documentation.comments).to be == method_definition.comments 56 | end 57 | end 58 | 59 | with "adjacent comment extraction" do 60 | let(:path) {File.expand_path(".fixtures/adjacent_comments.rb", __dir__)} 61 | let(:source) {Decode::Source.new(path, language)} 62 | let(:definitions) {language.definitions_for(source).to_a} 63 | 64 | it "should only extract adjacent comments" do 65 | # Find the documented_method: 66 | documented_method = definitions.find {|definition| definition.name == :documented_method} 67 | expect(documented_method).not.to be_nil 68 | 69 | # Should only have the 2 adjacent comments (lines 10-11): 70 | expect(documented_method.comments.size).to be == 2 71 | expect(documented_method.comments[0]).to be == "This is the actual method comment" 72 | expect(documented_method.comments[1]).to be == "that SHOULD be included." 73 | 74 | # Find the another_method: 75 | another_method = definitions.find {|definition| definition.name == :another_method} 76 | expect(another_method).not.to be_nil 77 | 78 | # Should only have the 1 adjacent comment (line 16): 79 | expect(another_method.comments.size).to be == 1 80 | expect(another_method.comments[0]).to be == "This is another method comment" 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /.context/sus/mocking.md: -------------------------------------------------------------------------------- 1 | # Mocking 2 | 3 | There are two types of mocking in sus: `receive` and `mock`. The `receive` matcher is a subset of full mocking and is used to set expectations on method calls, while `mock` can be used to replace method implementations or set up more complex behavior. 4 | 5 | Mocking non-local objects permanently changes the object's ancestors, so it should be used with care. For local objects, you can use `let` to define the object and then mock it. 6 | 7 | Sus does not support the concept of test doubles, but you can use `receive` and `mock` to achieve similar functionality. 8 | 9 | ## Method Call Expectations 10 | 11 | The `receive(:method)` expectation is used to set up an expectation that a method will be called on an object. You can also specify arguments and return values. However, `receive` is not sequenced, meaning it does not enforce the order of method calls. If you need to enforce the order, use `mock` instead. 12 | 13 | ```ruby 14 | describe MyThing do 15 | let(:my_thing) {subject.new} 16 | 17 | it "calls the expected method" do 18 | expect(my_thing).to receive(:my_method) 19 | 20 | expect(my_thing.my_method).to be == 42 21 | end 22 | end 23 | ``` 24 | 25 | ### With Arguments 26 | 27 | ```ruby 28 | it "calls the method with arguments" do 29 | expect(object).to receive(:method_name).with(arg1, arg2) 30 | # or .with_arguments(be == [arg1, arg2]) 31 | # or .with_options(be == {option1: value1, option2: value2}) 32 | # or .with_block 33 | 34 | object.method_name(arg1, arg2) 35 | end 36 | ``` 37 | 38 | ### Returning Values 39 | 40 | ```ruby 41 | it "returns a value" do 42 | expect(object).to receive(:method_name).and_return("expected value") 43 | result = object.method_name 44 | expect(result).to be == "expected value" 45 | end 46 | ``` 47 | 48 | ### Raising Exceptions 49 | 50 | ```ruby 51 | it "raises an exception" do 52 | expect(object).to receive(:method_name).and_raise(StandardError, "error message") 53 | 54 | expect{object.method_name}.to raise_exception(StandardError, message: "error message") 55 | end 56 | ``` 57 | 58 | ### Multiple Calls 59 | 60 | ```ruby 61 | it "calls the method multiple times" do 62 | expect(object).to receive(:method_name).twice.and_return("result") 63 | # or .with_call_count(be == 2) 64 | expect(object.method_name).to be == "result" 65 | expect(object.method_name).to be == "result" 66 | end 67 | ``` 68 | 69 | ## Mock Objects 70 | 71 | Mock objects are used to replace method implementations or set up complex behavior. They can be used to intercept method calls, modify arguments, and control the flow of execution. They are thread-local, meaning they only affect the current thread, therefore are not suitable for use in tests that have multiple threads. 72 | 73 | ```ruby 74 | describe ApiClient do 75 | let(:http_client) {Object.new} 76 | let(:client) {ApiClient.new(http_client)} 77 | let(:users) {["Alice", "Bob"]} 78 | 79 | it "makes GET requests" do 80 | mock(http_client) do |mock| 81 | mock.replace(:get) do |url, headers: {}| 82 | expect(url).to be == "/api/users" 83 | expect(headers).to be == {"accept" => "application/json"} 84 | users.to_json 85 | end 86 | 87 | # or mock.before {|...| ...} 88 | # or mock.after {|...| ...} 89 | # or mock.wrap(:new) {|original, ...| original.call(...)} 90 | end 91 | 92 | expect(client.fetch_users).to be == users 93 | end 94 | end 95 | ``` 96 | -------------------------------------------------------------------------------- /lib/decode/language/reference.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Decode 7 | module Language 8 | # An reference which can be resolved to zero or more definitions. 9 | class Reference 10 | # Initialize the reference. 11 | # @parameter identifier [String] The identifier part of the reference. 12 | # @parameter language [Language::Generic] The language this reference belongs to. 13 | # @parameter lexical_path [Array(String)?] The lexical path scope for resolution. 14 | def initialize(identifier, language, lexical_path = nil) 15 | @identifier = identifier 16 | @language = language 17 | 18 | @lexical_path = lexical_path 19 | @path = nil 20 | end 21 | 22 | # Generate a string representation of the reference. 23 | def to_s 24 | "{#{self.language} #{self.identifier}}" 25 | end 26 | 27 | # Generate a debug representation of the reference. 28 | def inspect 29 | "\#<#{self.class} {#{self.identifier}}>" 30 | end 31 | 32 | # @attribute [String] The identifier part of the reference. 33 | attr :identifier 34 | 35 | # @attribute [Language::Generic] The language associated with this reference. 36 | attr :language 37 | 38 | # Whether the reference starts at the base of the lexical tree. 39 | def absolute? 40 | !self.relative? 41 | end 42 | 43 | # Check if this is a relative reference. 44 | def relative? 45 | prefix, name = self.lexical_path.first 46 | 47 | return prefix.nil? 48 | end 49 | 50 | # Split an identifier into prefix and name components. 51 | # @parameter identifier [String] The identifier to split. 52 | def split(identifier) 53 | identifier.scan(/(\W+)?(\w+)/) 54 | end 55 | 56 | # Get the lexical path of this reference. 57 | def lexical_path 58 | @lexical_path ||= self.split(@identifier) 59 | end 60 | 61 | # Calculate the priority of a definition for matching. 62 | # @parameter definition [String] The definition to check. 63 | # @parameter prefix [String] The prefix to match against. 64 | def priority(definition, prefix) 65 | if prefix.nil? 66 | return 1 67 | elsif definition.start_with?(prefix) 68 | return 0 69 | else 70 | return 2 71 | end 72 | end 73 | 74 | # Find the best matching definition from a list. 75 | # @parameter definitions [Array(Definition)] The definitions to choose from. 76 | # @returns [Definition?] The best matching definition, if any. 77 | def best(definitions) 78 | prefix, name = lexical_path.last 79 | 80 | first = nil #: Definition? 81 | without_prefix = nil #: Definition? 82 | 83 | definitions.each do |definition| 84 | first ||= definition 85 | 86 | next unless definition.language == @language 87 | 88 | if prefix.nil? 89 | without_prefix ||= definition 90 | elsif definition.start_with?(prefix) 91 | return definition 92 | end 93 | end 94 | 95 | return without_prefix || first 96 | end 97 | 98 | # The lexical path of the reference. 99 | # @returns [Array(Symbol)] 100 | def path 101 | @path ||= self.lexical_path.map{|_, name| name.to_sym} 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/decode/language/ruby/code.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "definition" 7 | require_relative "../../syntax/link" 8 | 9 | require "prism" 10 | 11 | module Decode 12 | module Language 13 | module Ruby 14 | # A Ruby-specific block of code. 15 | class Code 16 | # Initialize a new code block. 17 | # @parameter text [String] The code text. 18 | # @parameter index [Index] The index to use. 19 | # @parameter relative_to [Definition?] The definition this code is relative to. 20 | # @parameter language [Language::Generic] The language of the code. 21 | def initialize(text, index, relative_to: nil, language:) 22 | @text = text 23 | @root = ::Prism.parse(text) 24 | @index = index 25 | @relative_to = relative_to 26 | @language = language 27 | end 28 | 29 | # @attribute [String] The code text. 30 | attr :text 31 | 32 | # @attribute [untyped] The parsed syntax tree. 33 | attr :root 34 | 35 | # @attribute [Index] The index to use for lookups. 36 | attr :index 37 | 38 | # @attribute [Definition?] The definition this code is relative to. 39 | attr :relative_to 40 | 41 | # @attribute [Language::Generic] The language of the code. 42 | attr :language 43 | 44 | # Extract definitions from the code. 45 | # @parameter into [Array] The array to extract definitions into. 46 | # @returns [Array] The array with extracted definitions. 47 | def extract(into = []) 48 | if @index 49 | traverse(@root.value, into) 50 | end 51 | 52 | return into 53 | end 54 | 55 | private 56 | 57 | # Traverse the syntax tree and extract definitions. 58 | # @parameter node [untyped] The syntax tree node to traverse. 59 | # @parameter into [Array] The array to extract definitions into. 60 | # @returns [self] 61 | def traverse(node, into) 62 | case node&.type 63 | when :program_node 64 | traverse(node.statements, into) 65 | when :call_node 66 | if reference = Reference.from_const(node, @language) 67 | if definition = @index.lookup(reference, relative_to: @relative_to) 68 | # Use message_loc for the method name, not the entire call 69 | expression = node.message_loc 70 | range = expression.start_offset...expression.end_offset 71 | into << Syntax::Link.new(range, definition) 72 | end 73 | end 74 | 75 | # Extract constants from arguments: 76 | if node.arguments 77 | node.arguments.arguments.each do |argument_node| 78 | traverse(argument_node, into) 79 | end 80 | end 81 | when :constant_read_node 82 | if reference = Reference.from_const(node, @language) 83 | if definition = @index.lookup(reference, relative_to: @relative_to) 84 | expression = node.location 85 | range = expression.start_offset...expression.end_offset 86 | into << Syntax::Link.new(range, definition) 87 | end 88 | end 89 | when :statements_node 90 | node.body.each do |child| 91 | traverse(child, into) 92 | end 93 | end 94 | 95 | return self 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/decode/rbs/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/rbs/type" 7 | 8 | describe Decode::RBS::Type do 9 | with "#nullable?" do 10 | it "detects nullable types with question mark suffix" do 11 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("Boolean?"))).to be == true 12 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("String?"))).to be == true 13 | end 14 | 15 | it "detects union types with nil" do 16 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("String | nil"))).to be == true 17 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("nil | String"))).to be == true 18 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("Integer | String | nil"))).to be == true 19 | end 20 | 21 | it "detects nested union types with nil" do 22 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("(String | nil) | Integer"))).to be == true 23 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("String | (Integer | nil)"))).to be == true 24 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("((String | nil) | Integer) | Boolean"))).to be == true 25 | end 26 | 27 | it "detects tuple types with nil" do 28 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("[String?]"))).to be == true 29 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("[String, Integer?]"))).to be == true 30 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("[String, Integer]"))).to be == false 31 | end 32 | 33 | it "detects non-nullable types" do 34 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("Boolean"))).to be == false 35 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("String"))).to be == false 36 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("Integer | String"))).to be == false 37 | end 38 | 39 | it "detects direct nil type" do 40 | expect(Decode::RBS::Type.nullable?(::RBS::Parser.parse_type("nil"))).to be == true 41 | end 42 | end 43 | 44 | with "#parse" do 45 | it "parses valid type strings" do 46 | type = Decode::RBS::Type.parse("String") 47 | expect(type).to be_a(::RBS::Types::ClassInstance) 48 | expect(type.name.name).to be == :String 49 | end 50 | 51 | it "handles backwards compatibility transformations" do 52 | # () -> [] 53 | type = Decode::RBS::Type.parse("Array(Integer)") 54 | expect(type).to be_a(::RBS::Types::ClassInstance) 55 | expect(type.name.name).to be == :Array 56 | 57 | # | Nil -> ? 58 | type = Decode::RBS::Type.parse("String | Nil") 59 | expect(type).to be_a(::RBS::Types::Optional) 60 | expect(Decode::RBS::Type.nullable?(type)).to be == true 61 | 62 | # Boolean -> bool 63 | type = Decode::RBS::Type.parse("Boolean") 64 | expect(type).to be_a(::RBS::Types::Bases::Bool) 65 | end 66 | 67 | it "handles invalid type strings gracefully" do 68 | type = Decode::RBS::Type.parse(":::") # Invalid RBS syntax 69 | expect(type).to be_a(::RBS::Types::Bases::Any) # "untyped" parses to Any type 70 | end 71 | 72 | it "preserves complex union types" do 73 | type = Decode::RBS::Type.parse("String | Integer | nil") 74 | expect(type).to be_a(::RBS::Types::Union) 75 | expect(Decode::RBS::Type.nullable?(type)).to be == true 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/decode/comment/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | module Decode 7 | module Comment 8 | # Represents a node in a comment tree structure. 9 | class Node 10 | # Initialize the node. 11 | # @parameter children [Array(Node | Text)?] The initial children array containing both structured nodes and text content. 12 | def initialize(children) 13 | @children = children 14 | end 15 | 16 | # Whether this node has any children nodes. 17 | # Ignores {Text} instances. 18 | # @returns [bool] 19 | def children? 20 | @children&.any?{|child| child.is_a?(Node)} || false 21 | end 22 | 23 | # Add a child node to this node. 24 | # @parameter child [Node | Text] The node to add. 25 | def add(child) 26 | if children = @children 27 | children << child 28 | else 29 | @children = [child] 30 | end 31 | 32 | return self 33 | end 34 | 35 | # Contains a mix of Node objects (structured comment tags like `@parameter`, `@returns`) and Text objects (plain comment text and tag descriptions). 36 | # @attribute [Array(Node | Text)?] The children of this node. 37 | attr :children 38 | 39 | # Enumerate all non-text children nodes. 40 | # @yields {|node| process each node} 41 | # @parameter node [Node] A structured child node (Text nodes are filtered out). 42 | # @returns [Enumerator(Node)] Returns an enumerator if no block given. 43 | # @returns [self] Otherwise returns self. 44 | def each(&block) 45 | return to_enum unless block_given? 46 | 47 | @children&.each do |child| 48 | yield child if child.is_a?(Node) 49 | end 50 | 51 | return self 52 | end 53 | 54 | # Filter children nodes by class type. 55 | # @parameter klass [Class] The class to filter by. 56 | # @yields {|node| process each filtered node} 57 | # @parameter node [Object] A child node that is an instance of klass. 58 | # @returns [Enumerator(Node)] Returns an enumerator if no block given. 59 | # @returns [self] Otherwise returns self. 60 | def filter(klass) 61 | return to_enum(:filter, klass) unless block_given? 62 | 63 | @children&.each do |child| 64 | yield child if child.is_a?(klass) 65 | end 66 | 67 | return self 68 | end 69 | 70 | # Any lines of text associated with this node. 71 | # @returns [Array(String)?] The lines of text. 72 | def text 73 | if text = self.extract_text 74 | return text if text.any? 75 | end 76 | end 77 | 78 | # Traverse the tags from this node using {each}. Invoke `descend.call(child)` to recursively traverse the specified child. 79 | # 80 | # @yields {|node, descend| descend.call} 81 | # @parameter node [Node] The current node which is being traversed. 82 | # @parameter descend [Proc] The recursive method for traversing children. 83 | def traverse(&block) 84 | descend = ->(node){node.traverse(&block)} 85 | 86 | yield(self, descend) 87 | end 88 | 89 | protected 90 | 91 | # Extract text lines from Text children of this node. 92 | # @returns [Array(String)?] Array of text lines, or nil if no children. 93 | def extract_text 94 | if children = @children 95 | children.filter_map do |child| 96 | child.line if child.is_a?(Text) 97 | end 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/decode/rbs/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/rbs" 7 | require "decode/index" 8 | require "tmpdir" 9 | 10 | describe Decode::RBS::Generator do 11 | let(:generator) {subject.new} 12 | 13 | def create_or_compare(fixture_name, actual_output) 14 | fixture_path = File.expand_path(".fixtures/#{fixture_name}", __dir__) 15 | expected_path = "#{fixture_path}.rbs" 16 | 17 | if File.exist?(expected_path) 18 | # Compare with existing expected output 19 | expected_output = File.read(expected_path) 20 | expect(actual_output.strip).to be == expected_output.strip 21 | else 22 | # Create the expected output file 23 | File.write(expected_path, actual_output) 24 | inform "Created expected output: #{expected_path}" 25 | end 26 | end 27 | 28 | def generate_rbs_for_fixture(fixture_name) 29 | fixture_path = File.expand_path(".fixtures/#{fixture_name}.rb", __dir__) 30 | index = Decode::Index.for(fixture_path) 31 | 32 | output = StringIO.new 33 | generator.generate(index, output: output) 34 | output.string 35 | end 36 | 37 | with "#generate" do 38 | with "basic class" do 39 | it "generates correct RBS for a basic class" do 40 | actual_output = generate_rbs_for_fixture("basic_class") 41 | create_or_compare("basic_class", actual_output) 42 | end 43 | end 44 | 45 | with "inheritance" do 46 | it "generates correct RBS for class inheritance" do 47 | actual_output = generate_rbs_for_fixture("super_class") 48 | create_or_compare("super_class", actual_output) 49 | end 50 | end 51 | 52 | with "attribute type inference" do 53 | it "generates correct RBS for attributes with type annotations" do 54 | actual_output = generate_rbs_for_fixture("attribute_inference") 55 | create_or_compare("attribute_inference", actual_output) 56 | end 57 | end 58 | 59 | with "constant type inference" do 60 | it "generates correct RBS for constants with type annotations" do 61 | actual_output = generate_rbs_for_fixture("constant_inference") 62 | create_or_compare("constant_inference", actual_output) 63 | end 64 | end 65 | 66 | with "modules" do 67 | it "generates correct RBS for modules" do 68 | actual_output = generate_rbs_for_fixture("basic_module") 69 | create_or_compare("basic_module", actual_output) 70 | end 71 | end 72 | 73 | with "generics" do 74 | it "generates correct RBS for generic classes" do 75 | actual_output = generate_rbs_for_fixture("generics") 76 | create_or_compare("generics", actual_output) 77 | end 78 | end 79 | 80 | with "method types" do 81 | it "generates correct RBS for different method signatures" do 82 | actual_output = generate_rbs_for_fixture("method_types") 83 | create_or_compare("method_types", actual_output) 84 | end 85 | end 86 | 87 | with "empty input" do 88 | it "handles empty input gracefully" do 89 | index = Decode::Index.for() 90 | output = StringIO.new 91 | generator.generate(index, output: output) 92 | expect(output.string).to be(:empty?) 93 | end 94 | end 95 | 96 | with "multiple files" do 97 | it "can process multiple fixture files together" do 98 | basic_class_path = File.expand_path(".fixtures/basic_class.rb", __dir__) 99 | super_class_path = File.expand_path(".fixtures/super_class.rb", __dir__) 100 | 101 | index = Decode::Index.for(basic_class_path, super_class_path) 102 | output = StringIO.new 103 | generator.generate(index, output: output) 104 | 105 | result = output.string 106 | expect(result).to be(:include?, "class Animal") 107 | expect(result).to be(:include?, "class Dog < Animal") 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Decode 2 | 3 | A Ruby code analysis tool and documentation generator. 4 | 5 | [![Development Status](https://github.com/socketry/decode/workflows/Test/badge.svg)](https://github.com/socketry/decode/actions?workflow=Test) 6 | 7 | ## Motivation 8 | 9 | As part of my effort to build [better project documentation](https://github.com/socketry/utopia-project), I needed a better code analysis tool. While less featured than some of the more mature alternatives, this library focuses on the needs of documentation generation, including speed, cross-referencing and (eventually) multi-language support. 10 | 11 | ## Usage 12 | 13 | Please see the [project documentation](https://socketry.github.io/decode/) for more details. 14 | 15 | - [Getting Started](https://socketry.github.io/decode/guides/getting-started/index) - This guide explains how to use `decode` for source code analysis. 16 | 17 | - [Documentation Coverage](https://socketry.github.io/decode/guides/documentation-coverage/index) - This guide explains how to test and monitor documentation coverage in your Ruby projects using the Decode gem's built-in bake tasks. 18 | 19 | - [Extract Symbols](https://socketry.github.io/decode/guides/extract-symbols/index) - This example demonstrates how to extract symbols using the index. An instance of Decode::Index is used for loading symbols from source code files. These symbols are available as a flat list and as a trie structure. You can look up specific symbols using a reference using Decode::Index\#lookup. 20 | 21 | - [Ruby Documentation](https://socketry.github.io/decode/guides/ruby-documentation/index) - This guide covers documentation practices and pragmas supported by the Decode gem for documenting Ruby code. These pragmas provide structured documentation that can be parsed and used to generate API documentation and achieve complete documentation coverage. 22 | 23 | ## Releases 24 | 25 | Please see the [project releases](https://socketry.github.io/decode/releases/index) for all releases. 26 | 27 | ### v0.26.0 28 | 29 | - Add support for `@example` pragmas in Ruby documentation comments. 30 | 31 | ### v0.25.0 32 | 33 | - Singleton classes are not relevant for coverage, so they are now ignored by the coverage reporter. 34 | 35 | ### v0.24.4 36 | 37 | - Add support for `@constant [Type] Description.` tags. 38 | - Add support for instance variable type inference from `@attribute` tags. 39 | - Add support for method visibility in RBS output. 40 | 41 | ### v0.24.0 42 | 43 | - [Introduce support for RBS signature generation](https://socketry.github.io/decode/releases/index#introduce-support-for-rbs-signature-generation) 44 | 45 | ### v0.23.5 46 | 47 | - Fix handling of `&block` arguments in call nodes. 48 | 49 | ### v0.23.4 50 | 51 | - Fix handling of definitions nested within `if`/`unless`/`elsif`/`else` blocks. 52 | 53 | ## Contributing 54 | 55 | We welcome contributions to this project. 56 | 57 | 1. Fork it. 58 | 2. Create your feature branch (`git checkout -b my-new-feature`). 59 | 3. Commit your changes (`git commit -am 'Add some feature'`). 60 | 4. Push to the branch (`git push origin my-new-feature`). 61 | 5. Create new Pull Request. 62 | 63 | ### Developer Certificate of Origin 64 | 65 | In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. 66 | 67 | ### Community Guidelines 68 | 69 | This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers. 70 | -------------------------------------------------------------------------------- /lib/decode/languages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "language/generic" 7 | require_relative "language/ruby" 8 | 9 | module Decode 10 | # Represents a context for looking up languages based on file extension or name. 11 | class Languages 12 | # Create a new languages context with all supported languages. 13 | # @returns [Languages] A languages context with Ruby support enabled. 14 | def self.all 15 | self.new.tap do |languages| 16 | languages.add(Language::Ruby.new) 17 | end 18 | end 19 | 20 | # Initialize a new languages context. 21 | def initialize 22 | @named = {} 23 | @extensions = {} 24 | end 25 | 26 | # @attribute [Hash[String, Language::Generic]] The named languages. 27 | attr :named 28 | 29 | # @attribute [Hash[String, Language::Generic]] The languages by extension. 30 | attr :extensions 31 | 32 | # Freeze the languages context to prevent further modifications. 33 | def freeze 34 | return unless frozen? 35 | 36 | @named.freeze 37 | @extensions.freeze 38 | 39 | super 40 | end 41 | 42 | # Add a language to this context. 43 | # @parameter language [Language::Generic] The language to add. 44 | # @returns [self] 45 | def add(language) 46 | # Register by name: 47 | language.names.each do |name| 48 | @named[name] = language 49 | end 50 | 51 | # Register by file extension: 52 | language.extensions.each do |extension| 53 | @extensions[extension] = language 54 | end 55 | 56 | return self 57 | end 58 | 59 | # Fetch a language by name, creating a generic language if needed. 60 | # @parameter name [String] The name of the language to fetch. 61 | # @returns [Language::Generic?] The language instance for the given name, or nil if frozen and not found. 62 | def fetch(name) 63 | @named.fetch(name) do 64 | unless @named.frozen? 65 | @named[name] = Language::Generic.new(name) 66 | else 67 | nil 68 | end 69 | end 70 | end 71 | 72 | # Create a source object for the given file path. 73 | # @parameter path [String] The file system path to create a source for. 74 | # @returns [Source?] A source object if the file extension is supported, nil otherwise. 75 | def source_for(path) 76 | extension = File.extname(path) 77 | 78 | if language = @extensions[extension] 79 | Source.new(path, language) 80 | end 81 | end 82 | 83 | # @constant [Regexp] Regular expression for parsing language references. 84 | REFERENCE = /\A(?[a-z]+)?\s+(?.*?)\z/ 85 | 86 | # Parse a language agnostic reference. 87 | # @parameter text [String] The text to parse (e.g., "ruby MyModule::MyClass"). 88 | # @parameter default_language [Language::Generic?] The default language to use if none specified. 89 | # @returns [Language::Reference?] The parsed reference, or nil if parsing fails. 90 | def parse_reference(text, default_language: nil) 91 | if match = REFERENCE.match(text) 92 | name = match[:name] 93 | identifier = match[:identifier] 94 | 95 | if name 96 | language = self.fetch(name) || default_language 97 | else 98 | language = default_language 99 | end 100 | 101 | if language && identifier 102 | return language.reference_for(identifier) 103 | end 104 | elsif default_language 105 | return default_language.reference_for(text) 106 | end 107 | end 108 | 109 | # Create a reference for the given language and identifier. 110 | # @parameter name [String] The name of the language. 111 | # @parameter identifier [String] The identifier to create a reference for. 112 | # @returns [Language::Reference?] The created reference, or nil if language not found. 113 | def reference_for(name, identifier) 114 | self.fetch(name)&.reference_for(identifier) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/decode/language/generic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "reference" 7 | require_relative "../documentation" 8 | 9 | module Decode 10 | module Language 11 | # Represents a generic language implementation that can be extended for specific languages. 12 | class Generic 13 | EXTENSIONS = [] 14 | 15 | TAGS = Comment::Tags.build do |tags| 16 | tags["attribute"] = Comment::Attribute 17 | tags["parameter"] = Comment::Parameter 18 | tags["option"] = Comment::Option 19 | tags["yields"] = Comment::Yields 20 | tags["returns"] = Comment::Returns 21 | tags["raises"] = Comment::Raises 22 | tags["throws"] = Comment::Throws 23 | 24 | tags["deprecated"] = Comment::Pragma 25 | 26 | tags["asynchronous"] = Comment::Pragma 27 | 28 | tags["public"] = Comment::Pragma 29 | tags["private"] = Comment::Pragma 30 | 31 | tags["example"] = Comment::Example 32 | end 33 | 34 | # Initialize a new generic language. 35 | # @parameter name [String] The name of the language. 36 | # @parameter extensions [Array(String)] File extensions for this language. 37 | # @parameter tags [Comment::Tags] The comment tags to recognize. 38 | def initialize(name, extensions: self.class::EXTENSIONS, tags: self.class::TAGS) 39 | @name = name 40 | @extensions = extensions 41 | @tags = tags 42 | end 43 | 44 | # The name of this language. 45 | # @attribute [String] The language name. 46 | attr :name 47 | 48 | # Get all names for this language. 49 | # @returns [Array(String)] An array containing the language name. 50 | def names 51 | [@name] 52 | end 53 | 54 | # The file extensions this language supports. 55 | # @attribute [Array(String)] The supported file extensions. 56 | attr :extensions 57 | 58 | # The comment tags this language recognizes. 59 | # @attribute [Comment::Tags] The tag definitions. 60 | attr :tags 61 | 62 | # Generate a language-specific reference. 63 | # @parameter identifier [String] A valid identifier for this language. 64 | # @returns [Reference] A reference object for the given identifier. 65 | def reference_for(identifier) 66 | Reference.new(identifier, self) 67 | end 68 | 69 | # Get the parser for this language. 70 | # @returns [untyped] The parser instance, or nil if not available. 71 | def parser 72 | nil 73 | end 74 | 75 | # Parse the input yielding definitions. 76 | # @parameter source [Source] The input source file which contains the source code. 77 | # @yields {|definition| ...} Receives the definitions extracted from the source code. 78 | # @parameter definition [Definition] The source code definition including methods, classes, etc. 79 | # @returns [Enumerator(Segment)] If no block given. 80 | def definitions_for(source, &block) 81 | if parser = self.parser 82 | parser.definitions_for(source, &block) 83 | end 84 | end 85 | 86 | # Parse the input yielding segments. 87 | # Segments are constructed from a block of top level comments followed by a block of code. 88 | # @parameter source [Source] The input source file which contains the source code. 89 | # @yields {|segment| ...} 90 | # @parameter segment [Segment] 91 | # @returns [Enumerator(Segment)] If no block given. 92 | def segments_for(source, &block) 93 | if parser = self.parser 94 | parser.segments_for(source, &block) 95 | end 96 | end 97 | 98 | # Generate a code representation with syntax highlighting and link resolution. 99 | # @parameter text [String] The source code text to format. 100 | # @parameter index [Index] The index for resolving references. 101 | # @parameter relative_to [Definition] The definition to resolve relative references from. 102 | # @returns [untyped] A formatted code object with syntax highlighting. 103 | def code_for(text, index, relative_to: nil) 104 | raise NotImplementedError, "Code generation is not implemented for #{self.class}!" 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/decode/language/ruby/visibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/source" 8 | 9 | describe Decode::Language::Ruby do 10 | let(:language) {Decode::Language::Ruby.new} 11 | 12 | with "visibility modifiers" do 13 | let(:source) {Decode::Source.new("test/decode/language/ruby/.fixtures/inline_visibility.rb", language)} 14 | 15 | it "should handle standalone visibility modifiers" do 16 | definitions = language.definitions_for(source).to_a 17 | methods = definitions.select do |definition| 18 | definition.is_a?(Decode::Language::Ruby::Method) 19 | end 20 | 21 | # Test public methods 22 | public_methods = methods.select do |method| 23 | method.visibility == :public 24 | end 25 | public_method_names = public_methods.map(&:name) 26 | expect(public_method_names).to be(:include?, :public_method_1) 27 | expect(public_method_names).to be(:include?, :public_method_2) 28 | expect(public_method_names).to be(:include?, :public_method_3) 29 | expect(public_method_names).to be(:include?, :public_method_4) 30 | expect(public_method_names).to be(:include?, :public_method_5) 31 | 32 | # Test private methods 33 | private_methods = methods.select do |method| 34 | method.visibility == :private 35 | end 36 | private_method_names = private_methods.map(&:name) 37 | expect(private_method_names).to be(:include?, :private_method_1) 38 | expect(private_method_names).to be(:include?, :private_method_2) 39 | expect(private_method_names).to be(:include?, :private_method_3) 40 | 41 | # Test protected methods 42 | protected_methods = methods.select do |method| 43 | method.visibility == :protected 44 | end 45 | protected_method_names = protected_methods.map(&:name) 46 | expect(protected_method_names).to be(:include?, :protected_method_1) 47 | expect(protected_method_names).to be(:include?, :protected_method_2) 48 | end 49 | 50 | it "should handle inline visibility modifiers" do 51 | definitions = language.definitions_for(source).to_a 52 | methods = definitions.select do |definition| 53 | definition.is_a?(Decode::Language::Ruby::Method) 54 | end 55 | 56 | # private def private_method_1 should be private 57 | private_method_1 = methods.find do |method| 58 | method.name == :private_method_1 59 | end 60 | expect(private_method_1.visibility).to be == :private 61 | 62 | # protected def protected_method_1 should be protected 63 | protected_method_1 = methods.find do |method| 64 | method.name == :protected_method_1 65 | end 66 | expect(protected_method_1.visibility).to be == :protected 67 | end 68 | 69 | it "should reset visibility correctly after inline definitions" do 70 | definitions = language.definitions_for(source).to_a 71 | methods = definitions.select do |definition| 72 | definition.is_a?(Decode::Language::Ruby::Method) 73 | end 74 | 75 | # Method after inline private should still be public 76 | public_method_2 = methods.find do |method| 77 | method.name == :public_method_2 78 | end 79 | expect(public_method_2.visibility).to be == :public 80 | 81 | # Method after inline protected should still be public 82 | public_method_3 = methods.find do |method| 83 | method.name == :public_method_3 84 | end 85 | expect(public_method_3.visibility).to be == :public 86 | end 87 | end 88 | 89 | with "class methods and visibility" do 90 | it "should handle class method visibility correctly" do 91 | # Test that class methods can have visibility modifiers 92 | # This is a simplified test that uses the existing fixtures 93 | source = Decode::Source.new("test/decode/language/ruby/.fixtures/class_methods.rb", language) 94 | definitions = language.definitions_for(source).to_a 95 | methods = definitions.select do |definition| 96 | definition.is_a?(Decode::Language::Ruby::Method) 97 | end 98 | 99 | # All methods in class_methods.rb should have receivers 100 | class_methods = methods.select do |method| 101 | method.receiver 102 | end 103 | expect(class_methods.size).to be > 0 104 | 105 | class_methods.each do |method| 106 | expect(method.receiver).to be == "self" 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/decode/rbs/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "rbs" 7 | require_relative "wrapper" 8 | require_relative "method" 9 | require_relative "type" 10 | 11 | module Decode 12 | module RBS 13 | # Represents a Ruby module definition wrapper for RBS generation. 14 | class Module < Wrapper 15 | 16 | # Initialize a new module wrapper. 17 | # @parameter definition [Decode::Definition] The module definition to wrap. 18 | def initialize(definition) 19 | super 20 | end 21 | 22 | # Convert the module definition to RBS AST 23 | # @parameter method_definitions [Array(Method)] The method definitions to convert. 24 | # @parameter constant_definitions [Array(Constant)] The constant definitions to convert. 25 | # @parameter attribute_definitions [Array(Attribute)] The attribute definitions to convert. 26 | # @parameter index [Index?] The index for resolving references. 27 | # @returns [RBS::AST::Declarations::Module] The RBS AST for the module. 28 | def to_rbs_ast(method_definitions = [], constant_definitions = [], attribute_definitions = [], index = nil) 29 | name = simple_name_to_rbs(@definition.name) 30 | comment = self.comment 31 | 32 | # Build method definitions 33 | methods = method_definitions.map{|method_def| Method.new(method_def).to_rbs_ast(index)}.compact 34 | 35 | # Build constant definitions: 36 | constants = constant_definitions.map{|const_def| build_constant_rbs(const_def)}.compact 37 | 38 | # Build attribute definitions and infer instance variable types: 39 | attributes, instance_variables = build_attributes_rbs(attribute_definitions) 40 | 41 | ::RBS::AST::Declarations::Module.new( 42 | name: name, 43 | type_params: [], 44 | self_types: [], 45 | members: constants + attributes + instance_variables + methods, 46 | annotations: [], 47 | location: nil, 48 | comment: comment 49 | ) 50 | end 51 | 52 | private 53 | 54 | # Build a constant RBS declaration. 55 | def build_constant_rbs(constant_definition) 56 | # Look for @constant tags in the constant's documentation: 57 | documentation = constant_definition.documentation 58 | constant_tags = documentation&.filter(Decode::Comment::Constant)&.to_a 59 | 60 | if constant_tags&.any? 61 | type_string = constant_tags.first.type.strip 62 | type = ::Decode::RBS::Type.parse(type_string) 63 | 64 | ::RBS::AST::Declarations::Constant.new( 65 | name: constant_definition.name.to_sym, 66 | type: type, 67 | location: nil, 68 | comment: nil 69 | ) 70 | end 71 | end 72 | 73 | # Convert a simple name to RBS TypeName (not qualified). 74 | def simple_name_to_rbs(name) 75 | ::RBS::TypeName.new(name: name.to_sym, namespace: ::RBS::Namespace.empty) 76 | end 77 | 78 | # Build attribute RBS declarations and infer instance variable types. 79 | # @parameter attribute_definitions [Array] Array of Attribute definition objects 80 | # @returns [Array] A tuple of [attribute_declarations, instance_variable_declarations] 81 | def build_attributes_rbs(attribute_definitions) 82 | attributes = [] 83 | instance_variables = [] 84 | 85 | # Create a mapping from attribute names to their types: 86 | attribute_types = {} 87 | 88 | attribute_definitions.each do |attribute_definition| 89 | # Extract @attribute type annotation from documentation: 90 | documentation = attribute_definition.documentation 91 | attribute_tags = documentation&.filter(Decode::Comment::Attribute)&.to_a 92 | 93 | if attribute_tags&.any? 94 | type_string = attribute_tags.first.type.strip 95 | type = ::Decode::RBS::Type.parse(type_string) 96 | 97 | attribute_types[attribute_definition.name] = type 98 | 99 | # Generate attr_reader RBS declaration: 100 | attributes << ::RBS::AST::Members::AttrReader.new( 101 | name: attribute_definition.name.to_sym, 102 | type: type, 103 | ivar_name: :"@#{attribute_definition.name}", 104 | kind: :instance, 105 | annotations: [], 106 | location: nil, 107 | comment: nil 108 | ) 109 | 110 | # Generate instance variable declaration: 111 | instance_variables << ::RBS::AST::Members::InstanceVariable.new( 112 | name: :"@#{attribute_definition.name}", 113 | type: type, 114 | location: nil, 115 | comment: nil 116 | ) 117 | end 118 | end 119 | 120 | [attributes, instance_variables] 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/decode/trie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "source" 7 | 8 | module Decode 9 | # Represents a prefix-trie data structure for fast lexical lookups. 10 | # @rbs generic T 11 | class Trie 12 | # Represents a single node in the trie. 13 | class Node 14 | # Initialize an empty node. 15 | def initialize 16 | @children = {} 17 | @values = nil 18 | end 19 | 20 | # Generate a string representation of this node. 21 | # @returns [String] A formatted string showing the number of children. 22 | def inspect 23 | "#<#{self.class} #{@children.size} children>" 24 | end 25 | 26 | # Generate a string representation of the node. 27 | alias to_s inspect 28 | 29 | # A mutable array of all values that terminate at this node. 30 | # @attribute [Array[T]?] The values stored at this node, or nil if no values. 31 | attr_accessor :values 32 | 33 | # A hash table of all children nodes, indexed by name. 34 | # @attribute [Hash(Symbol, Node)] Child nodes indexed by their path component. 35 | attr :children 36 | 37 | # Look up a lexical path starting at this node. 38 | # @parameter path [Array(Symbol)] The path to resolve. 39 | # @parameter index [Integer] The current index in the path (used for recursion). 40 | # @returns [Node?] The node at the specified path, or nil if not found. 41 | def lookup(path, index = 0) 42 | if index < path.size 43 | if child = @children[path[index]] 44 | return child.lookup(path, index+1) 45 | end 46 | else 47 | return self 48 | end 49 | end 50 | 51 | # Traverse the trie from this node. 52 | # Invoke `descend.call` to traverse the children of the current node. 53 | # @parameter path [Array(Symbol)] The current lexical path. 54 | # @yields {|path, node, descend| ...} Called for each node during traversal. 55 | # @parameter path [Array(Symbol)] The current lexical path. 56 | # @parameter node [Node] The current node which is being traversed. 57 | # @parameter descend [Proc] The recursive method for traversing children. 58 | # @rbs (?Array[Symbol]) { (Array[Symbol], Node, Proc) -> void } -> void 59 | def traverse(path = [], &block) 60 | descend = lambda do 61 | @children.each do |name, node| 62 | node.traverse([*path, name], &block) 63 | end 64 | end 65 | 66 | yield(path, self, descend) 67 | end 68 | end 69 | 70 | # Initialize an empty trie. 71 | def initialize 72 | @root = Node.new 73 | end 74 | 75 | # The root of the trie. 76 | # @attribute [Node] The root node of the trie structure. 77 | attr :root 78 | 79 | # Insert the specified value at the given path into the trie. 80 | # @parameter path [Array(Symbol)] The lexical path where the value will be inserted. 81 | # @parameter value [T] The value to insert at the specified path. 82 | def insert(path, value) 83 | node = @root 84 | 85 | # Navigate to the target node, creating nodes as needed: 86 | path.each do |key| 87 | node = (node.children[key] ||= Node.new) 88 | end 89 | 90 | # Add the value to the target node: 91 | if node.values 92 | node.values << value 93 | else 94 | node.values = [value] 95 | end 96 | end 97 | 98 | # Lookup the values at the specified path. 99 | # @parameter path [Array(Symbol)] The lexical path which contains the values. 100 | # @returns [Node?] The node at the specified path, or nil if not found. 101 | def lookup(path) 102 | @root.lookup(path) 103 | end 104 | 105 | # Enumerate all lexical scopes under the specified path. 106 | # @parameter path [Array(Symbol)] The starting path to enumerate from. 107 | # @yields {|path, values| ...} Called for each path with values. 108 | # @parameter path [Array(Symbol)] The lexical path. 109 | # @parameter values [Array[T]?] The values that exist at the given path. 110 | # @rbs (?Array[Symbol]) { (Array[Symbol], (Array[T] | nil)) -> void } -> void 111 | def each(path = [], &block) 112 | if node = @root.lookup(path, 0) 113 | node.traverse(path) do |path, node, descend| 114 | yield path, node.values 115 | 116 | descend.call 117 | end 118 | end 119 | end 120 | 121 | # Traverse the trie starting from the specified path. 122 | # See {Node#traverse} for details. 123 | # @parameter path [Array(Symbol)] The starting path to traverse from. 124 | # @yields {|path, node, descend| ...} Called for each node during traversal. 125 | # @rbs (?Array[Symbol]) { (Array[Symbol], Node, Proc) -> void } -> void 126 | def traverse(path = [], &block) 127 | if node = @root.lookup(path) 128 | node.traverse(path, &block) 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/decode/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "source" 7 | require_relative "trie" 8 | 9 | require_relative "languages" 10 | 11 | module Decode 12 | # Represents a list of definitions organised for quick lookup and lexical enumeration. 13 | class Index 14 | # Create and populate an index from the given paths. 15 | # @parameter paths [Array(String)] Variable number of paths to index (files, directories, or glob patterns). 16 | # @parameter languages [Languages] The languages to support in this index. 17 | # @returns [Index] A new index populated with definitions from the given paths. 18 | # @rbs (*String, ?languages: Languages) -> Index 19 | def self.for(*paths, languages: Languages.all) 20 | # Resolve all paths to actual files: 21 | resolved_paths = paths.flat_map do |path| 22 | if File.directory?(path) 23 | Dir.glob(File.join(path, "**/*")) 24 | elsif File.file?(path) 25 | [path] 26 | else 27 | # Handle glob patterns or non-existent paths: 28 | Dir.glob(path) 29 | end 30 | end 31 | 32 | resolved_paths.sort! 33 | resolved_paths.uniq! 34 | 35 | # Create and populate the index: 36 | index = new(languages) 37 | index.update(resolved_paths) 38 | 39 | return index 40 | end 41 | 42 | # Initialize an empty index. 43 | # @parameter languages [Languages] The languages to support in this index. 44 | def initialize(languages = Languages.all) 45 | # Initialize with supported languages: 46 | @languages = languages 47 | 48 | # Initialize storage for sources and definitions: 49 | @sources = {} 50 | @definitions = {} 51 | 52 | # Create a prefix tree for efficient lookups: 53 | @trie = Trie.new 54 | end 55 | 56 | # Generate a string representation of this index. 57 | # @returns [String] A formatted string showing the number of definitions. 58 | def inspect 59 | "#<#{self.class} #{@definitions.size} definition(s)>" 60 | end 61 | 62 | # Generate a string representation of the index. 63 | alias to_s inspect 64 | 65 | # All supported languages for this index. 66 | # @attribute [Languages] The languages this index can parse. 67 | attr :languages 68 | 69 | # All source files that have been parsed. 70 | # @attribute [Hash(String, Source)] A mapping of file paths to source objects. 71 | attr :sources 72 | 73 | # All definitions which have been parsed. 74 | # @attribute [Hash(String, Definition)] A mapping of qualified names to definitions. 75 | attr :definitions 76 | 77 | # A (prefix) trie of lexically scoped definitions. 78 | # @attribute [Trie[Definition]] The trie structure for efficient lookups. 79 | attr :trie 80 | 81 | # Updates the index by parsing the specified files. 82 | # All extracted definitions are merged into the existing index. 83 | # @parameter paths [Array(String)] The source file paths to parse and index. 84 | def update(paths) 85 | paths.each do |path| 86 | if source = @languages.source_for(path) 87 | # Store the source file: 88 | @sources[path] = source 89 | 90 | # Extract and index all definitions: 91 | source.definitions do |symbol| 92 | # $stderr.puts "Adding #{symbol.qualified_name} to #{symbol.lexical_path.join(' -> ')}" 93 | 94 | # Add to definitions lookup: 95 | @definitions[symbol.qualified_name] = symbol 96 | 97 | # Add to trie for hierarchical lookup: 98 | @trie.insert(symbol.full_path, symbol) 99 | end 100 | end 101 | end 102 | end 103 | 104 | # Lookup the specified reference and return matching definitions. 105 | # @parameter reference [Language::Reference] The reference to match. 106 | # @parameter relative_to [Definition?] Lookup the reference relative to the scope of this definition. 107 | # @returns [Definition?] The best matching definition, or nil if not found. 108 | def lookup(reference, relative_to: nil) 109 | if reference.absolute? || relative_to.nil? 110 | # Start from root scope: 111 | lexical_path = [] #: Array[Symbol] 112 | else 113 | # Start from the given definition's scope: 114 | lexical_path = relative_to.full_path.dup 115 | end 116 | 117 | path = reference.path 118 | 119 | while true 120 | # Get the current scope node: 121 | node = @trie.lookup(lexical_path) 122 | 123 | if node.children[path.first] 124 | if target = node.lookup(path) 125 | # Return the best matching definition: 126 | if values = target.values 127 | return reference.best(values) 128 | else 129 | return nil 130 | end 131 | else 132 | return nil 133 | end 134 | end 135 | 136 | # Move up one scope level: 137 | break if lexical_path.empty? 138 | lexical_path.pop 139 | end 140 | end 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /.context/sus/shared.md: -------------------------------------------------------------------------------- 1 | # Shared Test Behaviors and Fixtures 2 | 3 | ## Overview 4 | 5 | Sus provides shared test contexts which can be used to define common behaviours or tests that can be reused across one or more test files. 6 | 7 | When you have common test behaviors that you want to apply to multiple test files, add them to the `fixtures/` directory. When you have common test behaviors that you want to apply to multiple implementations of the same interface, within a single test file, you can define them as shared contexts within that file. 8 | 9 | ## Shared Fixtures 10 | 11 | ### Directory Structure 12 | 13 | ``` 14 | my-gem/ 15 | ├── lib/ 16 | │ ├── my_gem.rb 17 | │ └── my_gem/ 18 | │ └── my_thing.rb 19 | ├── fixtures/ 20 | │ └── my_gem/ 21 | │ └── a_thing.rb # Provides MyGem::AThing shared context 22 | └── test/ 23 | ├── my_gem.rb 24 | └── my_gem/ 25 | └── my_thing.rb 26 | ``` 27 | 28 | ### Creating Shared Fixtures 29 | 30 | Create shared behaviors in the `fixtures/` directory using `Sus::Shared`: 31 | 32 | ```ruby 33 | # fixtures/my_gem/a_user.rb 34 | 35 | require "sus/shared" 36 | 37 | module MyGem 38 | AUser = Sus::Shared("a user") do |role| 39 | let(:user) do 40 | { 41 | name: "Test User", 42 | email: "test@example.com", 43 | role: role 44 | } 45 | end 46 | 47 | it "has a name" do 48 | expect(user[:name]).not.to be_nil 49 | end 50 | 51 | it "has a valid email" do 52 | expect(user[:email]).to be(:include?, "@") 53 | end 54 | 55 | it "has a role" do 56 | expect(user[:role]).to be_a(String) 57 | end 58 | end 59 | end 60 | ``` 61 | 62 | ### Using Shared Fixtures 63 | 64 | Require and use shared fixtures in your test files: 65 | 66 | ```ruby 67 | # test/my_gem/user_manager.rb 68 | require 'my_gem/a_user' 69 | 70 | describe MyGem::UserManager do 71 | it_behaves_like MyGem::AUser, "manager" 72 | # or include_context MyGem::AUser, "manager" 73 | end 74 | ``` 75 | 76 | ### Multiple Shared Fixtures 77 | 78 | You can create multiple shared fixtures for different scenarios: 79 | 80 | ```ruby 81 | # fixtures/my_gem/users.rb 82 | module MyGem 83 | module Users 84 | AStandardUser = Sus::Shared("a standard user") do 85 | let(:user) do 86 | { name: "John Doe", role: "user", active: true } 87 | end 88 | 89 | it "is active" do 90 | expect(user[:active]).to be_truthy 91 | end 92 | end 93 | 94 | AnAdminUser = Sus::Shared("an admin user") do 95 | let(:user) do 96 | { name: "Admin User", role: "admin", active: true } 97 | end 98 | 99 | it "has admin role" do 100 | expect(user[:role]).to be == "admin" 101 | end 102 | end 103 | end 104 | end 105 | ``` 106 | 107 | Use specific shared fixtures: 108 | 109 | ```ruby 110 | # test/my_gem/authorization.rb 111 | require 'my_gem/users' 112 | 113 | describe MyGem::Authorization do 114 | with "standard user" do 115 | # If there are no arguments, you can use `include` directly: 116 | include MyGem::Users::AStandardUser 117 | 118 | it "denies admin access" do 119 | auth = subject.new 120 | expect(auth.can_admin?(user)).to be_falsey 121 | end 122 | end 123 | 124 | with "admin user" do 125 | include MyGem::Users::AnAdminUser 126 | 127 | it "allows admin access" do 128 | auth = subject.new 129 | expect(auth.can_admin?(user)).to be_truthy 130 | end 131 | end 132 | end 133 | ``` 134 | 135 | ### Modules 136 | 137 | You can also define shared behaviors in modules and include them in your test files: 138 | 139 | ```ruby 140 | # fixtures/my_gem/shared_behaviors.rb 141 | module MyGem 142 | module SharedBehaviors 143 | def self.included(base) 144 | base.it "uses shared data" do 145 | expect(shared_data).to be == "some shared data" 146 | end 147 | end 148 | 149 | def shared_data 150 | "some shared data" 151 | end 152 | end 153 | end 154 | ``` 155 | 156 | ### Enumerating Tests 157 | 158 | Some tests will be run multiple times with different arguments (for example, multiple database adapters). You can use `Sus::Shared` to define these tests and then enumerate them: 159 | 160 | ```ruby 161 | # test/my_gem/database_adapter.rb 162 | 163 | require "sus/shared" 164 | 165 | ADatabaseAdapter = Sus::Shared("a database adapter") do |adapter| 166 | let(:database) {adapter.new} 167 | 168 | it "connects to the database" do 169 | expect(database.connect).to be_truthy 170 | end 171 | 172 | it "can execute queries" do 173 | expect(database.execute("SELECT 1")).to be == [[1]] 174 | end 175 | end 176 | 177 | # Enumerate the tests with different adapters 178 | MyGem::DatabaseAdapters.each do |adapter| 179 | describe "with #{adapter}", unique: adapter.name do 180 | it_behaves_like ADatabaseAdapter, adapter 181 | end 182 | end 183 | ``` 184 | 185 | Note the use of `unique: adapter.name` to ensure each test is uniquely identified, which is useful for reporting and debugging - otherwise the same test line number would be used for all iterations, which can make it hard to identify which specific test failed. 186 | -------------------------------------------------------------------------------- /lib/decode/rbs/class.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "rbs" 7 | require_relative "wrapper" 8 | require_relative "method" 9 | module Decode 10 | module RBS 11 | # Represents a Ruby class definition wrapper for RBS generation. 12 | class Class < Wrapper 13 | 14 | # Initialize a new class wrapper. 15 | # @parameter definition [Decode::Definition] The class definition to wrap. 16 | def initialize(definition) 17 | super 18 | @generics = nil 19 | end 20 | 21 | # Extract generic type parameters from the class definition. 22 | # @returns [Array[Symbol]] The generic type parameters for this class. 23 | def generics 24 | @generics ||= extract_generics 25 | end 26 | 27 | # Convert the class definition to RBS AST 28 | def to_rbs_ast(method_definitions = [], constant_definitions = [], attribute_definitions = [], index = nil) 29 | name = simple_name_to_rbs(@definition.name) 30 | comment = self.comment 31 | 32 | # Extract generics from RBS tags 33 | type_params = generics.map do |generic| 34 | ::RBS::AST::TypeParam.new( 35 | name: generic.to_sym, 36 | variance: nil, 37 | upper_bound: nil, 38 | location: nil 39 | ) 40 | end 41 | 42 | # Build method definitions: 43 | methods = method_definitions.map{|method_def| Method.new(method_def).to_rbs_ast(index)}.compact 44 | 45 | # Build constant definitions: 46 | constants = constant_definitions.map{|const_def| build_constant_rbs(const_def)}.compact 47 | 48 | # Build attribute definitions and infer instance variable types: 49 | attributes, instance_variables = build_attributes_rbs(attribute_definitions) 50 | 51 | # Extract super class if present: 52 | super_class = if @definition.super_class 53 | ::RBS::AST::Declarations::Class::Super.new( 54 | name: qualified_name_to_rbs(@definition.super_class), 55 | args: [], 56 | location: nil 57 | ) 58 | end 59 | 60 | # Create the class declaration with generics: 61 | ::RBS::AST::Declarations::Class.new( 62 | name: name, 63 | type_params: type_params, 64 | super_class: super_class, 65 | members: constants + attributes + instance_variables + methods, 66 | annotations: [], 67 | location: nil, 68 | comment: comment 69 | ) 70 | end 71 | 72 | private 73 | 74 | def extract_generics 75 | tags.select(&:generic?).map(&:generic_parameter) 76 | end 77 | 78 | # Build a constant RBS declaration. 79 | def build_constant_rbs(constant_definition) 80 | # Look for @constant tags in the constant's documentation: 81 | documentation = constant_definition.documentation 82 | constant_tags = documentation&.filter(Decode::Comment::Constant)&.to_a 83 | 84 | if constant_tags&.any? 85 | type_string = constant_tags.first.type.strip 86 | type = ::Decode::RBS::Type.parse(type_string) 87 | 88 | ::RBS::AST::Declarations::Constant.new( 89 | name: constant_definition.name.to_sym, 90 | type: type, 91 | location: nil, 92 | comment: nil 93 | ) 94 | end 95 | end 96 | 97 | # Convert a simple name to RBS TypeName (not qualified). 98 | def simple_name_to_rbs(name) 99 | ::RBS::TypeName.new(name: name.to_sym, namespace: ::RBS::Namespace.empty) 100 | end 101 | 102 | # Build attribute RBS declarations and infer instance variable types. 103 | # @parameter attribute_definitions [Array] Array of Attribute definition objects 104 | # @returns [Array] A tuple of [attribute_declarations, instance_variable_declarations] 105 | def build_attributes_rbs(attribute_definitions) 106 | attributes = [] 107 | instance_variables = [] 108 | 109 | # Create a mapping from attribute names to their types: 110 | attribute_types = {} 111 | 112 | attribute_definitions.each do |attr_def| 113 | # Extract @attribute type annotation from documentation: 114 | documentation = attr_def.documentation 115 | attribute_tags = documentation&.filter(Decode::Comment::Attribute)&.to_a 116 | 117 | if attribute_tags&.any? 118 | type_string = attribute_tags.first.type.strip 119 | type = ::Decode::RBS::Type.parse(type_string) 120 | 121 | attribute_types[attr_def.name] = type 122 | 123 | # Generate attr_reader RBS declaration: 124 | attributes << ::RBS::AST::Members::AttrReader.new( 125 | name: attr_def.name.to_sym, 126 | type: type, 127 | ivar_name: :"@#{attr_def.name}", 128 | kind: :instance, 129 | annotations: [], 130 | location: nil, 131 | comment: nil 132 | ) 133 | 134 | # Generate instance variable declaration: 135 | instance_variables << ::RBS::AST::Members::InstanceVariable.new( 136 | name: :"@#{attr_def.name}", 137 | type: type, 138 | location: nil, 139 | comment: nil 140 | ) 141 | end 142 | end 143 | 144 | [attributes, instance_variables] 145 | end 146 | 147 | # Convert a qualified name to RBS TypeName 148 | def qualified_name_to_rbs(qualified_name) 149 | parts = qualified_name.split("::") 150 | name = parts.pop 151 | 152 | # For simple names (no ::), create relative references within current namespace: 153 | if parts.empty? 154 | ::RBS::TypeName.new(name: name.to_sym, namespace: ::RBS::Namespace.empty) 155 | else 156 | # For qualified names within the same root namespace, use relative references. 157 | # This handles cases like `Comment::Node`, `Language::Generic` within `Decode` module. 158 | namespace = ::RBS::Namespace.new(path: parts.map(&:to_sym), absolute: false) 159 | ::RBS::TypeName.new(name: name.to_sym, namespace: namespace) 160 | end 161 | end 162 | 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/decode/definition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2020-2025, by Samuel Williams. 5 | 6 | require_relative "location" 7 | 8 | module Decode 9 | # A symbol with attached documentation. 10 | class Definition 11 | # Initialize the symbol. 12 | # @parameter path [Symbol | Array(Symbol)] The path of the definition relatve to the parent. 13 | # @parameter parent [Definition?] The parent lexical scope. 14 | # @parameter language [Language::Generic?] The language in which the symbol is defined in. 15 | # @parameter comments [Array(String)?] The comments associated with the definition. 16 | # @parameter visibility [Symbol] The visibility of the definition. 17 | # @parameter source [Source?] The source file containing this definition. 18 | def initialize(path, parent: nil, language: parent&.language, comments: nil, visibility: :public, source: parent&.source) 19 | @path = Array(path).map(&:to_sym) 20 | 21 | @parent = parent 22 | @language = language 23 | @source = source 24 | 25 | @comments = comments 26 | @visibility = visibility 27 | @documentation = nil 28 | 29 | @full_path = nil 30 | @qualified_name = nil 31 | @nested_name = nil 32 | end 33 | 34 | # Generate a debug representation of the definition. 35 | def inspect 36 | "\#<#{self.class} #{qualified_name}>" 37 | end 38 | 39 | # Generate a string representation of the definition. 40 | alias to_s inspect 41 | 42 | # @attribute [Symbol] The symbol name e.g. `:Decode`. 43 | def name 44 | @path.last 45 | end 46 | 47 | # @attribute [Array(Symbol)] The path to the definition, relative to the parent. 48 | attr :path 49 | 50 | # The full path to the definition. 51 | # @returns [Array(Symbol)] The complete path from root to this definition. 52 | def full_path 53 | @full_path ||= begin 54 | if parent = @parent 55 | parent.full_path + @path 56 | else 57 | @path 58 | end 59 | end 60 | end 61 | 62 | # The lexical path to the definition (full path including all namespaces). 63 | # @returns [Array(Symbol)] The complete path from root to this definition. 64 | alias lexical_path full_path 65 | 66 | # @attribute [Definition?] The parent definition, defining lexical scope. 67 | attr :parent 68 | 69 | # @attribute [Language::Generic] The language the symbol is defined within. 70 | attr :language 71 | 72 | # @attribute [Source?] The source file containing this definition. 73 | attr :source 74 | 75 | # @attribute [Array(String)?] The comment lines which directly preceeded the definition. 76 | attr :comments 77 | 78 | # Whether the definition is considered part of the public interface. 79 | # This is used to determine whether the definition should be documented for coverage purposes. 80 | # @returns [bool] True if the definition is public. 81 | def public? 82 | true 83 | end 84 | 85 | # @returns [bool] If the definition should be counted in coverage metrics. 86 | def coverage_relevant? 87 | self.public? 88 | end 89 | 90 | # Whether the definition has documentation. 91 | # @returns [bool] True if the definition has non-empty comments. 92 | def documented? 93 | @comments&.any? || false 94 | end 95 | 96 | # The qualified name is an absolute name which includes any and all namespacing. 97 | # @returns [String] 98 | def qualified_name 99 | @qualified_name ||= begin 100 | if parent = @parent 101 | [parent.qualified_name, self.nested_name].join("::") 102 | else 103 | self.nested_name 104 | end 105 | end 106 | end 107 | 108 | # @returns [String] The name relative to the parent. 109 | def nested_name 110 | @nested_name ||= "#{@path.join("::")}" 111 | end 112 | 113 | # Does the definition name match the specified prefix? 114 | # @parameter prefix [String] The prefix to match against. 115 | # @returns [bool] 116 | def start_with?(prefix) 117 | self.nested_name.start_with?(prefix) 118 | end 119 | 120 | # Convert this definition into another kind of definition. 121 | # @parameter kind [Symbol] The kind to convert to. 122 | def convert(kind) 123 | raise ArgumentError, "Unable to convert #{self} into #{kind}!" 124 | end 125 | 126 | # A short form of the definition. 127 | # e.g. `def short_form`. 128 | # 129 | # @returns [String?] 130 | def short_form 131 | end 132 | 133 | # A long form of the definition. 134 | # e.g. `def initialize(kind, name, comments, **options)`. 135 | # 136 | # @returns [String?] 137 | def long_form 138 | self.short_form 139 | end 140 | 141 | # A long form which uses the qualified name if possible. 142 | # Defaults to {long_form}. 143 | # 144 | # @returns [String?] 145 | def qualified_form 146 | self.long_form 147 | end 148 | 149 | # Whether the definition spans multiple lines. 150 | # 151 | # @returns [bool] 152 | def multiline? 153 | false 154 | end 155 | 156 | # The full text of the definition. 157 | # 158 | # @returns [String?] 159 | def text 160 | end 161 | 162 | # Whether this definition can contain nested definitions. 163 | # 164 | # @returns [bool] 165 | def container? 166 | false 167 | end 168 | 169 | # Whether this represents a single entity to be documented (along with it's contents). 170 | # 171 | # @returns [bool] 172 | def nested? 173 | container? 174 | end 175 | 176 | # Structured access to the definitions comments. 177 | # 178 | # @returns [Documentation?] A {Documentation} instance if this definition has comments. 179 | def documentation 180 | if comments = @comments and comments.any? 181 | @documentation ||= Documentation.new(comments, @language) 182 | end 183 | end 184 | 185 | # The location of the definition. 186 | # 187 | # @returns [Location?] A {Location} instance if this definition has a location. 188 | def location 189 | nil 190 | end 191 | 192 | # The visibility of the definition. 193 | # @attribute [Symbol] :public, :private, :protected 194 | attr_accessor :visibility 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /test/decode/rbs/module.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2025, by Samuel Williams. 5 | 6 | require "decode/language/ruby" 7 | require "decode/rbs/module" 8 | require "decode/definition" 9 | require "decode/documentation" 10 | require "decode/comment/text" 11 | 12 | describe Decode::RBS::Module do 13 | let(:language) {Decode::Language::Ruby.new} 14 | let(:comments) {[]} 15 | let(:definition) {Decode::Language::Ruby::Module.new([:TestModule], comments: comments, language: language)} 16 | let(:rbs_module) {subject.new(definition)} 17 | 18 | with "#initialize" do 19 | it "initializes with definition" do 20 | expect(rbs_module.instance_variable_get(:@definition)).to be == definition 21 | end 22 | 23 | it "inherits from Wrapper" do 24 | expect(rbs_module).to be_a(Decode::RBS::Wrapper) 25 | end 26 | end 27 | 28 | with "#to_rbs_ast" do 29 | with "basic module" do 30 | it "generates RBS AST for basic module" do 31 | ast = rbs_module.to_rbs_ast 32 | 33 | expect(ast).to be_a(::RBS::AST::Declarations::Module) 34 | expect(ast.name.name).to be == :TestModule 35 | expect(ast.name.namespace).to be == ::RBS::Namespace.empty 36 | expect(ast.type_params).to be(:empty?) 37 | expect(ast.self_types).to be(:empty?) 38 | expect(ast.members).to be(:empty?) 39 | end 40 | end 41 | 42 | with "module with methods" do 43 | let(:method_definition) {Decode::Language::Ruby::Method.new([:test_method])} 44 | 45 | it "includes method definitions in members" do 46 | ast = rbs_module.to_rbs_ast([method_definition]) 47 | 48 | expect(ast.members).not.to be(:empty?) 49 | expect(ast.members.length).to be == 1 50 | end 51 | end 52 | 53 | with "module with documentation" do 54 | let(:comments) {["This is a test module"]} 55 | 56 | it "includes comment in RBS AST" do 57 | ast = rbs_module.to_rbs_ast 58 | 59 | expect(ast.comment).not.to be_nil 60 | expect(ast.comment.string).to be == "This is a test module" 61 | end 62 | end 63 | end 64 | 65 | with "private methods" do 66 | with "#simple_name_to_rbs" do 67 | it "converts simple name to RBS TypeName" do 68 | type_name = rbs_module.send(:simple_name_to_rbs, "TestModule") 69 | 70 | expect(type_name).to be_a(::RBS::TypeName) 71 | expect(type_name.name).to be == :TestModule 72 | expect(type_name.namespace).to be == ::RBS::Namespace.empty 73 | end 74 | end 75 | 76 | with "#comment" do 77 | with "definition with text documentation" do 78 | let(:comments) {["Test module comment"]} 79 | 80 | it "extracts comment from documentation" do 81 | comment = rbs_module.comment 82 | 83 | expect(comment).to be_a(::RBS::AST::Comment) 84 | expect(comment.string).to be == "Test module comment" 85 | end 86 | end 87 | 88 | with "definition without documentation" do 89 | it "returns nil when no documentation" do 90 | comment = rbs_module.comment 91 | expect(comment).to be_nil 92 | end 93 | end 94 | 95 | with "definition with multiple text lines" do 96 | let(:comments) {["First line", "Second line"]} 97 | 98 | it "joins multiple text lines with newlines" do 99 | comment = rbs_module.comment 100 | 101 | expect(comment).to be_a(::RBS::AST::Comment) 102 | expect(comment.string).to be == "First line\nSecond line" 103 | end 104 | end 105 | 106 | with "#build_constant_rbs (constant type inference in modules)" do 107 | with "module constants with type annotations" do 108 | let(:language) {Decode::Language::Ruby.new} 109 | let(:comments) {["@constant [Integer] The default timeout value."]} 110 | let(:const_definition) {Decode::Language::Ruby::Constant.new([:DEFAULT_TIMEOUT], comments: comments, language: language)} 111 | let(:definition) {Decode::Language::Ruby::Module.new([:TestModule], language: language)} 112 | let(:rbs_module) {subject.new(definition)} 113 | 114 | it "generates constant RBS declaration in modules" do 115 | constant_rbs = rbs_module.send(:build_constant_rbs, const_definition) 116 | 117 | expect(constant_rbs).to be_a(::RBS::AST::Declarations::Constant) 118 | expect(constant_rbs.name).to be == :DEFAULT_TIMEOUT 119 | expect(constant_rbs.type).to be_a(::RBS::Types::ClassInstance) 120 | end 121 | end 122 | 123 | with "module constants without annotations" do 124 | let(:language) {Decode::Language::Ruby.new} 125 | let(:comments) {["Regular comment without @constant tag."]} 126 | let(:const_definition) {Decode::Language::Ruby::Constant.new([:REGULAR_CONSTANT], comments: comments, language: language)} 127 | let(:definition) {Decode::Language::Ruby::Module.new([:TestModule], language: language)} 128 | let(:rbs_module) {subject.new(definition)} 129 | 130 | it "ignores module constants without @constant annotations" do 131 | constant_rbs = rbs_module.send(:build_constant_rbs, const_definition) 132 | expect(constant_rbs).to be_nil 133 | end 134 | end 135 | end 136 | 137 | with "#to_rbs_ast with constant definitions in modules" do 138 | let(:language) {Decode::Language::Ruby.new} 139 | let(:comments) {["@constant [String] The module version."]} 140 | let(:const_definition) {Decode::Language::Ruby::Constant.new([:VERSION], comments: comments, language: language)} 141 | let(:definition) {Decode::Language::Ruby::Module.new([:TestModule], language: language)} 142 | let(:rbs_module) {subject.new(definition)} 143 | 144 | it "includes constants in generated module AST members" do 145 | ast = rbs_module.to_rbs_ast([], [const_definition], []) 146 | 147 | # Should include constants in module members 148 | constants = ast.members.select {|m| m.is_a?(::RBS::AST::Declarations::Constant)} 149 | 150 | expect(constants).to have_attributes(length: be == 1) 151 | expect(constants.first.name).to be == :VERSION 152 | expect(constants.first.type).to be_a(::RBS::Types::ClassInstance) 153 | end 154 | end 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /.context/agent-context/usage.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## What is agent-context? 4 | 5 | `agent-context` is a tool that helps you discover and install contextual information from Ruby gems for AI agents. Gems can provide additional documentation, examples, and guidance in a `context/` directory. 6 | 7 | ## Quick Commands 8 | 9 | ```bash 10 | # See what context is available 11 | bake agent:context:list 12 | 13 | # Install all available context 14 | bake agent:context:install 15 | 16 | # Install context from a specific gem 17 | bake agent:context:install --gem async 18 | 19 | # See what context files a gem provides 20 | bake agent:context:list --gem async 21 | 22 | # View a specific context file 23 | bake agent:context:show --gem async --file thread-safety 24 | ``` 25 | 26 | ## Understanding context/ vs .context/ 27 | 28 | **Important distinction:** 29 | - **`context/`** (no dot) = Directory in gems that contains context files to share. 30 | - **`.context/`** (with dot) = Directory in your project where context gets installed. 31 | 32 | ### What happens when you install context? 33 | 34 | When you run `bake agent:context:install`, the tool: 35 | 36 | 1. Scans all installed gems for `context/` directories (in the gem's root). 37 | 2. Creates a `.context/` directory in your current project. 38 | 3. Copies context files organized by gem name. 39 | 40 | For example: 41 | ``` 42 | your-project/ 43 | ├── .context/ # ← Installed context (with dot) 44 | │ ├── async/ # ← From the 'async' gem's context/ directory 45 | │ │ ├── thread-safety.md 46 | │ │ └── performance.md 47 | │ └── rack/ # ← From the 'rack' gem's context/ directory 48 | │ └── middleware.md 49 | ├── lib/ 50 | └── Gemfile 51 | ``` 52 | 53 | Meanwhile, in the gems themselves: 54 | ``` 55 | async-gem/ 56 | ├── context/ # ← Source context (no dot) 57 | │ ├── thread-safety.md 58 | │ └── performance.md 59 | ├── lib/ 60 | └── async.gemspec 61 | ``` 62 | 63 | ## Using Context (For Gem Users) 64 | 65 | ### Why use this? 66 | 67 | - **Discover hidden documentation** that gems provide. 68 | - **Get practical examples** and guidance. 69 | - **Understand best practices** from gem authors. 70 | - **Access migration guides** and troubleshooting tips. 71 | 72 | ### Key Points for Users 73 | 74 | - Run `bake agent:context:install` to copy context to `.context/` (with dot). 75 | - The `.context/` directory is where installed context lives in your project. 76 | - Don't edit files in `.context/` - they get completely replaced when you reinstall. 77 | 78 | ## Providing Context (For Gem Authors) 79 | 80 | ### How to provide context in your gem 81 | 82 | #### 1. Create a `context/` directory 83 | 84 | In your gem's root directory, create a `context/` folder (no dot): 85 | 86 | ``` 87 | your-gem/ 88 | ├── context/ # ← Source context (no dot) - this is what you create 89 | │ ├── getting-started.md 90 | │ ├── configuration.md 91 | │ └── troubleshooting.md 92 | ├── lib/ 93 | └── your-gem.gemspec 94 | ``` 95 | 96 | **Important:** This is different from `.context/` (with dot) which is where context gets installed in user projects. 97 | 98 | #### 2. Add context files 99 | 100 | Create files with helpful information for users of your gem. Common types include: 101 | 102 | - **getting-started.md** - Quick start guide for using your gem. 103 | - **configuration.md** - Configuration options and examples. 104 | - **troubleshooting.md** - Common issues and solutions. 105 | - **migration.md** - Migration guides between versions. 106 | - **performance.md** - Performance tips and best practices. 107 | - **security.md** - Security considerations. 108 | 109 | **Focus on the agent experience:** These files should help AI agents understand how to use your gem effectively, not document your gem's internal APIs. 110 | 111 | #### 3. Document your context 112 | 113 | Add a section to your gem's README: 114 | 115 | ```markdown 116 | ## Context 117 | 118 | This gem provides additional context files that can be installed using `bake agent:context:install`. 119 | 120 | Available context files: 121 | - `getting-started.md` - Quick start guide. 122 | - `configuration.md` - Configuration options. 123 | - `troubleshooting.md` - Common issues and solutions. 124 | ``` 125 | 126 | #### 4. File format and content guidelines 127 | 128 | Context files can be in any format, but `.md` is commonly used for documentation. The content should be: 129 | 130 | - **Practical** - Include real examples and working code. 131 | - **Focused** - One topic per file. 132 | - **Clear** - Easy to understand and follow. 133 | - **Actionable** - Provide specific guidance and next steps. 134 | - **Agent-focused** - Help AI agents understand how to use your gem effectively. 135 | 136 | ### Key Points for Gem Authors 137 | 138 | - Create a `context/` directory (no dot) in your gem's root. 139 | - Put helpful guides for users of your gem there. 140 | - Focus on practical usage, not API documentation. 141 | 142 | ## Example Context Files 143 | 144 | For examples of well-structured context files, see the existing files in this directory: 145 | - `usage.md` - Shows how to use the tool (this file). 146 | - `examples.md` - Demonstrates practical usage scenarios. 147 | 148 | ## Key Differences from API Documentation 149 | 150 | Context files are NOT the same as API documentation: 151 | 152 | - **Context files**: Help agents accomplish tasks ("How do I configure authentication?"). 153 | - **API documentation**: Document methods and classes ("Method `authenticate` returns Boolean"). 154 | 155 | Context files should answer questions like: 156 | - "How do I get started?". 157 | - "How do I configure this for production?". 158 | - "What do I do when X goes wrong?". 159 | - "How do I migrate from version Y to Z?". 160 | 161 | ## Testing Your Context 162 | 163 | Before publishing, test your context files: 164 | 165 | 1. Have an AI agent try to follow your getting-started guide. 166 | 2. Check that all code examples actually work. 167 | 3. Ensure the files are focused and don't try to cover too much. 168 | 4. Verify that they complement rather than duplicate your main documentation. 169 | 170 | ## Summary 171 | 172 | - **`context/`** = source (in gems). 173 | - **`.context/`** = destination (in your project). 174 | --------------------------------------------------------------------------------