diff --git a/README.md b/README.md
index 2106257..e7ac360 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,27 @@ echo $highlighter->highlight($code, 'php');
`
…
`. Emit a theme stylesheet
once per page, then reuse the highlighter for every code block.
+## Parse without rendering
+
+Use `CodeParser` when another component needs semantic tokens instead of HTML:
+
+```php
+use Alto\Code\Highlight\CodeParser;
+
+$stream = (new CodeParser())->parse(
+ '$total = array_sum($prices);',
+ 'php',
+);
+
+foreach ($stream as $token) {
+ echo $token->text.' '.$token->scope->value.PHP_EOL;
+}
+```
+
+`parse()` returns a `ParsedStream` and preserves the caller's source exactly:
+`$stream->toString()` is the original code. It also resolves configured
+embedded languages, without requiring a theme or choosing an output format.
+
## What it covers
- **27 languages:** the PHP web stack plus common programming, markup, data,
@@ -76,7 +97,7 @@ once per page, then reuse the highlighter for every code block.
| [Create a theme](docs/theming/creating.md) | Implement `ThemeInterface` |
| [Embedded languages](docs/languages/embedded.md) | HTML, SVG, Markdown, and Twig |
| [Theme adapters](docs/theming/adapters.md) | Highlight.js, Prism, and TextMate |
-| [Public API](docs/api/index.md) | Supported entry points and extension contracts |
+| [Public API](docs/api/index.md) | Parsing, rendering, and extension contracts |
| [Examples](docs/examples.md) | Compact examples and generated previews |
The complete source examples are available in [`examples/languages/`](examples/languages/).
diff --git a/docs/api/index.md b/docs/api/index.md
index 50f7d33..244b72f 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -4,9 +4,26 @@ Alto Code Highlight follows semantic versioning for the supported entry points
and extension contracts described here. Patch and minor releases preserve
their documented signatures and behavior throughout the 1.x series.
-## Main entry point
+## Parsing
-`Highlighter` is the primary facade. Its supported operations are:
+`CodeParser` parses source into a `ParsedStream` without rendering it. Its
+supported operations are:
+
+- construction with an optional embedding registry and optional language list;
+- `parse()` for semantically scoped tokens;
+- `registerLanguage()` for adding or replacing a parser;
+- `getEmbeddedRegistry()` for inspecting embedding plans;
+- `setEmbeddingEnabled()` for toggling a configured host and target pair.
+
+`parse()` preserves the source supplied by the caller. Concatenating the token
+text, or calling `ParsedStream::toString()`, returns that source exactly.
+Selecting `php` parses PHP from the first byte even when the opening tag is
+omitted.
+
+## HTML rendering
+
+`Highlighter` renders the same parsed representation as escaped HTML. Its
+supported operations are:
- construction with a `ThemeInterface`, optional embedding registry, and
optional language list;
@@ -17,8 +34,7 @@ their documented signatures and behavior throughout the 1.x series.
- `setEmbeddingEnabled()` for toggling a configured host and target pair.
`HighlighterInterface` defines the portable highlighting operation for code
-that depends on an abstraction rather than the concrete facade. Selecting
-`php` parses PHP from the first byte even when the opening tag is omitted.
+that depends on an abstraction rather than the concrete facade.
## Theme extension contract
diff --git a/docs/index.md b/docs/index.md
index 0e986ab..796b7ad 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -34,12 +34,15 @@ require a browser-side highlighter.
## Public API at a glance
-`Alto\Code\Highlight\Highlighter` is the main entry point:
+Use `CodeParser` when you need tokens, and `Highlighter` when you need HTML:
```php
+use Alto\Code\Highlight\CodeParser;
use Alto\Code\Highlight\Highlighter;
use Alto\Code\Highlight\Theme\AltoTheme;
+$tokens = (new CodeParser())->parse('$answer = 42;', 'php');
+
$highlighter = new Highlighter(new AltoTheme());
$html = $highlighter->highlight('getTheme()->getStylesheet();
diff --git a/src/CodeParser.php b/src/CodeParser.php
new file mode 100644
index 0000000..576b800
--- /dev/null
+++ b/src/CodeParser.php
@@ -0,0 +1,139 @@
+
+ */
+final class CodeParser
+{
+ /**
+ * @var array
+ */
+ private array $languages = [];
+
+ private EmbeddedLanguageRegistry $embeddedRegistry;
+
+ /**
+ * @var array
+ */
+ private array $embeddedLanguageToggles = [];
+
+ /**
+ * @param list|null $languages
+ */
+ public function __construct(
+ ?EmbeddedLanguageRegistry $embeddedRegistry = null,
+ ?array $languages = null,
+ ) {
+ $this->embeddedRegistry = $embeddedRegistry ?? new EmbeddedLanguageRegistry();
+
+ $languages ??= Languages::getDefaultLanguages();
+ foreach ($languages as $language) {
+ if (!$language instanceof LanguageInterface) {
+ throw new \InvalidArgumentException('All languages must implement LanguageInterface.');
+ }
+
+ $this->registerLanguage($language);
+ }
+ }
+
+ /**
+ * Parse source code without rendering it.
+ */
+ public function parse(string $code, string $language): ParsedStream
+ {
+ $language = strtolower(trim($language));
+
+ if ('php-snippet' === $language) {
+ $language = 'php';
+ }
+
+ return $this->parseWithLanguage($this->getLanguage($language), $code);
+ }
+
+ /**
+ * Register a language parser.
+ */
+ public function registerLanguage(LanguageInterface $language): void
+ {
+ $this->languages[$language->getIdentifier()] = $language;
+ }
+
+ public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
+ {
+ return $this->embeddedRegistry;
+ }
+
+ /**
+ * Enable or disable a specific embedded language.
+ *
+ * @param string $host The host language (e.g., 'html')
+ * @param string $target The embedded language to toggle (e.g., 'javascript')
+ */
+ public function setEmbeddingEnabled(string $host, string $target, bool $enabled): void
+ {
+ $this->embeddedLanguageToggles[strtolower($host) . ':' . strtolower($target)] = $enabled;
+ }
+
+ /**
+ * @throws LanguageNotFoundException
+ */
+ private function getLanguage(string $identifier): LanguageInterface
+ {
+ if (!isset($this->languages[$identifier])) {
+ throw new LanguageNotFoundException($identifier);
+ }
+
+ return $this->languages[$identifier];
+ }
+
+ private function parseWithLanguage(LanguageInterface $language, string $code): ParsedStream
+ {
+ if ($language instanceof EmbeddedLanguageCapable) {
+ $plan = $this->embeddedRegistry->getPlan($language->getIdentifier());
+
+ if (null !== $plan) {
+ $host = $language->getIdentifier();
+ $triggers = array_filter($plan->getTriggers(), function ($trigger) use ($host) {
+ $key = $host . ':' . $trigger->targetLanguage;
+
+ return $this->embeddedLanguageToggles[$key] ?? true;
+ });
+ $plan = EmbeddedLanguagePlan::forHost($host, array_values($triggers));
+ }
+
+ $context = EmbeddedLanguageContext::fromResolver(function (string $identifier, string $embeddedCode): ParsedStream {
+ $embeddedLanguage = $this->getLanguage($identifier);
+
+ return $this->parseWithLanguage($embeddedLanguage, $embeddedCode);
+ }, $plan);
+
+ return $language->parseWithEmbedding($code, $context);
+ }
+
+ return $language->parse($code);
+ }
+}
diff --git a/src/Highlighter.php b/src/Highlighter.php
index 6fd38c2..bec98ca 100644
--- a/src/Highlighter.php
+++ b/src/Highlighter.php
@@ -13,13 +13,8 @@
namespace Alto\Code\Highlight;
-use Alto\Code\Highlight\Embedded\EmbeddedLanguagePlan;
use Alto\Code\Highlight\Embedded\EmbeddedLanguageRegistry;
-use Alto\Code\Highlight\Exception\LanguageNotFoundException;
-use Alto\Code\Highlight\Language\EmbeddedLanguageCapable;
-use Alto\Code\Highlight\Language\EmbeddedLanguageContext;
use Alto\Code\Highlight\Language\LanguageInterface;
-use Alto\Code\Highlight\Language\Languages;
use Alto\Code\Highlight\Parser\ParsedStream;
/**
@@ -31,17 +26,7 @@
*/
final class Highlighter implements HighlighterInterface
{
- /**
- * @var array
- */
- private array $languages = [];
-
- private EmbeddedLanguageRegistry $embeddedRegistry;
-
- /**
- * @var array
- */
- private array $embeddedLanguageToggles = [];
+ private CodeParser $parser;
/**
* @param list|null $languages
@@ -51,17 +36,7 @@ public function __construct(
?EmbeddedLanguageRegistry $embeddedRegistry = null,
?array $languages = null,
) {
- $this->embeddedRegistry = $embeddedRegistry ?? new EmbeddedLanguageRegistry();
-
- // Register built-in languages
- $languages ??= Languages::getDefaultLanguages();
- foreach ($languages as $language) {
- if (!$language instanceof LanguageInterface) {
- throw new \InvalidArgumentException('All languages must implement LanguageInterface.');
- }
-
- $this->registerLanguage($language);
- }
+ $this->parser = new CodeParser($embeddedRegistry, $languages);
}
/**
@@ -88,11 +63,7 @@ public function highlight(
$language = 'php';
}
- // Get the language parser
- $languageParser = $this->getLanguage($language);
-
- // Parse the code (recursively handling embedded languages when supported)
- $parsedStream = $this->parseWithLanguage($languageParser, $code);
+ $parsedStream = $this->parser->parse($code, $language);
// Format the output
return $this->format($parsedStream, $language, $lineNumbers, $highlightLines);
@@ -103,7 +74,7 @@ public function highlight(
*/
public function registerLanguage(LanguageInterface $language): void
{
- $this->languages[$language->getIdentifier()] = $language;
+ $this->parser->registerLanguage($language);
}
/**
@@ -114,54 +85,9 @@ public function getTheme(): ThemeInterface
return $this->theme;
}
- /**
- * Get a registered language parser.
- *
- * @throws LanguageNotFoundException
- */
- private function getLanguage(string $identifier): LanguageInterface
- {
- if (!isset($this->languages[$identifier])) {
- throw new LanguageNotFoundException($identifier);
- }
-
- return $this->languages[$identifier];
- }
-
- /**
- * Parse source code with the given language, handling embedded delegation when available.
- */
- private function parseWithLanguage(LanguageInterface $language, string $code): ParsedStream
- {
- if ($language instanceof EmbeddedLanguageCapable) {
- $plan = $this->embeddedRegistry->getPlan($language->getIdentifier());
-
- if (null !== $plan) {
- $host = $language->getIdentifier();
- $triggers = array_filter($plan->getTriggers(), function ($trigger) use ($host) {
- $key = $host . ':' . $trigger->targetLanguage;
-
- return $this->embeddedLanguageToggles[$key] ?? true;
- });
- // Create a new plan with filtered triggers
- $plan = EmbeddedLanguagePlan::forHost($host, array_values($triggers));
- }
-
- $context = EmbeddedLanguageContext::fromResolver(function (string $identifier, string $embeddedCode): ParsedStream {
- $embeddedLanguage = $this->getLanguage($identifier);
-
- return $this->parseWithLanguage($embeddedLanguage, $embeddedCode);
- }, $plan);
-
- return $language->parseWithEmbedding($code, $context);
- }
-
- return $language->parse($code);
- }
-
public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
{
- return $this->embeddedRegistry;
+ return $this->parser->getEmbeddedRegistry();
}
/**
@@ -172,7 +98,7 @@ public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
*/
public function setEmbeddingEnabled(string $host, string $target, bool $enabled): void
{
- $this->embeddedLanguageToggles[strtolower($host) . ':' . strtolower($target)] = $enabled;
+ $this->parser->setEmbeddingEnabled($host, $target, $enabled);
}
/**
diff --git a/tests/Unit/CodeParserTest.php b/tests/Unit/CodeParserTest.php
new file mode 100644
index 0000000..778a234
--- /dev/null
+++ b/tests/Unit/CodeParserTest.php
@@ -0,0 +1,143 @@
+getByRole("button")->click();';
+
+ $stream = (new CodeParser())->parse($code, 'php');
+
+ self::assertSame($code, $stream->toString());
+ self::assertContains(Scope::Variable, array_map(
+ static fn(ParsedToken $token): Scope => $token->scope,
+ $stream->tokens,
+ ));
+ self::assertContains(Scope::FunctionCall, array_map(
+ static fn(ParsedToken $token): Scope => $token->scope,
+ $stream->tokens,
+ ));
+ }
+
+ public function testNormalizesLanguageIdentifierAndPhpSnippetAlias(): void
+ {
+ $parser = new CodeParser();
+ $code = '$answer = 42;';
+
+ self::assertSame($code, $parser->parse($code, ' PHP ')->toString());
+ self::assertSame($code, $parser->parse($code, 'php-snippet')->toString());
+ }
+
+ public function testThrowsForUnknownLanguage(): void
+ {
+ $this->expectException(LanguageNotFoundException::class);
+
+ (new CodeParser())->parse('code', 'unknown-language');
+ }
+
+ public function testCanRegisterAndParseACustomLanguage(): void
+ {
+ $language = self::customLanguage('custom');
+ $parser = new CodeParser(languages: []);
+ $parser->registerLanguage($language);
+
+ $stream = $parser->parse('source', 'custom');
+
+ self::assertSame('source', $stream->toString());
+ self::assertSame(Scope::String, $stream->tokens[0]->scope);
+ }
+
+ public function testAcceptsLanguagesInConstructor(): void
+ {
+ $parser = new CodeParser(languages: [self::customLanguage('custom')]);
+
+ self::assertSame('source', $parser->parse('source', 'custom')->toString());
+ }
+
+ public function testRejectsInvalidConstructorLanguage(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('All languages must implement LanguageInterface.');
+
+ new CodeParser(languages: [new \stdClass()]);
+ }
+
+ public function testExposesInjectedEmbeddedLanguageRegistry(): void
+ {
+ $registry = new EmbeddedLanguageRegistry([]);
+
+ $parser = new CodeParser($registry);
+
+ self::assertSame($registry, $parser->getEmbeddedRegistry());
+ self::assertSame(
+ '',
+ $parser->parse('', 'html')->toString(),
+ );
+ }
+
+ public function testCanDisableAndReEnableEmbeddedLanguageParsing(): void
+ {
+ $parser = new CodeParser();
+ $code = '';
+
+ self::assertTrue($this->hasToken($parser->parse($code, 'html'), 'var', Scope::KeywordDeclaration));
+
+ $parser->setEmbeddingEnabled('HTML', 'JAVASCRIPT', false);
+ self::assertFalse($this->hasToken($parser->parse($code, 'html'), 'var', Scope::KeywordDeclaration));
+
+ $parser->setEmbeddingEnabled('html', 'javascript', true);
+ self::assertTrue($this->hasToken($parser->parse($code, 'html'), 'var', Scope::KeywordDeclaration));
+ }
+
+ private static function customLanguage(string $identifier): LanguageInterface
+ {
+ return new class ($identifier) implements LanguageInterface {
+ public function __construct(private readonly string $identifier) {}
+
+ public function parse(string $code): ParsedStream
+ {
+ return new ParsedStream([new ParsedToken($code, Scope::String)]);
+ }
+
+ public function getIdentifier(): string
+ {
+ return $this->identifier;
+ }
+ };
+ }
+
+ private function hasToken(ParsedStream $stream, string $text, Scope $scope): bool
+ {
+ foreach ($stream as $token) {
+ if ($text === $token->text && $scope === $token->scope) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}