wexample/php-pseudocode

A basic PHP package

Maintainers

Package info

github.com/wexample/php-pseudocode

pkg:composer/wexample/php-pseudocode

Transparency log

Statistics

Installs: 280

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

2.1.3 2026-08-28 09:32 UTC

README

Version: 2.1.3

wexample/php-pseudocode turns PHP source into a YAML description of its API — classes, methods, properties, functions and constants with their types, defaults and docblock descriptions, but no bodies — and turns that YAML back into compilable PHP skeletons. The two directions are PseudocodeGenerator (.php.yml, built on nikic/php-parser) and CodeGenerator (.yml.php), both able to work file by file through generateFromFileAndSave(). It is meant for tooling that needs a language-neutral, body-free view of a codebase: cross-language port scaffolding, API contracts kept under version control, or feeding a class structure to a generator without shipping its implementation.

Table of Contents

Architecture

The package is one PSR-4 root, Wexample\Pseudocode\src/, with no service container and no configuration file: everything is instantiated directly by the caller. Four layers do the work — two generators, a config tree, a registry that dispatches to it, and a parser built on nikic/php-parser.

The two directions

PseudocodeGenerator (src/Generator/PseudocodeGenerator.php) goes .php.yml. CodeGenerator (src/Generator/CodeGenerator.php) goes .yml.php. Both extend src/Generator/AbstractGenerator.php, which declares the contract each direction fills:

abstract public function getSourceFileExtension(): string;
abstract public function getTargetFileExtension(): string;
abstract public function generate(string $inputText): string;
abstract protected function generateConfig(string $inputText): array;

and supplies the file-level wrappers on top of it. generateFromFileAndSave() computes the output path with PathHelper::getCousin(), swapping the source extension for the target one and running every path segment through TextHelper::toSnake(), so src/Generator/CodeGenerator.php lands as <target>/generator/code_generator.yml. It returns '' without writing when generate() produced nothing.

The config tree

src/Config/ holds the whole intermediate representation. Every class extends src/Config/AbstractConfig.php, which defines the four conversions each node type must be able to perform, in both directions:

  • static::fromNode(NodeAbstract $node, mixed $inlineComment, ?ParserContext $context) — AST → config, the only abstract one.
  • static::fromConfig(mixed $data, ?GeneratorConfig $global) — YAML array → config.
  • toConfig(?AbstractConfig $parentConfig) — config → YAML array.
  • toCode(?AbstractConfig $parentConfig, int $indentationLevel) — config → PHP source.

AbstractConfig::fromConfig() normalizes input through static::unpackData(), then reflects the constructor and refuses unknown keys before instantiating:

$unknownParameters = array_diff(array_keys($data), $allowedParameters);
if (! empty($unknownParameters)) {
    throw new \InvalidArgumentException(...);
}

return new static(...$data);

A YAML key that no constructor parameter matches is therefore a hard error, not a silent drop. The base class also carries the shared AST utilities the subclasses reuse — getTypeName(), isNullableType(), parseValue(), formatValue(), getIndentation().

Three types are top-level items: ClassConfig (type: class), FunctionConfig (type: function), ConstantConfig (type: constant). The rest are owned by a parent and instantiated by it, never dispatched: ClassPropertyConfig and ClassMethodConfig under a class, FunctionParameterConfig and FunctionReturnConfig under a function, DocCommentConfig with its DocCommentParameterConfig / DocCommentReturnConfig under any of them. ClassMethodConfig extends FunctionConfig and differs by two things: public const TYPE = 'method', and a signature prefixed with public.

GeneratorConfig is the odd one — it carries language-specific rendering options rather than code, is read from the top-level generator.php mapping of the YAML, and never comes out of parsing (fromNode() returns null). ConstantConfig::toCode() is the one consumer today:

