diff --git a/README.md b/README.md index 0b14ea0..325e24b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ sudo mv amber amber-lsp /usr/local/bin/ **Windows:** Use WSL2 or a virtual machine. Native Windows support is not currently available. +### Package Manager Compatibility + +Amber CLI is designed to work with upstream `crystal-lang/shards`, and we also +validate it against the additive fork currently distributed as `shards-alpha`. +The goal is straightforward: standard Amber workflows should continue to work +with plain `shards` commands, while compatible forks can add tooling without +changing how a new Amber project gets started. + ### Create Your First App ```bash diff --git a/scripts/lsp_smoke.rb b/scripts/lsp_smoke.rb new file mode 100755 index 0000000..559fd4e --- /dev/null +++ b/scripts/lsp_smoke.rb @@ -0,0 +1,290 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# lsp_smoke.rb — live-fire proof that the built `amber-lsp` binary really +# speaks LSP over stdio and really publishes diagnostics. +# +# The Crystal specs under spec/amber_lsp/ exercise the server class in-process +# with an IO::Memory pair. That proves the code, not the binary: it cannot +# catch a stale binary on disk, a broken build, or a server that hangs instead +# of answering. This script spawns the ACTUAL executable, drives a real framed +# stdio session against a throwaway Amber-shaped project, and asserts on the +# `textDocument/publishDiagnostics` notifications that come back. +# +# scripts/lsp_smoke.rb # uses ./bin/amber-lsp +# scripts/lsp_smoke.rb --server /path/to/amber-lsp +# scripts/lsp_smoke.rb --timeout 20 --keep # keep the fixture project +# +# Exit 0 = the violating fixture produced >= 1 diagnostic AND the clean fixture +# produced exactly 0. Exit 1 = live-fire expectations not met. Exit 2 = could +# not run at all (no binary, handshake failure, timeout). A timeout is never +# reported as a pass — "I could not measure it" is not "it is clean". +# +# Ruby 2.6 compatible on purpose: it must run under macOS system ruby with no +# gems, so it works anywhere the binary does. + +require "json" +require "fileutils" +require "tmpdir" + +module LSPSmoke + EXIT_OK = 0 + EXIT_FAILED = 1 + EXIT_CANNOT = 2 + + # LSP DiagnosticSeverity + SEVERITY = { 1 => "error", 2 => "warning", 3 => "info", 4 => "hint" }.freeze + + # Reads `Content-Length: N\r\n\r\n` frames off a pipe under a deadline. + # Buffered + non-blocking so a server that goes quiet times out instead of + # wedging the script forever. + class FrameReader + def initialize(io) + @io = io + @buf = String.new.force_encoding(Encoding::BINARY) + end + + def read_frame(deadline) + loop do + frame = take_frame + return frame if frame + return nil unless fill(deadline) + end + end + + private + + def take_frame + idx = @buf.index("\r\n\r\n") + return nil unless idx + + header = @buf[0, idx] + length = header[/Content-Length:\s*(\d+)/i, 1] + raise "malformed LSP header: #{header.inspect}" if length.nil? + + length = length.to_i + total = idx + 4 + length + return nil if @buf.bytesize < total + + body = @buf.byteslice(idx + 4, length) + @buf = @buf.byteslice(total, @buf.bytesize - total) + body.force_encoding(Encoding::UTF_8) + end + + def fill(deadline) + remaining = deadline - Time.now + return false if remaining <= 0 + return false unless IO.select([@io], nil, nil, remaining) + + @buf << @io.read_nonblock(65_536) + true + rescue IO::WaitReadable + true + rescue EOFError, Errno::EIO + false + end + end + + module_function + + def frame(message) + json = JSON.generate(message) + "Content-Length: #{json.bytesize}\r\n\r\n#{json}" + end + + # A minimal project the LSP will actually accept: ProjectContext.detect only + # switches diagnostics on when shard.yml has an `amber` DEPENDENCY. Without + # it the server stays silent and every file looks clean — the exact false + # green this script exists to make impossible. + def build_fixture(dir) + FileUtils.mkdir_p(File.join(dir, "src", "controllers")) + FileUtils.mkdir_p(File.join(dir, "spec", "controllers")) + + File.write(File.join(dir, "shard.yml"), <<~YAML) + name: lsp_smoke_app + version: 0.1.0 + dependencies: + amber: + github: amberframework/amber + YAML + + # Violating: class name does not end in Controller (amber/controller-naming, + # error severity) and the action never renders (amber/action-return-type). + File.write(File.join(dir, "src", "controllers", "users_controller.cr"), <<~CRYSTAL) + class UsersHandler < Amber::Controller::Base + def index + users = ["Alice", "Bob"] + end + end + CRYSTAL + File.write(File.join(dir, "spec", "controllers", "users_controller_spec.cr"), + "# spec placeholder\n") + + # Clean: correct suffix, renders, documented, fully typed. + File.write(File.join(dir, "src", "controllers", "posts_controller.cr"), <<~CRYSTAL) + # Serves the blog post pages. + class PostsController < Amber::Controller::Base + # Renders the list of posts. + def index : String + render("index.ecr") + end + end + CRYSTAL + File.write(File.join(dir, "spec", "controllers", "posts_controller_spec.cr"), + "# spec placeholder\n") + end + + def session(server_bin, dir, timeout) + root_uri = "file://#{dir}" + bad_uri = "file://#{dir}/src/controllers/users_controller.cr" + good_uri = "file://#{dir}/src/controllers/posts_controller.cr" + + messages = [ + frame("jsonrpc" => "2.0", "id" => 1, "method" => "initialize", + "params" => { "rootUri" => root_uri, "capabilities" => {} }), + frame("jsonrpc" => "2.0", "method" => "initialized", "params" => {}), + frame("jsonrpc" => "2.0", "method" => "textDocument/didOpen", + "params" => { "textDocument" => { + "uri" => bad_uri, "languageId" => "crystal", "version" => 1, + "text" => File.read(File.join(dir, "src", "controllers", "users_controller.cr")) + } }), + frame("jsonrpc" => "2.0", "method" => "textDocument/didOpen", + "params" => { "textDocument" => { + "uri" => good_uri, "languageId" => "crystal", "version" => 1, + "text" => File.read(File.join(dir, "src", "controllers", "posts_controller.cr")) + } }), + frame("jsonrpc" => "2.0", "id" => 2, "method" => "shutdown"), + frame("jsonrpc" => "2.0", "method" => "exit") + ] + + published = {} + initialized = false + + io = IO.popen([server_bin], "r+", err: File::NULL) + begin + io.binmode + io.write(messages.join) + io.flush + + reader = FrameReader.new(io) + deadline = Time.now + timeout + + while published.size < 2 || !initialized + raw = reader.read_frame(deadline) + break if raw.nil? + + msg = JSON.parse(raw) + initialized = true if msg["id"] == 1 && msg.key?("result") + next unless msg["method"] == "textDocument/publishDiagnostics" + + published[msg["params"]["uri"]] = msg + end + ensure + begin + io.close + rescue StandardError + nil + end + end + + { initialized: initialized, published: published, bad_uri: bad_uri, good_uri: good_uri } + end + + def describe(diagnostics) + diagnostics.map do |d| + line = d["range"]["start"]["line"].to_i + 1 + sev = SEVERITY.fetch(d["severity"].to_i, d["severity"].to_s) + " #{sev} L#{line} [#{d['code']}] #{d['message']}" + end + end + + def main(argv) + server = File.join(Dir.pwd, "bin", "amber-lsp") + timeout = 15 + keep = false + + until argv.empty? + case (arg = argv.shift) + when "--server" then server = argv.shift.to_s + when "--timeout" then timeout = argv.shift.to_i + when "--keep" then keep = true + when "-h", "--help" + puts "usage: lsp_smoke.rb [--server PATH] [--timeout SECONDS] [--keep]" + return EXIT_OK + else + warn "lsp_smoke: unknown argument #{arg.inspect}" + return EXIT_CANNOT + end + end + + unless File.file?(server) && File.executable?(server) + warn "lsp_smoke: no executable server at #{server}" + warn "lsp_smoke: build it first — CRYSTAL=crystal-alpha shards build amber-lsp" + return EXIT_CANNOT + end + + dir = Dir.mktmpdir("amber_lsp_smoke") + begin + build_fixture(dir) + puts "server: #{server}" + puts "fixture: #{dir}" + puts + + result = session(server, dir, timeout) + + unless result[:initialized] + warn "lsp_smoke: never received a response to `initialize` within #{timeout}s — handshake FAILED." + return EXIT_CANNOT + end + + bad = result[:published][result[:bad_uri]] + good = result[:published][result[:good_uri]] + + if bad.nil? || good.nil? + missing = [] + missing << "violating fixture" if bad.nil? + missing << "clean fixture" if good.nil? + warn "lsp_smoke: no publishDiagnostics for #{missing.join(' and ')} within #{timeout}s." + warn "lsp_smoke: a silent server is NOT a clean server — treating as could-not-measure." + return EXIT_CANNOT + end + + bad_diags = bad["params"]["diagnostics"] + good_diags = good["params"]["diagnostics"] + + puts "--- publishDiagnostics: VIOLATING fixture (src/controllers/users_controller.cr) ---" + puts JSON.pretty_generate(bad) + puts describe(bad_diags) + puts + puts "--- publishDiagnostics: CLEAN fixture (src/controllers/posts_controller.cr) ---" + puts JSON.pretty_generate(good) + puts describe(good_diags) + puts + + ok = true + if bad_diags.empty? + warn "FAIL: violating fixture produced 0 diagnostics (expected >= 1)." + ok = false + end + unless good_diags.empty? + warn "FAIL: clean fixture produced #{good_diags.size} diagnostic(s) (expected 0)." + ok = false + end + + if ok + puts "LIVE-FIRE OK: violating=#{bad_diags.size} diagnostic(s), clean=0." + EXIT_OK + else + EXIT_FAILED + end + ensure + FileUtils.rm_rf(dir) unless keep + puts "fixture kept at #{dir}" if keep + end + rescue StandardError => e + warn "lsp_smoke: could not run (#{e.class}: #{e.message})" + EXIT_CANNOT + end +end + +exit LSPSmoke.main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/shard.yml b/shard.yml index 77d07d9..6d443ad 100644 --- a/shard.yml +++ b/shard.yml @@ -48,6 +48,20 @@ dependencies: version: ~> 1.2.2 development_dependencies: + # Pinned to the 1.7 dev line rather than `~> 1.6.4`, because Crystal 1.21 + # REMOVED Crystal::Lexer#next_string_array_token and every released ameba up + # to 1.6.4 calls it unguarded from src/ameba/tokenizer.cr:88. That makes + # `shards install` (and therefore `shards build `) impossible to + # finish on 1.21: it dies inside ameba's own postinstall, before a single + # line of this project is compiled. + # + # ameba fixed it in 4057e0c5e "Support Crystal v1.21.0+" (2026-05-04), which + # guards the call with `lexer.responds_to?(:next_string_array_token)`. That + # commit is reachable only from the untagged 1.7 line. The SHA below is what + # the `v1.7.0-dev` tag points at today; pinned by SHA and not by that tag + # because a `-dev` tag is a moving pointer and this needs to be reproducible. + # + # TODO: switch to `version: ~> 1.7` once ameba 1.7.0 is actually tagged. ameba: github: crystal-ameba/ameba - version: ~> 1.6.4 + commit: 34a3de4598aa009c878a1812bb018f0deaf15126 diff --git a/spec/amber_lsp/fsdd/doc_block_required_spec.cr b/spec/amber_lsp/fsdd/doc_block_required_spec.cr new file mode 100644 index 0000000..dd68d95 --- /dev/null +++ b/spec/amber_lsp/fsdd/doc_block_required_spec.cr @@ -0,0 +1,112 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/doc_block_required_rule" + +describe AmberLSP::Rules::FSDD::DocBlockRequiredRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::DocBlockRequiredRule.new) + end + + describe "#check" do + it "flags a public def without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "def foo : String\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/doc-block-required") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Information) + diagnostics[0].message.should contain("foo") + end + + it "flags a public class without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "class User\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("User") + end + + it "stays quiet when a doc comment immediately precedes a public def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# Returns a greeting\ndef foo : String\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet when a doc comment immediately precedes a public class" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# User model\nclass User\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on private def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "private def foo : String\nend\n") + diagnostics.should be_empty + end + + it "stays quiet on protected def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "protected def foo : String\nend\n") + diagnostics.should be_empty + end + + it "stays quiet on a def preceded by a standalone private keyword" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "private\n\ndef foo : String\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "flags multiple undocumented public methods in one file" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = <<-CRYSTAL + def foo : String + "foo" + end + + def bar : Int32 + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(2) + end + + it "flags only the undocumented method when another method has a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = <<-CRYSTAL + # Documented + def good : String + "ok" + end + + def bad : Int32 + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("bad") + end + + it "stays quiet on an abstract class preceded by a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# Base rule\nabstract class BaseRule\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "flags an abstract def without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "abstract def perform : Void\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("perform") + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/spec/amber_lsp/fsdd/method_type_signature_spec.cr b/spec/amber_lsp/fsdd/method_type_signature_spec.cr new file mode 100644 index 0000000..408faa7 --- /dev/null +++ b/spec/amber_lsp/fsdd/method_type_signature_spec.cr @@ -0,0 +1,103 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/method_type_signature_rule" + +describe AmberLSP::Rules::FSDD::MethodTypeSignatureRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new) + end + + describe "#check" do + it "flags a method with an untyped parameter" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def foo(x)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/method-type-signature") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("foo") + diagnostics[0].message.should contain("untyped parameter") + end + + it "flags a method with typed params but no return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def bar(x : Int32)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/method-type-signature") + diagnostics[0].message.should contain("bar") + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a fully typed method" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def baz(x : Int32) : String\nend\n") + diagnostics.should be_empty + end + + it "flags a method with no params and no return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def greet\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a no-param method with return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def count : Int32\nend\n") + diagnostics.should be_empty + end + + it "flags both issues when params are untyped AND return type is missing" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/services/calculator.cr", "def add(a, b)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("untyped parameter") + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a method with multiple typed params and return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = "def add(a : Int32, b : Int32) : Int32\nend\n" + diagnostics = rule.check("src/services/calculator.cr", content) + diagnostics.should be_empty + end + + it "skips initialize methods" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def initialize(@name : String)\nend\n") + diagnostics.should be_empty + end + + it "flags a method with a mix of typed and untyped params" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = "def process(name, count : Int32) : Bool\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("untyped parameter") + end + + it "handles multiple methods in one file" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = <<-CRYSTAL + def foo(x : Int32) : String + x.to_s + end + + def bar(y) + y + end + + def qux + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(2) + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/spec/amber_lsp/fsdd/process_manager_structure_spec.cr b/spec/amber_lsp/fsdd/process_manager_structure_spec.cr new file mode 100644 index 0000000..196c571 --- /dev/null +++ b/spec/amber_lsp/fsdd/process_manager_structure_spec.cr @@ -0,0 +1,130 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/process_manager_structure_rule" + +describe AmberLSP::Rules::FSDD::ProcessManagerStructureRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new) + end + + describe "#check" do + it "returns empty for files outside process_managers or processes directories" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + diagnostics = rule.check("src/models/user.cr", "class Foo\nend\n") + diagnostics.should be_empty + end + + it "flags a class missing initialize" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/process-manager-structure") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("LockAccounts") + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + + it "flags a class missing a public perform or call method" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32) + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "reports both issues when both are missing" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = "class Billing::LockAccounts\nend\n" + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "stays quiet when class has typed initialize and public perform" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32, @reason : String) + end + + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.should be_empty + end + + it "stays quiet when class uses call instead of perform" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Users::CreateUser + def initialize(@email : String) + end + + def call : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "flags a class whose initialize has no typed parameters" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(account_id, reason) + end + + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + + it "flags a class whose perform method is private" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32) + end + + private def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "also applies to files under processes/ directory" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::RetryPayments + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/processes/billing/retry_payments.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + end +end diff --git a/spec/amber_lsp/fsdd/story_grammar_spec.cr b/spec/amber_lsp/fsdd/story_grammar_spec.cr new file mode 100644 index 0000000..9157744 --- /dev/null +++ b/spec/amber_lsp/fsdd/story_grammar_spec.cr @@ -0,0 +1,114 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/story_grammar_rule" + +describe AmberLSP::Rules::FSDD::StoryGrammarRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::StoryGrammarRule.new) + end + + describe "#check" do + it "fires on a story block missing the action verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user\n# views the dashboard\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/story-grammar") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("missing action verb") + end + + it "fires on a story block missing the model name" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user, I want to GET something from the list\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing capitalized Model name") + end + + it "fires on a story block missing both verb and model" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing action verb") + diagnostics[0].message.should contain("missing capitalized Model name") + end + + it "stays quiet on a complete persona story with GET verb and model" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As an Admin, I want to GET Users so I can manage the Account\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a complete persona story with POST verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As an Admin, I want to POST a new User to the system\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a complete scheduling story using perform verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# At midnight on Monday, perform RetryPayments for all pending Orders\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a recurring story with do verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# Every night, do LockExpiredAccounts for all Users\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a comment block with no story initiator" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# Private helper that validates inputs\n# Returns nil if invalid\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on regular code with no comments" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "def perform : Void\n validate\nend\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "flags only the incomplete story block in a mixed file" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = <<-CRYSTAL + # Regular comment about the class + class Foo + # As a user + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing action verb") + end + + it "stays quiet on a complete multi-line story block" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = <<-CRYSTAL + # As an Admin, I want to DELETE a User + # so that I can manage the Account membership + # views the user management page after successful deletion + def destroy : Void + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + diagnostics = rule.check("src/process_managers/users/create_user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/spec/amber_lsp/integration/agent_e2e_spec.cr b/spec/amber_lsp/integration/agent_e2e_spec.cr index 912b3a7..213269e 100644 --- a/spec/amber_lsp/integration/agent_e2e_spec.cr +++ b/spec/amber_lsp/integration/agent_e2e_spec.cr @@ -121,9 +121,18 @@ describe "Agent E2E: LSP diagnostic feedback loop" do # The corrected code: # - Renamed "UsersHandler" → "UsersController" (fixes controller-naming) # - Added render call in index (fixes action-return-type) + # - Doc comment on the class and the action (fixes fsdd/doc-block-required) + # - Explicit return type on the action (fixes fsdd/method-type-signature) + # + # The FSDD rules are on by default and they apply to us too. "Clean" in + # this spec means clean by OUR OWN conventions, not merely free of the two + # violations the fixture was originally written to demonstrate — otherwise + # this spec would quietly assert that our conventions are optional. fixed_code = <<-CRYSTAL + # Serves the user-facing account pages. class UsersController < Amber::Controller::Base - def index + # Renders the list of users. + def index : String users = ["Alice", "Bob"] render("index.ecr") end diff --git a/spec/fsdd_docs/parser_spec.cr b/spec/fsdd_docs/parser_spec.cr new file mode 100644 index 0000000..5c3f6ad --- /dev/null +++ b/spec/fsdd_docs/parser_spec.cr @@ -0,0 +1,272 @@ +require "spec" +require "json" +require "../../src/amber_cli/fsdd_docs/parser" + +FSDD_FIXTURE_JSON = <<-JSON + { + "repository_name": "TestProject", + "body": "", + "program": { + "html_id": "", + "path": "", + "kind": "module", + "full_name": "TestProject", + "name": "TestProject", + "abstract": false, + "types": [ + { + "html_id": "auth-service", + "name": "AuthService", + "full_name": "AuthService", + "kind": "class", + "summary": "Authentication service", + "doc": "**Personas:** Developer, QA\\n**Requires:** User model exists\\n**Since:** v1.0\\n**Ramp:** Basic → Advanced\\n**Use:** `authenticate(email, password)`\\n**Result:** Returns JWT token on success\\n\\nRefined story: As a Developer, I want to authenticate users so they can access protected resources.", + "locations": [{"filename": "src/auth_service.cr", "line_number": 1, "url": null}], + "instance_methods": [ + { + "html_id": "authenticate", + "name": "authenticate", + "abstract": false, + "args_string": "(email : String, password : String)", + "doc": "**Personas:** Developer\\n**Use:** `service.authenticate(email, password)`\\n**Result:** Returns Bool", + "summary": "Authenticate a user by email and password", + "location": {"filename": "src/auth_service.cr", "line_number": 10, "url": null}, + "def": { + "name": "authenticate", + "args": [], + "return_type": "Bool", + "visibility": "Public", + "body": "" + } + }, + { + "html_id": "logout", + "name": "logout", + "abstract": false, + "args_string": "(session_id : String)", + "doc": null, + "summary": null, + "location": {"filename": "src/auth_service.cr", "line_number": 20, "url": null}, + "def": { + "name": "logout", + "args": [], + "return_type": "Nil", + "visibility": "Public", + "body": "" + } + } + ], + "class_methods": [ + { + "html_id": "create", + "name": "create", + "abstract": false, + "args_string": "(config : Hash(String, String))", + "doc": null, + "summary": null, + "location": {"filename": "src/auth_service.cr", "line_number": 5, "url": null}, + "def": { + "name": "create", + "args": [], + "return_type": "AuthService", + "visibility": "Public", + "body": "" + } + } + ], + "types": [] + }, + { + "html_id": "plain-module", + "name": "Helpers", + "full_name": "Helpers", + "kind": "module", + "summary": "Utility helpers", + "doc": null, + "locations": [], + "instance_methods": [], + "class_methods": [], + "types": [] + } + ] + } + } + JSON + +describe AmberCLI::FSDDDocs::Parser do + describe ".parse_json" do + it "returns a list of ParsedType from valid JSON" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.size.should eq(2) + end + + it "captures the type name, full_name, and kind" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" } + auth.should_not be_nil + auth = auth.not_nil! + auth.full_name.should eq("AuthService") + auth.kind.should eq("class") + end + + it "captures the summary" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.summary.should eq("Authentication service") + end + + it "captures source locations" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.locations.should eq(["src/auth_service.cr:1"]) + end + + it "parses instance methods" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + instance_methods = auth.methods.select { |m| !m.is_class_method } + instance_methods.size.should eq(2) + names = instance_methods.map(&.name) + names.should contain("authenticate") + names.should contain("logout") + end + + it "parses class methods" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + class_methods = auth.methods.select(&.is_class_method) + class_methods.size.should eq(1) + class_methods.first.name.should eq("create") + end + + it "captures method args_string and return_type" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.args_string.should eq("(email : String, password : String)") + m.return_type.should eq("Bool") + m.visibility.should eq("Public") + end + + it "builds the full method signature" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.signature.should eq("authenticate(email : String, password : String) : Bool") + end + + it "captures method source location" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.location.should eq("src/auth_service.cr:10") + end + + it "handles missing program gracefully" do + json = JSON.parse("{}") + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.should be_empty + end + + it "generates a safe slug by replacing :: with -" do + json = JSON.parse(<<-JSON2) + {"repository_name":"T","body":"","program":{"types":[{"name":"Foo","full_name":"AmberCLI::Commands::Foo","kind":"class","summary":null,"doc":null,"locations":[],"instance_methods":[],"class_methods":[],"types":[]}]}} + JSON2 + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.first.slug.should eq("AmberCLI-Commands-Foo") + end + end + + describe ".extract_fsdd_sections" do + it "extracts all FSDD labels from a doc comment" do + doc = "**Personas:** Developer, QA\n**Requires:** User model\n**Since:** v1.0\n**Ramp:** Basic → Advanced\n**Use:** `authenticate(email, password)`\n**Result:** Returns Bool\n\nRefined story: As a Developer, I want to do something." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.personas.should eq("Developer, QA") + sections.requires.should eq("User model") + sections.since_version.should eq("v1.0") + sections.ramp.should eq("Basic → Advanced") + sections.use.should eq("`authenticate(email, password)`") + sections.result.should eq("Returns Bool") + sections.story.should_not be_nil + sections.story.not_nil!.should contain("As a Developer") + end + + it "returns empty FSDDSections for nil doc" do + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(nil) + sections.has_fsdd_content?.should be_false + end + + it "returns empty FSDDSections for empty doc" do + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections("") + sections.has_fsdd_content?.should be_false + end + + it "returns nil for labels not present in doc" do + doc = "**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.personas.should eq("Developer") + sections.requires.should be_nil + sections.since_version.should be_nil + sections.ramp.should be_nil + sections.use.should be_nil + sections.result.should be_nil + sections.story.should be_nil + end + + it "detects has_fsdd_content? correctly" do + doc = "**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.has_fsdd_content?.should be_true + end + + it "extracts the Refined story block" do + doc = "Some preamble.\n\nRefined story: As a Developer, I want to authenticate users so they can access protected resources." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.story.should_not be_nil + sections.story.not_nil!.should contain("As a Developer") + end + + it "extracts summary as first non-label line" do + doc = "Handles user authentication.\n**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.summary.should eq("Handles user authentication.") + end + + it "skips label lines when extracting summary" do + doc = "**Personas:** Developer\nHandles user authentication." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.summary.should eq("Handles user authentication.") + end + end + + describe "ParsedMethod#kind_label" do + it "returns 'instance' for instance methods" do + sections = AmberCLI::FSDDDocs::FSDDSections.new + m = AmberCLI::FSDDDocs::ParsedMethod.new("foo", "()", "Nil", "Public", nil, nil, sections, false) + m.kind_label.should eq("instance") + end + + it "returns 'class' for class methods" do + sections = AmberCLI::FSDDDocs::FSDDSections.new + m = AmberCLI::FSDDDocs::ParsedMethod.new("foo", "()", "Nil", "Public", nil, nil, sections, true) + m.kind_label.should eq("class") + end + end + + describe "ParsedType#slug" do + it "converts namespaced name to flat slug" do + types = AmberCLI::FSDDDocs::Parser.parse_json(JSON.parse(FSDD_FIXTURE_JSON)) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.slug.should eq("AuthService") + end + end +end diff --git a/spec/fsdd_docs/version_ramp_spec.cr b/spec/fsdd_docs/version_ramp_spec.cr new file mode 100644 index 0000000..1cd798b --- /dev/null +++ b/spec/fsdd_docs/version_ramp_spec.cr @@ -0,0 +1,191 @@ +require "spec" +require "json" +require "file_utils" +require "../../src/amber_cli/fsdd_docs/version_ramp" + +private def with_tempdir(&) + dir = File.join(Dir.tempdir, "fsdd_ramp_test_#{Random::Secure.hex(6)}") + Dir.mkdir_p(dir) + begin + yield dir + ensure + FileUtils.rm_rf(dir) + end +end + +# Minimal Crystal docs JSON with one type and one method. +private def docs_json_for(type_name : String, method_name : String) : String + <<-JSON + { + "repository_name": "T","body": "", + "program": { + "types": [{ + "name": "#{type_name}", + "full_name": "#{type_name}", + "kind": "class", + "summary": null, + "doc": null, + "locations": [], + "instance_methods": [{ + "name": "#{method_name}", + "args_string": "()", + "doc": null, + "summary": null, + "location": null, + "def": { "name": "#{method_name}", "args": [], "return_type": "Nil", "visibility": "Public", "body": "" } + }], + "class_methods": [], + "types": [] + }] + } + } + JSON +end + +describe AmberCLI::FSDDDocs::VersionRamp do + describe ".empty" do + it "returns a VersionRamp with no versions and no entries" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.versions.should be_empty + ramp.entries.should be_empty + end + + it "returns '' for ramp_symbol on empty ramp" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_symbol("SomeType", "1.0.0").should eq("") + end + + it "returns nil for ramp_for on empty ramp" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_for("SomeType").should be_nil + end + end + + describe ".load" do + it "returns empty ramp when versions.json is absent" do + with_tempdir do |dir| + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.versions.should be_empty + end + end + + it "loads versions from versions.json" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + # No json sub-dirs, so entries will be empty but versions loaded + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.versions.should eq(["1.0.0", "2.0.0"]) + end + end + + it "builds entries from per-version index.json files" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\"])) + json_dir = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(json_dir) + File.write(File.join(json_dir, "index.json"), docs_json_for("AuthService", "authenticate")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.entries.has_key?("AuthService").should be_true + ramp.entries.has_key?("AuthService#authenticate").should be_true + end + end + + it "tracks the first version an entry appeared" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("AuthService", "authenticate")) + + dir_v2 = File.join(dir, "json", "2.0.0") + Dir.mkdir_p(dir_v2) + File.write(File.join(dir_v2, "index.json"), docs_json_for("AuthService", "authenticate")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + entry = ramp.ramp_for("AuthService") + entry.should_not be_nil + entry.not_nil!.first_version.should eq("1.0.0") + end + end + end + + describe "#ramp_symbol" do + it "returns ↑ when entry is new in current version" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + # Only in v2 + dir_v2 = File.join(dir, "json", "2.0.0") + Dir.mkdir_p(dir_v2) + File.write(File.join(dir_v2, "index.json"), docs_json_for("NewFeature", "go")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("NewFeature", "2.0.0").should eq("↑") + end + end + + it "returns ↓ when entry was removed in current version" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + # Only in v1 + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("OldFeature", "gone")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("OldFeature", "2.0.0").should eq("↓") + end + end + + it "returns empty string when entry is stable across versions" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + ["1.0.0", "2.0.0"].each do |v| + vdir = File.join(dir, "json", v) + Dir.mkdir_p(vdir) + File.write(File.join(vdir, "index.json"), docs_json_for("StableType", "stable")) + end + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("StableType", "2.0.0").should eq("") + end + end + + it "returns empty string when current_version is nil" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_symbol("SomeType", nil).should eq("") + end + + it "returns empty string when entry is not found" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\"])) + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("NonExistentType", "1.0.0").should eq("") + end + end + end + + describe "#first_appeared" do + it "returns the first version as 'vX.Y.Z'" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("MyType", "method")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.since_label("MyType").should eq("v1.0.0") + end + end + + it "returns nil for unknown key" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.since_label("Unknown").should be_nil + end + end +end diff --git a/src/amber_cli.cr b/src/amber_cli.cr index 9542a23..dd7e4e5 100644 --- a/src/amber_cli.cr +++ b/src/amber_cli.cr @@ -25,6 +25,7 @@ require "./amber_cli/commands/plugin" require "./amber_cli/commands/pipelines" require "./amber_cli/commands/generate" require "./amber_cli/commands/setup_lsp" +require "./amber_cli/commands/docs_fsdd" backend = Log::IOBackend.new backend.formatter = Log::Formatter.new do |entry, io| @@ -75,6 +76,7 @@ module AmberCLI plugin (pl) Generate application plugins pipelines Show application pipelines and plugs setup:lsp (lsp) Set up Amber LSP for Claude Code integration + docs:fsdd Generate FSDD static HTML doc site from crystal docs JSON Options: --version, -v Show version number diff --git a/src/amber_cli/commands/docs_fsdd.cr b/src/amber_cli/commands/docs_fsdd.cr new file mode 100644 index 0000000..537c353 --- /dev/null +++ b/src/amber_cli/commands/docs_fsdd.cr @@ -0,0 +1,150 @@ +require "../core/base_command" +require "../fsdd_docs/renderer" + +module AmberCLI::Commands + # The `docs:fsdd` command generates a static FSDD help-doc site from Crystal + # docs JSON. It reads `FSDD_docs/json//index.json` (generating it via + # `crystal docs` if absent), applies the version ramp across all stored versions, + # and writes a self-contained static HTML site to `FSDD_docs/html/`. + # + # ## Usage + # ``` + # amber docs:fsdd [OPTIONS] + # ``` + # + # ## Examples + # ``` + # amber docs:fsdd + # amber docs:fsdd --repo /path/to/project --version 2.1.0 + # ``` + class DocsFSDDCommand < AmberCLI::Core::BaseCommand + getter repo_option : String = Dir.current + getter version_option : String? = nil + + def help_description : String + <<-HELP + Generate a static FSDD help-doc site from Crystal docs JSON + + Usage: amber docs:fsdd [OPTIONS] + + This command: + 1. Runs `crystal docs --format=json` (or reads FSDD_docs/json//index.json) + 2. Parses FSDD doc-comment sections (Personas, Requires, Since, Ramp, Use, Result, Story) + 3. Computes a version ramp across all stored versions + 4. Renders a static HTML site to FSDD_docs/html/ + + Options: + --repo=DIR Crystal project root (default: current directory) + --version=VER Version label to use (default: read from shard.yml or "snapshot") + HELP + end + + def setup_command_options + option_parser.separator "" + option_parser.separator "Options:" + + option_parser.on("--repo=DIR", "Crystal project root (default: current directory)") do |dir| + @repo_option = dir + end + + option_parser.on("--version=VER", "Version label (default: read from shard.yml or 'snapshot')") do |v| + @version_option = v + end + end + + def execute + repo = File.expand_path(repo_option) + + unless Dir.exists?(repo) + error "Repo directory not found: #{repo}" + exit(1) + end + + version = version_option || read_shard_version(repo) || "snapshot" + info "Generating FSDD docs for version #{version} in #{repo}" + + fsdd_docs_dir = File.join(repo, "FSDD_docs") + json_dir = File.join(fsdd_docs_dir, "json", version) + json_path = File.join(json_dir, "index.json") + output_dir = File.join(fsdd_docs_dir, "html") + + unless File.exists?(json_path) + generate_crystal_docs(repo, json_dir, json_path) + else + info "Using existing docs JSON: #{json_path}" + end + + update_versions_json(fsdd_docs_dir, version) + + info "Loading version ramp..." + ramp = FSDDDocs::VersionRamp.load(fsdd_docs_dir) + + info "Parsing #{json_path}..." + types = FSDDDocs::Parser.parse_file(json_path) + info "Found #{types.size} types" + + info "Rendering to #{output_dir}..." + renderer = FSDDDocs::Renderer.new(types, ramp, output_dir, current_version: version) + renderer.render + + success "FSDD docs generated: #{output_dir}" + info " #{types.size} type pages + index.html + style.css" + end + + private def read_shard_version(repo : String) : String? + shard_path = File.join(repo, "shard.yml") + return nil unless File.exists?(shard_path) + File.read(shard_path).each_line do |line| + if line.starts_with?("version:") + parts = line.split(":", 2) + return parts[1].strip if parts.size == 2 + end + end + nil + end + + private def generate_crystal_docs(repo : String, json_dir : String, json_path : String) : Nil + info "Running crystal docs in #{repo}..." + Dir.mkdir_p(json_dir) + + result = Process.run( + "crystal", + ["docs", "--output=#{json_dir}"], + chdir: repo, + output: Process::Redirect::Inherit, + error: Process::Redirect::Inherit + ) + + unless result.success? + error "crystal docs failed (exit #{result.exit_code})" + exit(1) + end + + unless File.exists?(json_path) + error "crystal docs did not produce index.json at #{json_path}" + exit(1) + end + + success "crystal docs complete: #{json_path}" + end + + private def update_versions_json(fsdd_docs_dir : String, version : String) : Nil + versions_path = File.join(fsdd_docs_dir, "versions.json") + Dir.mkdir_p(fsdd_docs_dir) + + versions = if File.exists?(versions_path) + JSON.parse(File.read(versions_path)).as_a.map(&.as_s) + else + [] of String + end + + unless versions.includes?(version) + versions << version + File.write(versions_path, versions.to_json) + info "Updated versions.json: #{versions.inspect}" + end + end + end +end + +AmberCLI::Core::CommandRegistry.register("docs:fsdd", ["fsdd-docs"], AmberCLI::Commands::DocsFSDDCommand) diff --git a/src/amber_cli/fsdd_docs/parser.cr b/src/amber_cli/fsdd_docs/parser.cr new file mode 100644 index 0000000..1a5c262 --- /dev/null +++ b/src/amber_cli/fsdd_docs/parser.cr @@ -0,0 +1,244 @@ +require "json" + +module AmberCLI::FSDDDocs + class FSDDSections + getter personas : String? + getter requires : String? + getter since_version : String? + getter ramp : String? + getter use : String? + getter result : String? + getter story : String? + getter summary : String? + + def initialize( + @personas = nil, + @requires = nil, + @since_version = nil, + @ramp = nil, + @use = nil, + @result = nil, + @story = nil, + @summary = nil + ) + end + + def has_fsdd_content? : Bool + [personas, requires, since_version, ramp, use, result, story].any? + end + end + + class ParsedMethod + getter name : String + getter args_string : String + getter return_type : String? + getter visibility : String + getter location : String? + getter doc : String? + getter fsdd : FSDDSections + getter is_class_method : Bool + + def initialize( + @name, + @args_string, + @return_type, + @visibility, + @location, + @doc, + @fsdd, + @is_class_method + ) + end + + def signature : String + rt = return_type + if rt && !rt.empty? + "#{name}#{args_string} : #{rt}" + else + "#{name}#{args_string}" + end + end + + def kind_label : String + is_class_method ? "class" : "instance" + end + end + + class ParsedType + getter name : String + getter full_name : String + getter kind : String + getter summary : String? + getter doc : String? + getter fsdd : FSDDSections + getter locations : Array(String) + getter methods : Array(ParsedMethod) + + def initialize( + @name, + @full_name, + @kind, + @summary, + @doc, + @fsdd, + @locations, + @methods + ) + end + + # Filename-safe slug: replaces :: with - for flat output + def slug : String + full_name.gsub("::", "-") + end + + def primary_location : String? + locations.first? + end + end + + class Parser + def self.parse_file(path : String) : Array(ParsedType) + parse_json(JSON.parse(File.read(path))) + end + + def self.parse_json(json : JSON::Any) : Array(ParsedType) + program = json["program"]? + return [] of ParsedType unless program + + types_json = program["types"]? + return [] of ParsedType unless types_json + + result = [] of ParsedType + types_json.as_a.each do |type_json| + result.concat(parse_type_recursive(type_json)) + end + result + end + + def self.extract_fsdd_sections(doc : String?) : FSDDSections + return FSDDSections.new unless doc && !doc.empty? + + FSDDSections.new( + personas: extract_label(doc, "Personas"), + requires: extract_label(doc, "Requires"), + since_version: extract_label(doc, "Since"), + ramp: extract_label(doc, "Ramp"), + use: extract_label(doc, "Use"), + result: extract_label(doc, "Result"), + story: extract_story_block(doc), + summary: extract_summary(doc) + ) + end + + private def self.parse_type_recursive(type_json : JSON::Any) : Array(ParsedType) + result = [] of ParsedType + + name = type_json["name"]?.try(&.as_s?) || "" + full_name = type_json["full_name"]?.try(&.as_s?) || name + kind = type_json["kind"]?.try(&.as_s?) || "class" + summary = type_json["summary"]?.try(&.as_s?) + doc = type_json["doc"]?.try(&.as_s?) + + locations = [] of String + if locs_json = type_json["locations"]?.try(&.as_a?) + locs_json.each do |loc| + if loc_h = loc.as_h? + fn = loc_h["filename"]?.try(&.as_s?) + ln = loc_h["line_number"]?.try(&.as_i?) + locations << "#{fn}:#{ln}" if fn && ln + end + end + end + + methods = [] of ParsedMethod + + if im_json = type_json["instance_methods"]? + im_json.as_a.each do |m| + methods << parse_method(m, is_class_method: false) + end + end + + if cm_json = type_json["class_methods"]? + cm_json.as_a.each do |m| + methods << parse_method(m, is_class_method: true) + end + end + + fsdd = extract_fsdd_sections(doc) + result << ParsedType.new(name, full_name, kind, summary, doc, fsdd, locations, methods) + + if nested_json = type_json["types"]? + nested_json.as_a.each do |nested| + result.concat(parse_type_recursive(nested)) + end + end + + result + end + + private def self.parse_method(m : JSON::Any, is_class_method : Bool) : ParsedMethod + name = m["name"]?.try(&.as_s?) || "" + args_string = m["args_string"]?.try(&.as_s?) || "()" + doc = m["doc"]?.try(&.as_s?) + + def_h = m["def"]?.try(&.as_h?) + return_type = def_h.try { |d| d["return_type"]?.try(&.as_s?) } + visibility = def_h.try { |d| d["visibility"]?.try(&.as_s?) } || "Public" + + location : String? = nil + if loc_h = m["location"]?.try(&.as_h?) + fn = loc_h["filename"]?.try(&.as_s?) + ln = loc_h["line_number"]?.try(&.as_i?) + location = "#{fn}:#{ln}" if fn && ln + end + + fsdd = extract_fsdd_sections(doc) + ParsedMethod.new(name, args_string, return_type, visibility, location, doc, fsdd, is_class_method) + end + + private def self.extract_label(text : String, label : String) : String? + text.each_line do |line| + if line.includes?("**#{label}:**") + parts = line.split("**#{label}:**", 2) + if parts.size == 2 + val = parts[1].strip + return val unless val.empty? + end + end + end + nil + end + + private def self.extract_story_block(text : String) : String? + idx = text.index("Refined story") + return nil unless idx + + start = idx + "Refined story".size + # Skip colon and whitespace + while start < text.size + ch = text[start] + break unless ch == ':' || ch == ' ' || ch == '\n' || ch == '\r' + start += 1 + end + + remaining = text[start..] + val = if end_idx = remaining.index(/\*\*\w+:\*\*/) + remaining[0...end_idx].strip + else + remaining.strip + end + + val.empty? ? nil : val + end + + private def self.extract_summary(text : String) : String? + text.each_line do |line| + stripped = line.strip + next if stripped.empty? + next if stripped.starts_with?("**") + return stripped + end + nil + end + end +end diff --git a/src/amber_cli/fsdd_docs/renderer.cr b/src/amber_cli/fsdd_docs/renderer.cr new file mode 100644 index 0000000..2977e42 --- /dev/null +++ b/src/amber_cli/fsdd_docs/renderer.cr @@ -0,0 +1,93 @@ +require "ecr" +require "html" +require "./parser" +require "./version_ramp" + +module AmberCLI::FSDDDocs + CSS_CONTENT = <<-CSS + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { font: 15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color: #24292f; background: #f6f8fa; } + a { color: #0969da; } + a:hover { text-decoration: underline; } + code, pre { font-family: "SFMono-Regular",Consolas,monospace; } + nav { background: #24292f; color: #e6edf3; padding: 12px 24px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } + nav .nav-title { font-size: 1.1rem; font-weight: 600; } + nav a { color: #79c0ff; text-decoration: none; } + nav a:hover { text-decoration: underline; } + .ver { background: #388bfd; color: #fff; padding: 2px 8px; border-radius: 10px; font-size: .8rem; font-weight: 600; } + main { max-width: 960px; margin: 0 auto; padding: 24px; } + h2 { font-size: 1rem; border-bottom: 1px solid #d0d7de; padding-bottom: 6px; margin: 20px 0 12px; } + .badge { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: .72rem; font-weight: 700; text-transform: uppercase; } + .badge.class { background: #ddf4ff; color: #0550ae; } + .badge.module { background: #fff8c5; color: #9a6700; } + .badge.struct { background: #ffd8d3; color: #82071e; } + .badge.enum { background: #e8d5ff; color: #6e40c9; } + .type-list { list-style: none; } + .type-item { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; } + .type-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #0969da; font-weight: 600; } + .type-link .type-name { font-weight: 600; } + .type-link:hover .type-name { text-decoration: underline; } + .summary { font-size: .85rem; color: #57606a; margin-top: 4px; } + .fsdd-marker { font-size: .7rem; font-weight: 700; color: #0969da; background: #ddf4ff; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; } + .ramp { display: flex; gap: 14px; align-items: center; background: #f0f7ff; border: 1px solid #cce5ff; border-radius: 6px; padding: 7px 14px; margin: 14px 0; font-size: .85rem; flex-wrap: wrap; } + .sym { font-size: 1rem; font-weight: 700; color: #0969da; } + .fsdd { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin: 12px 0; } + .fsdd-row { display: flex; gap: 10px; margin: 3px 0; font-size: .875rem; align-items: baseline; } + .lbl { font-weight: 600; color: #57606a; min-width: 70px; flex-shrink: 0; } + .story-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #d0d7de; font-style: italic; font-size: .875rem; white-space: pre-wrap; } + .method { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin-bottom: 10px; } + .msig { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } + .mkind { font-size: .7rem; font-weight: 700; text-transform: uppercase; color: #57606a; background: #f6f8fa; padding: 2px 5px; border-radius: 3px; border: 1px solid #d0d7de; } + .mname { font-family: "SFMono-Regular",Consolas,monospace; font-size: .9rem; color: #0969da; background: #f6f8fa; padding: 2px 6px; border-radius: 3px; } + .mloc { font-size: .75rem; color: #57606a; margin: 4px 0; } + CSS + + class IndexView + getter types : Array(ParsedType) + getter ramp : VersionRamp + getter current_version : String? + + def initialize(@types : Array(ParsedType), @ramp : VersionRamp, @current_version : String?) + end + + ECR.def_to_s "#{__DIR__}/templates/index.ecr" + end + + class TypeView + getter type : ParsedType + getter ramp : VersionRamp + getter current_version : String? + + def initialize(@type : ParsedType, @ramp : VersionRamp, @current_version : String?) + end + + ECR.def_to_s "#{__DIR__}/templates/type_page.ecr" + end + + class Renderer + def initialize( + @types : Array(ParsedType), + @ramp : VersionRamp, + @output_dir : String, + @current_version : String? = nil + ) + end + + def render : Nil + Dir.mkdir_p(@output_dir) + File.write(File.join(@output_dir, "style.css"), CSS_CONTENT) + render_index + @types.each { |t| render_type(t) } + end + + private def render_index : Nil + html = IndexView.new(@types, @ramp, @current_version).to_s + File.write(File.join(@output_dir, "index.html"), html) + end + + private def render_type(type : ParsedType) : Nil + html = TypeView.new(type, @ramp, @current_version).to_s + File.write(File.join(@output_dir, "#{type.slug}.html"), html) + end + end +end diff --git a/src/amber_cli/fsdd_docs/templates/index.ecr b/src/amber_cli/fsdd_docs/templates/index.ecr new file mode 100644 index 0000000..23ac88d --- /dev/null +++ b/src/amber_cli/fsdd_docs/templates/index.ecr @@ -0,0 +1,36 @@ + + + + + + FSDD Docs + + + + +
+

