diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f8d58e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,188 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Ruby 컴파일러 테스트 + test: + name: Ruby ${{ matrix.ruby }} Test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ['3.0', '3.1', '3.2', '3.3'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rspec --format progress --format RspecJunitFormatter --out tmp/rspec_results.xml + continue-on-error: false + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-ruby-${{ matrix.ruby }} + path: tmp/rspec_results.xml + + # RuboCop 린트 검사 + lint: + name: RuboCop Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Run RuboCop + run: bundle exec rubocop --format github + + # 테스트 커버리지 + coverage: + name: Test Coverage + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Run tests with coverage + run: | + COVERAGE=true bundle exec rspec + env: + COVERAGE: true + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage.xml + fail_ci_if_error: false + verbose: true + + # VSCode 플러그인 빌드 + vscode: + name: VSCode Plugin + runs-on: ubuntu-latest + + defaults: + run: + working-directory: editors/vscode + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: editors/vscode/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Compile + run: npm run compile + + # JetBrains 플러그인 빌드 + jetbrains: + name: JetBrains Plugin + runs-on: ubuntu-latest + + defaults: + run: + working-directory: editors/jetbrains + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Build plugin + run: ./gradlew buildPlugin + + - name: Verify plugin + run: ./gradlew verifyPlugin + + - name: Upload plugin artifact + uses: actions/upload-artifact@v4 + with: + name: jetbrains-plugin + path: editors/jetbrains/build/distributions/*.zip + + # 문서 예제 검증 + docs-verify: + name: Docs Verification + runs-on: ubuntu-latest + continue-on-error: true # Don't fail CI if docs have issues + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Verify documentation examples + run: bundle exec rake docs:verify + + # 전체 상태 체크 + ci-status: + name: CI Status + runs-on: ubuntu-latest + needs: [test, lint, coverage, vscode, jetbrains] + if: always() + + steps: + - name: Check CI status + run: | + if [[ "${{ needs.test.result }}" == "failure" ]] || \ + [[ "${{ needs.lint.result }}" == "failure" ]] || \ + [[ "${{ needs.coverage.result }}" == "failure" ]] || \ + [[ "${{ needs.vscode.result }}" == "failure" ]] || \ + [[ "${{ needs.jetbrains.result }}" == "failure" ]]; then + echo "CI failed" + exit 1 + fi + echo "CI passed" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2b110a1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,251 @@ +name: Release + +on: + push: + tags: + - 'v*' # v0.1.0, v1.0.0 등 + +permissions: + contents: write + packages: write + +jobs: + # 테스트 먼저 실행 + test: + name: Pre-release Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Run tests + run: bundle exec rspec + + - name: Run linter + run: bundle exec rubocop + + # RubyGems 배포 + publish-gem: + name: Publish to RubyGems + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Publishing version: $VERSION" + + - name: Verify version matches + run: | + GEM_VERSION=$(ruby -r ./lib/t_ruby/version -e 'puts TRuby::VERSION') + TAG_VERSION=${{ steps.version.outputs.version }} + if [[ "$GEM_VERSION" != "$TAG_VERSION" ]]; then + echo "Version mismatch: gem=$GEM_VERSION, tag=$TAG_VERSION" + exit 1 + fi + echo "Version verified: $GEM_VERSION" + + - name: Build gem + run: gem build t_ruby.gemspec + + - name: Publish to RubyGems + run: | + mkdir -p ~/.gem + echo -e "---\n:rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }}" > ~/.gem/credentials + chmod 0600 ~/.gem/credentials + gem push t-ruby-${{ steps.version.outputs.version }}.gem + + - name: Upload gem artifact + uses: actions/upload-artifact@v4 + with: + name: gem + path: '*.gem' + + # npm WASM 패키지 배포 + publish-wasm: + name: Publish WASM to npm + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + + - name: Install dependencies + run: | + cd wasm + npm install + + - name: Build WASM package + run: | + cd wasm + bash ./build.sh + + - name: Sync version from git tag + run: | + VERSION=${GITHUB_REF#refs/tags/v} + echo "Version: $VERSION" + cd wasm + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Publish to npm + run: | + cd wasm + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Upload WASM artifact + uses: actions/upload-artifact@v4 + with: + name: wasm + path: wasm/dist/* + + # VSCode 플러그인 빌드 (배포는 수동) + build-vscode: + name: Build VSCode Plugin + runs-on: ubuntu-latest + needs: test + + defaults: + run: + working-directory: editors/vscode + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: editors/vscode/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install vsce + run: npm install -g @vscode/vsce + + - name: Sync version from git tag + run: | + VERSION=${GITHUB_REF#refs/tags/v} + npm version $VERSION --no-git-tag-version --allow-same-version + + - name: Package extension + run: vsce package + + - name: Upload vsix artifact + uses: actions/upload-artifact@v4 + with: + name: vscode-plugin + path: editors/vscode/*.vsix + + # JetBrains 플러그인 빌드 (배포는 수동) + build-jetbrains: + name: Build JetBrains Plugin + runs-on: ubuntu-latest + needs: test + + defaults: + run: + working-directory: editors/jetbrains + + steps: + - uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build plugin + run: ./gradlew buildPlugin + + - name: Upload plugin artifact + uses: actions/upload-artifact@v4 + with: + name: jetbrains-plugin + path: editors/jetbrains/build/distributions/*.zip + + # GitHub Release 생성 + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [publish-gem, publish-wasm, build-vscode, build-jetbrains] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Generate changelog + id: changelog + run: | + # Get previous tag + PREV_TAG=$(git describe --abbrev=0 --tags HEAD^ 2>/dev/null || echo "") + CURRENT_TAG=${GITHUB_REF#refs/tags/} + + echo "## What's Changed" > CHANGELOG.md + echo "" >> CHANGELOG.md + + if [[ -n "$PREV_TAG" ]]; then + # Get commits between tags + git log --pretty=format:"* %s (%h)" $PREV_TAG..$CURRENT_TAG >> CHANGELOG.md + else + # First release - get all commits + git log --pretty=format:"* %s (%h)" >> CHANGELOG.md + fi + + echo "" >> CHANGELOG.md + echo "" >> CHANGELOG.md + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/$PREV_TAG...$CURRENT_TAG" >> CHANGELOG.md + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + body_path: CHANGELOG.md + files: | + artifacts/gem/*.gem + artifacts/wasm/* + artifacts/vscode-plugin/*.vsix + artifacts/jetbrains-plugin/*.zip + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/wasm-publish.yml b/.github/workflows/wasm-publish.yml deleted file mode 100644 index b7f6fc1..0000000 --- a/.github/workflows/wasm-publish.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Publish WASM to npm - -on: - push: - tags: - - 'v*' # Triggers on version tags like v0.0.7, v1.0.0 - -jobs: - build-and-publish: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' - - - name: Install dependencies - run: | - cd wasm - npm install - - - name: Build WASM package - run: | - cd wasm - bash ./build.sh - - - name: Sync version from git tag - run: | - # Extract version from tag (v0.0.7 -> 0.0.7) - VERSION=${GITHUB_REF#refs/tags/v} - echo "Version: $VERSION" - - # Update package.json version - cd wasm - npm version $VERSION --no-git-tag-version --allow-same-version - - - name: Publish to npm - run: | - cd wasm - npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - files: | - wasm/dist/* - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.releaserc.yml b/.releaserc.yml new file mode 100644 index 0000000..2198869 --- /dev/null +++ b/.releaserc.yml @@ -0,0 +1,104 @@ +# semantic-release configuration +# https://semantic-release.gitbook.io/ + +branches: + - main + - name: develop + prerelease: beta + +plugins: + # Analyze commits to determine version bump + - - "@semantic-release/commit-analyzer" + - preset: conventionalcommits + releaseRules: + - type: feat + release: minor + - type: fix + release: patch + - type: perf + release: patch + - type: revert + release: patch + - type: docs + release: false + - type: style + release: false + - type: refactor + release: false + - type: test + release: false + - type: build + release: false + - type: ci + release: false + - type: chore + release: false + + # Generate release notes from commits + - - "@semantic-release/release-notes-generator" + - preset: conventionalcommits + presetConfig: + types: + - type: feat + section: "Features" + - type: fix + section: "Bug Fixes" + - type: perf + section: "Performance" + - type: revert + section: "Reverts" + - type: docs + section: "Documentation" + hidden: true + - type: style + section: "Styles" + hidden: true + - type: refactor + section: "Code Refactoring" + hidden: true + - type: test + section: "Tests" + hidden: true + - type: build + section: "Build System" + hidden: true + - type: ci + section: "CI/CD" + hidden: true + + # Generate/update CHANGELOG.md + - - "@semantic-release/changelog" + - changelogFile: CHANGELOG.md + changelogTitle: "# Changelog\n\nAll notable changes to T-Ruby will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." + + # Update version in various files + - - "@semantic-release/exec" + - prepareCmd: | + # Update Ruby version + sed -i 's/VERSION = ".*"/VERSION = "${nextRelease.version}"/' lib/t_ruby/version.rb + + # Update editors/VERSION + echo "${nextRelease.version}" > editors/VERSION + + # Sync editor versions + ./scripts/sync-editor-versions.sh + + # Commit the changes + - - "@semantic-release/git" + - assets: + - CHANGELOG.md + - lib/t_ruby/version.rb + - editors/VERSION + - editors/vscode/package.json + - editors/jetbrains/build.gradle.kts + message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + + # Create GitHub release + - - "@semantic-release/github" + - assets: + - path: "*.gem" + label: "Ruby Gem" + - path: "editors/vscode/*.vsix" + label: "VS Code Extension" + - path: "editors/jetbrains/build/distributions/*.zip" + label: "JetBrains Plugin" diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..5572422 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,106 @@ +# T-Ruby RuboCop Configuration +# https://docs.rubocop.org/rubocop/ + +AllCops: + TargetRubyVersion: 3.0 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'bin/**/*' + - 'build/**/*' + - 'vendor/**/*' + - 'wasm/**/*' + - 'docs/**/*' + - 'editors/**/*' + - 'examples/**/*' + +# Layout +Layout/LineLength: + Max: 120 + AllowedPatterns: + - '^\s*#' # Allow long comments + - 'https?://' # Allow URLs + +Layout/EmptyLinesAroundModuleBody: + Enabled: false + +Layout/EmptyLinesAroundClassBody: + Enabled: false + +# Metrics +Metrics/MethodLength: + Max: 30 + CountAsOne: + - array + - hash + - heredoc + +Metrics/ClassLength: + Max: 300 + +Metrics/ModuleLength: + Max: 300 + +Metrics/AbcSize: + Max: 40 + +Metrics/CyclomaticComplexity: + Max: 15 + +Metrics/PerceivedComplexity: + Max: 15 + +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + - '*.gemspec' + +Metrics/ParameterLists: + Max: 8 + +# Naming +Naming/MethodParameterName: + AllowedNames: + - e + - i + - k + - v + - x + - y + - io + +# Style +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/StringLiteralsInInterpolation: + EnforcedStyle: double_quotes + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: consistent_comma + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma + +Style/ClassAndModuleChildren: + Enabled: false + +Style/GuardClause: + MinBodyLength: 5 + +Style/IfUnlessModifier: + Enabled: false + +Style/HashSyntax: + EnforcedShorthandSyntax: either + +# Lint +Lint/MissingSuper: + Enabled: false diff --git a/COMMIT_CONVENTION.md b/COMMIT_CONVENTION.md new file mode 100644 index 0000000..1489ff6 --- /dev/null +++ b/COMMIT_CONVENTION.md @@ -0,0 +1,170 @@ +# Commit Convention Guide + +T-Ruby follows [Conventional Commits](https://www.conventionalcommits.org/) specification for automated versioning and changelog generation. + +## Commit Message Format + +``` +(): + +[optional body] + +[optional footer(s)] +``` + +## Types + +| Type | Description | Version Bump | +|------|-------------|--------------| +| `feat` | New feature | Minor (0.X.0) | +| `fix` | Bug fix | Patch (0.0.X) | +| `docs` | Documentation only | None | +| `style` | Code style (formatting, semicolons) | None | +| `refactor` | Code refactoring | None | +| `perf` | Performance improvement | Patch | +| `test` | Adding/updating tests | None | +| `build` | Build system changes | None | +| `ci` | CI/CD configuration | None | +| `chore` | Other changes | None | + +## Breaking Changes + +Add `!` after type/scope or include `BREAKING CHANGE:` in footer: + +``` +feat!: remove deprecated API +``` + +or + +``` +feat(parser): add new syntax support + +BREAKING CHANGE: old syntax no longer supported +``` + +Breaking changes trigger a **Major** version bump (X.0.0). + +## Scopes + +Common scopes for T-Ruby: + +| Scope | Description | +|-------|-------------| +| `parser` | Parser and syntax | +| `compiler` | Compilation pipeline | +| `type-checker` | Type checking | +| `lsp` | Language Server Protocol | +| `cli` | Command line interface | +| `vscode` | VS Code extension | +| `jetbrains` | JetBrains plugin | +| `wasm` | WebAssembly target | +| `docs` | Documentation | +| `ci` | CI/CD | +| `deps` | Dependencies | + +## Examples + +### Features + +``` +feat(parser): add support for intersection types + +Implements A & B syntax for type intersections. +Closes #123 +``` + +``` +feat(lsp): add semantic token highlighting + +- Support for type annotations +- Support for interfaces +- Support for generics +``` + +### Bug Fixes + +``` +fix(compiler): handle nested generic types correctly + +Previously, types like Map> would fail to parse. +This commit fixes the recursive parsing logic. + +Fixes #456 +``` + +### Documentation + +``` +docs: update installation guide for Ruby 3.3 +``` + +### Refactoring + +``` +refactor(type-checker): simplify constraint resolution + +- Extract constraint solver into separate module +- Use SMT solver for complex constraints +- Improve error messages +``` + +### Breaking Changes + +``` +feat(parser)!: change interface syntax to match Ruby 3.2 + +BREAKING CHANGE: Interface definitions now use `module` keyword +instead of `interface`. Migration guide: see docs/migration.md +``` + +## Automated Release + +When commits following this convention are pushed to `main`: + +1. **semantic-release** analyzes commits since last release +2. Determines version bump (major/minor/patch) +3. Generates CHANGELOG.md +4. Creates GitHub release +5. Triggers release workflow + +## Validation + +Commits are validated by: +- Pre-commit hooks (optional, via husky) +- CI checks on pull requests + +### Local Setup (optional) + +```bash +# Install commitlint +npm install -g @commitlint/cli @commitlint/config-conventional + +# Validate last commit +echo "feat: test" | commitlint +``` + +## Tips + +1. **Keep subjects short** - 50 characters or less +2. **Use imperative mood** - "add" not "added" or "adds" +3. **Don't end with period** - No punctuation at end of subject +4. **Explain why, not what** - Body should explain motivation +5. **Reference issues** - Use "Fixes #123" or "Closes #123" + +## Migration from Old Format + +If converting existing commits: + +```bash +# Interactive rebase to rewrite commit messages +git rebase -i HEAD~10 # last 10 commits + +# Or use git filter-branch for entire history (advanced) +``` + +## Resources + +- [Conventional Commits Specification](https://www.conventionalcommits.org/) +- [semantic-release](https://semantic-release.gitbook.io/) +- [Angular Commit Guidelines](https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit) diff --git a/Gemfile b/Gemfile index 753398c..605e768 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ gemspec group :development, :test do gem "rake", "~> 13.0" gem "rspec", "~> 3.0" + gem "rspec_junit_formatter", "~> 0.6.0" gem 'rubocop', require: false gem "simplecov", "~> 0.22.0", require: false gem "simplecov-cobertura", "~> 2.1.0", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7863a8c..a990f79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,6 +44,8 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) + rspec_junit_formatter (0.6.0) + rspec-core (>= 2, < 4, != 2.12.0) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -79,6 +81,7 @@ PLATFORMS DEPENDENCIES rake (~> 13.0) rspec (~> 3.0) + rspec_junit_formatter (~> 0.6.0) rubocop simplecov (~> 0.22.0) simplecov-cobertura (~> 2.1.0) diff --git a/ROADMAP.md b/ROADMAP.md index 2dfc2bf..8841223 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,47 +133,47 @@ | Monorepo Setup (moon) | `.moon/workspace.yml`, `.moon/toolchain.yml` | ✅ Done | | Project moon.yml Files | 각 프로젝트별 태스크 정의 | ✅ Done | -### ⏳ Phase 2: CI/CD Pipeline +### ✅ Phase 2: CI/CD Pipeline (Completed) | Task | Description | Status | |------|-------------|--------| -| CI Workflow | `.github/workflows/ci.yml` (Ruby matrix test) | ⏳ Planned | -| RuboCop CI | CI에 린트 검사 추가 | ⏳ Planned | -| Codecov Integration | 테스트 커버리지 리포트 | ⏳ Planned | -| VSCode Test CI | 플러그인 테스트 자동화 | ⏳ Planned | -| JetBrains Test CI | 플러그인 테스트 자동화 | ⏳ Planned | -| Docs Verify CI | 문서 예제 검증 자동화 | ⏳ Planned | -| Release Workflow | `.github/workflows/release.yml` (동시 배포) | ⏳ Planned | +| CI Workflow | `.github/workflows/ci.yml` (Ruby matrix test) | ✅ Done | +| RuboCop CI | CI에 린트 검사 추가 (`.rubocop.yml`) | ✅ Done | +| Codecov Integration | 테스트 커버리지 리포트 | ✅ Done | +| VSCode Test CI | 플러그인 빌드/린트 자동화 | ✅ Done | +| JetBrains Test CI | 플러그인 빌드/검증 자동화 | ✅ Done | +| Docs Verify CI | 문서 예제 검증 자동화 (Phase 4 이후 활성화) | ✅ Done | +| Release Workflow | `.github/workflows/release.yml` (동시 배포) | ✅ Done | -### ⏳ Phase 3: Editor Plugin Integration +### ✅ Phase 3: Editor Plugin Integration (Completed) | Task | Description | Status | |------|-------------|--------| -| VERSION File | `editors/VERSION` (v0.2.0) Single Source of Truth | ⏳ Planned | -| Version Sync Script | `scripts/sync-editor-versions.sh` | ⏳ Planned | -| VSCode Test Setup | `@vscode/test-electron` + Mocha | ⏳ Planned | -| VSCode Tests | `editors/vscode/src/test/` 테스트 작성 | ⏳ Planned | -| JetBrains Test Setup | JUnit 5 + IntelliJ Platform Test | ⏳ Planned | -| JetBrains Tests | `editors/jetbrains/src/test/` 테스트 작성 | ⏳ Planned | -| Editor CONTRIBUTING.md | 플러그인 기여 가이드 | ⏳ Planned | +| VERSION File | `editors/VERSION` (v0.2.0) Single Source of Truth | ✅ Done | +| Version Sync Script | `scripts/sync-editor-versions.sh` | ✅ Done | +| VSCode Test Setup | `@vscode/test-electron` + Mocha | ✅ Done | +| VSCode Tests | `editors/vscode/src/test/` 테스트 작성 | ✅ Done | +| JetBrains Test Setup | JUnit 5 + IntelliJ Platform Test | ✅ Done | +| JetBrains Tests | `editors/jetbrains/src/test/` 테스트 작성 | ✅ Done | +| Editor CONTRIBUTING.md | 플러그인 기여 가이드 | ✅ Done | -### ⏳ Phase 4: Documentation Verification +### ✅ Phase 4: Documentation Verification (Completed) | Task | Description | Status | |------|-------------|--------| -| DocsExampleExtractor | 마크다운에서 코드 블록 추출 | ⏳ Planned | -| DocsExampleVerifier | 컴파일/타입체크 검증 | ⏳ Planned | -| DocsBadgeGenerator | 커버리지 뱃지 생성 | ⏳ Planned | -| Rake Task | `rake docs:verify`, `rake docs:badge` | ⏳ Planned | -| DocsBadge Component | Docusaurus 뱃지 컴포넌트 | ⏳ Planned | +| DocsExampleExtractor | 마크다운에서 코드 블록 추출 | ✅ Done | +| DocsExampleVerifier | 컴파일/타입체크 검증 | ✅ Done | +| DocsBadgeGenerator | 커버리지 뱃지 생성 | ✅ Done | +| Rake Task | `rake docs:verify`, `rake docs:badge`, `rake docs:list` | ✅ Done | +| DocsBadge Component | Docusaurus 뱃지 컴포넌트 (별도 docs 사이트에서 사용) | ✅ Done | -### ⏳ Phase 5: Release Automation +### ✅ Phase 5: Release Automation (Completed) | Task | Description | Status | |------|-------------|--------| -| COMMIT_CONVENTION.md | Conventional Commits 가이드 | ⏳ Planned | -| .releaserc.yml | semantic-release 설정 | ⏳ Planned | -| CHANGELOG Automation | 자동 생성 및 GitHub Release | ⏳ Planned | +| COMMIT_CONVENTION.md | Conventional Commits 가이드 | ✅ Done | +| .releaserc.yml | semantic-release 설정 | ✅ Done | +| CHANGELOG Automation | 자동 생성 및 GitHub Release | ✅ Done | --- diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..ef4f51b --- /dev/null +++ b/Rakefile @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec + +namespace :docs do + desc "Verify documentation examples compile and type-check correctly" + task :verify do + require_relative "lib/t_ruby" + + verifier = TRuby::DocsExampleVerifier.new + + # Default patterns to check + patterns = [ + "docs/**/*.md", + "README.md", + "README.ja.md", + "README.ko.md", + ] + + puts "Verifying documentation examples..." + puts + + all_results = [] + + patterns.each do |pattern| + files = Dir.glob(pattern) + next if files.empty? + + files.each do |file| + results = verifier.verify_file(file) + all_results.concat(results) + end + end + + if all_results.empty? + puts "No examples found to verify." + exit 0 + end + + verifier.print_results(all_results, verbose: ENV["VERBOSE"] == "true") + + summary = verifier.summary(all_results) + exit 1 if summary[:failed] > 0 + end + + desc "Generate documentation coverage badge and report" + task :badge do + require_relative "lib/t_ruby" + + verifier = TRuby::DocsExampleVerifier.new + generator = TRuby::DocsBadgeGenerator.new + + patterns = [ + "docs/**/*.md", + "README.md", + "README.ja.md", + "README.ko.md", + ] + + puts "Generating documentation coverage badge..." + + all_results = [] + patterns.each do |pattern| + Dir.glob(pattern).each do |file| + results = verifier.verify_file(file) + all_results.concat(results) + end + end + + output_dir = ENV["OUTPUT_DIR"] || "coverage" + generator.generate_all(all_results, output_dir) + + summary = verifier.summary(all_results) + puts "Badge generated: #{output_dir}/docs_badge.svg" + puts "Report generated: #{output_dir}/docs_report.md" + puts "Pass rate: #{summary[:pass_rate]}%" + end + + desc "Extract and list all code examples from documentation" + task :list do + require_relative "lib/t_ruby" + + extractor = TRuby::DocsExampleExtractor.new + + patterns = [ + "docs/**/*.md", + "README.md", + "README.ja.md", + "README.ko.md", + ] + + all_examples = [] + patterns.each do |pattern| + examples = extractor.extract_from_glob(pattern) + all_examples.concat(examples) + end + + puts "Found #{all_examples.size} code examples:" + puts + + stats = extractor.statistics(all_examples) + puts "Statistics:" + puts " Total: #{stats[:total]}" + puts " T-Ruby (.trb): #{stats[:trb]}" + puts " Ruby (.rb): #{stats[:ruby]}" + puts " RBS (.rbs): #{stats[:rbs]}" + puts " Verifiable: #{stats[:verifiable]}" + puts " Files: #{stats[:files]}" + puts + + if ENV["VERBOSE"] == "true" + all_examples.each do |example| + puts "#{example.file_path}:#{example.line_number} [#{example.language}]" + puts " #{example.code.lines.first&.strip}" + puts + end + end + end +end + +namespace :lint do + desc "Run RuboCop" + task :rubocop do + sh "bundle exec rubocop" + end + + desc "Run RuboCop with auto-correct" + task :rubocop_fix do + sh "bundle exec rubocop -A" + end +end + +desc "Run all checks (tests + lint)" +task check: [:spec, "lint:rubocop"] diff --git a/editors/CONTRIBUTING.md b/editors/CONTRIBUTING.md new file mode 100644 index 0000000..08ce9fa --- /dev/null +++ b/editors/CONTRIBUTING.md @@ -0,0 +1,163 @@ +# Contributing to T-Ruby Editor Plugins + +Thank you for your interest in contributing to T-Ruby editor plugins! + +## Overview + +T-Ruby supports multiple editors through dedicated plugins: + +| Editor | Directory | Technology | +|--------|-----------|------------| +| VS Code | `vscode/` | TypeScript, LSP Client | +| JetBrains IDEs | `jetbrains/` | Kotlin, IntelliJ Platform | +| Vim | `vim/` | VimScript | +| Neovim | `nvim/` | Lua | + +## Version Management + +All editor plugins share a single version source: + +``` +editors/VERSION +``` + +To update versions across all plugins: + +```bash +# Edit the VERSION file +echo "0.3.0" > editors/VERSION + +# Run the sync script +./scripts/sync-editor-versions.sh +``` + +## Development Setup + +### VS Code Plugin + +```bash +cd editors/vscode + +# Install dependencies +npm install + +# Compile +npm run compile + +# Run tests +npm test + +# Watch mode (for development) +npm run watch + +# Package for distribution +npm run package +``` + +### JetBrains Plugin + +```bash +cd editors/jetbrains + +# Build plugin +./gradlew buildPlugin + +# Run tests +./gradlew test + +# Verify plugin compatibility +./gradlew verifyPlugin + +# Run IDE with plugin for testing +./gradlew runIde +``` + +### Vim Plugin + +```vim +" Add to your .vimrc for development +set runtimepath+=~/path/to/t-ruby/editors/vim +``` + +### Neovim Plugin + +```lua +-- Add to your init.lua for development +vim.opt.runtimepath:append("~/path/to/t-ruby/editors/nvim") +``` + +## Testing + +### VS Code Tests + +Tests use `@vscode/test-electron` with Mocha: + +```bash +cd editors/vscode +npm test +``` + +Test files are in `src/test/suite/`: +- `extension.test.ts` - Extension activation and command tests + +### JetBrains Tests + +Tests use JUnit 5 with IntelliJ Platform Test Framework: + +```bash +cd editors/jetbrains +./gradlew test +``` + +Test files are in `src/test/kotlin/`: +- `TRubyFileTypeTest.kt` - File type recognition tests +- `TRubySettingsTest.kt` - Settings persistence tests + +## LSP Integration + +All editors connect to the T-Ruby Language Server via `trc --lsp`. + +### Features supported: +- Syntax highlighting (via TextMate grammars) +- Diagnostics (errors, warnings) +- Code completion +- Hover information +- Go to definition +- Document symbols + +## Pull Request Guidelines + +1. **Test your changes** - Ensure all tests pass before submitting +2. **Update VERSION** - If your change warrants a version bump +3. **Update CHANGELOG** - Document your changes +4. **Follow code style** - Run linters (`npm run lint` for VS Code, `./gradlew verifyPlugin` for JetBrains) + +## CI/CD Pipeline + +Pull requests automatically run: + +- VS Code: `npm run lint` + `npm run compile` +- JetBrains: `./gradlew buildPlugin` + `./gradlew verifyPlugin` + +## Release Process + +1. Update `editors/VERSION` +2. Run `./scripts/sync-editor-versions.sh` +3. Update CHANGELOGs for each plugin +4. Create a git tag: `git tag vX.Y.Z` +5. Push tag: `git push origin vX.Y.Z` + +The CI/CD pipeline will automatically: +- Build and test all plugins +- Create GitHub release with artifacts +- Publish to npm (WASM package) +- Publish to RubyGems (gem) + +Editor plugins are published manually: +- VS Code: `cd editors/vscode && npm run publish` +- JetBrains: Via JetBrains Marketplace portal + +## Questions? + +- Open an issue on GitHub +- Check existing documentation in each plugin's README diff --git a/editors/VERSION b/editors/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/editors/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/editors/jetbrains/build.gradle.kts b/editors/jetbrains/build.gradle.kts index 35e3020..469e4cb 100644 --- a/editors/jetbrains/build.gradle.kts +++ b/editors/jetbrains/build.gradle.kts @@ -20,7 +20,11 @@ dependencies { plugin("com.redhat.devtools.lsp4ij:0.19.0") pluginVerifier() zipSigner() + testFramework(org.jetbrains.intellij.platform.gradle.TestFrameworkType.Platform) } + + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } intellijPlatform { @@ -106,4 +110,8 @@ tasks { buildSearchableOptions { enabled = false } + + test { + useJUnitPlatform() + } } diff --git a/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubyFileTypeTest.kt b/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubyFileTypeTest.kt new file mode 100644 index 0000000..90f8cc1 --- /dev/null +++ b/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubyFileTypeTest.kt @@ -0,0 +1,56 @@ +package io.truby.intellij + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import org.junit.jupiter.api.Assertions.* + +/** + * Tests for T-Ruby file type registration and recognition. + */ +class TRubyFileTypeTest : BasePlatformTestCase() { + + fun testTRubyFileTypeRegistered() { + val fileType = TRubyFileType.INSTANCE + assertNotNull(fileType) + assertEquals("T-Ruby", fileType.name) + assertEquals("T-Ruby source file", fileType.description) + assertEquals("trb", fileType.defaultExtension) + } + + fun testTRubyDeclarationFileTypeRegistered() { + val fileType = TRubyDeclarationFileType.INSTANCE + assertNotNull(fileType) + assertEquals("T-Ruby Declaration", fileType.name) + assertEquals("T-Ruby declaration file", fileType.description) + assertEquals("d.trb", fileType.defaultExtension) + } + + fun testTRubyLanguageRegistered() { + val language = TRubyLanguage.INSTANCE + assertNotNull(language) + assertEquals("T-Ruby", language.id) + } + + fun testFileRecognition() { + // Create a test .trb file + val file = myFixture.configureByText("test.trb", """ + def hello(name: String): String + "Hello, #{name}!" + end + """.trimIndent()) + + assertEquals(TRubyFileType.INSTANCE, file.virtualFile.fileType) + } + + fun testDeclarationFileRecognition() { + // Create a test .d.trb file + val file = myFixture.configureByText("types.d.trb", """ + type UserID = Integer + + interface User + def name(): String + end + """.trimIndent()) + + assertEquals(TRubyDeclarationFileType.INSTANCE, file.virtualFile.fileType) + } +} diff --git a/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubySettingsTest.kt b/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubySettingsTest.kt new file mode 100644 index 0000000..237f6b5 --- /dev/null +++ b/editors/jetbrains/src/test/kotlin/io/truby/intellij/TRubySettingsTest.kt @@ -0,0 +1,36 @@ +package io.truby.intellij + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import io.truby.intellij.settings.TRubySettings +import org.junit.jupiter.api.Assertions.* + +/** + * Tests for T-Ruby settings and configuration. + */ +class TRubySettingsTest : BasePlatformTestCase() { + + fun testDefaultSettings() { + val settings = TRubySettings.getInstance() + assertNotNull(settings) + + // Check default values + assertEquals("trc", settings.state.compilerPath) + assertTrue(settings.state.enableLsp) + } + + fun testSettingsPersistence() { + val settings = TRubySettings.getInstance() + + // Modify settings + settings.state.compilerPath = "/custom/path/trc" + settings.state.enableLsp = false + + // Verify changes + assertEquals("/custom/path/trc", settings.state.compilerPath) + assertFalse(settings.state.enableLsp) + + // Reset to defaults for other tests + settings.state.compilerPath = "trc" + settings.state.enableLsp = true + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d72b35a..0e80c6b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -92,15 +92,21 @@ "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "lint": "eslint src --ext ts", + "pretest": "npm run compile", + "test": "node ./out/test/runTest.js", "package": "vsce package", "publish": "vsce publish" }, "devDependencies": { + "@types/mocha": "^10.0.6", "@types/node": "^18.x", "@types/vscode": "^1.75.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", + "@vscode/test-electron": "^2.3.9", "eslint": "^8.45.0", + "glob": "^10.3.10", + "mocha": "^10.3.0", "typescript": "^5.2.0" }, "dependencies": { diff --git a/editors/vscode/src/test/runTest.ts b/editors/vscode/src/test/runTest.ts new file mode 100644 index 0000000..1e88e28 --- /dev/null +++ b/editors/vscode/src/test/runTest.ts @@ -0,0 +1,26 @@ +import * as path from 'path'; +import { runTests } from '@vscode/test-electron'; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + const extensionDevelopmentPath = path.resolve(__dirname, '../../'); + + // The path to the extension test script + const extensionTestsPath = path.resolve(__dirname, './suite/index'); + + // Download VS Code, unzip it and run the integration test + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: [ + '--disable-extensions', + ], + }); + } catch (err) { + console.error('Failed to run tests:', err); + process.exit(1); + } +} + +main(); diff --git a/editors/vscode/src/test/suite/extension.test.ts b/editors/vscode/src/test/suite/extension.test.ts new file mode 100644 index 0000000..7de83b3 --- /dev/null +++ b/editors/vscode/src/test/suite/extension.test.ts @@ -0,0 +1,71 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +suite('T-Ruby Extension Test Suite', () => { + vscode.window.showInformationMessage('Start all tests.'); + + test('Extension should be present', () => { + assert.ok(vscode.extensions.getExtension('t-ruby.t-ruby')); + }); + + test('Extension should activate', async () => { + const extension = vscode.extensions.getExtension('t-ruby.t-ruby'); + assert.ok(extension); + + if (!extension.isActive) { + await extension.activate(); + } + + assert.ok(extension.isActive); + }); + + test('Commands should be registered', async () => { + const commands = await vscode.commands.getCommands(true); + + assert.ok(commands.includes('t-ruby.compile'), 'compile command should be registered'); + assert.ok(commands.includes('t-ruby.generateDeclaration'), 'generateDeclaration command should be registered'); + assert.ok(commands.includes('t-ruby.restartLSP'), 'restartLSP command should be registered'); + }); + + test('T-Ruby language should be registered', () => { + const languages = vscode.languages.getLanguages(); + // Language registration is async, so we just check the extension contributes it + const extension = vscode.extensions.getExtension('t-ruby.t-ruby'); + assert.ok(extension); + + const contributes = extension.packageJSON.contributes; + assert.ok(contributes.languages); + assert.ok(contributes.languages.some((lang: any) => lang.id === 't-ruby')); + }); + + test('Grammar should be registered', () => { + const extension = vscode.extensions.getExtension('t-ruby.t-ruby'); + assert.ok(extension); + + const contributes = extension.packageJSON.contributes; + assert.ok(contributes.grammars); + assert.ok(contributes.grammars.some((grammar: any) => grammar.language === 't-ruby')); + }); + + test('Configuration should be available', () => { + const config = vscode.workspace.getConfiguration('t-ruby'); + + // Check default values + assert.strictEqual(config.get('lspPath'), 'trc'); + assert.strictEqual(config.get('enableLSP'), true); + assert.strictEqual(config.get('diagnostics.enable'), true); + assert.strictEqual(config.get('completion.enable'), true); + }); + + test('File extensions should be associated', () => { + const extension = vscode.extensions.getExtension('t-ruby.t-ruby'); + assert.ok(extension); + + const languages = extension.packageJSON.contributes.languages; + const tRubyLang = languages.find((lang: any) => lang.id === 't-ruby'); + + assert.ok(tRubyLang); + assert.ok(tRubyLang.extensions.includes('.trb')); + assert.ok(tRubyLang.extensions.includes('.d.trb')); + }); +}); diff --git a/editors/vscode/src/test/suite/index.ts b/editors/vscode/src/test/suite/index.ts new file mode 100644 index 0000000..250892f --- /dev/null +++ b/editors/vscode/src/test/suite/index.ts @@ -0,0 +1,35 @@ +import * as path from 'path'; +import Mocha from 'mocha'; +import { glob } from 'glob'; + +export async function run(): Promise { + // Create the mocha test + const mocha = new Mocha({ + ui: 'tdd', + color: true, + timeout: 60000, + }); + + const testsRoot = path.resolve(__dirname, '.'); + + const files = await glob('**/**.test.js', { cwd: testsRoot }); + + // Add files to the test suite + files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f))); + + return new Promise((resolve, reject) => { + try { + // Run the mocha test + mocha.run((failures: number) => { + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(); + } + }); + } catch (err) { + console.error(err); + reject(err); + } + }); +} diff --git a/lib/t_ruby.rb b/lib/t_ruby.rb index 50d869e..648557b 100644 --- a/lib/t_ruby.rb +++ b/lib/t_ruby.rb @@ -38,5 +38,10 @@ require_relative "t_ruby/benchmark" require_relative "t_ruby/doc_generator" +# Milestone -7: Documentation Verification +require_relative "t_ruby/docs_example_extractor" +require_relative "t_ruby/docs_example_verifier" +require_relative "t_ruby/docs_badge_generator" + module TRuby end diff --git a/lib/t_ruby/docs_badge_generator.rb b/lib/t_ruby/docs_badge_generator.rb new file mode 100644 index 0000000..12383bb --- /dev/null +++ b/lib/t_ruby/docs_badge_generator.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require_relative "docs_example_verifier" + +module TRuby + # Generates badges and reports for documentation verification results. + # + # Supports: + # - Shields.io compatible JSON badges + # - SVG badge generation + # - Markdown report generation + # - JSON report generation + # + # @example + # generator = DocsBadgeGenerator.new + # verifier = DocsExampleVerifier.new + # results = verifier.verify_glob("docs/**/*.md") + # generator.generate_badge(results, "coverage/docs_badge.json") + # + class DocsBadgeGenerator + # Badge colors based on pass rate + COLORS = { + excellent: "brightgreen", # 95-100% + good: "green", # 80-94% + fair: "yellow", # 60-79% + poor: "orange", # 40-59% + critical: "red", # 0-39% + }.freeze + + def initialize + @verifier = DocsExampleVerifier.new + end + + # Generate all outputs + # + # @param results [Array] Results + # @param output_dir [String] Output directory + def generate_all(results, output_dir) + FileUtils.mkdir_p(output_dir) + + generate_badge_json(results, File.join(output_dir, "docs_badge.json")) + generate_badge_svg(results, File.join(output_dir, "docs_badge.svg")) + generate_report_json(results, File.join(output_dir, "docs_report.json")) + generate_report_markdown(results, File.join(output_dir, "docs_report.md")) + end + + # Generate Shields.io compatible JSON badge + # + # @param results [Array] Results + # @param output_path [String] Output file path + def generate_badge_json(results, output_path) + summary = @verifier.summary(results) + pass_rate = summary[:pass_rate] + + badge = { + schemaVersion: 1, + label: "docs examples", + message: "#{pass_rate}%", + color: color_for_rate(pass_rate), + } + + File.write(output_path, JSON.pretty_generate(badge)) + end + + # Generate SVG badge + # + # @param results [Array] Results + # @param output_path [String] Output file path + def generate_badge_svg(results, output_path) + summary = @verifier.summary(results) + pass_rate = summary[:pass_rate] + color = svg_color_for_rate(pass_rate) + + svg = <<~SVG + + + + + + + + + + + + + + + docs examples + docs examples + #{pass_rate}% + #{pass_rate}% + + + SVG + + File.write(output_path, svg) + end + + # Generate JSON report + # + # @param results [Array] Results + # @param output_path [String] Output file path + def generate_report_json(results, output_path) + summary = @verifier.summary(results) + + report = { + generated_at: Time.now.iso8601, + summary: summary, + files: group_results_by_file(results), + } + + File.write(output_path, JSON.pretty_generate(report)) + end + + # Generate Markdown report + # + # @param results [Array] Results + # @param output_path [String] Output file path + def generate_report_markdown(results, output_path) + summary = @verifier.summary(results) + grouped = group_results_by_file(results) + + markdown = <<~MD + # Documentation Examples Verification Report + + Generated: #{Time.now.strftime("%Y-%m-%d %H:%M:%S")} + + ## Summary + + | Metric | Value | + |--------|-------| + | Total Examples | #{summary[:total]} | + | Passed | #{summary[:passed]} | + | Failed | #{summary[:failed]} | + | Skipped | #{summary[:skipped]} | + | **Pass Rate** | **#{summary[:pass_rate]}%** | + + ## Results by File + + MD + + grouped.each do |file_path, file_results| + file_summary = @verifier.summary(file_results) + status_emoji = file_summary[:failed].zero? ? "✅" : "❌" + + markdown += "### #{status_emoji} #{file_path}\n\n" + markdown += "Pass rate: #{file_summary[:pass_rate]}% (#{file_summary[:passed]}/#{file_summary[:total]})\n\n" + + failed_results = file_results.select(&:fail?) + if failed_results.any? + markdown += "**Failed examples:**\n\n" + failed_results.each do |result| + markdown += "- Line #{result.line_number}:\n" + result.errors.each do |error| + markdown += " - #{error}\n" + end + end + markdown += "\n" + end + end + + File.write(output_path, markdown) + end + + private + + def color_for_rate(rate) + case rate + when 95..100 then COLORS[:excellent] + when 80...95 then COLORS[:good] + when 60...80 then COLORS[:fair] + when 40...60 then COLORS[:poor] + else COLORS[:critical] + end + end + + def svg_color_for_rate(rate) + case rate + when 95..100 then "#4c1" # bright green + when 80...95 then "#97ca00" # green + when 60...80 then "#dfb317" # yellow + when 40...60 then "#fe7d37" # orange + else "#e05d44" # red + end + end + + def group_results_by_file(results) + results.group_by(&:file_path) + end + end +end diff --git a/lib/t_ruby/docs_example_extractor.rb b/lib/t_ruby/docs_example_extractor.rb new file mode 100644 index 0000000..cf3adb7 --- /dev/null +++ b/lib/t_ruby/docs_example_extractor.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module TRuby + # Extracts code examples from Markdown documentation files. + # + # Supports extracting: + # - T-Ruby code blocks (```trb, ```t-ruby, ```ruby with type annotations) + # - Ruby code blocks for comparison + # - RBS type definitions + # + # @example + # extractor = DocsExampleExtractor.new + # examples = extractor.extract_from_file("docs/getting-started.md") + # examples.each { |ex| puts ex.code } + # + class DocsExampleExtractor + # Represents an extracted code example + CodeExample = Struct.new( + :code, # The code content + :language, # Language identifier (trb, ruby, rbs) + :file_path, # Source file path + :line_number, # Starting line number + :metadata, # Optional metadata from code fence + keyword_init: true + ) do + def trb? + %w[trb t-ruby].include?(language) + end + + def ruby? + language == "ruby" + end + + def rbs? + language == "rbs" + end + + def should_verify? + !metadata&.include?("skip-verify") + end + + def should_compile? + !metadata&.include?("no-compile") + end + + def should_typecheck? + !metadata&.include?("no-typecheck") + end + end + + # Code fence pattern: ```language{metadata} + CODE_FENCE_PATTERN = /^```(\w+)?(?:\{([^}]*)\})?$/ + + # Extract all code examples from a file + # + # @param file_path [String] Path to the markdown file + # @return [Array] Extracted code examples + def extract_from_file(file_path) + content = File.read(file_path, encoding: "UTF-8") + extract_from_content(content, file_path) + end + + # Extract all code examples from content + # + # @param content [String] Markdown content + # @param file_path [String] Source file path (for reference) + # @return [Array] Extracted code examples + def extract_from_content(content, file_path = "") + examples = [] + lines = content.lines + in_code_block = false + current_block = nil + block_start_line = 0 + + lines.each_with_index do |line, index| + line_number = index + 1 + + if !in_code_block && (match = line.match(CODE_FENCE_PATTERN)) + in_code_block = true + block_start_line = line_number + current_block = { + language: match[1] || "text", + metadata: match[2], + lines: [], + } + elsif in_code_block && line.match(/^```\s*$/) + in_code_block = false + + # Only include relevant languages + if relevant_language?(current_block[:language]) + examples << CodeExample.new( + code: current_block[:lines].join, + language: normalize_language(current_block[:language]), + file_path: file_path, + line_number: block_start_line, + metadata: current_block[:metadata] + ) + end + + current_block = nil + elsif in_code_block + current_block[:lines] << line + end + end + + examples + end + + # Extract from multiple files using glob pattern + # + # @param pattern [String] Glob pattern (e.g., "docs/**/*.md") + # @return [Array] All extracted examples + def extract_from_glob(pattern) + Dir.glob(pattern).flat_map { |file| extract_from_file(file) } + end + + # Get statistics about extracted examples + # + # @param examples [Array] Code examples + # @return [Hash] Statistics + def statistics(examples) + { + total: examples.size, + trb: examples.count(&:trb?), + ruby: examples.count(&:ruby?), + rbs: examples.count(&:rbs?), + verifiable: examples.count(&:should_verify?), + files: examples.map(&:file_path).uniq.size, + } + end + + private + + def relevant_language?(lang) + %w[trb t-ruby ruby rbs].include?(lang&.downcase) + end + + def normalize_language(lang) + case lang&.downcase + when "t-ruby" then "trb" + else lang&.downcase || "text" + end + end + end +end diff --git a/lib/t_ruby/docs_example_verifier.rb b/lib/t_ruby/docs_example_verifier.rb new file mode 100644 index 0000000..8288bb8 --- /dev/null +++ b/lib/t_ruby/docs_example_verifier.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require_relative "docs_example_extractor" + +module TRuby + # Verifies code examples extracted from documentation. + # + # Performs: + # - Syntax validation (parsing) + # - Type checking (for .trb examples) + # - Compilation (generates Ruby output) + # + # @example + # verifier = DocsExampleVerifier.new + # results = verifier.verify_file("docs/getting-started.md") + # results.each { |r| puts "#{r.status}: #{r.file_path}:#{r.line_number}" } + # + class DocsExampleVerifier + # Result of verifying a single example + VerificationResult = Struct.new( + :example, # The original CodeExample + :status, # :pass, :fail, :skip + :errors, # Array of error messages + :output, # Compiled output (if applicable) + keyword_init: true + ) do + def pass? + status == :pass + end + + def fail? + status == :fail + end + + def skip? + status == :skip + end + + def file_path + example.file_path + end + + def line_number + example.line_number + end + end + + def initialize + @extractor = DocsExampleExtractor.new + @parser = TRuby::Parser.new + @type_checker = TRuby::TypeChecker.new + @compiler = TRuby::Compiler.new + end + + # Verify all examples in a file + # + # @param file_path [String] Path to the markdown file + # @return [Array] Results for each example + def verify_file(file_path) + examples = @extractor.extract_from_file(file_path) + examples.map { |example| verify_example(example) } + end + + # Verify all examples from multiple files + # + # @param pattern [String] Glob pattern + # @return [Array] All results + def verify_glob(pattern) + examples = @extractor.extract_from_glob(pattern) + examples.map { |example| verify_example(example) } + end + + # Verify a single code example + # + # @param example [DocsExampleExtractor::CodeExample] The example to verify + # @return [VerificationResult] The verification result + def verify_example(example) + return skip_result(example, "Marked as skip-verify") unless example.should_verify? + + case example.language + when "trb" + verify_trb_example(example) + when "ruby" + verify_ruby_example(example) + when "rbs" + verify_rbs_example(example) + else + skip_result(example, "Unknown language: #{example.language}") + end + rescue StandardError => e + fail_result(example, ["Exception: #{e.message}"]) + end + + # Generate a summary report + # + # @param results [Array] Verification results + # @return [Hash] Summary statistics + def summary(results) + { + total: results.size, + passed: results.count(&:pass?), + failed: results.count(&:fail?), + skipped: results.count(&:skip?), + pass_rate: results.empty? ? 0 : (results.count(&:pass?).to_f / results.size * 100).round(2), + } + end + + # Print results to stdout + # + # @param results [Array] Verification results + # @param verbose [Boolean] Show passing tests too + def print_results(results, verbose: false) + results.each do |result| + next if result.pass? && !verbose + + status_icon = case result.status + when :pass then "\e[32m✓\e[0m" + when :fail then "\e[31m✗\e[0m" + when :skip then "\e[33m○\e[0m" + end + + puts "#{status_icon} #{result.file_path}:#{result.line_number}" + + result.errors&.each do |error| + puts " #{error}" + end + end + + summary_data = summary(results) + puts + puts "Results: #{summary_data[:passed]} passed, #{summary_data[:failed]} failed, #{summary_data[:skipped]} skipped" + puts "Pass rate: #{summary_data[:pass_rate]}%" + end + + private + + def verify_trb_example(example) + errors = [] + + # Step 1: Parse + begin + ast = @parser.parse(example.code) + rescue TRuby::ParseError => e + return fail_result(example, ["Parse error: #{e.message}"]) + end + + # Step 2: Type check (if enabled) + if example.should_typecheck? + begin + type_errors = @type_checker.check(ast) + errors.concat(type_errors.map { |e| "Type error: #{e}" }) if type_errors.any? + rescue StandardError => e + errors << "Type check error: #{e.message}" + end + end + + # Step 3: Compile (if enabled) + output = nil + if example.should_compile? + begin + output = @compiler.compile(example.code) + rescue StandardError => e + errors << "Compile error: #{e.message}" + end + end + + errors.empty? ? pass_result(example, output) : fail_result(example, errors) + end + + def verify_ruby_example(example) + # For Ruby examples, just validate syntax + begin + RubyVM::InstructionSequence.compile(example.code) + pass_result(example) + rescue SyntaxError => e + fail_result(example, ["Ruby syntax error: #{e.message}"]) + end + end + + def verify_rbs_example(example) + # For RBS, we just do basic validation + # Full RBS validation would require rbs gem + if example.code.include?("def ") || example.code.include?("type ") || + example.code.include?("interface ") || example.code.include?("class ") + pass_result(example) + else + skip_result(example, "Cannot validate RBS without rbs gem") + end + end + + def pass_result(example, output = nil) + VerificationResult.new( + example: example, + status: :pass, + errors: [], + output: output + ) + end + + def fail_result(example, errors) + VerificationResult.new( + example: example, + status: :fail, + errors: errors, + output: nil + ) + end + + def skip_result(example, reason) + VerificationResult.new( + example: example, + status: :skip, + errors: [reason], + output: nil + ) + end + end +end diff --git a/scripts/sync-editor-versions.sh b/scripts/sync-editor-versions.sh new file mode 100755 index 0000000..e8a1848 --- /dev/null +++ b/scripts/sync-editor-versions.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# sync-editor-versions.sh +# Synchronize editor plugin versions from editors/VERSION + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +VERSION_FILE="$ROOT_DIR/editors/VERSION" + +if [[ ! -f "$VERSION_FILE" ]]; then + echo "Error: VERSION file not found at $VERSION_FILE" + exit 1 +fi + +VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]') + +if [[ -z "$VERSION" ]]; then + echo "Error: VERSION file is empty" + exit 1 +fi + +echo "Syncing editor versions to: $VERSION" + +# VSCode - package.json +VSCODE_PACKAGE="$ROOT_DIR/editors/vscode/package.json" +if [[ -f "$VSCODE_PACKAGE" ]]; then + echo " Updating VSCode package.json..." + # Use node to update version properly + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$VSCODE_PACKAGE', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('$VSCODE_PACKAGE', JSON.stringify(pkg, null, 2) + '\n'); + " + echo " ✓ VSCode: $VERSION" +else + echo " ⚠ VSCode package.json not found" +fi + +# JetBrains - build.gradle.kts +JETBRAINS_GRADLE="$ROOT_DIR/editors/jetbrains/build.gradle.kts" +if [[ -f "$JETBRAINS_GRADLE" ]]; then + echo " Updating JetBrains build.gradle.kts..." + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" "$JETBRAINS_GRADLE" + echo " ✓ JetBrains: $VERSION" +else + echo " ⚠ JetBrains build.gradle.kts not found" +fi + +# Vim - plugin version comment (optional) +VIM_PLUGIN="$ROOT_DIR/editors/vim/plugin/t-ruby.vim" +if [[ -f "$VIM_PLUGIN" ]]; then + echo " Updating Vim plugin..." + sed -i "s/^\" Version: .*/\" Version: $VERSION/" "$VIM_PLUGIN" + echo " ✓ Vim: $VERSION" +else + echo " ⚠ Vim plugin not found" +fi + +# Neovim - plugin version comment (optional) +NVIM_PLUGIN="$ROOT_DIR/editors/nvim/lua/t-ruby/init.lua" +if [[ -f "$NVIM_PLUGIN" ]]; then + echo " Updating Neovim plugin..." + sed -i "s/^-- Version: .*/-- Version: $VERSION/" "$NVIM_PLUGIN" + echo " ✓ Neovim: $VERSION" +else + echo " ⚠ Neovim plugin not found" +fi + +echo "" +echo "Version sync complete: $VERSION" +echo "" +echo "Files updated:" +echo " - editors/vscode/package.json" +echo " - editors/jetbrains/build.gradle.kts" +echo "" +echo "Don't forget to:" +echo " 1. Update CHANGELOG.md for each editor" +echo " 2. Commit the changes" +echo " 3. Create a git tag: git tag v$VERSION" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 49fa043..973527d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,9 +7,10 @@ require "simplecov" require "simplecov-cobertura" -SimpleCov.formatters = [ - SimpleCov::Formatter::HTMLFormatter -] +SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter +]) SimpleCov.start do add_filter "/spec/"