Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Describable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom;

interface Describable
{
/**
* Return a human-readable description of this source,
* suitable for textualising the source tree for AI consumption.
*/
public function describe(): string;
}
24 changes: 23 additions & 1 deletion src/Sources/InfixExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,35 @@

namespace Superscript\Axiom\Sources;

use Superscript\Axiom\Describable;
use Superscript\Axiom\Source;

final readonly class InfixExpression implements Source
final readonly class InfixExpression implements Source, Describable
{
public function __construct(
public Source $left,
public string $operator,
public Source $right,
) {}

public function describe(): string
{
$left = $this->describeOperand($this->left);
$right = $this->describeOperand($this->right);

return sprintf('%s %s %s', $left, $this->operator, $right);
}

private function describeOperand(Source $source): string
{
$description = $source instanceof Describable
? $source->describe()
: (new \ReflectionClass($source))->getShortName();

if ($source instanceof self) {
return sprintf('(%s)', $description);
}

return $description;
}
}
9 changes: 8 additions & 1 deletion src/Sources/StaticSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@

namespace Superscript\Axiom\Sources;

use SebastianBergmann\Exporter\Exporter;
use Superscript\Axiom\Describable;
use Superscript\Axiom\Source;

final readonly class StaticSource implements Source
final readonly class StaticSource implements Source, Describable
{
public function __construct(
public mixed $value,
) {}

public function describe(): string
{
return (new Exporter())->shortenedExport($this->value);
}
}
10 changes: 9 additions & 1 deletion src/Sources/SymbolSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@

namespace Superscript\Axiom\Sources;

use Superscript\Axiom\Describable;
use Superscript\Axiom\Source;

final readonly class SymbolSource implements Source
final readonly class SymbolSource implements Source, Describable
{
public function __construct(
public string $name,
public ?string $namespace = null,
) {}

public function describe(): string
{
return $this->namespace !== null
? sprintf('%s.%s', $this->namespace, $this->name)
: $this->name;
}
}
14 changes: 13 additions & 1 deletion src/Sources/TypeDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,25 @@

namespace Superscript\Axiom\Sources;

use Superscript\Axiom\Describable;
use Superscript\Axiom\Source;
use Superscript\Axiom\Types\Type;

final readonly class TypeDefinition implements Source
final readonly class TypeDefinition implements Source, Describable
{
public function __construct(
public Type $type,
public Source $source,
) {}

public function describe(): string
{
$shortName = (new \ReflectionClass($this->type))->getShortName();
$typeName = lcfirst(str_ends_with($shortName, 'Type') ? substr($shortName, 0, -4) : $shortName);
$sourceDescription = $this->source instanceof Describable
? $this->source->describe()
: (new \ReflectionClass($this->source))->getShortName();

return sprintf('%s (as %s)', $sourceDescription, $typeName);
}
}
17 changes: 14 additions & 3 deletions src/Sources/UnaryExpression.php
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Sources;

use Superscript\Axiom\Describable;
use Superscript\Axiom\Source;

final readonly class UnaryExpression implements Source
final readonly class UnaryExpression implements Source, Describable
{
public function __construct(
public string $operator,
public Source $operand,
) {
) {}

public function describe(): string
{
$operand = $this->operand instanceof Describable
? $this->operand->describe()
: (new \ReflectionClass($this->operand))->getShortName();

return sprintf('%s%s', $this->operator, $operand);
}
}
}
244 changes: 244 additions & 0 deletions tests/Sources/DescribableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Tests\Sources;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
use Superscript\Axiom\Source;
use Superscript\Axiom\Sources\InfixExpression;
use Superscript\Axiom\Sources\StaticSource;
use Superscript\Axiom\Sources\SymbolSource;
use Superscript\Axiom\Sources\TypeDefinition;
use Superscript\Axiom\Sources\UnaryExpression;
use Superscript\Axiom\Types\BooleanType;
use Superscript\Axiom\Types\ListType;
use Superscript\Axiom\Types\NumberType;
use Superscript\Axiom\Types\StringType;