Types (<%= types.size %>)

+ +
+ + diff --git a/src/amber_cli/fsdd_docs/templates/type_page.ecr b/src/amber_cli/fsdd_docs/templates/type_page.ecr new file mode 100644 index 0000000..a658807 --- /dev/null +++ b/src/amber_cli/fsdd_docs/templates/type_page.ecr @@ -0,0 +1,104 @@ + + + + + + <%= HTML.escape(type.full_name) %> — FSDD Docs + + + + +
+ + <% + type_key = type.full_name + ramp_entry = ramp.ramp_for(type_key) + sym = ramp.ramp_symbol(type_key, current_version) + %> + <% if ramp_entry || current_version %> +
+ <% if re = ramp_entry %> + First appeared: v<%= HTML.escape(re.first_version) %> + <% end %> + <% if v = current_version %> + Current: v<%= HTML.escape(v) %> + <% end %> + <% unless sym.empty? %> + <%= HTML.escape(sym) %> + <% end %> +
+ <% end %> + + <% if loc = type.primary_location %> +

Source: <%= HTML.escape(loc) %>

+ <% end %> + + <% fsdd = type.fsdd %> + <% if fsdd.has_fsdd_content? %> +
+ <% if p = fsdd.personas %>
Personas<%= HTML.escape(p) %>
<% end %> + <% if r = fsdd.requires %>
Requires<%= HTML.escape(r) %>
<% end %> + <% if s = fsdd.since_version %>
Since<%= HTML.escape(s) %>
<% end %> + <% if r = fsdd.ramp %>
Ramp<%= HTML.escape(r) %>
<% end %> + <% if u = fsdd.use %>
Use<%= HTML.escape(u) %>
<% end %> + <% if r = fsdd.result %>
Result<%= HTML.escape(r) %>
<% end %> + <% if st = fsdd.story %> +
Refined story: +<%= HTML.escape(st) %>
+ <% end %> +
+ <% end %> + + <% public_methods = type.methods.select { |m| m.visibility == "Public" } %> + <% unless public_methods.empty? %> +
+

