Skip to content

[BUGFIX] Render a code block without a language instead of aborting the run - #1353

Merged
linawolf merged 2 commits into
phpDocumentor:mainfrom
netresearch:fix/code-node-without-language
Aug 19, 2026
Merged

[BUGFIX] Render a code block without a language instead of aborting the run#1353
linawolf merged 2 commits into
phpDocumentor:mainfrom
netresearch:fix/code-node-without-language

Conversation

@CybotTM

@CybotTM CybotTM commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Rendering a code block whose language is not set aborts the entire run:

In CodeExtension.php line 40:
  phpDocumentor\Guides\Code\Twig\CodeExtension::highlight(): Argument #3 ($language) must be of type string, null given

CodeNode::getLanguage() is nullable, and packages/guides/resources/template/html/body/code.html.twigbody/code/highlighted-code.html.twig passes it straight into the filter as {{ node.value|highlight(node.language) }}. The filter's signature already carries the intended fallback — string $language = 'text' — but a default value cannot apply to an argument that is passed explicitly, so null reaches a non-nullable parameter and PHP throws.

Reproduction

The shortest way to reach a CodeNode without a language is a literalinclude without :language::

index
=====

..  literalinclude:: _Example.php

vendor/bin/guides run input --output=out on that project fails with the TypeError above and renders nothing. Measured on main at 90594491, so this is not a regression from anything currently in flight.

The project needs <extension class="phpDocumentor\Guides\Code"/> in its guides.xml, the way this repository's own guides.xml has it. Without the extension the container keeps the fallback template packages/guides/resources/template/html/body/code/highlighted-code.html.twig, a bare {{ node.value }}, and the filter is never called.

code-block is not affected: CodeBlockDirective always calls setLanguage(), either with the given language or with getCodeBlockDefaultLanguage(), which returns a string.

Fix

CodeExtension::highlight() accepts string|null and falls back to text, which is what the existing default already expressed.

code.html.twig writes class="language-{{ node.language ?? 'text' }}", so the class attribute names the language the content is actually highlighted with. Without this the block rendered as text but was labelled class="language-".

Only null is mapped, matching the filter. An empty-string language still renders class="language-", which is what a literal block (::) and a code-block without language produce today; the highlighter maps '' to text as well, so normalizing that too would be consistent, but it rewrites ten further expectations across nine files and is a separate change.

Tests

packages/guides-code/tests/unit/Twig/CodeExtensionTest.php asserts that a null language renders exactly like an explicit text, plus one test that a real language still highlights.

The fixture is <a> & "b" on purpose: a highlighter returns plain input unchanged for every language, so a plain fixture would pass no matter what the fallback is. With this one, text leaves the quoted part alone while php wraps it in a string token, and a second assertion states that the two differ, so the fixture itself is pinned as discriminating. Two mutations were checked against it: removing the null acceptance fails with the TypeError above, and changing the fallback to php fails the equality assertion.

tests/Integration/tests/code/literalinclude-without-language runs the reproduction above through the CLI. Its guides.xml enables the Code extension, because otherwise the fallback template packages/guides/resources/template/html/body/code/highlighted-code.html.twig renders and the filter is never reached. Both halves of the fix were mutated against it: reverting the filter signature fails with the original TypeError raised in body/code/highlighted-code.html.twig, reverting the template fallback fails on language- vs language-text.

tests/Integration/tests/markdown/code-md changes: a fenced code block without an info string carries a null language too, so its two expectations move from language- to language-text.

The TeX template renders \lstset{language={{ node.language }}} without a fallback as well, but null and '' produce the same \lstset{language=} there, which is the committed expectation for a language-less block in tests/Functional/tests/code/code.tex. There is nothing null-specific to fix there, so it is left unchanged.

Local run of phpunit (unit, functional, integration), phpcs, phpstan and deptrac: green.

Note

There is a second, complementary option: let LiteralincludeDirective fall back to getCodeBlockDefaultLanguage() the way CodeBlockDirective does — the producer-side approach #1302 took for markdown fenced code blocks. I did not do that here because it would leave the crash reachable from any other producer, and because the type mismatch really is at the template-to-filter boundary. Happy to add the directive-side default as well if you prefer both.

Assisted by claude-code:claude-opus-5 — Session

Rendering a code block whose language is not set aborts the whole run with

    CodeExtension::highlight(): Argument phpDocumentor#3 ($language) must be of type
    string, null given

`CodeNode::getLanguage()` is nullable and `body/code/highlighted-code.html.twig`
passes it straight into the filter, so the filter has to accept null. Its
signature already carries the intended fallback, `string $language = 'text'`,
but a default cannot apply to an argument that is passed explicitly.

Accept `string|null` and fall back to `text`.

Reproduced with a `literalinclude` without the `:language:` option, which is
the shortest way to reach a CodeNode with no language; `code-block` never
does, because it always sets either the given language or the configured
default.

Assisted-by: claude-code:claude-opus-5
Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss
Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
@linawolf

linawolf commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Thanks a lot for your Bug report and suggested solution, I think it would also be helpful to turn your reproduction instructions into an integration test.

I am also wondering if it really makes sense to create a Code Node without language in the first place. In TYPO3 we try to guess the language by file ending and default to text and a warning if it cannot be auto detected / resolves to an unsupported language. That would however be something for a follow up

@linawolf

Copy link
Copy Markdown
Contributor

One more thing: code.html.twig:10 still does class="language-{{ node.language }}" with no null fallback. That was unreachable before (the render crashed first), but now that highlight() defaults null to text, this renders class="language-" instead of class="language-text" — worth aligning with ?? 'text'.

@CybotTM
CybotTM marked this pull request as draft August 19, 2026 12:01
The HTML template wrote class="language-{{ node.language }}" without a fallback, so a
CodeNode with a null language rendered class="language-" while the highlight filter
rendered its content as text. Align the class attribute with that fallback.