#[CoversClass(StaticSource::class)]
#[CoversClass(SymbolSource::class)]
#[CoversClass(TypeDefinition::class)]
#[CoversClass(InfixExpression::class)]
#[CoversClass(UnaryExpression::class)]
#[UsesClass(NumberType::class)]
#[UsesClass(StringType::class)]
#[UsesClass(BooleanType::class)]
#[UsesClass(ListType::class)]
class DescribableTest extends TestCase
{
#[Test]
public function static_source_describes_string_value(): void
{
$source = new StaticSource('hello');
$this->assertSame("'hello'", $source->describe());
}

#[Test]
public function static_source_describes_integer_value(): void
{
$source = new StaticSource(42);
$this->assertSame('42', $source->describe());
}

#[Test]
public function static_source_describes_null_value(): void
{
$source = new StaticSource(null);
$this->assertSame('null', $source->describe());
}

#[Test]
public function static_source_describes_boolean_value(): void
{
$source = new StaticSource(true);
$this->assertSame('true', $source->describe());
}

#[Test]
public function symbol_source_describes_name(): void
{
$source = new SymbolSource('price');
$this->assertSame('price', $source->describe());
}

#[Test]
public function symbol_source_describes_namespaced_name(): void
{
$source = new SymbolSource('pi', 'math');
$this->assertSame('math.pi', $source->describe());
}

#[Test]
public function type_definition_describes_as_type(): void
{
$source = new TypeDefinition(new NumberType(), new SymbolSource('price'));
$this->assertSame('price (as number)', $source->describe());
}

#[Test]
public function type_definition_describes_string_type(): void
{
$source = new TypeDefinition(new StringType(), new StaticSource('hello'));
$this->assertSame("'hello' (as string)", $source->describe());
}

#[Test]
public function type_definition_describes_boolean_type(): void
{
$source = new TypeDefinition(new BooleanType(), new SymbolSource('active'));
$this->assertSame('active (as boolean)', $source->describe());
}

#[Test]
public function type_definition_describes_list_type(): void
{
$source = new TypeDefinition(new ListType(new NumberType()), new SymbolSource('values'));
$this->assertSame('values (as list)', $source->describe());
}

#[Test]
public function type_definition_with_non_describable_source_falls_back_to_class_name(): void
{
$anonymous = new class implements Source {};

$source = new TypeDefinition(new NumberType(), $anonymous);

$description = $source->describe();
$this->assertStringEndsWith('(as number)', $description);
}

#[Test]
public function infix_expression_describes_operation(): void
{
$source = new InfixExpression(
new StaticSource(1),
'+',
new StaticSource(2),
);
$this->assertSame('1 + 2', $source->describe());
}

#[Test]
public function infix_expression_describes_comparison(): void
{
$source = new InfixExpression(
new SymbolSource('age'),
'>=',
new StaticSource(18),
);
$this->assertSame('age >= 18', $source->describe());
}

#[Test]
public function infix_expression_describes_custom_operator(): void
{
$source = new InfixExpression(
new SymbolSource('tags'),
'has',
new StaticSource('featured'),
);
$this->assertSame("tags has 'featured'", $source->describe());
}

#[Test]
public function infix_expression_wraps_nested_infix_in_parentheses(): void
{
$source = new InfixExpression(
new SymbolSource('price'),
'*',
new InfixExpression(
new StaticSource(1),
'-',
new SymbolSource('discount'),
),
);
$this->assertSame(
'price * (1 - discount)',
$source->describe(),
);
}

#[Test]
public function infix_with_non_describable_left_falls_back_to_class_name(): void
{
$anonymous = new class implements Source {};

$source = new InfixExpression(
$anonymous,
'+',
new StaticSource(1),
);

$description = $source->describe();
$this->assertStringEndsWith('+ 1', $description);
}

#[Test]
public function infix_with_non_describable_right_falls_back_to_class_name(): void
{
$anonymous = new class implements Source {};

$source = new InfixExpression(
new StaticSource(1),
'+',
$anonymous,
);

$description = $source->describe();
$this->assertStringStartsWith('1 + ', $description);
}

#[Test]
public function unary_expression_describes_negation(): void
{
$source = new UnaryExpression('!', new SymbolSource('active'));
$this->assertSame('!active', $source->describe());
}

#[Test]
public function unary_expression_describes_negative(): void
{
$source = new UnaryExpression('-', new StaticSource(5));
$this->assertSame('-5', $source->describe());
}

#[Test]
public function unary_with_non_describable_source_falls_back_to_class_name(): void
{
$anonymous = new class implements Source {};

$source = new UnaryExpression('!', $anonymous);

$description = $source->describe();
$this->assertStringStartsWith('!', $description);
}

#[Test]
public function complex_nested_expression(): void
{
$source = new InfixExpression(
new TypeDefinition(
new NumberType(),
new SymbolSource('price'),
),
'*',
new InfixExpression(
new StaticSource(1),
'-',
new TypeDefinition(
new NumberType(),
new SymbolSource('discount', 'rates'),
),
),
);

$this->assertSame(
'price (as number) * (1 - rates.discount (as number))',
$source->describe(),
);
}
}