if ($this->generator && $this->generator->constantDeclaration === 'define') {

ConfigEnum::NOT_PROVIDED (src/Enum/ConfigEnum.php) is the sentinel that separates "no default" from a default of null, used by property and parameter configs in both toConfig() and toCode().

The registry

src/Common/ConfigRegistry.php is a flat list of the three top-level config classes, filled in its constructor and queried by two symmetric lookups, findMatchingNodeParser(Node) and findMatchingConfigLoader(array). Each walks the list and returns the first class whose static predicate answers yes — canParse() against an AST node, canLoad() against a YAML mapping. The predicates are one-liners on the config side: $node instanceof Node\Stmt\Class_, $data['type'] === 'class'. Note the unkeyed array access: every item mapping in a .yml must carry a type.

Both generators reach the registry through WithConfigRegistry (src/Common/Traits/WithConfigRegistry.php), which lazily builds it. The trait declares a getConfigRegistryClass() override point, but getConfigRegistry() still hardcodes new ConfigRegistry() — a subclass wanting extra item types registers them on the instance instead.

PHP → YAML

PseudocodeGenerator::generate() calls generateConfigData(), which builds a PhpParser (src/Parser/PhpParser.php) carrying the generator's ParserContext and hands it the source.

PhpParser is itself the visitor. parse() builds the AST with (new ParserFactory())->createForHostVersion(), then registers a registry of end-of-line comments before traversing: buildInlineCommentsRegistry() keeps only top-level comments that start with // or # and contain no newline, keyed by start line. The traversal stacks three visitors, and the order matters — ParentConnectingVisitor and NameResolver both exist for the inheritance resolver downstream:

$traverser->addVisitor(new ParentConnectingVisitor());
$traverser->addVisitor(new NameResolver());
$traverser->addVisitor($this);

enterNode() then does the dispatch, one node at a time:

if ($configClass = $registry->findMatchingNodeParser($node)) {
    $endLine = $node->getEndLine();
    $item = $configClass::fromNode($node, $this->allInlineComments[$endLine] ?? null, $this->context);

A null return is a legitimate "not exportable" and the node is skipped — ClassConfig::fromNode() uses it to ignore every class not marked #[PseudocodeExport].

Back in the generator, each item's toConfig() produces the mapping, they are wrapped as ['items' => …], and dumpPseudocode() serializes with Yaml::dump($items, inline: 10, indent: 2) followed by a regex that pulls list items back onto the dash line. An empty items makes generate() return ''.

YAML → PHP

CodeGenerator::generateConfig() parses the YAML, builds the global GeneratorConfig from a top-level generator key if present, then for each entry of items asks the registry for a loader and calls $configClass::fromConfig($data, $globalGeneratorConfig). Each config's own fromConfig() recursively converts its children before delegating to the parent — ClassConfig turns properties and methods into collections, FunctionConfig folds parameters and return into the DocCommentConfig so the docblock and the signature stay in sync.

generate() emits "<?php\n\n" and concatenates $config->toCode(). Rendering walks back down the tree with an incrementing indentation level; DocCommentConfig::toCode() accepts three formats — block, inlineBlock, inline — and throws on anything else. Function bodies are always stubs: implementationGuidelines lines are emitted as // comments, followed by // TODO: Implement function body.

Inherited members

#[PseudocodeExport] (src/Attribute/PseudocodeExport.php) gates class export and carries one option, inherited. When it is true, ClassConfig::fromNode() needs a resolver and says so loudly:

$resolver = $context?->getInheritedMembersResolver();
if (! $resolver) {
    throw new \RuntimeException('Inherited export requires a configured inherited members resolver.');
}

That resolver is the single thing ParserContext (src/Parser/ParserContext.php) transports, behind InheritedMembersResolverInterface. The context is optional and injected by the caller via PseudocodeGenerator::setParserContext(), which is what keeps the parse path free of reflection by default.

ReflectionInheritanceResolver (src/Resolver/ReflectionInheritanceResolver.php) is the shipped implementation, and it leaves the AST: it resolves the class FQCN from the namespacedName attribute left by NameResolver, falling back to walking parent attributes left by ParentConnectingVisitor up to the enclosing Namespace_, then requires the class to be autoloadable and reflects it. It skips members declared locally and private members inherited from elsewhere, and returns ['properties' => …, 'methods' => …] of ordinary config objects. ClassConfig::mergeByName() merges them, local declarations overwriting inherited ones by getName().

Helpers

Three stateless classes in src/Helper/, all static. AttributeHelper finds an attribute on a node by FQCN, resolved name or short name, and reads a bool option by name or position. DocCommentParserHelper extracts descriptions, @param and @return from raw docblocks with regexes and returns DocCommentConfig / DocCommentReturnConfig objects directly. PhpNodeHelper::isOptional() is deliberately narrow — a parameter counts as optional only when its default is the literal null.

Tests

tests/AbstractGeneratorTest.php is the base case: it instantiates both generators in setUp(), and each concrete test declares its item type via getItemType(). Fixtures come in .php / .yml pairs under tests/resources/item/<type>/, the directory being the config's short class name lowercased, with the old tests/Item/<Type>/resources/ layout still honoured as a fallback.

The two directions are asserted by the traits under src/Testing/, which are shipped rather than kept in tests/ so downstream packages can reuse them. CodeToPseudocodeTestTrait::assertCodeToPseudocode() compares generated config data against the fixture YAML, after filterIgnoredKeys() strips generator and implementationGuidelines — neither survives a round trip from source. PseudocodeToCodeTestTrait::assertPseudocodeToCode() regenerates PHP from the fixture YAML, not from the freshly parsed config, precisely so generator options that cannot be inferred from code are exercised; both sides go through normalizeCode(), which strips comments and collapses whitespace, before assertEquals. Both traits dump intermediate files into sys_get_temp_dir() . '/pseudocode_tests' for inspection.

Adding an item type

Extend AbstractConfig, implement canParse(), canLoad(), fromNode(), toConfig() and toCode() — plus fromConfig() if the type owns children or accepts a scalar shorthand, in which case override unpackData() — and add a $this->register(YourConfig::class) line in the ConfigRegistry constructor. Nothing else dispatches: a config the registry does not know is unreachable from both generators, and a config a parent instantiates directly needs no registration at all.

Integration in the Suite

This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.

Related Packages

The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.

Visit the Wexample Suite documentation for the complete package ecosystem.

Dependencies

  • php: >=7.4
  • symfony/yaml: ^7.0
  • nikic/php-parser: ^5.0
  • wexample/php-helpers: >=3.0.0

Versioning & Compatibility Policy

Wexample packages follow Semantic Versioning (SemVer):

  • MAJOR: Breaking changes
  • MINOR: New features, backward compatible
  • PATCH: Bug fixes, backward compatible

We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Free to use in both personal and commercial projects.

About us

Wexample stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.

This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.

Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.

Migration Notes

When upgrading between major versions, refer to the migration guides in the documentation.

Breaking changes are clearly documented with upgrade paths and examples.