Only null is mapped, matching the filter: an empty-string language keeps rendering
class="language-" as before.

The integration test drives the reported reproduction, a literalinclude without
:language:, through the CLI with the Code extension enabled, which is where the
TypeError was raised.

Markdown fenced code blocks without an info string also carry a null language; their
expectation changes from language- to language-text.

Signed-off-by: Sebastian Mendel <sebastian.mendel@netresearch.de>
@CybotTM

CybotTM commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Both points are in e5edfbb.

Class attribute. code.html.twig:10 now writes class="language-{{ node.language ?? 'text' }}". Scope of that: only null is mapped. An empty-string language keeps rendering class="language-" — that is what a literal block (::) and a code-block without language produce today. The highlighter maps '' to text as well (HighlightPhpHighlighter::__invoke() short-circuits on 'text', '' and 'rest'), so normalizing those two the same way would be consistent, but it rewrites ten further expectations across nine files. Say the word and I add it here, otherwise it is a separate change.

Integration test. tests/Integration/tests/code/literalinclude-without-language, the reproduction from the description run through the CLI. It carries a guides.xml that enables the Code extension: without it the container falls back to packages/guides/resources/template/html/body/code/highlighted-code.html.twig, which is a bare {{ node.value }}, and the filter is never reached — the existing literalinclude fixture never touches the highlighter for that reason.

Both halves were mutated against the new fixture: reverting the filter signature fails it with the original TypeError in body/code/highlighted-code.html.twig, reverting the template fallback fails it on language- vs language-text.

One expectation changed as a consequence: tests/Integration/tests/markdown/code-md. A fenced code block without an info string carries a null language too, so its two language- become language-text.

I also checked body/code.tex.twig, which has the same missing fallback. Nothing to do there: null and '' both render \lstset{language=}, which is the committed expectation for a language-less block in tests/Functional/tests/code/code.tex, so there is nothing null-specific to fix there.

On guessing the language from the file extension: agreed, and out of scope here — happy to open an issue for it if you want it tracked.

Marked as draft while the empty-string question above is open.

@CybotTM CybotTM changed the title [BUGFIX] Accept a CodeNode without a language in the highlight filter [BUGFIX] Render a code block without a language instead of aborting the run Aug 19, 2026
@CybotTM
CybotTM marked this pull request as ready for review August 19, 2026 13:14
@linawolf

Copy link
Copy Markdown
Contributor

Thanks a lot for your contribution, yes an issue about the auto discovery of the language would be good

@linawolf
linawolf merged commit 9292930 into phpDocumentor:main Aug 19, 2026
59 checks passed
@phpdoc-bot

Copy link
Copy Markdown

💔 All backports failed

Status Branch Result
1.x The branch "1.x" does not exist

Manual backport

To create the backport manually run:

backport --pr 1353

Questions ?

Please refer to the Backport tool documentation and see the Github Action logs for details

CybotTM added a commit to netresearch/typo3-core-contributions-skill that referenced this pull request Aug 19, 2026
…w count hide

Three findings from an upstream review round on phpDocumentor/guides#1353, each with a
direct TYPO3 counterpart.

proving-a-test: a test that stays green after the fix is reverted may be testing
nothing, because the harness never loaded the changed code — $coreExtensionsToLoad
and $testExtensionsToLoad decide that silently. Check the wiring before the
assertion. Second addition: count with git grep, since grep -r in a post-run
checkout counts var/, typo3temp/ and .Build/ as source.

gerrit-review-patterns: a reviewer names the site they saw. Enumerate the other
producers and renderers of the same value, and say in the reply which ones were
checked and left alone.

evals: one scenario per finding.
Signed-off-by: Sebastian Mendel <info@sebastianmendel.de>
CybotTM added a commit to netresearch/typo3-core-contributions-skill that referenced this pull request Aug 19, 2026
…w count hide (#35)

Three findings from an upstream review round on
[phpDocumentor/guides#1353](phpDocumentor/guides#1353).
Each one has a direct TYPO3 counterpart, which is what makes them worth
writing down here.

**`proving-a-test.md` — when the mutation does not fail.** The procedure
so far treats a green test after a reverted fix as a weak assertion.
There is a second explanation that produces the same symptom: the
harness never loaded the code under test. In TYPO3 that is
`$coreExtensionsToLoad` / `$testExtensionsToLoad` — an extension missing
from those lists is simply absent, with no error to say so. In the
guides monorepo the same shape cost a review round: the existing
`literalinclude` integration fixture had never enabled the optional
highlighting package, so it rendered a fallback template and could not
reach the crash it appeared to cover. Enabling the package in the
fixture's own config was what made the reverted fix fail — with the
exact `TypeError` from the bug report.

**`proving-a-test.md` — counting occurrences.** `grep -r` in a checkout
where the suite has just run counts `var/`, `typo3temp/`, `.Build/` and
fixture output directories as source. A count taken that way was wrong
by four, went into a public review comment, and had to be corrected
afterwards. `git grep` sees tracked files only.

**`gerrit-review-patterns.md` — one named occurrence, a whole class
behind it.** A reviewer names the site their eye landed on. In #1353 the
same missing fallback sat in a second template and a second producer
carried the same null value, which moved an existing expectation file;
implementing the comment verbatim would have fixed a third of its own
case. The new theme asks for the enumeration and for the reply to name
the sites that were checked and deliberately left alone — including the
one that turned out to need no change.

**Evals**: one scenario per finding, all three graded by
`content_contains`.

Checks run locally: `validate-evals.sh` 69 passed / 0 failed,
`validate-skill.sh` 0 errors (9 pre-existing README warnings,
untouched).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants