From a5dfcc91032a2ad9f5cc4acd77a5cc5d48293ed6 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 10 Feb 2026 12:47:27 +0100 Subject: [PATCH 1/5] Remove custom benchmark script --- benchmark.php | 478 -------------------------------------------------- 1 file changed, 478 deletions(-) delete mode 100644 benchmark.php diff --git a/benchmark.php b/benchmark.php deleted file mode 100644 index ccbb896..0000000 --- a/benchmark.php +++ /dev/null @@ -1,478 +0,0 @@ - */ - private array $fixtures = []; - - public function __construct( - private int $iterations = 500, - private int $warmup = 50, - ) { - $this->highlighter = new Highlighter(new CssTheme()); - } - - public function addFixture(string $language, string $code): self - { - $this->fixtures[$language] = $code; - - return $this; - } - - public function runSingle(string $language, string $code): BenchmarkResult - { - $highlighter = new Highlighter(new CssTheme()); - - for ($i = 0; $i < $this->warmup; $i++) { - $highlighter->parse($code, $language); - } - - $times = []; - - for ($i = 0; $i < $this->iterations; $i++) { - $start = hrtime(true); - $highlighter->parse($code, $language); - $end = hrtime(true); - $times[] = ($end - $start) / 1_000_000; - } - - sort($times); - - return new BenchmarkResult( - language: $language, - codeLength: strlen($code), - avg: array_sum($times) / count($times), - min: min($times), - max: max($times), - p50: $times[(int) (count($times) * 0.5)], - p95: $times[(int) (count($times) * 0.95)], - ); - } - - public function runMultiLanguage(): MultiLanguageResult - { - $highlighter = new Highlighter(new CssTheme()); - - $start = hrtime(true); - - for ($i = 0; $i < $this->iterations; $i++) { - foreach ($this->fixtures as $language => $code) { - $highlighter->parse($code, $language); - } - } - - $end = hrtime(true); - $totalMs = ($end - $start) / 1_000_000; - - return new MultiLanguageResult( - totalMs: $totalMs, - avgPerIteration: $totalMs / $this->iterations, - ); - } - - /** @return array */ - public function runAll(): array - { - $results = []; - - foreach ($this->fixtures as $language => $code) { - $results[$language] = $this->runSingle($language, $code); - } - - return $results; - } -} - -final readonly class BenchmarkReport -{ - private const int SEPARATOR_WIDTH = 60; - - public function __construct( - private int $iterations, - private int $warmup, - ) {} - - public function printHeader(): void - { - echo '=== Tempest Highlight Benchmark ===' . PHP_EOL; - echo "Iterations: {$this->iterations} (warmup: {$this->warmup})" . PHP_EOL; - echo 'PHP ' . PHP_VERSION . PHP_EOL; - echo str_repeat('-', self::SEPARATOR_WIDTH) . PHP_EOL; - } - - public function printResult(BenchmarkResult $result): void - { - echo sprintf( - '%-12s | %5d chars | avg: %7.3fms | p50: %7.3fms | p95: %7.3fms | min: %7.3fms | max: %7.3fms', - $result->language, - $result->codeLength, - $result->avg, - $result->p50, - $result->p95, - $result->min, - $result->max, - ) . PHP_EOL; - } - - public function printMultiLanguageResult(MultiLanguageResult $result): void - { - echo str_repeat('-', self::SEPARATOR_WIDTH) . PHP_EOL; - echo sprintf( - 'Multi-lang | all langs | total: %8.1fms | avg/iter: %7.3fms', - $result->totalMs, - $result->avgPerIteration, - ) . PHP_EOL; - } - - public function printFooter(): void - { - echo str_repeat('-', self::SEPARATOR_WIDTH) . PHP_EOL; - echo sprintf( - 'Peak memory: %.2f MB', - memory_get_peak_usage(true) / 1024 / 1024, - ) . PHP_EOL; - } -} - -final readonly class FixtureProvider -{ - /** @return array */ - public static function all(): array - { - return [ - 'php' => self::php(), - 'html' => self::html(), - 'javascript' => self::javascript(), - 'sql' => self::sql(), - ]; - } - - public static function php(): string - { - return <<<'PHP' - cache->remember("user.{$id}", 3600, function () use ($id) { - $user = $this->repository->find($id); - - if ($user === null) { - throw new UserNotFoundException("User {$id} not found"); - } - - $this->events->dispatch(new UserAccessed($user)); - - return $user; - }); - } - - public function getAllActive(): Collection - { - return $this->repository - ->query() - ->where('status', '=', 'active') - ->where('deleted_at', null) - ->orderBy('created_at', 'desc') - ->get() - ->map(fn (User $user) => new UserDTO( - id: $user->id, - name: $user->name, - email: $user->email, - role: $user->role->getValue(), - isAdmin: $user->role === Role::ADMIN, - createdAt: $user->created_at->toISOString(), - )); - } - - /** - * @param array $data - * @return User - * @throws ValidationException - */ - public function create(array $data): User - { - $validated = $this->validate($data, [ - 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users', - 'password' => 'required|min:8', - ]); - - $user = new User(); - $user->name = $validated['name']; - $user->email = $validated['email']; - $user->password = password_hash($validated['password'], PASSWORD_ARGON2ID); - $user->status = 'active'; - $user->save(); - - $this->events->dispatch(new UserCreated($user)); - $this->cache->forget('users.active'); - - return $user; - } - - private function validate(array $data, array $rules): array - { - foreach ($rules as $field => $rule) { - $constraints = explode('|', $rule); - - foreach ($constraints as $constraint) { - match (true) { - $constraint === 'required' => isset($data[$field]) ?: throw new ValidationException("{$field} is required"), - str_starts_with($constraint, 'max:') => strlen($data[$field] ?? '') <= (int) substr($constraint, 4), - str_starts_with($constraint, 'min:') => strlen($data[$field] ?? '') >= (int) substr($constraint, 4), - $constraint === 'email' => filter_var($data[$field] ?? '', FILTER_VALIDATE_EMAIL) !== false, - default => true, - }; - } - } - - return $data; - } - } - PHP; - } - - public static function html(): string - { - return <<<'HTML' - - - - - - Dashboard - - - - - -
-
-
-

Statistics

-

{{ $stats['total'] }}

-
-
-
- - - HTML; - } - - public static function javascript(): string - { - return <<<'JS' - import { useState, useEffect, useCallback, useMemo } from 'react'; - import { createClient } from '@supabase/supabase-js'; - - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY - ); - - export function useDataFetcher(tableName, options = {}) { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const filters = useMemo(() => ({ - limit: options.limit ?? 50, - offset: options.offset ?? 0, - orderBy: options.orderBy ?? 'created_at', - ascending: options.ascending ?? false, - ...options.filters, - }), [options]); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - let query = supabase - .from(tableName) - .select('*') - .order(filters.orderBy, { ascending: filters.ascending }) - .range(filters.offset, filters.offset + filters.limit - 1); - - if (filters.status) { - query = query.eq('status', filters.status); - } - - const { data: result, error: fetchError } = await query; - - if (fetchError) throw fetchError; - setData(result ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - console.error(`Failed to fetch from ${tableName}:`, err); - } finally { - setLoading(false); - } - }, [tableName, filters]); - - useEffect(() => { - fetchData(); - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); - }, [fetchData]); - - const refetch = useCallback(() => fetchData(), [fetchData]); - - return { data, loading, error, refetch }; - } - - class DataProcessor { - #cache = new Map(); - #maxCacheSize; - - constructor(maxCacheSize = 1000) { - this.#maxCacheSize = maxCacheSize; - } - - process(items) { - return items - .filter(item => item !== null && item !== undefined) - .map(item => this.#transform(item)) - .reduce((acc, item) => { - const key = item.category ?? 'uncategorized'; - acc[key] = acc[key] ?? []; - acc[key].push(item); - return acc; - }, {}); - } - - #transform(item) { - const cacheKey = JSON.stringify(item); - if (this.#cache.has(cacheKey)) { - return this.#cache.get(cacheKey); - } - const result = { - ...item, - processedAt: new Date().toISOString(), - hash: this.#computeHash(item), - }; - if (this.#cache.size >= this.#maxCacheSize) { - const firstKey = this.#cache.keys().next().value; - this.#cache.delete(firstKey); - } - this.#cache.set(cacheKey, result); - return result; - } - } - JS; - } - - public static function sql(): string - { - return <<<'SQL' - CREATE TABLE users ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - email VARCHAR(255) UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role VARCHAR(50) DEFAULT 'user', - status VARCHAR(20) DEFAULT 'active', - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() - ); - - CREATE INDEX idx_users_email ON users(email); - CREATE INDEX idx_users_status ON users(status) WHERE status = 'active'; - - SELECT - u.id, - u.name, - u.email, - COUNT(o.id) AS order_count, - COALESCE(SUM(o.total), 0) AS total_spent, - MAX(o.created_at) AS last_order - FROM users u - LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed' - WHERE u.status = 'active' - AND u.created_at >= NOW() - INTERVAL '1 year' - GROUP BY u.id, u.name, u.email - HAVING COUNT(o.id) > 0 - ORDER BY total_spent DESC - LIMIT 100; - SQL; - } -} - -$iterations = 500; -$warmup = 50; - -$runner = new BenchmarkRunner($iterations, $warmup); -$report = new BenchmarkReport($iterations, $warmup); - -foreach (FixtureProvider::all() as $language => $code) { - $runner->addFixture($language, $code); -} - -$report->printHeader(); - -foreach ($runner->runAll() as $result) { - $report->printResult($result); -} - -$report->printMultiLanguageResult($runner->runMultiLanguage()); -$report->printFooter(); From add70c1aabfac53466fa5ebe9678fe0434c06804 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 10 Feb 2026 12:47:31 +0100 Subject: [PATCH 2/5] Add phpbench configuration --- .gitignore | 3 ++- composer.json | 6 ++++-- phpbench.json | 6 ++++++ 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 phpbench.json diff --git a/.gitignore b/.gitignore index 1c8c3f2..31fe8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .phpunit.result.cache composer.lock /.php-cs-fixer.cache +.phpbench/ .env .idea -build/ \ No newline at end of file +build/ diff --git a/composer.json b/composer.json index 869f304..02fcca0 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "friendsofphp/php-cs-fixer": "^3.84", "league/commonmark": "^2.4", "assertchris/ellison": "^1.0.2", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "phpbench/phpbench": "^1.4" }, "suggest": { "assertchris/ellison": "Allows you to analyse sentence complexity", @@ -40,7 +41,8 @@ "composer csfixer", "composer phpstan", "composer phpunit" - ] + ], + "bench": "vendor/bin/phpbench run --report=aggregate" }, "license": "MIT" } diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..d62e210 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,6 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/Bench", + "runner.file_pattern": "*Bench.php" +} From 930e6cc1b56f513fc7a1a35ec34846c4367d05f0 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 10 Feb 2026 12:47:35 +0100 Subject: [PATCH 3/5] Add benchmark suite with fixtures for all languages --- tests/Bench/Fixtures/blade.txt | 65 +++++++++++++++++ tests/Bench/Fixtures/css.txt | 97 +++++++++++++++++++++++++ tests/Bench/Fixtures/diff.txt | 39 ++++++++++ tests/Bench/Fixtures/dockerfile.txt | 58 +++++++++++++++ tests/Bench/Fixtures/dotenv.txt | 52 ++++++++++++++ tests/Bench/Fixtures/ellison.txt | 11 +++ tests/Bench/Fixtures/gdscript.txt | 95 ++++++++++++++++++++++++ tests/Bench/Fixtures/html.txt | 33 +++++++++ tests/Bench/Fixtures/ini.txt | 56 +++++++++++++++ tests/Bench/Fixtures/javascript.txt | 97 +++++++++++++++++++++++++ tests/Bench/Fixtures/json.txt | 64 +++++++++++++++++ tests/Bench/Fixtures/markdown.txt | 59 +++++++++++++++ tests/Bench/Fixtures/php.txt | 97 +++++++++++++++++++++++++ tests/Bench/Fixtures/python.txt | 108 ++++++++++++++++++++++++++++ tests/Bench/Fixtures/sql.txt | 29 ++++++++ tests/Bench/Fixtures/twig.txt | 80 +++++++++++++++++++++ tests/Bench/Fixtures/xml.txt | 81 +++++++++++++++++++++ tests/Bench/Fixtures/yaml.txt | 95 ++++++++++++++++++++++++ tests/Bench/HighlighterBench.php | 64 +++++++++++++++++ 19 files changed, 1280 insertions(+) create mode 100644 tests/Bench/Fixtures/blade.txt create mode 100644 tests/Bench/Fixtures/css.txt create mode 100644 tests/Bench/Fixtures/diff.txt create mode 100644 tests/Bench/Fixtures/dockerfile.txt create mode 100644 tests/Bench/Fixtures/dotenv.txt create mode 100644 tests/Bench/Fixtures/ellison.txt create mode 100644 tests/Bench/Fixtures/gdscript.txt create mode 100644 tests/Bench/Fixtures/html.txt create mode 100644 tests/Bench/Fixtures/ini.txt create mode 100644 tests/Bench/Fixtures/javascript.txt create mode 100644 tests/Bench/Fixtures/json.txt create mode 100644 tests/Bench/Fixtures/markdown.txt create mode 100644 tests/Bench/Fixtures/php.txt create mode 100644 tests/Bench/Fixtures/python.txt create mode 100644 tests/Bench/Fixtures/sql.txt create mode 100644 tests/Bench/Fixtures/twig.txt create mode 100644 tests/Bench/Fixtures/xml.txt create mode 100644 tests/Bench/Fixtures/yaml.txt create mode 100644 tests/Bench/HighlighterBench.php diff --git a/tests/Bench/Fixtures/blade.txt b/tests/Bench/Fixtures/blade.txt new file mode 100644 index 0000000..a50111b --- /dev/null +++ b/tests/Bench/Fixtures/blade.txt @@ -0,0 +1,65 @@ +@extends('layouts.app') + +@section('title', 'User Dashboard') + +@push('styles') + +@endpush + +@section('content') +
+

+ Welcome, {{ $user->name }} +

+ + @if($user->isAdmin()) + + @elseif($user->isModerator()) +
+

Moderator tools are available.

+
+ @else +

You have {{ $notifications->count() }} unread notifications.

+ @endif + + @forelse($orders as $order) +
+

Order #{{ $order->id }}

+

Total: ${{ number_format($order->total, 2) }}

+ + {{ ucfirst($order->status) }} + + + @unless($order->isCompleted()) +
+ @csrf + @method('DELETE') + +
+ @endunless +
+ @empty +

No orders found.

+ @endforelse + + @isset($featuredProducts) +

Featured Products

+ @each('partials.product-card', $featuredProducts, 'product') + @endisset + + @auth + + @endauth + + {{-- This is a Blade comment --}} + @include('partials.footer', ['year' => date('Y')]) +
+@endsection + +@push('scripts') + +@endpush diff --git a/tests/Bench/Fixtures/css.txt b/tests/Bench/Fixtures/css.txt new file mode 100644 index 0000000..e7b88ca --- /dev/null +++ b/tests/Bench/Fixtures/css.txt @@ -0,0 +1,97 @@ +:root { + --color-primary: #3b82f6; + --color-secondary: #6366f1; + --spacing-unit: 0.25rem; + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + line-height: 1.6; + color: #1f2937; + background-color: #f9fafb; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 calc(var(--spacing-unit) * 4); +} + +.card { + background: white; + border-radius: 0.75rem; + box-shadow: var(--shadow-lg); + padding: 1.5rem; + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.375rem; + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: #2563eb; +} + +@media (max-width: 768px) { + .container { + padding: 0 1rem; + } + + .grid { + grid-template-columns: 1fr; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-out forwards; +} + +@layer utilities { + .truncate-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } +} diff --git a/tests/Bench/Fixtures/diff.txt b/tests/Bench/Fixtures/diff.txt new file mode 100644 index 0000000..e0e7ce4 --- /dev/null +++ b/tests/Bench/Fixtures/diff.txt @@ -0,0 +1,39 @@ +diff --git a/src/Services/UserService.php b/src/Services/UserService.php +index 4a2b3c1..8f7d6e2 100644 +--- a/src/Services/UserService.php ++++ b/src/Services/UserService.php +@@ -12,6 +12,7 @@ use App\Contracts\Repository; + use Illuminate\Support\Collection; ++use Illuminate\Support\Facades\Log; + + #[AsService] + final readonly class UserService implements Repository +@@ -20,14 +21,22 @@ final readonly class UserService implements Repository + public function __construct( + private UserRepository $repository, + private CacheManager $cache, +- private EventDispatcher $events, ++ private EventDispatcher $events, ++ private RateLimiter $rateLimiter, + ) {} + + public function findById(int $id): ?User + { +- return $this->cache->remember("user.{$id}", 3600, function () use ($id) { +- $user = $this->repository->find($id); ++ if (!$this->rateLimiter->attempt("user-lookup:{$id}", 60, 10)) { ++ Log::warning('Rate limit exceeded for user lookup', ['id' => $id]); ++ throw new RateLimitExceededException(); ++ } ++ ++ return $this->cache->remember("user.{$id}", 7200, function () use ($id) { ++ $user = $this->repository->findWithRelations($id, ['profile', 'roles']); + + if ($user === null) { + throw new UserNotFoundException("User {$id} not found"); + } + ++ Log::info('User accessed', ['id' => $id]); + $this->events->dispatch(new UserAccessed($user)); + + return $user; diff --git a/tests/Bench/Fixtures/dockerfile.txt b/tests/Bench/Fixtures/dockerfile.txt new file mode 100644 index 0000000..91cc765 --- /dev/null +++ b/tests/Bench/Fixtures/dockerfile.txt @@ -0,0 +1,58 @@ +FROM php:8.4-fpm-alpine AS base + +LABEL maintainer="team@example.com" +LABEL version="1.0" + +ARG APP_ENV=production +ARG NODE_VERSION=20 + +ENV APP_ENV=${APP_ENV} \ + APP_DEBUG=false \ + COMPOSER_ALLOW_SUPERUSER=1 \ + PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 + +RUN apk add --no-cache \ + icu-dev \ + libzip-dev \ + libpng-dev \ + oniguruma-dev \ + postgresql-dev \ + && docker-php-ext-install \ + intl \ + zip \ + gd \ + pdo_pgsql \ + opcache \ + mbstring \ + && rm -rf /var/cache/apk/* + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +COPY composer.json composer.lock ./ +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist + +COPY . . +RUN composer dump-autoload --optimize --classmap-authoritative + +FROM base AS development + +RUN apk add --no-cache $PHPIZE_DEPS \ + && pecl install xdebug \ + && docker-php-ext-enable xdebug + +COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini + +EXPOSE 9000 + +FROM base AS production + +RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache +USER www-data + +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD php-fpm-healthcheck || exit 1 + +EXPOSE 9000 +CMD ["php-fpm"] diff --git a/tests/Bench/Fixtures/dotenv.txt b/tests/Bench/Fixtures/dotenv.txt new file mode 100644 index 0000000..ab758a6 --- /dev/null +++ b/tests/Bench/Fixtures/dotenv.txt @@ -0,0 +1,52 @@ +APP_NAME="My Application" +APP_ENV=production +APP_KEY=base64:xK9v2mQ8Rn4p3Ls7Yt6Wz1Ao5Bh8Cj0Dk2Fl4Gm6= +APP_DEBUG=false +APP_URL=https://example.com +APP_PORT=8080 + +# Logging +LOG_CHANNEL=stack +LOG_LEVEL=warning + +# Database Configuration +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=myapp +DB_USERNAME=postgres +DB_PASSWORD=s3cretP@ssw0rd + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 +REDIS_PREFIX=myapp_ + +# Mail +MAIL_MAILER=smtp +MAIL_HOST=smtp.mailgun.org +MAIL_PORT=587 +MAIL_USERNAME=postmaster@example.com +MAIL_PASSWORD=secret +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS="noreply@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +# AWS +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET=my-app-bucket + +# Queue +QUEUE_CONNECTION=redis + +# Session +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=true + +# Feature Flags +FEATURE_NEW_DASHBOARD=true +FEATURE_BETA_API=false diff --git a/tests/Bench/Fixtures/ellison.txt b/tests/Bench/Fixtures/ellison.txt new file mode 100644 index 0000000..be11c00 --- /dev/null +++ b/tests/Bench/Fixtures/ellison.txt @@ -0,0 +1,11 @@ +The app highlights lengthy, complex sentences and common errors; if you see a yellow sentence, shorten or split it. If you see a red highlight, your sentence is so dense and complicated that your readers will get lost trying to follow its meandering, splitting logic — try editing this sentence to remove the red. + +You can utilize a shorter word in place of a purple one. Click on highlights to fix them. + +Adverbs and weakening phrases are helpfully shown in blue. Get rid of them and pick words with force, perhaps. + +Phrases in green have been marked to show passive voice. + +Writing is basically about communicating clearly and effectively with your readers. Good writing should be concise and direct, avoiding unnecessary complexity that might potentially confuse your audience. It is believed that shorter sentences are generally easier to understand. + +The report was completed by the team and subsequently reviewed by management before it was finally approved by the director who had been previously consulted about the matter at hand. diff --git a/tests/Bench/Fixtures/gdscript.txt b/tests/Bench/Fixtures/gdscript.txt new file mode 100644 index 0000000..a3e48b4 --- /dev/null +++ b/tests/Bench/Fixtures/gdscript.txt @@ -0,0 +1,95 @@ +class_name Player +extends CharacterBody2D + +@export var speed: float = 200.0 +@export var jump_velocity: float = -400.0 +@export var max_health: int = 100 + +@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D +@onready var collision_shape: CollisionShape2D = $CollisionShape2D + +var health: int = max_health +var is_attacking: bool = false +var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity") + +signal health_changed(new_health: int) +signal died + +enum State { + IDLE, + RUNNING, + JUMPING, + FALLING, + ATTACKING, +} + +var current_state: State = State.IDLE + +func _ready() -> void: + health = max_health + animated_sprite.animation_finished.connect(_on_animation_finished) + +func _physics_process(delta: float) -> void: + if not is_on_floor(): + velocity.y += gravity * delta + + if Input.is_action_just_pressed("jump") and is_on_floor(): + velocity.y = jump_velocity + current_state = State.JUMPING + + var direction := Input.get_axis("move_left", "move_right") + + if direction != 0: + velocity.x = direction * speed + animated_sprite.flip_h = direction < 0 + if is_on_floor(): + current_state = State.RUNNING + else: + velocity.x = move_toward(velocity.x, 0, speed) + if is_on_floor(): + current_state = State.IDLE + + if Input.is_action_just_pressed("attack") and not is_attacking: + _perform_attack() + + move_and_slide() + _update_animation() + +func take_damage(amount: int) -> void: + health = clamp(health - amount, 0, max_health) + health_changed.emit(health) + + if health <= 0: + _die() + +func _perform_attack() -> void: + is_attacking = true + current_state = State.ATTACKING + animated_sprite.play("attack") + + var enemies := get_tree().get_nodes_in_group("enemies") + for enemy in enemies: + if global_position.distance_to(enemy.global_position) < 50.0: + enemy.take_damage(25) + +func _die() -> void: + died.emit() + queue_free() + +func _update_animation() -> void: + if is_attacking: + return + + match current_state: + State.IDLE: + animated_sprite.play("idle") + State.RUNNING: + animated_sprite.play("run") + State.JUMPING: + animated_sprite.play("jump") + State.FALLING: + animated_sprite.play("fall") + +func _on_animation_finished() -> void: + if animated_sprite.animation == "attack": + is_attacking = false diff --git a/tests/Bench/Fixtures/html.txt b/tests/Bench/Fixtures/html.txt new file mode 100644 index 0000000..05585ef --- /dev/null +++ b/tests/Bench/Fixtures/html.txt @@ -0,0 +1,33 @@ + + + + + + Dashboard + + + + + +
+
+
+

Statistics

+

{{ $stats['total'] }}

+
+
+
+ + diff --git a/tests/Bench/Fixtures/ini.txt b/tests/Bench/Fixtures/ini.txt new file mode 100644 index 0000000..e5bd5e3 --- /dev/null +++ b/tests/Bench/Fixtures/ini.txt @@ -0,0 +1,56 @@ +[app] +name = "My Application" +version = 1.4.2 +debug = false +timezone = UTC + +[database] +driver = pgsql +host = 127.0.0.1 +port = 5432 +name = myapp +username = admin +password = secret +charset = utf8 +prefix = +pool_size = 10 +ssl_mode = prefer + +[cache] +driver = redis +host = 127.0.0.1 +port = 6379 +prefix = myapp_cache_ +ttl = 3600 + +[logging] +level = warning +channel = stack +path = /var/log/myapp/app.log +max_files = 14 +max_size = 100M + +[mail] +driver = smtp +host = smtp.mailgun.org +port = 587 +encryption = tls +username = postmaster@example.com +password = secret +from_address = noreply@example.com +from_name = "${app.name}" + +[session] +driver = redis +lifetime = 120 +encrypt = true +cookie = myapp_session +domain = .example.com +secure = true +http_only = true +same_site = lax + +[queue] +driver = redis +retry_after = 90 +max_tries = 3 diff --git a/tests/Bench/Fixtures/javascript.txt b/tests/Bench/Fixtures/javascript.txt new file mode 100644 index 0000000..31c3af0 --- /dev/null +++ b/tests/Bench/Fixtures/javascript.txt @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { createClient } from '@supabase/supabase-js'; + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY +); + +export function useDataFetcher(tableName, options = {}) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const filters = useMemo(() => ({ + limit: options.limit ?? 50, + offset: options.offset ?? 0, + orderBy: options.orderBy ?? 'created_at', + ascending: options.ascending ?? false, + ...options.filters, + }), [options]); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + let query = supabase + .from(tableName) + .select('*') + .order(filters.orderBy, { ascending: filters.ascending }) + .range(filters.offset, filters.offset + filters.limit - 1); + + if (filters.status) { + query = query.eq('status', filters.status); + } + + const { data: result, error: fetchError } = await query; + + if (fetchError) throw fetchError; + setData(result ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + console.error(`Failed to fetch from ${tableName}:`, err); + } finally { + setLoading(false); + } + }, [tableName, filters]); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, [fetchData]); + + const refetch = useCallback(() => fetchData(), [fetchData]); + + return { data, loading, error, refetch }; +} + +class DataProcessor { + #cache = new Map(); + #maxCacheSize; + + constructor(maxCacheSize = 1000) { + this.#maxCacheSize = maxCacheSize; + } + + process(items) { + return items + .filter(item => item !== null && item !== undefined) + .map(item => this.#transform(item)) + .reduce((acc, item) => { + const key = item.category ?? 'uncategorized'; + acc[key] = acc[key] ?? []; + acc[key].push(item); + return acc; + }, {}); + } + + #transform(item) { + const cacheKey = JSON.stringify(item); + if (this.#cache.has(cacheKey)) { + return this.#cache.get(cacheKey); + } + const result = { + ...item, + processedAt: new Date().toISOString(), + hash: this.#computeHash(item), + }; + if (this.#cache.size >= this.#maxCacheSize) { + const firstKey = this.#cache.keys().next().value; + this.#cache.delete(firstKey); + } + this.#cache.set(cacheKey, result); + return result; + } +} diff --git a/tests/Bench/Fixtures/json.txt b/tests/Bench/Fixtures/json.txt new file mode 100644 index 0000000..0a8ab9b --- /dev/null +++ b/tests/Bench/Fixtures/json.txt @@ -0,0 +1,64 @@ +{ + "name": "my-application", + "version": "2.1.0", + "description": "A comprehensive web application with API support", + "private": true, + "license": "MIT", + "author": { + "name": "John Doe", + "email": "john@example.com", + "url": "https://example.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/example/my-application.git" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" + }, + "scripts": { + "dev": "next dev --turbo", + "build": "next build", + "start": "next start", + "lint": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@tanstack/react-query": "^5.0.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "eslint": "^9.0.0", + "typescript": "^5.6.0", + "vitest": "^2.0.0" + }, + "config": { + "api": { + "baseUrl": "https://api.example.com/v2", + "timeout": 30000, + "retries": 3, + "rateLimit": { + "requests": 100, + "window": 60 + } + }, + "features": { + "darkMode": true, + "analytics": false, + "betaFeatures": ["dashboard-v2", "ai-search"] + }, + "pagination": { + "defaultPageSize": 25, + "maxPageSize": 100 + } + }, + "keywords": ["web", "api", "react", "typescript", "next.js"] +} diff --git a/tests/Bench/Fixtures/markdown.txt b/tests/Bench/Fixtures/markdown.txt new file mode 100644 index 0000000..c38d1aa --- /dev/null +++ b/tests/Bench/Fixtures/markdown.txt @@ -0,0 +1,59 @@ +# User Authentication API + +## Overview + +This module provides **secure authentication** for the application using JWT tokens and OAuth 2.0 providers. It supports *multiple strategies* including local credentials, Google, and GitHub. + +## Installation + +```bash +composer require vendor/auth-module +``` + +## Configuration + +| Parameter | Type | Default | Description | +|-----------------|----------|---------|------------------------------| +| `token_ttl` | integer | 3600 | Token lifetime in seconds | +| `refresh_ttl` | integer | 86400 | Refresh token lifetime | +| `max_attempts` | integer | 5 | Max login attempts per hour | +| `lockout_time` | integer | 900 | Lockout duration in seconds | + +## Usage + +### Basic Authentication + +To authenticate a user with email and password: + +```php +$auth = new Authenticator($config); +$token = $auth->attempt([ + 'email' => $request->email, + 'password' => $request->password, +]); +``` + +### Token Refresh + +> **Note:** Refresh tokens are single-use. Each refresh generates a new token pair. + +1. Send the refresh token to the endpoint +2. Receive a new access token and refresh token +3. Store the new tokens securely + +### Error Handling + +The module throws the following exceptions: + +- `InvalidCredentialsException` - Wrong email or password +- `AccountLockedException` - Too many failed attempts +- `TokenExpiredException` - The JWT token has expired +- `RateLimitExceededException` - API rate limit reached + +## Links + +For more details, see the [full documentation](https://docs.example.com/auth) or visit the [GitHub repository](https://github.com/example/auth-module). + +--- + +*Last updated: 2025-01-15* diff --git a/tests/Bench/Fixtures/php.txt b/tests/Bench/Fixtures/php.txt new file mode 100644 index 0000000..cd9651f --- /dev/null +++ b/tests/Bench/Fixtures/php.txt @@ -0,0 +1,97 @@ +cache->remember("user.{$id}", 3600, function () use ($id) { + $user = $this->repository->find($id); + + if ($user === null) { + throw new UserNotFoundException("User {$id} not found"); + } + + $this->events->dispatch(new UserAccessed($user)); + + return $user; + }); + } + + public function getAllActive(): Collection + { + return $this->repository + ->query() + ->where('status', '=', 'active') + ->where('deleted_at', null) + ->orderBy('created_at', 'desc') + ->get() + ->map(fn (User $user) => new UserDTO( + id: $user->id, + name: $user->name, + email: $user->email, + role: $user->role->getValue(), + isAdmin: $user->role === Role::ADMIN, + createdAt: $user->created_at->toISOString(), + )); + } + + /** + * @param array $data + * @return User + * @throws ValidationException + */ + public function create(array $data): User + { + $validated = $this->validate($data, [ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users', + 'password' => 'required|min:8', + ]); + + $user = new User(); + $user->name = $validated['name']; + $user->email = $validated['email']; + $user->password = password_hash($validated['password'], PASSWORD_ARGON2ID); + $user->status = 'active'; + $user->save(); + + $this->events->dispatch(new UserCreated($user)); + $this->cache->forget('users.active'); + + return $user; + } + + private function validate(array $data, array $rules): array + { + foreach ($rules as $field => $rule) { + $constraints = explode('|', $rule); + + foreach ($constraints as $constraint) { + match (true) { + $constraint === 'required' => isset($data[$field]) ?: throw new ValidationException("{$field} is required"), + str_starts_with($constraint, 'max:') => strlen($data[$field] ?? '') <= (int) substr($constraint, 4), + str_starts_with($constraint, 'min:') => strlen($data[$field] ?? '') >= (int) substr($constraint, 4), + $constraint === 'email' => filter_var($data[$field] ?? '', FILTER_VALIDATE_EMAIL) !== false, + default => true, + }; + } + } + + return $data; + } +} diff --git a/tests/Bench/Fixtures/python.txt b/tests/Bench/Fixtures/python.txt new file mode 100644 index 0000000..5c472c8 --- /dev/null +++ b/tests/Bench/Fixtures/python.txt @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Any, AsyncGenerator, Protocol + +logger = logging.getLogger(__name__) + + +class Status(Enum): + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" + DELETED = "deleted" + + +class Repository(Protocol): + async def find(self, id: int) -> dict[str, Any] | None: ... + async def save(self, entity: dict[str, Any]) -> None: ... + async def delete(self, id: int) -> bool: ... + + +@dataclass(frozen=True, slots=True) +class UserDTO: + id: int + name: str + email: str + status: Status = Status.ACTIVE + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @property + def is_active(self) -> bool: + return self.status == Status.ACTIVE + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "email": self.email, + "status": self.status.value, + "created_at": self.created_at.isoformat(), + } + + +class UserService: + def __init__(self, repository: Repository, cache_ttl: int = 3600) -> None: + self._repository = repository + self._cache: dict[int, UserDTO] = {} + self._cache_ttl = cache_ttl + self._lock = asyncio.Lock() + + async def get_user(self, user_id: int) -> UserDTO | None: + if user_id in self._cache: + return self._cache[user_id] + + async with self._lock: + data = await self._repository.find(user_id) + if data is None: + logger.warning("User %d not found", user_id) + return None + + user = UserDTO( + id=data["id"], + name=data["name"], + email=data["email"], + status=Status(data.get("status", "active")), + ) + self._cache[user_id] = user + return user + + async def get_active_users(self) -> AsyncGenerator[UserDTO, None]: + for user_id, user in self._cache.items(): + if user.is_active: + yield user + + async def create_user(self, name: str, email: str) -> UserDTO: + user_data = { + "name": name, + "email": email, + "status": Status.ACTIVE.value, + "created_at": datetime.now(timezone.utc).isoformat(), + } + + await self._repository.save(user_data) + user = UserDTO(id=user_data.get("id", 0), name=name, email=email) + self._cache[user.id] = user + + logger.info("Created user: %s <%s>", name, email) + return user + + async def deactivate_user(self, user_id: int) -> bool: + user = await self.get_user(user_id) + if user is None or not user.is_active: + return False + + try: + await self._repository.save( + {**user.to_dict(), "status": Status.SUSPENDED.value} + ) + self._cache.pop(user_id, None) + logger.info("Deactivated user %d", user_id) + return True + except Exception as exc: + logger.error("Failed to deactivate user %d: %s", user_id, exc) + raise diff --git a/tests/Bench/Fixtures/sql.txt b/tests/Bench/Fixtures/sql.txt new file mode 100644 index 0000000..cf5f58e --- /dev/null +++ b/tests/Bench/Fixtures/sql.txt @@ -0,0 +1,29 @@ +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role VARCHAR(50) DEFAULT 'user', + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status) WHERE status = 'active'; + +SELECT + u.id, + u.name, + u.email, + COUNT(o.id) AS order_count, + COALESCE(SUM(o.total), 0) AS total_spent, + MAX(o.created_at) AS last_order +FROM users u +LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'completed' +WHERE u.status = 'active' + AND u.created_at >= NOW() - INTERVAL '1 year' +GROUP BY u.id, u.name, u.email +HAVING COUNT(o.id) > 0 +ORDER BY total_spent DESC +LIMIT 100; diff --git a/tests/Bench/Fixtures/twig.txt b/tests/Bench/Fixtures/twig.txt new file mode 100644 index 0000000..b2516f9 --- /dev/null +++ b/tests/Bench/Fixtures/twig.txt @@ -0,0 +1,80 @@ +{% extends 'layouts/base.html.twig' %} + +{% block title %}{{ page.title }} - {{ site_name }}{% endblock %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} + +{% block body %} +
+

{{ 'welcome.heading'|trans({'%name%': user.name}) }}

+ + {% if user.isAdmin %} +
+

{{ 'admin.message'|trans }}

+
+ {% elseif user.roles|length > 1 %} +

You have {{ user.roles|length }} roles assigned.

+ {% endif %} + + + + + + + + + + + + {% for member in team_members %} + + + + + + + {% else %} + + + + {% endfor %} + +
{{ 'table.name'|trans }}{{ 'table.email'|trans }}{{ 'table.status'|trans }}{{ 'table.actions'|trans }}
{{ member.name|title }} + + {{ member.email }} + + + {% if member.isActive %} + Active + {% else %} + Inactive + {% endif %} + + Edit + {% if member.id != user.id %} + + Delete + + {% endif %} +
No team members found.
+ + {# Pagination #} + {% if members.haveToPaginate %} + + {% endif %} + + {% set total = team_members|reduce((carry, m) => carry + m.score, 0) %} +

Total score: {{ total|number_format(2, '.', ',') }}

+
+{% endblock %} + +{% block javascripts %} + {{ parent() }} + {{ encore_entry_script_tags('dashboard') }} +{% endblock %} diff --git a/tests/Bench/Fixtures/xml.txt b/tests/Bench/Fixtures/xml.txt new file mode 100644 index 0000000..32f4447 --- /dev/null +++ b/tests/Bench/Fixtures/xml.txt @@ -0,0 +1,81 @@ + + + 4.0.0 + + com.example + my-application + 2.1.0 + jar + + My Application + A comprehensive web application + https://example.com + + + 21 + 3.3.0 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot.version} + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring-boot.version} + + + org.postgresql + postgresql + 42.7.2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + ${spring-boot.version} + test + + + org.junit.vintage + junit-vintage-engine + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.example.Application + + true + + + + + + + + + production + + false + + + prod + + + + diff --git a/tests/Bench/Fixtures/yaml.txt b/tests/Bench/Fixtures/yaml.txt new file mode 100644 index 0000000..6c87374 --- /dev/null +++ b/tests/Bench/Fixtures/yaml.txt @@ -0,0 +1,95 @@ +version: "3.8" + +services: + app: + build: + context: . + dockerfile: Dockerfile + target: production + args: + APP_ENV: production + NODE_VERSION: 20 + image: my-application:latest + container_name: myapp + restart: unless-stopped + ports: + - "8080:80" + - "8443:443" + volumes: + - ./storage:/var/www/html/storage + - ./logs:/var/log/app + environment: + APP_ENV: production + APP_DEBUG: "false" + DB_HOST: database + DB_PORT: 5432 + DB_DATABASE: myapp + DB_USERNAME: postgres + DB_PASSWORD: ${DB_PASSWORD} + REDIS_HOST: cache + REDIS_PORT: 6379 + depends_on: + database: + condition: service_healthy + cache: + condition: service_started + networks: + - backend + - frontend + deploy: + replicas: 3 + resources: + limits: + cpus: "0.50" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + database: + image: postgres:16-alpine + container_name: myapp-db + restart: unless-stopped + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + environment: + POSTGRES_DB: myapp + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DB_PASSWORD} + PGDATA: /var/lib/postgresql/data/pgdata + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + cache: + image: redis:7-alpine + container_name: myapp-cache + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + +networks: + backend: + driver: bridge + frontend: + driver: bridge diff --git a/tests/Bench/HighlighterBench.php b/tests/Bench/HighlighterBench.php new file mode 100644 index 0000000..0516f96 --- /dev/null +++ b/tests/Bench/HighlighterBench.php @@ -0,0 +1,64 @@ + 'blade.txt', + 'css' => 'css.txt', + 'diff' => 'diff.txt', + 'dockerfile' => 'dockerfile.txt', + 'dotenv' => 'dotenv.txt', + 'ellison' => 'ellison.txt', + 'gdscript' => 'gdscript.txt', + 'html' => 'html.txt', + 'ini' => 'ini.txt', + 'javascript' => 'javascript.txt', + 'json' => 'json.txt', + 'markdown' => 'markdown.txt', + 'php' => 'php.txt', + 'python' => 'python.txt', + 'sql' => 'sql.txt', + 'twig' => 'twig.txt', + 'xml' => 'xml.txt', + 'yaml' => 'yaml.txt', + ]; + + private Highlighter $highlighter; + + public function __construct() + { + $this->highlighter = new Highlighter(new CssTheme()); + } + + #[Bench\Revs(100)] + #[Bench\Iterations(5)] + #[Bench\ParamProviders('provideLanguages')] + public function benchParse(array $params): void + { + $this->highlighter->parse($params['code'], $params['language']); + } + + public function provideLanguages(): Generator + { + foreach (self::LANGUAGES as $language => $file) { + yield $language => [ + 'language' => $language, + 'code' => file_get_contents(self::FIXTURES_DIR . '/' . $file), + ]; + } + } +} From c1c6e2f0bddd593e486ba6751c2c818833718161 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 10 Feb 2026 12:47:40 +0100 Subject: [PATCH 4/5] Add benchmark fixture coverage tests --- tests/Bench/HighlighterBenchTest.php | 123 +++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/Bench/HighlighterBenchTest.php diff --git a/tests/Bench/HighlighterBenchTest.php b/tests/Bench/HighlighterBenchTest.php new file mode 100644 index 0000000..401013e --- /dev/null +++ b/tests/Bench/HighlighterBenchTest.php @@ -0,0 +1,123 @@ +getCanonicalLanguages($highlighter); + + $benchLanguages = array_keys(HighlighterBench::LANGUAGES); + + $missing = []; + + foreach ($languages as $language) { + $nameAndAliases = [$language->getName(), ...$language->getAliases()]; + + if (array_intersect($nameAndAliases, $benchLanguages) === []) { + $missing[] = $language->getName(); + } + } + + $this->assertSame( + [], + $missing, + sprintf( + 'The following languages are supported but have no bench entry: %s. Add them to HighlighterBench::LANGUAGES and create fixture files.', + implode(', ', $missing), + ), + ); + } + + #[Test] + public function every_bench_fixture_file_exists(): void + { + foreach (HighlighterBench::LANGUAGES as $language => $file) { + $path = self::FIXTURES_DIR . '/' . $file; + + $this->assertFileExists( + $path, + sprintf('Fixture file for language "%s" is missing: %s', $language, $file), + ); + } + } + + #[Test] + public function every_bench_fixture_file_is_not_empty(): void + { + foreach (HighlighterBench::LANGUAGES as $language => $file) { + $path = self::FIXTURES_DIR . '/' . $file; + + if (! file_exists($path)) { + continue; + } + + $this->assertNotEmpty( + trim(file_get_contents($path)), + sprintf('Fixture file for language "%s" is empty: %s', $language, $file), + ); + } + } + + #[Test] + public function bench_does_not_reference_unknown_languages(): void + { + $highlighter = new Highlighter(); + $supportedNames = $highlighter->getSupportedLanguageNames(); + + foreach (array_keys(HighlighterBench::LANGUAGES) as $language) { + $this->assertContains( + $language, + $supportedNames, + sprintf('Bench references language "%s" which is not registered in the Highlighter.', $language), + ); + } + } + + /** @return Language[] */ + private function getCanonicalLanguages(Highlighter $highlighter): array + { + $allNames = $highlighter->getSupportedLanguageNames(); + + $seen = []; + $languages = []; + + foreach ($allNames as $name) { + $highlighter->parse('', $name); + $language = $highlighter->getCurrentLanguage(); + + $id = spl_object_id($language); + + if (isset($seen[$id])) { + continue; + } + + $seen[$id] = true; + + if (in_array($language->getName(), self::INTERNAL_LANGUAGES, true)) { + continue; + } + + $languages[] = $language; + } + + return $languages; + } +} From d234a487c2ebd93f21d3a000365dfbf1a8f8bd7f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 10 Feb 2026 12:47:44 +0100 Subject: [PATCH 5/5] Add benchmark CI workflow for PRs --- .github/workflows/benchmark.yml | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/benchmark.yml diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..28289f7 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,75 @@ +name: Benchmark + +on: + pull_request: + branches: [main] + +permissions: + pull-requests: write + +jobs: + benchmark: + name: Performance Regression Check + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Benchmark base branch + run: | + git checkout ${{ github.event.pull_request.base.sha }} + composer install --no-interaction --prefer-dist --quiet + vendor/bin/phpbench run --tag=base --store --progress=dots + + - name: Benchmark PR branch + run: | + git checkout ${{ github.event.pull_request.head.sha }} + composer install --no-interaction --prefer-dist --quiet + vendor/bin/phpbench run --tag=pr --store --ref=base --report=aggregate --progress=dots > benchmark-result.txt 2>&1 || true + + - name: Read benchmark result + id: bench + run: | + { + echo "result<> "$GITHUB_OUTPUT" + + - name: Find existing comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: github-actions[bot] + body-includes: "## Benchmark Results" + + - name: Post benchmark results + uses: peter-evans/create-or-update-comment@v5 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + ## Benchmark Results + + Comparison of `${{ github.head_ref }}` against `${{ github.base_ref }}` (`${{ github.event.pull_request.base.sha }}`). + + ``` + ${{ steps.bench.outputs.result }} + ``` + + Generated by phpbench against commit ${{ github.event.pull_request.head.sha }}