Methods

+ <% public_methods.each do |m| %> + <% + mkey_sep = m.is_class_method ? "." : "#" + mkey = "#{type.full_name}#{mkey_sep}#{m.name}" + msym = ramp.ramp_symbol(mkey, current_version) + mramp = ramp.ramp_for(mkey) + %> +
+
+ <%= HTML.escape(m.kind_label) %> + <%= HTML.escape(m.signature) %> + <% unless msym.empty? %><%= HTML.escape(msym) %><% end %> +
+ <% if mloc = m.location %> +

Source: <%= HTML.escape(mloc) %>

+ <% end %> + <% if mre = mramp %> +

First appeared: v<%= HTML.escape(mre.first_version) %>

+ <% end %> + <% mfsdd = m.fsdd %> + <% if mfsdd.has_fsdd_content? %> +
+ <% if p = mfsdd.personas %>
Personas<%= HTML.escape(p) %>
<% end %> + <% if r = mfsdd.requires %>
Requires<%= HTML.escape(r) %>
<% end %> + <% if s = mfsdd.since_version %>
Since<%= HTML.escape(s) %>
<% end %> + <% if r = mfsdd.ramp %>
Ramp<%= HTML.escape(r) %>
<% end %> + <% if u = mfsdd.use %>
Use<%= HTML.escape(u) %>
<% end %> + <% if r = mfsdd.result %>
Result<%= HTML.escape(r) %>
<% end %> + <% if st = mfsdd.story %> +
Refined story: +<%= HTML.escape(st) %>
+ <% end %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + +
+ + diff --git a/src/amber_cli/fsdd_docs/version_ramp.cr b/src/amber_cli/fsdd_docs/version_ramp.cr new file mode 100644 index 0000000..3d2d769 --- /dev/null +++ b/src/amber_cli/fsdd_docs/version_ramp.cr @@ -0,0 +1,104 @@ +require "./parser" + +module AmberCLI::FSDDDocs + class VersionEntry + getter key : String + getter versions : Array(String) + getter first_version : String + + def initialize(@key : String, @versions : Array(String)) + @first_version = @versions.first? || "" + end + + def present_in?(version : String) : Bool + @versions.includes?(version) + end + end + + class VersionRamp + getter versions : Array(String) + getter entries : Hash(String, VersionEntry) + + def initialize(@versions : Array(String), @entries : Hash(String, VersionEntry)) + end + + def self.empty : VersionRamp + new([] of String, {} of String => VersionEntry) + end + + def self.load(fsdd_docs_dir : String) : VersionRamp + versions_path = File.join(fsdd_docs_dir, "versions.json") + versions = if File.exists?(versions_path) + JSON.parse(File.read(versions_path)).as_a.map(&.as_s) + else + [] of String + end + + return empty if versions.empty? + + all_keys = Hash(String, Array(String)).new + + versions.each do |v| + json_path = File.join(fsdd_docs_dir, "json", v, "index.json") + next unless File.exists?(json_path) + + types = Parser.parse_file(json_path) + types.each do |t| + type_key = t.full_name + (all_keys[type_key] ||= [] of String) << v + + t.methods.each do |m| + sep = m.is_class_method ? "." : "#" + method_key = "#{t.full_name}#{sep}#{m.name}" + (all_keys[method_key] ||= [] of String) << v + end + end + end + + entries = all_keys.transform_values { |vs| VersionEntry.new(vs.first? || "", vs) } + new(versions, entries) + end + + def ramp_for(key : String) : VersionEntry? + entries[key]? + end + + def first_appeared(key : String) : String? + entries[key]?.try(&.first_version) + end + + # Returns ↑ if newly added in current_version, ↓ if removed, "" otherwise. + def ramp_symbol(key : String, current_version : String?) : String + return "" unless current_version + entry = entries[key]? + return "" unless entry + + current_idx = versions.index(current_version) + return "" unless current_idx + + is_in_current = entry.present_in?(current_version) + + if current_idx > 0 + prev_version = versions[current_idx - 1] + is_in_prev = entry.present_in?(prev_version) + + if is_in_current && !is_in_prev + "↑" + elsif !is_in_current && is_in_prev + "↓" + else + "" + end + else + "" + end + end + + # Human-readable "Since: v1.0" label for a key. + def since_label(key : String) : String? + entry = entries[key]? + return nil unless entry + "v#{entry.first_version}" + end + end +end diff --git a/src/amber_cli/generators/native_app.cr b/src/amber_cli/generators/native_app.cr index ca07901..9d51818 100644 --- a/src/amber_cli/generators/native_app.cr +++ b/src/amber_cli/generators/native_app.cr @@ -243,7 +243,7 @@ all: macos # --- First-time setup --- setup: - shards-alpha install || shards install || true + shards install || shards-alpha install || true @# crystal-audio shard name has a hyphen but source uses underscore @# Crystal's require resolution needs the underscore directory @if [ ! -e lib/crystal_audio ]; then \\ diff --git a/src/amber_cli/helpers/process_runner.cr b/src/amber_cli/helpers/process_runner.cr index e9fa9e0..34c5ea0 100644 --- a/src/amber_cli/helpers/process_runner.cr +++ b/src/amber_cli/helpers/process_runner.cr @@ -121,11 +121,11 @@ module Sentry ok_to_run = true else log :run, "Building..." - time = Time.monotonic + time = Time.instant build_result = Amber::CLI::Helpers.run(build_command_run) exit 1 unless build_result.is_a? Process::Status if build_result.success? - log :run, "Compiled in #{(Time.monotonic - time)}" + log :run, "Compiled in #{(Time.instant - time)}" stop_processes("run") if @app_running ok_to_run = true elsif !@app_running # first run diff --git a/src/amber_lsp.cr b/src/amber_lsp.cr index 472f055..0621acc 100644 --- a/src/amber_lsp.cr +++ b/src/amber_lsp.cr @@ -17,6 +17,7 @@ require "./amber_lsp/rules/file_naming/*" require "./amber_lsp/rules/routing/*" require "./amber_lsp/rules/specs/*" require "./amber_lsp/rules/sockets/*" +require "./amber_lsp/rules/fsdd/*" require "./amber_lsp/rules/custom_rule" require "./amber_lsp/document_store" require "./amber_lsp/project_context" diff --git a/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr b/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr new file mode 100644 index 0000000..4754c64 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr @@ -0,0 +1,91 @@ +module AmberLSP::Rules::FSDD + class DocBlockRequiredRule < AmberLSP::Rules::BaseRule + CLASS_RE = /^\s*(abstract\s+)?class\s+/ + DEF_RE = /^\s*(abstract\s+)?def\s+/ + PRIV_RE = /^\s*(private|protected)\s+(abstract\s+)?(def|class)\s+/ + NAME_RE = /\b(?:class|def)\s+(?:self\.)?(\w+[?!]?)/ + + def id : String + "fsdd/doc-block-required" + end + + def description : String + "Every public class and public method must have a preceding doc comment" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Information + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + lines = content.lines + + # prev_nb[i] = index of the previous non-blank line before line i, or -1 + prev_nb = Array(Int32).new(lines.size, -1) + last_non_blank = -1 + lines.each_with_index do |line, i| + prev_nb[i] = last_non_blank + last_non_blank = i unless line.strip.empty? + end + + lines.each_with_index do |line, i| + next if line.strip.empty? + + is_class = CLASS_RE.matches?(line) + is_def = DEF_RE.matches?(line) + next unless is_class || is_def + + # Skip singleton class (class << self) + next if /^\s*class\s+<= 0 + prev_stripped = lines[prev_idx].strip + # Preceded by standalone private / protected + next if prev_stripped == "private" || prev_stripped == "protected" + has_doc = prev_stripped.starts_with?("#") + else + has_doc = false + end + + next if has_doc + + name_match = NAME_RE.match(line) + if nm = name_match + name = nm[1] + start_char = (nm.begin(1) || 0).to_i32 + end_char = (nm.end(1) || line.size).to_i32 + else + name = is_class ? "class" : "method" + start_char = 0 + end_char = 0 + end + + kind = is_class ? "Class" : "Method" + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(i.to_i32, start_char), + Position.new(i.to_i32, end_char) + ), + severity: default_severity, + code: id, + message: "#{kind} '#{name}' is missing a doc comment" + ) + end + + diagnostics + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::DocBlockRequiredRule.new) diff --git a/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr b/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr new file mode 100644 index 0000000..6932486 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr @@ -0,0 +1,92 @@ +module AmberLSP::Rules::FSDD + # Crystal lifecycle methods that conventionally omit return type annotations. + SKIP_METHODS = {"initialize", "finalize"} + + class MethodTypeSignatureRule < AmberLSP::Rules::BaseRule + def id : String + "fsdd/method-type-signature" + end + + def description : String + "All method definitions must have fully typed signatures: typed parameters and an explicit return type (FSDD verbose + fully-typed standard)" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + + # Group 1: leading whitespace + "def ", Group 2: method name, Group 3: rest of signature + def_pattern = /^(\s*def\s+)(\w+[!?]?)(.*)/ + + content.each_line.with_index do |line, line_number| + match = def_pattern.match(line) + next unless match + + method_name = match[2] + rest = match[3] + + next if SKIP_METHODS.includes?(method_name) + + has_untyped_params = false + has_return_type = false + + if rest.includes?("(") + paren_start = rest.index('(') + paren_end = rest.rindex(')') + + # Multi-line signatures (no closing ) on this line) are skipped — known limitation + next unless paren_start && paren_end && paren_end > paren_start + + params_str = rest[paren_start + 1...paren_end] + after_paren = rest[paren_end + 1..] + + has_return_type = !!(/\s*:\s*\w/.match(after_paren)) + has_untyped_params = untyped_params?(params_str) + else + # No parens: "def foo : ReturnType" or "def foo" (no params) + has_return_type = !!(/\s*:\s*\w/.match(rest)) + end + + issues = [] of String + issues << "untyped parameter(s)" if has_untyped_params + issues << "missing return type" unless has_return_type + next if issues.empty? + + name_start = (match.begin(2) || 0).to_i32 + name_end = (match.end(2) || line.size).to_i32 + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(line_number.to_i32, name_start), + Position.new(line_number.to_i32, name_end) + ), + severity: default_severity, + code: id, + message: "Method '#{method_name}' has incomplete type signature: #{issues.join(", ")}" + ) + end + + diagnostics + end + + private def untyped_params?(params_str : String) : Bool + return false if params_str.strip.empty? + params_str.split(",").any? do |param| + param = param.strip + next false if param.empty? + # Splat/double-splat/block params are exempt (typed differently in Crystal) + next false if param.starts_with?("*") || param.starts_with?("&") + !param.includes?(":") + end + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new) diff --git a/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr b/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr new file mode 100644 index 0000000..5c94503 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr @@ -0,0 +1,66 @@ +module AmberLSP::Rules::FSDD + class ProcessManagerStructureRule < AmberLSP::Rules::BaseRule + def id : String + "fsdd/process-manager-structure" + end + + def description : String + "Process manager classes must define an initialize with typed parameters and a public perform or call method" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**/process_managers/**", "src/**/processes/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + return [] of Diagnostic unless file_path.includes?("process_managers/") || file_path.includes?("processes/") + + diagnostics = [] of Diagnostic + lines = content.lines + + # def initialize with at least one typed parameter: matches ( ... : ... ) + init_typed_re = /^\s*def\s+initialize\s*\([^)]*:[^)]*\)/ + # Public def perform or call; private def perform|call is excluded + perform_re = /^\s*def\s+(perform|call)\b/ + private_perform_re = /^\s*private\s+def\s+(perform|call)\b/ + + has_typed_init = lines.any? { |line| init_typed_re.matches?(line) } + has_public_perform = lines.any? do |line| + perform_re.matches?(line) && !private_perform_re.matches?(line) + end + + return [] of Diagnostic if has_typed_init && has_public_perform + + content.each_line.with_index do |line, line_number| + match = /^\s*class\s+(\w[\w:]*)/.match(line) + next unless match + + class_name = match[1] + start_char = (match.begin(1) || 0).to_i32 + end_char = (match.end(1) || line.size).to_i32 + + issues = [] of String + issues << "missing initialize with typed parameters" unless has_typed_init + issues << "missing public perform or call method" unless has_public_perform + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(line_number.to_i32, start_char), + Position.new(line_number.to_i32, end_char) + ), + severity: default_severity, + code: id, + message: "Process manager '#{class_name}': #{issues.join(", ")}" + ) + end + + diagnostics + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new) diff --git a/src/amber_lsp/rules/fsdd/story_grammar_rule.cr b/src/amber_lsp/rules/fsdd/story_grammar_rule.cr new file mode 100644 index 0000000..67669de --- /dev/null +++ b/src/amber_lsp/rules/fsdd/story_grammar_rule.cr @@ -0,0 +1,101 @@ +module AmberLSP::Rules::FSDD + class StoryGrammarRule < AmberLSP::Rules::BaseRule + STORY_INITIATOR_RE = /\b(As a|At |Every )/ + ACTION_VERB_RE = /\b(GET|POST|PUT|PATCH|DELETE|perform|do)\b/ + STORY_INITIATOR_WORDS = {"As", "At", "Every"} + MODEL_NAME_RE = /\b[A-Z][a-z][a-zA-Z0-9]*\b/ + + def id : String + "fsdd/story-grammar" + end + + def description : String + "Feature story doc comments must include an action verb and a capitalized Model name or process reference" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + lines = content.lines + + in_block = false + block_lines = [] of String + block_start = 0 + + lines.each_with_index do |line, i| + is_comment = line.strip.starts_with?("#") + + if is_comment + unless in_block + in_block = true + block_start = i + block_lines = [] of String + end + block_lines << line.strip.lchop("#").strip + elsif in_block + in_block = false + emit_story_diagnostics(block_lines, block_start, diagnostics) + end + end + + emit_story_diagnostics(block_lines, block_start, diagnostics) if in_block + + diagnostics + end + + private def emit_story_diagnostics( + block_lines : Array(String), + block_start : Int32, + diagnostics : Array(Diagnostic) + ) : Nil + block_text = block_lines.join(" ") + return unless STORY_INITIATOR_RE.matches?(block_text) + + issues = [] of String + issues << "missing action verb (GET, POST, PUT, PATCH, DELETE, perform, or do)" unless ACTION_VERB_RE.matches?(block_text) + issues << "missing capitalized Model name or process reference" unless has_model_name?(block_text) + return if issues.empty? + + # Point to the line that contains the story initiator + story_line = block_start + block_lines.each_with_index do |line_content, bi| + if STORY_INITIATOR_RE.matches?(line_content) + story_line = block_start + bi + break + end + end + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(story_line, 0), + Position.new(story_line, 0) + ), + severity: default_severity, + code: id, + message: "Story comment: #{issues.join("; ")}" + ) + end + + # Returns true if text contains a PascalCase word that looks like a model + # or process name (4+ chars, not a story initiator word like "As"/"At"/"Every"). + private def has_model_name?(text : String) : Bool + pos = 0 + while (m = MODEL_NAME_RE.match(text, pos)) + word = m[0] + return true if word.size >= 4 && !STORY_INITIATOR_WORDS.includes?(word) + new_pos = (m.end(0) || pos + 1).to_i32 + pos = new_pos > pos ? new_pos : pos + 1 + end + false + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::StoryGrammarRule.new)