diff --git a/.art/readme/classes_pulse.gif b/.art/readme/classes_pulse.gif deleted file mode 100644 index cbde9a7..0000000 Binary files a/.art/readme/classes_pulse.gif and /dev/null differ diff --git a/.art/readme/example_character_ranges.png b/.art/readme/example_character_ranges.png index 21bfdaf..a5d2cf2 100644 Binary files a/.art/readme/example_character_ranges.png and b/.art/readme/example_character_ranges.png differ diff --git a/.art/readme/example_colguides.png b/.art/readme/example_colguides.png new file mode 100644 index 0000000..dc70da4 Binary files /dev/null and b/.art/readme/example_colguides.png differ diff --git a/.art/readme/example_collapse_click_to_show.png b/.art/readme/example_collapse_click_to_show.png index 7a674a2..3744067 100644 Binary files a/.art/readme/example_collapse_click_to_show.png and b/.art/readme/example_collapse_click_to_show.png differ diff --git a/.art/readme/example_custom_line_number_style.png b/.art/readme/example_custom_line_number_style.png index b949870..a031cf1 100644 Binary files a/.art/readme/example_custom_line_number_style.png and b/.art/readme/example_custom_line_number_style.png differ diff --git a/.art/readme/example_custom_starting_line_number.png b/.art/readme/example_custom_starting_line_number.png index 805915e..c4f83cc 100644 Binary files a/.art/readme/example_custom_starting_line_number.png and b/.art/readme/example_custom_starting_line_number.png differ diff --git a/.art/readme/example_diff.png b/.art/readme/example_diff.png index 4cf3f18..1220932 100644 Binary files a/.art/readme/example_diff.png and b/.art/readme/example_diff.png differ diff --git a/.art/readme/example_diff_no_indicators.png b/.art/readme/example_diff_no_indicators.png index 5f3308b..c7703ce 100644 Binary files a/.art/readme/example_diff_no_indicators.png and b/.art/readme/example_diff_no_indicators.png differ diff --git a/.art/readme/example_diff_preserve_colors.png b/.art/readme/example_diff_preserve_colors.png index 21057e1..e1e1563 100644 Binary files a/.art/readme/example_diff_preserve_colors.png and b/.art/readme/example_diff_preserve_colors.png differ diff --git a/.art/readme/example_diff_standalone.png b/.art/readme/example_diff_standalone.png index 2c45b54..11bfd79 100644 Binary files a/.art/readme/example_diff_standalone.png and b/.art/readme/example_diff_standalone.png differ diff --git a/.art/readme/example_diff_with_indicators.png b/.art/readme/example_diff_with_indicators.png index 6821feb..1220932 100644 Binary files a/.art/readme/example_diff_with_indicators.png and b/.art/readme/example_diff_with_indicators.png differ diff --git a/.art/readme/example_disabled_collapse_gutter.png b/.art/readme/example_disabled_collapse_gutter.png index 82e030d..fdd5967 100644 Binary files a/.art/readme/example_disabled_collapse_gutter.png and b/.art/readme/example_disabled_collapse_gutter.png differ diff --git a/.art/readme/example_disabling_annotations.png b/.art/readme/example_disabling_annotations.png index 8f5687b..2f6f12a 100644 Binary files a/.art/readme/example_disabling_annotations.png and b/.art/readme/example_disabling_annotations.png differ diff --git a/.art/readme/example_files.png b/.art/readme/example_files.png index a8194cc..3448fd5 100644 Binary files a/.art/readme/example_files.png and b/.art/readme/example_files.png differ diff --git a/.art/readme/example_highlight_1.png b/.art/readme/example_highlight_1.png index 92f0ce9..9d255b6 100644 Binary files a/.art/readme/example_highlight_1.png and b/.art/readme/example_highlight_1.png differ diff --git a/.art/readme/example_html_comment.png b/.art/readme/example_html_comment.png index 1de4e52..d62616b 100644 Binary files a/.art/readme/example_html_comment.png and b/.art/readme/example_html_comment.png differ diff --git a/.art/readme/example_intro_focus.png b/.art/readme/example_intro_focus.png index 0bf4374..edf02a3 100644 Binary files a/.art/readme/example_intro_focus.png and b/.art/readme/example_intro_focus.png differ diff --git a/.art/readme/example_json_focus.png b/.art/readme/example_json_focus.png index 05b1369..eb4861e 100644 Binary files a/.art/readme/example_json_focus.png and b/.art/readme/example_json_focus.png differ diff --git a/.art/readme/example_line_number_colors.png b/.art/readme/example_line_number_colors.png index 4bef5fc..a831372 100644 Binary files a/.art/readme/example_line_number_colors.png and b/.art/readme/example_line_number_colors.png differ diff --git a/.art/readme/example_negative_many_lines.png b/.art/readme/example_negative_many_lines.png index 6619d91..a7c3004 100644 Binary files a/.art/readme/example_negative_many_lines.png and b/.art/readme/example_negative_many_lines.png differ diff --git a/.art/readme/example_no_immediate_reindex.png b/.art/readme/example_no_immediate_reindex.png index 725bc35..2d0fddf 100644 Binary files a/.art/readme/example_no_immediate_reindex.png and b/.art/readme/example_no_immediate_reindex.png differ diff --git a/.art/readme/example_no_line_numbers.png b/.art/readme/example_no_line_numbers.png index 9900386..b916e72 100644 Binary files a/.art/readme/example_no_line_numbers.png and b/.art/readme/example_no_line_numbers.png differ diff --git a/.art/readme/example_offset_length_1.png b/.art/readme/example_offset_length_1.png index 469ff5a..42a61ed 100644 Binary files a/.art/readme/example_offset_length_1.png and b/.art/readme/example_offset_length_1.png differ diff --git a/.art/readme/example_range_many_lines.png b/.art/readme/example_range_many_lines.png index 3477f14..ca9ffd0 100644 Binary files a/.art/readme/example_range_many_lines.png and b/.art/readme/example_range_many_lines.png differ diff --git a/.art/readme/example_range_single_line.png b/.art/readme/example_range_single_line.png index 2e8c94f..d23b78c 100644 Binary files a/.art/readme/example_range_single_line.png and b/.art/readme/example_range_single_line.png differ diff --git a/.art/readme/example_reindex_any_number.png b/.art/readme/example_reindex_any_number.png index f9296f9..a5de72b 100644 Binary files a/.art/readme/example_reindex_any_number.png and b/.art/readme/example_reindex_any_number.png differ diff --git a/.art/readme/example_reindex_new_number.png b/.art/readme/example_reindex_new_number.png index 4402644..bc8c80e 100644 Binary files a/.art/readme/example_reindex_new_number.png and b/.art/readme/example_reindex_new_number.png differ diff --git a/.art/readme/example_reindex_no_line_number.png b/.art/readme/example_reindex_no_line_number.png index de702db..6b93e98 100644 Binary files a/.art/readme/example_reindex_no_line_number.png and b/.art/readme/example_reindex_no_line_number.png differ diff --git a/.art/readme/example_reindex_relative_changes.png b/.art/readme/example_reindex_relative_changes.png index b6366b3..8f490ac 100644 Binary files a/.art/readme/example_reindex_relative_changes.png and b/.art/readme/example_reindex_relative_changes.png differ diff --git a/.art/readme/example_reindex_single_line.png b/.art/readme/example_reindex_single_line.png index 8f0e062..3503be6 100644 Binary files a/.art/readme/example_reindex_single_line.png and b/.art/readme/example_reindex_single_line.png differ diff --git a/.art/readme/example_reindex_stanza_voodoo.png b/.art/readme/example_reindex_stanza_voodoo.png index 9a190c1..b6bc363 100644 Binary files a/.art/readme/example_reindex_stanza_voodoo.png and b/.art/readme/example_reindex_stanza_voodoo.png differ diff --git a/.art/readme/example_right_padding.png b/.art/readme/example_right_padding.png index 9804ad9..d1545f5 100644 Binary files a/.art/readme/example_right_padding.png and b/.art/readme/example_right_padding.png differ diff --git a/.art/readme/example_right_padding_diff_indicators.png b/.art/readme/example_right_padding_diff_indicators.png index 44af9a0..802fa8e 100644 Binary files a/.art/readme/example_right_padding_diff_indicators.png and b/.art/readme/example_right_padding_diff_indicators.png differ diff --git a/.art/readme/example_start_end_modifiers.png b/.art/readme/example_start_end_modifiers.png index fb4a4b7..5c00f24 100644 Binary files a/.art/readme/example_start_end_modifiers.png and b/.art/readme/example_start_end_modifiers.png differ diff --git a/.art/readme/example_summary_closed.png b/.art/readme/example_summary_closed.png index 46ac369..a627270 100644 Binary files a/.art/readme/example_summary_closed.png and b/.art/readme/example_summary_closed.png differ diff --git a/.art/readme/example_summary_indicator_default.png b/.art/readme/example_summary_indicator_default.png index 8899f29..73b3725 100644 Binary files a/.art/readme/example_summary_indicator_default.png and b/.art/readme/example_summary_indicator_default.png differ diff --git a/.art/readme/example_summary_open.png b/.art/readme/example_summary_open.png index 5b740b3..afdc98b 100644 Binary files a/.art/readme/example_summary_open.png and b/.art/readme/example_summary_open.png differ diff --git a/.art/readme/example_summary_text_customized.png b/.art/readme/example_summary_text_customized.png index b6a77b3..0b30728 100644 Binary files a/.art/readme/example_summary_text_customized.png and b/.art/readme/example_summary_text_customized.png differ diff --git a/.art/readme/example_text_range.png b/.art/readme/example_text_range.png index 9f0dc9d..4e8c6d7 100644 Binary files a/.art/readme/example_text_range.png and b/.art/readme/example_text_range.png differ diff --git a/.art/readme/example_vim_preserve.png b/.art/readme/example_vim_preserve.png index 462fc7f..330d587 100644 Binary files a/.art/readme/example_vim_preserve.png and b/.art/readme/example_vim_preserve.png differ diff --git a/.art/readme/example_vim_relative.png b/.art/readme/example_vim_relative.png index 829dd4d..dc651d0 100644 Binary files a/.art/readme/example_vim_relative.png and b/.art/readme/example_vim_relative.png differ diff --git a/README.md b/README.md index 2e79898..47c7745 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ How simple is that? We're pretty proud of it and know you'll love it, too. * [Tailwind](#tailwind) * [Dark Mode](#dark-mode) * [Available Themes](#available-themes) + * [Custom Themes](#custom-themes) + * [Registering a Theme from a File](#registering-a-theme-from-a-file) + * [Registering a Theme from an Array](#registering-a-theme-from-an-array) + * [Overriding Theme Colors](#overriding-theme-colors) * [Annotations](#annotations) * [Plain Text Annotations](#plain-text-annotations) * [JSON Annotations](#json-annotations) @@ -77,6 +81,7 @@ How simple is that? We're pretty proud of it and know you'll love it, too. * [Collapsing Required CSS](#collapsing-required-css) * [Default to Open](#default-to-open) * [Removing Summary Carets](#removing-summary-carets) + * [Hiding Lines](#hiding-lines) * [Diffs](#diffs) * [Diff Shorthand](#diff-shorthand) * [Removing Diff Indicators](#removing-diff-indicators) @@ -84,9 +89,12 @@ How simple is that? We're pretty proud of it and know you'll love it, too. * [Diff Indicators Without Line Numbers](#diff-indicators-without-line-numbers) * [Diff Ranges](#diff-ranges) * [Preserving Syntax Colors](#preserving-syntax-colors) + * [Word Diff](#word-diff) * [Classes and IDs](#classes-and-ids) * [Using Range Modifiers](#using-range-modifiers) * [Character Ranges](#character-ranges) + + * [Linking](#linking) * [Auto-linking URLs](#auto-linking-urls) * [Link Requirements](#link-requirements) * [Link Ranges](#link-ranges) @@ -109,7 +117,15 @@ How simple is that? We're pretty proud of it and know you'll love it, too. * [Summary Indicator](#summary-indicator) * [Adding Extra Classes to the Torchlight Code Element](#adding-extra-classes-to-the-torchlight-code-element) * [The Copyable Option](#the-copyable-option) + * [Indent Guides](#indent-guides) + * [Column Guides](#column-guides) * [Disabling Torchlight Annotations](#disabling-torchlight-annotations) +* [Extensibility](#extensibility) + * [Custom Annotations](#custom-annotations) + * [Annotation Macros](#annotation-macros) + * [Replacers](#replacers) + * [Token Transformers](#token-transformers) + * [Block Decorators](#block-decorators) * [Reporting Issues](#reporting-issues) * [Contributing](#contributing) * [Credits](#credits) @@ -158,7 +174,7 @@ The CommonMark extension provides a few different ways to modify its behavior. #### Specifying the Extension's Default Language -To change the extension's default language that should be used when author's omit the language on a code block, we can call the `setDefaultGrammar` on the underlying renderer: +To change the extension's default language that should be used when authors omit the language on a code block, we can call the `setDefaultGrammar` on the underlying renderer: ```php toHtml( +$engine->codeToHtml( $code, // The code to highlight 'php', // The language 'github-light' // The theme(s) to use @@ -303,15 +319,15 @@ There are plans to upgrade both of these packages to support Torchlight Engine. ### Will this package replace the existing CommonMark package? -No, there are no immediate plans to deprecate the [existing CommonMark package](https://github.com/torchlight-api/torchlight-commonmark-php) as it provides additional features not currently available in the extension shipped with this package (notably integration with the `torchlight.php` configuration file and replacers). However, if you need a CommonMark extension that has no Laravel dependency, the extension provided by this package is what you are looking for. +No, there are no immediate plans to deprecate the [existing CommonMark package](https://github.com/torchlight-api/torchlight-commonmark-php) as it provides additional features not currently available in the extension shipped with this package (notably integration with the `torchlight.php` configuration file). However, if you need a CommonMark extension that has no Laravel dependency, the extension provided by this package is what you are looking for. ### Some themes are missing compared to the API version. How come? -Some themes available via. Torchlight API are not available with Torchlight Engine; this is largely due to them not being distributed any longer, or licensing information was not readily available. More information on adding custom themes to Torchlight will be coming in the future. +Some themes available via. Torchlight API are not available with Torchlight Engine; this is largely due to them not being distributed any longer, or licensing information was not readily available. You can add your own themes using the methods described in [Custom Themes](#custom-themes). ### Can I add custom themes to Torchlight Engine? -Technically _yes_, but it is a slightly involved process to account for the Torchlight colors. More information on adding custom themes will be coming in the future once the process is a bit simpler. +Yes! You can register custom themes from a file or an array, and override colors on existing themes. See [Custom Themes](#custom-themes) for details. ### Are the custom grammars from the API version supported? @@ -435,7 +451,7 @@ pre code.torchlight .summary-caret { ### Dark Mode -Torchlight Engine utilizes Phiki for syntax highlighting, and recommends using it's multi-theme support for dark mode. +Torchlight Engine utilizes Phiki for syntax highlighting, and recommends using its multi-theme support for dark mode. When instantiating an instance of the CommonMark extension, you may supply multiple themes like so: @@ -564,15 +580,96 @@ The following themes are available: * winter-is-coming-dark * winter-is-coming-light +### Custom Themes + +You can register custom themes from a VS Code theme file, an array, or override colors on existing themes. + +#### Registering a Theme from a File + +```php +registerTheme('my-theme', '/path/to/my-theme.json'); + +echo $engine->codeToHtml('echo "hello";', 'php', 'my-theme'); +``` + +The theme file should be a standard VS Code theme JSON file with `colors` and `tokenColors` keys. + +#### Registering a Theme from an Array + +```php +$engine->registerTheme('my-theme', [ + 'name' => 'My Custom Theme', + 'colors' => [ + 'editor.background' => '#1a1a2e', + 'editor.foreground' => '#e0e0e0', + 'editorLineNumber.foreground' => '#666666', + ], + 'tokenColors' => [ + [ + 'scope' => ['keyword', 'storage.type'], + 'settings' => ['foreground' => '#c792ea'], + ], + [ + 'scope' => ['string'], + 'settings' => ['foreground' => '#c3e88d'], + ], + ], +]); +``` + +#### Overriding Theme Colors + +You can override specific colors on any theme using `Theme::override()`: + +```php +codeToHtml( + 'echo "hello";', + 'php', + Theme::override('nord', [ + 'editor.background' => '#000000', + 'editor.lineHighlightBackground' => '#ff000033', + ]) +); +``` + +This works with multi-theme dark mode as well: + +```php +$engine->codeToHtml($code, 'php', [ + 'light' => Theme::override('github-light', [ + 'editor.background' => '#fafafa', + ]), + 'dark' => Theme::override('nord', [ + 'editor.background' => '#111111', + ]), +]); +``` + +Torchlight-specific color keys are also available, such as `torchlight.markupInsertedBackground`, `torchlight.markupDeletedBackground`, `torchlight.activeLineNumberColor`, and `torchlight.lineNumberColor`. + ## Annotations One of the things that makes Torchlight such a joy to author with is that you can control how your code is rendered via _comments in the code you're writing._ -If you want to highlight a specific line, you can add a code comment with the magic syntax `[tl! highglight]` and that line will be highlighted. +If you want to highlight a specific line, you can add a code comment with the magic syntax `[tl! highlight]` and that line will be highlighted. Gone are the days of inscrutable line number definitions at the top of your file, only to have them become outdated the moment you add or remove a line. Most other tools use a series of line numbers up front to denote highlight or focus lines: + ````text ```php{3}{2,4-5}{9} return [ @@ -824,7 +921,7 @@ EOT; // [tl! highlight:-7,3] #### Applying an Annotation to All Lines -You may use the `all` modifier to apply an annotation to _all_ lines. For example, the following would apply the `autolinks` annotation to every line: +You may use the `all` modifier to apply an annotation to _all_ lines. For example, the following would apply the `autolink` annotation to every line: ```text ### Added [tl! autolink:all] @@ -857,13 +954,21 @@ return [ All of them! Ranges are supported for all of the Torchlight annotation keywords: -* `highlight` -* `focus` -* `insert` -* `remove` +* `highlight` (alias `~~`) +* `focus` (alias `**`) +* `add` (alias `++`) +* `remove` (alias `--`) * `collapse` * `autolink` * `reindex` +* `word-diff` (alias `wd`) +* `mono` +* `hide` +* `mark` +* `link` +* `lens` +* `gutter` +* `region` Custom classes and IDs are supported as well. @@ -1056,7 +1161,7 @@ return [ ![Collapsed Section Open](./.art/readme/example_summary_open.png) -These lines will now be wrapped in a `summary` / `detail` pair of tags, that allows the user to natively toggle the open and closed start of the block. Torchlight will also add a `has-summaries` class to your `code` tag anytime you define a summary range. +These lines will now be wrapped in a `summary` / `detail` pair of tags, that allows the user to natively toggle the open and closed state of the block. Torchlight will also add a `has-summaries` class to your `code` tag anytime you define a summary range. You can use the `start` `end` method of defining a range, or any of the other [range modifiers](#ranges). @@ -1209,6 +1314,32 @@ Setting this to `false` will disable the collapse gutter entirely: ![Disabling Summary Carets](./.art/readme/example_disabled_collapse_gutter.png) +### Hiding Lines + +The `hide` annotation removes lines from the rendered output entirely. Contiguous hidden ranges are replaced with an elision indicator: + +```php +return [ + 'heading_permalink' => [ // [tl! hide:start] + 'html_class' => 'permalink', + 'id_prefix' => 'user-content', + 'insert' => 'before', + 'title' => 'Permalink', + 'symbol' => '#', + ], // [tl! hide:end] + + 'extensions' => [ + TorchlightExtension::class, + ] +] +``` + +The hidden lines will be replaced with a `...` marker. You can customize the placeholder text by passing it as an argument: `[tl! hide("--- snip ---")]`. + +The first line of a hidden range receives the `line-elided` class, and remaining hidden lines receive the `line-hidden` class. The code block gets a `has-hidden-lines` class. + +Unlike `collapse`, hidden lines cannot be expanded by the user -- they are fully removed from the output. + ### Diffs To demonstrate the addition and removal of lines, you can use the `add` and `remove` keywords. @@ -1275,7 +1406,7 @@ If you'd like to show the `+`/`-` indicators, you can do so by turning them on a For these examples we'll do it at the block level so we can see how it works. -Let's change the behavior by sending `diffIndicators: true` to the API. +Let's change the behavior by passing `diffIndicators: true`. ```php // torchlight! {"diffIndicators": true} @@ -1394,6 +1525,19 @@ return [ ![Preserving Diff Syntax Colors](./.art/readme/example_diff_preserve_colors.png) +#### Word Diff + +For more granular diffs, you can use the `word-diff` annotation (shorthand `wd`) to show _word-level_ differences between two consecutive lines. Place the annotation on the second line, and Torchlight will compute the differences against the line above it: + +```php +$greeting = "Hello, World!"; +$greeting = "Hello, PHP!"; // [tl! wd] +``` + +The annotated line is removed from the output, and the differences are rendered inline on the previous line using `` and `` elements. A `has-word-diff` class is added to the code block, and the modified line receives a `line-word-diff` class. + +You can use multiple `wd` annotations in a single block, each comparing against its preceding line. + ### Classes and IDs You can add your own custom classes by preceding them with a `.`, or add an ID with a `#`. @@ -1442,8 +1586,6 @@ return [ ] ``` -![Example of Pulse Class](./.art/readme//classes_pulse.gif) - Check out the [range docs](#ranges) for more details, but here is a quick cheat sheet. ```text @@ -1503,6 +1645,28 @@ For example, the range `.inner-highlight:c26,34` instructs Torchlight to wrap th > [!NOTE] > You will need to add the desired CSS to style your character range classes. +### Linking + +The `link` annotation makes lines or character ranges clickable. Pass the URL as an argument: + +```php +// Learn more about Torchlight: [tl! link("https://torchlight.dev")] +TorchlightExtension::class, +``` + +This wraps the line content in an `` tag and adds a `has-links` class to the code block. + +For inline links on specific text, use a character range: + +```php +TorchlightExtension::class, // [tl! link("https://torchlight.dev"):c0,26] +``` + +This adds a `tl-link` class and `data-href` attribute to the character range, which you can style with CSS. + +> [!NOTE] +> For automatically linking URLs that appear in your code, see [Auto-linking URLs](#auto-linking-urls) instead. + ### Auto-linking URLs Sometimes your code contains URLs to other supporting documentation. It's a nice experience for the reader if those URLs were actually links instead of having to copy-paste them. @@ -1531,7 +1695,7 @@ The resulting link will look like this (color will change depending on your them Torchlight adds a `torchlight-link` class, and `rel` + `target` attributes. -The `rel=noopener` attribute ensures that no a malicious website doesn't have access to the `window.opener` property. Although this is less of a concern now with modern browsers, we still want you to be covered. +The `rel=noopener` attribute ensures that a malicious website doesn't have access to the `window.opener` property. Although this is less of a concern now with modern browsers, we still want you to be covered. Read more about `rel=noopener` at [mathiasbynens.github.io/rel-noopener](https://mathiasbynens.github.io/rel-noopener/). @@ -1669,7 +1833,7 @@ return [ #### Reindexing with Range Modifiers -The `reindex` annotation _does_ work with the [annotation range modifiers](ranges), so you can do some pretty wacky stuff. +The `reindex` annotation _does_ work with the [annotation range modifiers](#ranges), so you can do some pretty wacky stuff. If you wanted to reach down several lines and apply a reindex, you totally could! @@ -1804,9 +1968,17 @@ Each of these is covered in detail on its own page, but here is an overview of e * [lineNumbers](#line-numbers) - turn line numbers on or off * [lineNumbersStart](#changing-the-starting-line-number) - the number of the first line * [lineNumbersStyle](#changing-line-number-styles) - the CSS style to apply to line numbers +* [lineNumberAndDiffIndicatorRightPadding](#adding-line-number-right-padding) - padding to the right of line numbers/indicators * [diffIndicators](#removing-diff-indicators) - turn on diff indicators (`+`/`-`) * [diffIndicatorsInPlaceOfLineNumbers](#standalone-diff-indicators) - use the line number location for diff indicators +* [diffPreserveSyntaxColors](#preserving-syntax-colors) - keep syntax colors on diff lines * [summaryCollapsedIndicator](#summary-indicator) - the text to show when a range is collapsed +* [classes](#adding-extra-classes-to-the-torchlight-code-element) - extra CSS classes on the code element +* [copyable](#the-copyable-option) - add a hidden element for copy-to-clipboard +* [indentGuides](#indent-guides) - show indent guide lines (`"html"`, `"ascii"`, or `false`) +* [indentGuidesTabWidth](#indent-guides) - control the tab width used for indent guide calculation +* [columnGuides](#column-guides) - show vertical column guides at specific positions +* [withGutter](#gutter-visibility) - show or hide the gutter area * [torchlightAnnotations](#disabling-torchlight-annotations) - disable Torchlight annotation processing altogether. ### Setting Default Options Globally @@ -1901,7 +2073,9 @@ Some blocks you'll want to set options individually. Any options you set on the To set block level options, the _first_ line of your block must be a comment, in the language of the block. -The comment _must_ begin with `torchlight!` and be followed valid JSON. +The comment _must_ begin with `torchlight!` and be followed by valid JSON. + +Line-based annotations may also be configured directly through block or global options. The following option keys map to their matching annotations: `highlightLines`, `addLines`, `removeLines`, `focusLines`, `autolinkLines`, `monoLines`, and `hideLines`. Here is an example turning line numbers off for a single block: @@ -1924,7 +2098,7 @@ Any option that you can set at the global level, you can set at the block level. ### Line Numbers -Torchlight add line numbers by default, but you can disable them globally or on the block level by changing the `lineNumbers` option to false. +Torchlight adds line numbers by default, but you can disable them globally or on the block level by changing the `lineNumbers` option to false. Here's an example of the block level change: @@ -2118,6 +2292,152 @@ class AppServiceProvider extends ServiceProvider } ``` +### Indent Guides + +Torchlight can render vertical indent guide lines to help visualize code structure. Enable them with the `indentGuides` option: + +```text +// torchlight! {"indentGuides": "html"} +return [ + 'extensions' => [ + AttributesExtension::class, + + TorchlightExtension::class, + ] +] +``` + +The `indentGuides` option accepts: + +* `"html"` - render stylable guide spans inside the highlighted output +* `"ascii"` - render ASCII guide characters directly in the code output +* `false` - disable indent guides (default) + +You can also set the tab width used for guide calculation with `indentGuidesTabWidth`. If not set, Torchlight will auto-detect it from the code. + +```text +// torchlight! {"indentGuides": "html", "indentGuidesTabWidth": 4} +``` + +When using `indentGuides: "html"`, indent guides require CSS for styling. The following snippet provides an example of how you might style indent guides: + +```css +.has-indent-guides .tl-guide { + display: inline-block; + line-height: inherit; + border-right: 1px solid transparent; +} + +.has-codelens .codelens .tl-guide { + display: inline-block; + line-height: inherit; +} + +.has-indent-guides .tl-guide-d1 { + background-color: rgba(255, 128, 128, 0.06); + border-right-color: rgba(255, 128, 128, 0.28); +} + +.has-indent-guides .tl-guide-d2 { + background-color: rgba(255, 185, 100, 0.06); + border-right-color: rgba(255, 185, 100, 0.28); +} + +.has-indent-guides .tl-guide-d3 { + background-color: rgba(255, 230, 90, 0.06); + border-right-color: rgba(255, 230, 90, 0.28); +} + +.has-indent-guides .tl-guide-d4 { + background-color: rgba(120, 210, 120, 0.06); + border-right-color: rgba(120, 210, 120, 0.28); +} + +.has-indent-guides .tl-guide-d5 { + background-color: rgba(100, 200, 220, 0.06); + border-right-color: rgba(100, 200, 220, 0.28); +} + +.has-indent-guides .tl-guide-d6 { + background-color: rgba(130, 150, 255, 0.06); + border-right-color: rgba(130, 150, 255, 0.28); +} + +.has-indent-guides .tl-guide-d7 { + background-color: rgba(180, 130, 255, 0.06); + border-right-color: rgba(180, 130, 255, 0.28); +} + +.has-indent-guides .tl-guide-d8 { + background-color: rgba(240, 130, 220, 0.06); + border-right-color: rgba(240, 130, 220, 0.28); +} + +.has-indent-guides .tl-guide-d9 { background-color: rgba(255, 128, 128, 0.06); border-right-color: rgba(255, 128, 128, 0.28); } +.has-indent-guides .tl-guide-d10 { background-color: rgba(255, 185, 100, 0.06); border-right-color: rgba(255, 185, 100, 0.28); } +.has-indent-guides .tl-guide-d11 { background-color: rgba(255, 230, 90, 0.06); border-right-color: rgba(255, 230, 90, 0.28); } +.has-indent-guides .tl-guide-d12 { background-color: rgba(120, 210, 120, 0.06); border-right-color: rgba(120, 210, 120, 0.28); } + +``` + +### Column Guides + +Column guides render vertical guide markers at specific column positions, useful for showing line length limits: + +```text +// torchlight! {"columnGuides": [80]} +``` + +You can specify multiple column positions: + +```text +// torchlight! {"columnGuides": [80, 120]} +``` + +For each column guide, Torchlight injects a `` element into each line and adds `torchlight-colguide-{col}` classes to line elements. The block receives a `has-column-guides` class and a `--tl-colguide-max` CSS variable set to the largest column value. + +You may style these column guides with CSS: + +```css +.has-column-guides .line { + position: relative; + min-width: calc(var(--tl-colguide-max) * 1ch); +} + +.torchlight-colguide { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--col) * 1ch); + width: 1px; + background: rgba(68, 68, 68, 0.25); + pointer-events: none; +} +``` + +```text +// torchlight! {"columnGuides": [40, 65, 80]} +return [ + 'extensions' => [ + // Add attributes straight from markdown. + AttributesExtension::class, + + // Add Torchlight syntax highlighting. + TorchlightExtension::class, // [tl! ++] + ] +] +``` + +![Column Guides](./.art/readme/example_colguides.png) + +### Gutter Visibility + +Set `withGutter` to `false` to remove the entire gutter area, including line numbers, diff markers, collapse indicators, and any custom gutter content: + +```text +// torchlight! {"withGutter": false} +``` + ### Disabling Torchlight Annotations If for whatever reason you want to disable _all_ of the Torchlight annotations, you may do so with the `torchlightAnnotations` option. @@ -2139,6 +2459,158 @@ return [ ![Disabling Torchlight Annotations](./.art/readme/example_disabling_annotations.png) +## Extensibility + +Torchlight Engine provides several extension points for customizing behavior without modifying the core. + +### Custom Annotations + +You can register custom annotations using closures: + +```php +registerAnnotation('important', function (AnnotationContext $ctx) { + $ctx->addBlockClass('has-important-lines') + ->addLineClass('line-important'); +}); +``` + +The `AnnotationContext` provides helpers for modifying lines and character ranges: + +* `addBlockClass(string $class)` - add a class to the code block +* `addLineClass(array|string $class)` - add a class to the annotated line(s) +* `addLineAttribute(string $name, string $value)` - add an HTML attribute to the line +* `addAttributesToCharacterRange(array $attributes)` - add attributes to a character range +* `addClassToCharacterRange(string $class)` - add a class to a character range +* `getMethodArgs()` - get the annotation's method arguments (e.g., the value in `[tl! name("value")]`) +* `getOptions()` - get the annotation's options +* `isCharacterRange()` - check if this is a character range annotation +* `getLineText(int $line)` - get the text content of a line +* `getStartLine()` / `getEndLine()` - get the annotation's line range + +To support character ranges, pass `true` as the third argument: + +```php +$engine->registerAnnotation('badge', function (AnnotationContext $ctx) { + if ($ctx->isCharacterRange()) { + $ctx->addClassToCharacterRange('badge'); + } else { + $ctx->addLineClass('line-badge'); + } +}, charRanges: true); +``` + +### Annotation Macros + +Macros compose existing annotations under a single name: + +```php +$engine->registerAnnotationMacro('important', ['highlight', '.important-line']); +``` + +Now `[tl! important]` will apply both `highlight` and the `.important-line` class. + +### Replacers + +Replacers post-process the final HTML output. Use them to redact secrets or transform content: + +```php +// String replacement +$engine->addReplacer('sk_live_abc123', 'YOUR_API_KEY'); + +// Callable replacement +$engine->addReplacer(function (string $html) { + return str_replace('old-class', 'new-class', $html); +}); +``` + +### Token Transformers + +Token transformers modify the token output for specific grammars before rendering. Implement the `TokenTransformer` interface: + +```php +registerTokenTransformerFactory(function () { + return new class implements TokenTransformer { + public function transform(RenderContext $context, array $tokens): array + { + // Modify tokens here + return $tokens; + } + + public function supports(string $grammarName): bool + { + return $grammarName === 'my-grammar'; + } + }; +}); +``` + +### Block Decorators + +Block decorators add content after the main code block. Implement the `BlockDecorator` interface: + +```php +registerBlockDecoratorFactory(function () { + return new class implements BlockDecorator { + public function shouldRender(RenderContext $context): bool + { + return true; + } + + public function render(RenderContext $context, string $cleanedText): string + { + return ''; + } + + public function getPriority(): int + { + return 100; + } + }; +}); +``` + +### Upgrading Custom Preprocessors + +If you implement `Torchlight\Engine\Contracts\Preprocessor`, add a `supports()` method when upgrading: + +```php +use Torchlight\Engine\Contracts\Preprocessor; +use Torchlight\Engine\Engine; +use Torchlight\Engine\Preprocessors\PreprocessorArgs; + +class MyPreprocessor implements Preprocessor +{ + public function process(PreprocessorArgs $args, Engine $engine): array + { + return $args->tokens; + } + + public function supports(?string $grammarName): bool + { + return true; + } +} +``` + +If your preprocessor should only run for specific languages, return `true` only for those grammar names. Closure-based preprocessors are unchanged. + ## Reporting Issues When reporting issues, please include _all_ of the following information: @@ -2148,7 +2620,7 @@ When reporting issues, please include _all_ of the following information: * Phiki version * Minimum input text required to reproduce the issue -If you know an issue is related to [Phiki](https://github.com/phikiphp/phiki) and _not_ the Torchlight renderer, please create an issue [here](https://github.com/phikiphp/phiki/issues). If you are not sure, feel free to create an issue in this repository and it will eventually end up in the right place 🙂 +If you know an issue is related to [Phiki](https://github.com/phikiphp/phiki) and _not_ the Torchlight renderer, please create an issue [here](https://github.com/phikiphp/phiki/issues). If you are not sure, feel free to create an issue in this repository and it will eventually end up in the right place. Some issues may be difficult to resolve and take time to implement. Everyone involved thanks you in advance for your patience. diff --git a/composer.json b/composer.json index 412040a..28121f1 100644 --- a/composer.json +++ b/composer.json @@ -19,14 +19,16 @@ ], "require": { "php": "^8.2", - "phiki/phiki": "^1.1.4", + "phiki/phiki": "^2", "league/commonmark": "^2.5.3" }, "require-dev": { "pestphp/pest": "^2", "laravel/pint": "^1.13", "ext-libxml": "*", - "ext-dom": "*" + "ext-dom": "*", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.1" }, "autoload": { "psr-4": { @@ -38,6 +40,9 @@ "Torchlight\\Engine\\Tests\\": "tests" } }, + "scripts": { + "review-tests": "php -S localhost:8000 -t tests/review" + }, "config": { "allow-plugins": { "pestphp/pest-plugin": true diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..a6cece8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 10 + paths: + - src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..2491847 --- /dev/null +++ b/rector.php @@ -0,0 +1,15 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withPhpSets() + ->withPreparedSets(deadCode: true) + ->withTypeCoverageLevel(0) + ->withCodeQualityLevel(0); diff --git a/resources/languages/html.tmLanguage.json b/resources/languages/html.tmLanguage.json deleted file mode 100644 index 01c8c07..0000000 --- a/resources/languages/html.tmLanguage.json +++ /dev/null @@ -1,2592 +0,0 @@ -{ - "displayName": "HTML", - "injections": { - "R:text.html - (comment.block, text.html meta.embedded, meta.tag.*.*.html, meta.tag.*.*.*.html, meta.tag.*.*.*.*.html)": { - "comment": "Uses R: to ensure this matches after any other injections.", - "patterns": [ - { - "match": "<", - "name": "invalid.illegal.bad-angle-bracket.html" - } - ] - } - }, - "name": "html", - "patterns": [ - { - "include": "#xml-processing" - }, - { - "include": "#comment" - }, - { - "include": "#doctype" - }, - { - "include": "#cdata" - }, - { - "include": "#tags-valid" - }, - { - "include": "#tags-invalid" - }, - { - "include": "#entities" - } - ], - "repository": { - "attribute": { - "patterns": [ - { - "begin": "(s(hape|cope|t(ep|art)|ize(s)?|p(ellcheck|an)|elected|lot|andbox|rc(set|doc|lang)?)|h(ttp-equiv|i(dden|gh)|e(ight|aders)|ref(lang)?)|n(o(nce|validate|module)|ame)|c(h(ecked|arset)|ite|o(nt(ent(editable)?|rols)|ords|l(s(pan)?|or))|lass|rossorigin)|t(ype(mustmatch)?|itle|a(rget|bindex)|ranslate)|i(s(map)?|n(tegrity|putmode)|tem(scope|type|id|prop|ref)|d)|op(timum|en)|d(i(sabled|r(name)?)|ownload|e(coding|f(er|ault))|at(etime|a)|raggable)|usemap|p(ing|oster|la(ysinline|ceholder)|attern|reload)|enctype|value|kind|for(m(novalidate|target|enctype|action|method)?)?|w(idth|rap)|l(ist|o(op|w)|a(ng|bel))|a(s(ync)?|c(ce(sskey|pt(-charset)?)|tion)|uto(c(omplete|apitalize)|play|focus)|l(t|low(usermedia|paymentrequest|fullscreen))|bbr)|r(ows(pan)?|e(versed|quired|ferrerpolicy|l|adonly))|m(in(length)?|u(ted|ltiple)|e(thod|dia)|a(nifest|x(length)?)))(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "HTML5 attributes, not event handlers", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "begin": "style(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "HTML5 style attribute", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.style.html", - "patterns": [ - { - "begin": "=", - "beginCaptures": { - "0": { - "name": "punctuation.separator.key-value.html" - } - }, - "end": "(?<=[^\\s=])(?!\\s*=)|(?=/?>)", - "patterns": [ - { - "begin": "(?=[^\\s=<>`/]|/(?!>))", - "end": "(?!\\G)", - "name": "meta.embedded.line.css", - "patterns": [ - { - "captures": { - "0": { - "name": "source.css" - } - }, - "match": "([^\\s\"'=<>`/]|/(?!>))+", - "name": "string.unquoted.html" - }, - { - "begin": "\"", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "contentName": "source.css", - "end": "(\")", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - }, - "1": { - "name": "source.css" - } - }, - "name": "string.quoted.double.html", - "patterns": [ - { - "include": "#entities" - } - ] - }, - { - "begin": "'", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "contentName": "source.css", - "end": "(')", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - }, - "1": { - "name": "source.css" - } - }, - "name": "string.quoted.single.html", - "patterns": [ - { - "include": "#entities" - } - ] - } - ] - }, - { - "match": "=", - "name": "invalid.illegal.unexpected-equals-sign.html" - } - ] - } - ] - }, - { - "begin": "on(s(croll|t(orage|alled)|u(spend|bmit)|e(curitypolicyviolation|ek(ing|ed)|lect))|hashchange|c(hange|o(ntextmenu|py)|u(t|echange)|l(ick|ose)|an(cel|play(through)?))|t(imeupdate|oggle)|in(put|valid)|o(nline|ffline)|d(urationchange|r(op|ag(start|over|e(n(ter|d)|xit)|leave)?)|blclick)|un(handledrejection|load)|p(opstate|lay(ing)?|a(ste|use|ge(show|hide))|rogress)|e(nded|rror|mptied)|volumechange|key(down|up|press)|focus|w(heel|aiting)|l(oad(start|e(nd|d(data|metadata)))?|anguagechange)|a(uxclick|fterprint|bort)|r(e(s(ize|et)|jectionhandled)|atechange)|m(ouse(o(ut|ver)|down|up|enter|leave|move)|essage(error)?)|b(efore(unload|print)|lur))(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "HTML5 attributes, event handlers", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.event-handler.$1.html", - "patterns": [ - { - "begin": "=", - "beginCaptures": { - "0": { - "name": "punctuation.separator.key-value.html" - } - }, - "end": "(?<=[^\\s=])(?!\\s*=)|(?=/?>)", - "patterns": [ - { - "begin": "(?=[^\\s=<>`/]|/(?!>))", - "end": "(?!\\G)", - "name": "meta.embedded.line.js", - "patterns": [ - { - "captures": { - "0": { - "name": "source.js" - }, - "1": { - "patterns": [ - { - "include": "source.js" - } - ] - } - }, - "match": "(([^\\s\"'=<>`/]|/(?!>))+)", - "name": "string.unquoted.html" - }, - { - "begin": "\"", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "contentName": "source.js", - "end": "(\")", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - }, - "1": { - "name": "source.js" - } - }, - "name": "string.quoted.double.html", - "patterns": [ - { - "captures": { - "0": { - "patterns": [ - { - "include": "source.js" - } - ] - } - }, - "match": "([^\\n\"/]|/(?![/*]))+" - }, - { - "begin": "//", - "beginCaptures": { - "0": { - "name": "punctuation.definition.comment.js" - } - }, - "end": "(?=\")|\\n", - "name": "comment.line.double-slash.js" - }, - { - "begin": "/\\*", - "beginCaptures": { - "0": { - "name": "punctuation.definition.comment.begin.js" - } - }, - "end": "(?=\")|\\*/", - "endCaptures": { - "0": { - "name": "punctuation.definition.comment.end.js" - } - }, - "name": "comment.block.js" - } - ] - }, - { - "begin": "'", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "contentName": "source.js", - "end": "(')", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - }, - "1": { - "name": "source.js" - } - }, - "name": "string.quoted.single.html", - "patterns": [ - { - "captures": { - "0": { - "patterns": [ - { - "include": "source.js" - } - ] - } - }, - "match": "([^\\n'/]|/(?![/*]))+" - }, - { - "begin": "//", - "beginCaptures": { - "0": { - "name": "punctuation.definition.comment.js" - } - }, - "end": "(?=')|\\n", - "name": "comment.line.double-slash.js" - }, - { - "begin": "/\\*", - "beginCaptures": { - "0": { - "name": "punctuation.definition.comment.begin.js" - } - }, - "end": "(?=')|\\*/", - "endCaptures": { - "0": { - "name": "punctuation.definition.comment.end.js" - } - }, - "name": "comment.block.js" - } - ] - } - ] - }, - { - "match": "=", - "name": "invalid.illegal.unexpected-equals-sign.html" - } - ] - } - ] - }, - { - "begin": "(data-[a-z\\-]+)(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "HTML5 attributes, data-*", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.data-x.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "begin": "(align|bgcolor|border)(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "invalid.deprecated.entity.other.attribute-name.html" - } - }, - "comment": "HTML attributes, deprecated", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "begin": "([^\\x{0020}\"'<>/=\\x{0000}-\\x{001F}\\x{007F}-\\x{009F}\\x{FDD0}-\\x{FDEF}\\x{FFFE}\\x{FFFF}\\x{1FFFE}\\x{1FFFF}\\x{2FFFE}\\x{2FFFF}\\x{3FFFE}\\x{3FFFF}\\x{4FFFE}\\x{4FFFF}\\x{5FFFE}\\x{5FFFF}\\x{6FFFE}\\x{6FFFF}\\x{7FFFE}\\x{7FFFF}\\x{8FFFE}\\x{8FFFF}\\x{9FFFE}\\x{9FFFF}\\x{AFFFE}\\x{AFFFF}\\x{BFFFE}\\x{BFFFF}\\x{CFFFE}\\x{CFFFF}\\x{DFFFE}\\x{DFFFF}\\x{EFFFE}\\x{EFFFF}\\x{FFFFE}\\x{FFFFF}\\x{10FFFE}\\x{10FFFF}]+)", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "Anything else that is valid", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.unrecognized.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "match": "[^\\s>]+", - "name": "invalid.illegal.character-not-allowed-here.html" - } - ] - }, - "attribute-interior": { - "patterns": [ - { - "begin": "=", - "beginCaptures": { - "0": { - "name": "punctuation.separator.key-value.html" - } - }, - "end": "(?<=[^\\s=])(?!\\s*=)|(?=/?>)", - "patterns": [ - { - "match": "([^\\s\"'=<>`/]|/(?!>))+", - "name": "string.unquoted.html" - }, - { - "begin": "\"", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "end": "\"", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - } - }, - "name": "string.quoted.double.html", - "patterns": [ - { - "include": "#entities" - } - ] - }, - { - "begin": "'", - "beginCaptures": { - "0": { - "name": "punctuation.definition.string.begin.html" - } - }, - "end": "'", - "endCaptures": { - "0": { - "name": "punctuation.definition.string.end.html" - } - }, - "name": "string.quoted.single.html", - "patterns": [ - { - "include": "#entities" - } - ] - }, - { - "match": "=", - "name": "invalid.illegal.unexpected-equals-sign.html" - } - ] - } - ] - }, - "cdata": { - "begin": "", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.cdata.html" - }, - "comment": { - "begin": "", - "name": "comment.block.html", - "patterns": [ - { - "match": "\\G-?>", - "name": "invalid.illegal.characters-not-allowed-here.html" - }, - { - "match": ")", - "name": "invalid.illegal.characters-not-allowed-here.html" - }, - { - "match": "--!>", - "name": "invalid.illegal.characters-not-allowed-here.html" - } - ] - }, - "core-minus-invalid": { - "comment": "This should be the root pattern array includes minus #tags-invalid", - "patterns": [ - { - "include": "#xml-processing" - }, - { - "include": "#comment" - }, - { - "include": "#doctype" - }, - { - "include": "#cdata" - }, - { - "include": "#tags-valid" - }, - { - "include": "#entities" - } - ] - }, - "doctype": { - "begin": "", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.doctype.html", - "patterns": [ - { - "match": "\\G(?i:DOCTYPE)", - "name": "entity.name.tag.html" - }, - { - "begin": "\"", - "end": "\"", - "name": "string.quoted.double.html" - }, - { - "match": "[^\\s>]+", - "name": "entity.other.attribute-name.html" - } - ] - }, - "entities": { - "patterns": [ - { - "captures": { - "1": { - "name": "punctuation.definition.entity.html" - }, - "912": { - "name": "punctuation.definition.entity.html" - } - }, - "comment": "Yes this is a bit ridiculous, there are quite a lot of these", - "match": "(&)(?=[a-zA-Z])((a(s(ymp(eq)?|cr|t)|n(d(slope|d|v|and)?|g(s(t|ph)|zarr|e|le|rt(vb(d)?)?|msd(a(h|c|d|e|f|a|g|b))?)?)|c(y|irc|d|ute|E)?|tilde|o(pf|gon)|uml|p(id|os|prox(eq)?|e|E|acir)?|elig|f(r)?|w(conint|int)|l(pha|e(ph|fsym))|acute|ring|grave|m(p|a(cr|lg))|breve)|A(s(sign|cr)|nd|MP|c(y|irc)|tilde|o(pf|gon)|uml|pplyFunction|fr|Elig|lpha|acute|ring|grave|macr|breve))|(B(scr|cy|opf|umpeq|e(cause|ta|rnoullis)|fr|a(ckslash|r(v|wed))|reve)|b(s(cr|im(e)?|ol(hsub|b)?|emi)|n(ot|e(quiv)?)|c(y|ong)|ig(s(tar|qcup)|c(irc|up|ap)|triangle(down|up)|o(times|dot|plus)|uplus|vee|wedge)|o(t(tom)?|pf|wtie|x(h(d|u|D|U)?|times|H(d|u|D|U)?|d(R|l|r|L)|u(R|l|r|L)|plus|D(R|l|r|L)|v(R|h|H|l|r|L)?|U(R|l|r|L)|V(R|h|H|l|r|L)?|minus|box))|Not|dquo|u(ll(et)?|mp(e(q)?|E)?)|prime|e(caus(e)?|t(h|ween|a)|psi|rnou|mptyv)|karow|fr|l(ock|k(1(2|4)|34)|a(nk|ck(square|triangle(down|left|right)?|lozenge)))|a(ck(sim(eq)?|cong|prime|epsilon)|r(vee|wed(ge)?))|r(eve|vbar)|brk(tbrk)?))|(c(s(cr|u(p(e)?|b(e)?))|h(cy|i|eck(mark)?)|ylcty|c(irc|ups(sm)?|edil|a(ps|ron))|tdot|ir(scir|c(eq|le(d(R|circ|S|dash|ast)|arrow(left|right)))?|e|fnint|E|mid)?|o(n(int|g(dot)?)|p(y(sr)?|f|rod)|lon(e(q)?)?|m(p(fn|le(xes|ment))?|ma(t)?))|dot|u(darr(l|r)|p(s|c(up|ap)|or|dot|brcap)?|e(sc|pr)|vee|wed|larr(p)?|r(vearrow(left|right)|ly(eq(succ|prec)|vee|wedge)|arr(m)?|ren))|e(nt(erdot)?|dil|mptyv)|fr|w(conint|int)|lubs(uit)?|a(cute|p(s|c(up|ap)|dot|and|brcup)?|r(on|et))|r(oss|arr))|C(scr|hi|c(irc|onint|edil|aron)|ircle(Minus|Times|Dot|Plus)|Hcy|o(n(tourIntegral|int|gruent)|unterClockwiseContourIntegral|p(f|roduct)|lon(e)?)|dot|up(Cap)?|OPY|e(nterDot|dilla)|fr|lo(seCurly(DoubleQuote|Quote)|ckwiseContourIntegral)|a(yleys|cute|p(italDifferentialD)?)|ross))|(d(s(c(y|r)|trok|ol)|har(l|r)|c(y|aron)|t(dot|ri(f)?)|i(sin|e|v(ide(ontimes)?|onx)?|am(s|ond(suit)?)?|gamma)|Har|z(cy|igrarr)|o(t(square|plus|eq(dot)?|minus)?|ublebarwedge|pf|wn(harpoon(left|right)|downarrows|arrow)|llar)|d(otseq|a(rr|gger))?|u(har|arr)|jcy|e(lta|g|mptyv)|f(isht|r)|wangle|lc(orn|rop)|a(sh(v)?|leth|rr|gger)|r(c(orn|rop)|bkarow)|b(karow|lac)|Arr)|D(s(cr|trok)|c(y|aron)|Scy|i(fferentialD|a(critical(Grave|Tilde|Do(t|ubleAcute)|Acute)|mond))|o(t(Dot|Equal)?|uble(Right(Tee|Arrow)|ContourIntegral|Do(t|wnArrow)|Up(DownArrow|Arrow)|VerticalBar|L(ong(RightArrow|Left(RightArrow|Arrow))|eft(RightArrow|Tee|Arrow)))|pf|wn(Right(TeeVector|Vector(Bar)?)|Breve|Tee(Arrow)?|arrow|Left(RightVector|TeeVector|Vector(Bar)?)|Arrow(Bar|UpArrow)?))|Zcy|el(ta)?|D(otrahd)?|Jcy|fr|a(shv|rr|gger)))|(e(s(cr|im|dot)|n(sp|g)|c(y|ir(c)?|olon|aron)|t(h|a)|o(pf|gon)|dot|u(ro|ml)|p(si(v|lon)?|lus|ar(sl)?)|e|D(ot|Dot)|q(s(im|lant(less|gtr))|c(irc|olon)|u(iv(DD)?|est|als)|vparsl)|f(Dot|r)|l(s(dot)?|inters|l)?|a(ster|cute)|r(Dot|arr)|g(s(dot)?|rave)?|x(cl|ist|p(onentiale|ectation))|m(sp(1(3|4))?|pty(set|v)?|acr))|E(s(cr|im)|c(y|irc|aron)|ta|o(pf|gon)|NG|dot|uml|TH|psilon|qu(ilibrium|al(Tilde)?)|fr|lement|acute|grave|x(ists|ponentialE)|m(pty(SmallSquare|VerySmallSquare)|acr)))|(f(scr|nof|cy|ilig|o(pf|r(k(v)?|all))|jlig|partint|emale|f(ilig|l(ig|lig)|r)|l(tns|lig|at)|allingdotseq|r(own|a(sl|c(1(2|8|3|4|5|6)|78|2(3|5)|3(8|4|5)|45|5(8|6)))))|F(scr|cy|illed(SmallSquare|VerySmallSquare)|o(uriertrf|pf|rAll)|fr))|(G(scr|c(y|irc|edil)|t|opf|dot|T|Jcy|fr|amma(d)?|reater(Greater|SlantEqual|Tilde|Equal(Less)?|FullEqual|Less)|g|breve)|g(s(cr|im(e|l)?)|n(sim|e(q(q)?)?|E|ap(prox)?)|c(y|irc)|t(c(c|ir)|dot|quest|lPar|r(sim|dot|eq(qless|less)|less|a(pprox|rr)))?|imel|opf|dot|jcy|e(s(cc|dot(o(l)?)?|l(es)?)?|q(slant|q)?|l)?|v(nE|ertneqq)|fr|E(l)?|l(j|E|a)?|a(cute|p|mma(d)?)|rave|g(g)?|breve))|(h(s(cr|trok|lash)|y(phen|bull)|circ|o(ok(leftarrow|rightarrow)|pf|arr|rbar|mtht)|e(llip|arts(uit)?|rcon)|ks(earow|warow)|fr|a(irsp|lf|r(dcy|r(cir|w)?)|milt)|bar|Arr)|H(s(cr|trok)|circ|ilbertSpace|o(pf|rizontalLine)|ump(DownHump|Equal)|fr|a(cek|t)|ARDcy))|(i(s(cr|in(s(v)?|dot|v|E)?)|n(care|t(cal|prod|e(rcal|gers)|larhk)?|odot|fin(tie)?)?|c(y|irc)?|t(ilde)?|i(nfin|i(nt|int)|ota)?|o(cy|ta|pf|gon)|u(kcy|ml)|jlig|prod|e(cy|xcl)|quest|f(f|r)|acute|grave|m(of|ped|a(cr|th|g(part|e|line))))|I(scr|n(t(e(rsection|gral))?|visible(Comma|Times))|c(y|irc)|tilde|o(ta|pf|gon)|dot|u(kcy|ml)|Ocy|Jlig|fr|Ecy|acute|grave|m(plies|a(cr|ginaryI))?))|(j(s(cr|ercy)|c(y|irc)|opf|ukcy|fr|math)|J(s(cr|ercy)|c(y|irc)|opf|ukcy|fr))|(k(scr|hcy|c(y|edil)|opf|jcy|fr|appa(v)?|green)|K(scr|c(y|edil)|Hcy|opf|Jcy|fr|appa))|(l(s(h|cr|trok|im(e|g)?|q(uo(r)?|b)|aquo)|h(ar(d|u(l)?)|blk)|n(sim|e(q(q)?)?|E|ap(prox)?)|c(y|ub|e(il|dil)|aron)|Barr|t(hree|c(c|ir)|imes|dot|quest|larr|r(i(e|f)?|Par))?|Har|o(ng(left(arrow|rightarrow)|rightarrow|mapsto)|times|z(enge|f)?|oparrow(left|right)|p(f|lus|ar)|w(ast|bar)|a(ng|rr)|brk)|d(sh|ca|quo(r)?|r(dhar|ushar))|ur(dshar|uhar)|jcy|par(lt)?|e(s(s(sim|dot|eq(qgtr|gtr)|approx|gtr)|cc|dot(o(r)?)?|g(es)?)?|q(slant|q)?|ft(harpoon(down|up)|threetimes|leftarrows|arrow(tail)?|right(squigarrow|harpoons|arrow(s)?))|g)?|v(nE|ertneqq)|f(isht|loor|r)|E(g)?|l(hard|corner|tri|arr)?|a(ng(d|le)?|cute|t(e(s)?|ail)?|p|emptyv|quo|rr(sim|hk|tl|pl|fs|lp|b(fs)?)?|gran|mbda)|r(har(d)?|corner|tri|arr|m)|g(E)?|m(idot|oust(ache)?)|b(arr|r(k(sl(d|u)|e)|ac(e|k))|brk)|A(tail|arr|rr))|L(s(h|cr|trok)|c(y|edil|aron)|t|o(ng(RightArrow|left(arrow|rightarrow)|rightarrow|Left(RightArrow|Arrow))|pf|wer(RightArrow|LeftArrow))|T|e(ss(Greater|SlantEqual|Tilde|EqualGreater|FullEqual|Less)|ft(Right(Vector|Arrow)|Ceiling|T(ee(Vector|Arrow)?|riangle(Bar|Equal)?)|Do(ubleBracket|wn(TeeVector|Vector(Bar)?))|Up(TeeVector|DownVector|Vector(Bar)?)|Vector(Bar)?|arrow|rightarrow|Floor|A(ngleBracket|rrow(RightArrow|Bar)?)))|Jcy|fr|l(eftarrow)?|a(ng|cute|placetrf|rr|mbda)|midot))|(M(scr|cy|inusPlus|opf|u|e(diumSpace|llintrf)|fr|ap)|m(s(cr|tpos)|ho|nplus|c(y|omma)|i(nus(d(u)?|b)?|cro|d(cir|dot|ast)?)|o(dels|pf)|dash|u(ltimap|map)?|p|easuredangle|DDot|fr|l(cp|dr)|a(cr|p(sto(down|up|left)?)?|l(t(ese)?|e)|rker)))|(n(s(hort(parallel|mid)|c(cue|e|r)?|im(e(q)?)?|u(cc(eq)?|p(set(eq(q)?)?|e|E)?|b(set(eq(q)?)?|e|E)?)|par|qsu(pe|be)|mid)|Rightarrow|h(par|arr|Arr)|G(t(v)?|g)|c(y|ong(dot)?|up|edil|a(p|ron))|t(ilde|lg|riangle(left(eq)?|right(eq)?)|gl)|i(s(d)?|v)?|o(t(ni(v(c|a|b))?|in(dot|v(c|a|b)|E)?)?|pf)|dash|u(m(sp|ero)?)?|jcy|p(olint|ar(sl|t|allel)?|r(cue|e(c(eq)?)?)?)|e(s(im|ear)|dot|quiv|ar(hk|r(ow)?)|xist(s)?|Arr)?|v(sim|infin|Harr|dash|Dash|l(t(rie)?|e|Arr)|ap|r(trie|Arr)|g(t|e))|fr|w(near|ar(hk|r(ow)?)|Arr)|V(dash|Dash)|l(sim|t(ri(e)?)?|dr|e(s(s)?|q(slant|q)?|ft(arrow|rightarrow))?|E|arr|Arr)|a(ng|cute|tur(al(s)?)?|p(id|os|prox|E)?|bla)|r(tri(e)?|ightarrow|arr(c|w)?|Arr)|g(sim|t(r)?|e(s|q(slant|q)?)?|E)|mid|L(t(v)?|eft(arrow|rightarrow)|l)|b(sp|ump(e)?))|N(scr|c(y|edil|aron)|tilde|o(nBreakingSpace|Break|t(R(ightTriangle(Bar|Equal)?|everseElement)|Greater(Greater|SlantEqual|Tilde|Equal|FullEqual|Less)?|S(u(cceeds(SlantEqual|Tilde|Equal)?|perset(Equal)?|bset(Equal)?)|quareSu(perset(Equal)?|bset(Equal)?))|Hump(DownHump|Equal)|Nested(GreaterGreater|LessLess)|C(ongruent|upCap)|Tilde(Tilde|Equal|FullEqual)?|DoubleVerticalBar|Precedes(SlantEqual|Equal)?|E(qual(Tilde)?|lement|xists)|VerticalBar|Le(ss(Greater|SlantEqual|Tilde|Equal|Less)?|ftTriangle(Bar|Equal)?))?|pf)|u|e(sted(GreaterGreater|LessLess)|wLine|gative(MediumSpace|Thi(nSpace|ckSpace)|VeryThinSpace))|Jcy|fr|acute))|(o(s(cr|ol|lash)|h(m|bar)|c(y|ir(c)?)|ti(lde|mes(as)?)|S|int|opf|d(sold|iv|ot|ash|blac)|uml|p(erp|lus|ar)|elig|vbar|f(cir|r)|l(c(ir|ross)|t|ine|arr)|a(st|cute)|r(slope|igof|or|d(er(of)?|f|m)?|v|arr)?|g(t|on|rave)|m(i(nus|cron|d)|ega|acr))|O(s(cr|lash)|c(y|irc)|ti(lde|mes)|opf|dblac|uml|penCurly(DoubleQuote|Quote)|ver(B(ar|rac(e|ket))|Parenthesis)|fr|Elig|acute|r|grave|m(icron|ega|acr)))|(p(s(cr|i)|h(i(v)?|one|mmat)|cy|i(tchfork|v)?|o(intint|und|pf)|uncsp|er(cnt|tenk|iod|p|mil)|fr|l(us(sim|cir|two|d(o|u)|e|acir|mn|b)?|an(ck(h)?|kv))|ar(s(im|l)|t|a(llel)?)?|r(sim|n(sim|E|ap)|cue|ime(s)?|o(d|p(to)?|f(surf|line|alar))|urel|e(c(sim|n(sim|eqq|approx)|curlyeq|eq|approx)?)?|E|ap)?|m)|P(s(cr|i)|hi|cy|i|o(incareplane|pf)|fr|lusMinus|artialD|r(ime|o(duct|portion(al)?)|ecedes(SlantEqual|Tilde|Equal)?)?))|(q(scr|int|opf|u(ot|est(eq)?|at(int|ernions))|prime|fr)|Q(scr|opf|UOT|fr))|(R(s(h|cr)|ho|c(y|edil|aron)|Barr|ight(Ceiling|T(ee(Vector|Arrow)?|riangle(Bar|Equal)?)|Do(ubleBracket|wn(TeeVector|Vector(Bar)?))|Up(TeeVector|DownVector|Vector(Bar)?)|Vector(Bar)?|arrow|Floor|A(ngleBracket|rrow(Bar|LeftArrow)?))|o(undImplies|pf)|uleDelayed|e(verse(UpEquilibrium|E(quilibrium|lement)))?|fr|EG|a(ng|cute|rr(tl)?)|rightarrow)|r(s(h|cr|q(uo(r)?|b)|aquo)|h(o(v)?|ar(d|u(l)?))|nmid|c(y|ub|e(il|dil)|aron)|Barr|t(hree|imes|ri(e|f|ltri)?)|i(singdotseq|ng|ght(squigarrow|harpoon(down|up)|threetimes|left(harpoons|arrows)|arrow(tail)?|rightarrows))|Har|o(times|p(f|lus|ar)|a(ng|rr)|brk)|d(sh|ca|quo(r)?|ldhar)|uluhar|p(polint|ar(gt)?)|e(ct|al(s|ine|part)?|g)|f(isht|loor|r)|l(har|arr|m)|a(ng(d|e|le)?|c(ute|e)|t(io(nals)?|ail)|dic|emptyv|quo|rr(sim|hk|c|tl|pl|fs|w|lp|ap|b(fs)?)?)|rarr|x|moust(ache)?|b(arr|r(k(sl(d|u)|e)|ac(e|k))|brk)|A(tail|arr|rr)))|(s(s(cr|tarf|etmn|mile)|h(y|c(hcy|y)|ort(parallel|mid)|arp)|c(sim|y|n(sim|E|ap)|cue|irc|polint|e(dil)?|E|a(p|ron))?|t(ar(f)?|r(ns|aight(phi|epsilon)))|i(gma(v|f)?|m(ne|dot|plus|e(q)?|l(E)?|rarr|g(E)?)?)|zlig|o(pf|ftcy|l(b(ar)?)?)|dot(e|b)?|u(ng|cc(sim|n(sim|eqq|approx)|curlyeq|eq|approx)?|p(s(im|u(p|b)|et(neq(q)?|eq(q)?)?)|hs(ol|ub)|1|n(e|E)|2|d(sub|ot)|3|plus|e(dot)?|E|larr|mult)?|m|b(s(im|u(p|b)|et(neq(q)?|eq(q)?)?)|n(e|E)|dot|plus|e(dot)?|E|rarr|mult)?)|pa(des(uit)?|r)|e(swar|ct|tm(n|inus)|ar(hk|r(ow)?)|xt|mi|Arr)|q(su(p(set(eq)?|e)?|b(set(eq)?|e)?)|c(up(s)?|ap(s)?)|u(f|ar(e|f))?)|fr(own)?|w(nwar|ar(hk|r(ow)?)|Arr)|larr|acute|rarr|m(t(e(s)?)?|i(d|le)|eparsl|a(shp|llsetminus))|bquo)|S(scr|hort(RightArrow|DownArrow|UpArrow|LeftArrow)|c(y|irc|edil|aron)?|tar|igma|H(cy|CHcy)|opf|u(c(hThat|ceeds(SlantEqual|Tilde|Equal)?)|p(set|erset(Equal)?)?|m|b(set(Equal)?)?)|OFTcy|q(uare(Su(perset(Equal)?|bset(Equal)?)|Intersection|Union)?|rt)|fr|acute|mallCircle))|(t(s(hcy|c(y|r)|trok)|h(i(nsp|ck(sim|approx))|orn|e(ta(sym|v)?|re(4|fore))|k(sim|ap))|c(y|edil|aron)|i(nt|lde|mes(d|b(ar)?)?)|o(sa|p(cir|f(ork)?|bot)?|ea)|dot|prime|elrec|fr|w(ixt|ohead(leftarrow|rightarrow))|a(u|rget)|r(i(sb|time|dot|plus|e|angle(down|q|left(eq)?|right(eq)?)?|minus)|pezium|ade)|brk)|T(s(cr|trok)|RADE|h(i(nSpace|ckSpace)|e(ta|refore))|c(y|edil|aron)|S(cy|Hcy)|ilde(Tilde|Equal|FullEqual)?|HORN|opf|fr|a(u|b)|ripleDot))|(u(scr|h(ar(l|r)|blk)|c(y|irc)|t(ilde|dot|ri(f)?)|Har|o(pf|gon)|d(har|arr|blac)|u(arr|ml)|p(si(h|lon)?|harpoon(left|right)|downarrow|uparrows|lus|arrow)|f(isht|r)|wangle|l(c(orn(er)?|rop)|tri)|a(cute|rr)|r(c(orn(er)?|rop)|tri|ing)|grave|m(l|acr)|br(cy|eve)|Arr)|U(scr|n(ion(Plus)?|der(B(ar|rac(e|ket))|Parenthesis))|c(y|irc)|tilde|o(pf|gon)|dblac|uml|p(si(lon)?|downarrow|Tee(Arrow)?|per(RightArrow|LeftArrow)|DownArrow|Equilibrium|arrow|Arrow(Bar|DownArrow)?)|fr|a(cute|rr(ocir)?)|ring|grave|macr|br(cy|eve)))|(v(s(cr|u(pn(e|E)|bn(e|E)))|nsu(p|b)|cy|Bar(v)?|zigzag|opf|dash|prop|e(e(eq|bar)?|llip|r(t|bar))|Dash|fr|ltri|a(ngrt|r(s(igma|u(psetneq(q)?|bsetneq(q)?))|nothing|t(heta|riangle(left|right))|p(hi|i|ropto)|epsilon|kappa|r(ho)?))|rtri|Arr)|V(scr|cy|opf|dash(l)?|e(e|r(yThinSpace|t(ical(Bar|Separator|Tilde|Line))?|bar))|Dash|vdash|fr|bar))|(w(scr|circ|opf|p|e(ierp|d(ge(q)?|bar))|fr|r(eath)?)|W(scr|circ|opf|edge|fr))|(X(scr|i|opf|fr)|x(s(cr|qcup)|h(arr|Arr)|nis|c(irc|up|ap)|i|o(time|dot|p(f|lus))|dtri|u(tri|plus)|vee|fr|wedge|l(arr|Arr)|r(arr|Arr)|map))|(y(scr|c(y|irc)|icy|opf|u(cy|ml)|en|fr|ac(y|ute))|Y(scr|c(y|irc)|opf|uml|Icy|Ucy|fr|acute|Acy))|(z(scr|hcy|c(y|aron)|igrarr|opf|dot|e(ta|etrf)|fr|w(nj|j)|acute)|Z(scr|c(y|aron)|Hcy|opf|dot|e(ta|roWidthSpace)|fr|acute)))(;)", - "name": "constant.character.entity.named.$2.html" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.entity.html" - }, - "3": { - "name": "punctuation.definition.entity.html" - } - }, - "match": "(&)#[0-9]+(;)", - "name": "constant.character.entity.numeric.decimal.html" - }, - { - "captures": { - "1": { - "name": "punctuation.definition.entity.html" - }, - "3": { - "name": "punctuation.definition.entity.html" - } - }, - "match": "(&)#[xX][0-9a-fA-F]+(;)", - "name": "constant.character.entity.numeric.hexadecimal.html" - }, - { - "match": "&(?=[a-zA-Z0-9]+;)", - "name": "invalid.illegal.ambiguous-ampersand.html" - } - ] - }, - "math": { - "patterns": [ - { - "begin": "(?i)(<)(math)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.structure.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()", - "endCaptures": { - "0": { - "name": "meta.tag.structure.$2.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.element.structure.$2.html", - "patterns": [ - { - "begin": "(?)\\G", - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - } - ], - "repository": { - "attribute": { - "patterns": [ - { - "begin": "(s(hift|ymmetric|cript(sizemultiplier|level|minsize)|t(ackalign|retchy)|ide|u(pscriptshift|bscriptshift)|e(parator(s)?|lection)|rc)|h(eight|ref)|n(otation|umalign)|c(haralign|olumn(spa(n|cing)|width|lines|align)|lose|rossout)|i(n(dent(shift(first|last)?|target|align(first|last)?)|fixlinebreakstyle)|d)|o(pen|verflow)|d(i(splay(style)?|r)|e(nomalign|cimalpoint|pth))|position|e(dge|qual(columns|rows))|voffset|f(orm|ence|rame(spacing)?)|width|l(space|ine(thickness|leading|break(style|multchar)?)|o(ngdivstyle|cation)|ength|quote|argeop)|a(c(cent(under)?|tiontype)|l(t(text|img(-(height|valign|width))?)|ign(mentscope)?))|r(space|ow(spa(n|cing)|lines|align)|quote)|groupalign|x(link:href|mlns)|m(in(size|labelspacing)|ovablelimits|a(th(size|color|variant|background)|xsize))|bevelled)(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "begin": "([^\\x{0020}\"'<>/=\\x{0000}-\\x{001F}\\x{007F}-\\x{009F}\\x{FDD0}-\\x{FDEF}\\x{FFFE}\\x{FFFF}\\x{1FFFE}\\x{1FFFF}\\x{2FFFE}\\x{2FFFF}\\x{3FFFE}\\x{3FFFF}\\x{4FFFE}\\x{4FFFF}\\x{5FFFE}\\x{5FFFF}\\x{6FFFE}\\x{6FFFF}\\x{7FFFE}\\x{7FFFF}\\x{8FFFE}\\x{8FFFF}\\x{9FFFE}\\x{9FFFF}\\x{AFFFE}\\x{AFFFF}\\x{BFFFE}\\x{BFFFF}\\x{CFFFE}\\x{CFFFF}\\x{DFFFE}\\x{DFFFF}\\x{EFFFE}\\x{EFFFF}\\x{FFFFE}\\x{FFFFF}\\x{10FFFE}\\x{10FFFF}]+)", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "Anything else that is valid", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.unrecognized.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "match": "[^\\s>]+", - "name": "invalid.illegal.character-not-allowed-here.html" - } - ] - }, - "tags": { - "patterns": [ - { - "include": "#comment" - }, - { - "include": "#cdata" - }, - { - "captures": { - "0": { - "name": "meta.tag.structure.math.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(annotation|annotation-xml|semantics|menclose|merror|mfenced|mfrac|mpadded|mphantom|mroot|mrow|msqrt|mstyle|mmultiscripts|mover|mprescripts|msub|msubsup|msup|munder|munderover|none|mlabeledtr|mtable|mtd|mtr|mlongdiv|mscarries|mscarry|msgroup|msline|msrow|mstack|maction)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.structure.math.$2.html" - }, - { - "begin": "(?i)(<)(annotation|annotation-xml|semantics|menclose|merror|mfenced|mfrac|mpadded|mphantom|mroot|mrow|msqrt|mstyle|mmultiscripts|mover|mprescripts|msub|msubsup|msup|munder|munderover|none|mlabeledtr|mtable|mtd|mtr|mlongdiv|mscarries|mscarry|msgroup|msline|msrow|mstack|maction)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.structure.math.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.inline.math.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(mi|mn|mo|ms|mspace|mtext|maligngroup|malignmark)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.inline.math.$2.html" - }, - { - "begin": "(?i)(<)(mi|mn|mo|ms|mspace|mtext|maligngroup|malignmark)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.inline.math.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.object.math.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(mglyph)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.object.math.$2.html" - }, - { - "begin": "(?i)(<)(mglyph)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.object.math.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.other.invalid.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.unrecognized-tag.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(([\\w:]+))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.other.invalid.html" - }, - { - "begin": "(?i)(<)((\\w[^\\s>]*))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.other.invalid.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.unrecognized-tag.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.invalid.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "include": "#tags-invalid" - } - ] - } - } - }, - "svg": { - "patterns": [ - { - "begin": "(?i)(<)(svg)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.structure.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()", - "endCaptures": { - "0": { - "name": "meta.tag.structure.$2.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.element.structure.$2.html", - "patterns": [ - { - "begin": "(?)\\G", - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - } - ], - "repository": { - "attribute": { - "patterns": [ - { - "begin": "(s(hape-rendering|ystemLanguage|cale|t(yle|itchTiles|op-(color|opacity)|dDeviation|em(h|v)|artOffset|r(i(ng|kethrough-(thickness|position))|oke(-(opacity|dash(offset|array)|width|line(cap|join)|miterlimit))?))|urfaceScale|p(e(cular(Constant|Exponent)|ed)|acing|readMethod)|eed|lope)|h(oriz-(origin-x|adv-x)|eight|anging|ref(lang)?)|y(1|2|ChannelSelector)?|n(umOctaves|ame)|c(y|o(ntentS(criptType|tyleType)|lor(-(interpolation(-filters)?|profile|rendering))?)|ursor|l(ip(-(path|rule)|PathUnits)?|ass)|a(p-height|lcMode)|x)|t(ype|o|ext(-(decoration|anchor|rendering)|Length)|a(rget(X|Y)?|b(index|leValues))|ransform)|i(n(tercept|2)?|d(eographic)?|mage-rendering)|z(oomAndPan)?|o(p(erator|acity)|ver(flow|line-(thickness|position))|ffset|r(i(ent(ation)?|gin)|der))|d(y|i(splay|visor|ffuseConstant|rection)|ominant-baseline|ur|e(scent|celerate)|x)?|u(1|n(i(code(-(range|bidi))?|ts-per-em)|derline-(thickness|position))|2)|p(ing|oint(s(At(X|Y|Z))?|er-events)|a(nose-1|t(h(Length)?|tern(ContentUnits|Transform|Units))|int-order)|r(imitiveUnits|eserveA(spectRatio|lpha)))|e(n(d|able-background)|dgeMode|levation|x(ternalResourcesRequired|ponent))|v(i(sibility|ew(Box|Target))|-(hanging|ideographic|alphabetic|mathematical)|e(ctor-effect|r(sion|t-(origin-(y|x)|adv-y)))|alues)|k(1|2|3|e(y(Splines|Times|Points)|rn(ing|el(Matrix|UnitLength)))|4)?|f(y|il(ter(Res|Units)?|l(-(opacity|rule))?)|o(nt-(s(t(yle|retch)|ize(-adjust)?)|variant|family|weight)|rmat)|lood-(color|opacity)|r(om)?|x)|w(idth(s)?|ord-spacing|riting-mode)|l(i(ghting-color|mitingConeAngle)|ocal|e(ngthAdjust|tter-spacing)|ang)|a(scent|cc(umulate|ent-height)|ttribute(Name|Type)|zimuth|dditive|utoReverse|l(ignment-baseline|phabetic|lowReorder)|rabic-form|mplitude)|r(y|otate|e(s(tart|ult)|ndering-intent|peat(Count|Dur)|quired(Extensions|Features)|f(X|Y|errerPolicy)|l)|adius|x)?|g(1|2|lyph(Ref|-(name|orientation-(horizontal|vertical)))|radient(Transform|Units))|x(1|2|ChannelSelector|-height|link:(show|href|t(ype|itle)|a(ctuate|rcrole)|role)|ml:(space|lang|base))?|m(in|ode|e(thod|dia)|a(sk(ContentUnits|Units)?|thematical|rker(Height|-(start|end|mid)|Units|Width)|x))|b(y|ias|egin|ase(Profile|line-shift|Frequency)|box))(?![\\w:-])", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "begin": "([^\\x{0020}\"'<>/=\\x{0000}-\\x{001F}\\x{007F}-\\x{009F}\\x{FDD0}-\\x{FDEF}\\x{FFFE}\\x{FFFF}\\x{1FFFE}\\x{1FFFF}\\x{2FFFE}\\x{2FFFF}\\x{3FFFE}\\x{3FFFF}\\x{4FFFE}\\x{4FFFF}\\x{5FFFE}\\x{5FFFF}\\x{6FFFE}\\x{6FFFF}\\x{7FFFE}\\x{7FFFF}\\x{8FFFE}\\x{8FFFF}\\x{9FFFE}\\x{9FFFF}\\x{AFFFE}\\x{AFFFF}\\x{BFFFE}\\x{BFFFF}\\x{CFFFE}\\x{CFFFF}\\x{DFFFE}\\x{DFFFF}\\x{EFFFE}\\x{EFFFF}\\x{FFFFE}\\x{FFFFF}\\x{10FFFE}\\x{10FFFF}]+)", - "beginCaptures": { - "0": { - "name": "entity.other.attribute-name.html" - } - }, - "comment": "Anything else that is valid", - "end": "(?=\\s*+[^=\\s])", - "name": "meta.attribute.unrecognized.$1.html", - "patterns": [ - { - "include": "#attribute-interior" - } - ] - }, - { - "match": "[^\\s>]+", - "name": "invalid.illegal.character-not-allowed-here.html" - } - ] - }, - "tags": { - "patterns": [ - { - "include": "#comment" - }, - { - "include": "#cdata" - }, - { - "captures": { - "0": { - "name": "meta.tag.metadata.svg.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(color-profile|desc|metadata|script|style|title)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.metadata.svg.$2.html" - }, - { - "begin": "(?i)(<)(color-profile|desc|metadata|script|style|title)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.metadata.svg.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.structure.svg.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(animateMotion|clipPath|defs|feComponentTransfer|feDiffuseLighting|feMerge|feSpecularLighting|filter|g|hatch|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|pattern|radialGradient|switch|text|textPath)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.structure.svg.$2.html" - }, - { - "begin": "(?i)(<)(animateMotion|clipPath|defs|feComponentTransfer|feDiffuseLighting|feMerge|feSpecularLighting|filter|g|hatch|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|pattern|radialGradient|switch|text|textPath)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.structure.svg.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.inline.svg.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(a|animate|discard|feBlend|feColorMatrix|feComposite|feConvolveMatrix|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feMergeNode|feMorphology|feOffset|fePointLight|feSpotLight|feTile|feTurbulence|hatchPath|mpath|set|solidcolor|stop|tspan)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.inline.svg.$2.html" - }, - { - "begin": "(?i)(<)(a|animate|discard|feBlend|feColorMatrix|feComposite|feConvolveMatrix|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feMergeNode|feMorphology|feOffset|fePointLight|feSpotLight|feTile|feTurbulence|hatchPath|mpath|set|solidcolor|stop|tspan)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.inline.svg.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.object.svg.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(circle|ellipse|feImage|foreignObject|image|line|path|polygon|polyline|rect|symbol|use|view)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.object.svg.$2.html" - }, - { - "begin": "(?i)(<)(a|circle|ellipse|feImage|foreignObject|image|line|path|polygon|polyline|rect|symbol|use|view)(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.object.svg.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "5": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.other.svg.$2.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)((altGlyph|altGlyphDef|altGlyphItem|animateColor|animateTransform|cursor|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|glyph|glyphRef|hkern|missing-glyph|tref|vkern))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.other.svg.$2.html" - }, - { - "begin": "(?i)(<)((altGlyph|altGlyphDef|altGlyphItem|animateColor|animateTransform|cursor|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|glyph|glyphRef|hkern|missing-glyph|tref|vkern))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.other.svg.$2.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "captures": { - "0": { - "name": "meta.tag.other.invalid.void.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.unrecognized-tag.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "match": "(?i)(<)(([\\w:]+))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(/>))", - "name": "meta.element.other.invalid.html" - }, - { - "begin": "(?i)(<)((\\w[^\\s>]*))(?=\\s|/?>)(?:(([^\"'>]|\"[^\"]*\"|'[^']*')*)(>))?", - "beginCaptures": { - "0": { - "name": "meta.tag.other.invalid.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.unrecognized-tag.html" - }, - "4": { - "patterns": [ - { - "include": "#attribute" - } - ] - }, - "6": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(?i)()|(/>)|(?=)\\G", - "end": "(?=/>)|>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.invalid.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#tags" - } - ] - }, - { - "include": "#tags-invalid" - } - ] - } - } - }, - "tags-invalid": { - "patterns": [ - { - "begin": "(]*))(?)", - "endCaptures": { - "1": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.$2.html", - "patterns": [ - { - "include": "#attribute" - } - ] - } - ] - }, - "tags-valid": { - "patterns": [ - { - "begin": "(^[ \\t]+)?(?=<(?i:style)\\b(?!-))", - "beginCaptures": { - "1": { - "name": "punctuation.whitespace.embedded.leading.html" - } - }, - "end": "(?!\\G)([ \\t]*$\\n?)?", - "endCaptures": { - "1": { - "name": "punctuation.whitespace.embedded.trailing.html" - } - }, - "patterns": [ - { - "begin": "(?i)(<)(style)(?=\\s|/?>)", - "beginCaptures": { - "0": { - "name": "meta.tag.metadata.style.start.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "(?i)((<)/)(style)\\s*(>)", - "endCaptures": { - "0": { - "name": "meta.tag.metadata.style.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "source.css-ignored-vscode" - }, - "3": { - "name": "entity.name.tag.html" - }, - "4": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.embedded.block.html", - "patterns": [ - { - "begin": "\\G", - "captures": { - "1": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "(>)", - "name": "meta.tag.metadata.style.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?!\\G)", - "end": "(?=)", - "endCaptures": { - "0": { - "name": "meta.tag.metadata.script.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.embedded.block.html", - "patterns": [ - { - "begin": "\\G", - "end": "(?=/)", - "patterns": [ - { - "begin": "(>)", - "beginCaptures": { - "0": { - "name": "meta.tag.metadata.script.start.html" - }, - "1": { - "name": "punctuation.definition.tag.end.html" - } - }, - "end": "((<))(?=/(?i:script))", - "endCaptures": { - "0": { - "name": "meta.tag.metadata.script.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "source.js-ignored-vscode" - } - }, - "patterns": [ - { - "begin": "\\G", - "end": "(?=\t\t\t\t\t\t\t\t\t\t\t# Tag without type attribute\n\t\t\t\t\t\t\t\t\t\t\t\t | type(?=[\\s=])\n\t\t\t\t\t\t\t\t\t\t\t\t \t(?!\\s*=\\s*\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t''\t\t\t\t\t\t\t\t# Empty\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t | \"\"\t\t\t\t\t\t\t\t\t# Values\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t | ('|\"|)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttext/\t\t\t\t\t\t\t# Text mime-types\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tjavascript(1\\.[0-5])?\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | x-javascript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | jscript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | livescript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | (x-)?ecmascript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | babel\t\t\t\t\t\t# Javascript variant currently\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t \t\t\t\t\t\t\t\t# recognized as such\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t \t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | application/\t\t\t\t\t# Application mime-types\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t \t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t(x-)?javascript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | (x-)?ecmascript\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t | module\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t \t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t[\\s\"'>]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t)", - "name": "meta.tag.metadata.script.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?ix:\n\t\t\t\t\t\t\t\t\t\t\t\t(?=\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype\\s*=\\s*\n\t\t\t\t\t\t\t\t\t\t\t\t\t('|\"|)\n\t\t\t\t\t\t\t\t\t\t\t\t\ttext/\n\t\t\t\t\t\t\t\t\t\t\t\t\t(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tx-handlebars\n\t\t\t\t\t\t\t\t\t\t\t\t\t | (x-(handlebars-)?|ng-)?template\n\t\t\t\t\t\t\t\t\t\t\t\t\t | html\n\t\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t\t\t[\\s\"'>]\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t)", - "end": "((<))(?=/(?i:script))", - "endCaptures": { - "0": { - "name": "meta.tag.metadata.script.end.html" - }, - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "text.html.basic" - } - }, - "patterns": [ - { - "begin": "\\G", - "end": "(>)", - "endCaptures": { - "1": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.script.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?!\\G)", - "end": "(?=)", - "endCaptures": { - "1": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.script.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?!\\G)", - "end": "(?=)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(noscript|title)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(col|hr|input)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(address|article|aside|blockquote|body|button|caption|colgroup|datalist|dd|details|dialog|div|dl|dt|fieldset|figcaption|figure|footer|form|head|header|hgroup|html|h[1-6]|label|legend|li|main|map|menu|meter|nav|ol|optgroup|option|output|p|pre|progress|section|select|slot|summary|table|tbody|td|template|textarea|tfoot|th|thead|tr|ul)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(area|br|wbr)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(a|abbr|b|bdi|bdo|cite|code|data|del|dfn|em|i|ins|kbd|mark|q|rp|rt|ruby|s|samp|small|span|strong|sub|sup|time|u|var)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(embed|img|param|source|track)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)(audio|canvas|iframe|object|picture|video)(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((basefont|isindex))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.metadata.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((center|frameset|noembed|noframes))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.structure.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((acronym|big|blink|font|strike|tt|xmp))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.inline.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((frame))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": "/?>", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.void.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((applet))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.deprecated.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.object.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)(<)((dir|keygen|listing|menuitem|plaintext|spacer))(?=\\s|/?>)", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.no-longer-supported.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.$2.start.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "begin": "(?i)()", - "beginCaptures": { - "1": { - "name": "punctuation.definition.tag.begin.html" - }, - "2": { - "name": "entity.name.tag.html" - }, - "3": { - "name": "invalid.illegal.no-longer-supported.html" - } - }, - "end": ">", - "endCaptures": { - "0": { - "name": "punctuation.definition.tag.end.html" - } - }, - "name": "meta.tag.other.$2.end.html", - "patterns": [ - { - "include": "#attribute" - } - ] - }, - { - "include": "#math" - }, - { - "include": "#svg" - } - ] - }, - "xml-processing": { - "begin": "(<\\?)(xml)", - "captures": { - "1": { - "name": "punctuation.definition.tag.html" - }, - "2": { - "name": "entity.name.tag.html" - } - }, - "end": "(\\?>)", - "name": "meta.tag.metadata.processing.xml.html", - "patterns": [ - { - "include": "#attribute" - } - ] - } - }, - "scopeName": "text.html.basic" -} diff --git a/src/Annotations/AbstractAnnotation.php b/src/Annotations/AbstractAnnotation.php index 70f31fe..1262a64 100644 --- a/src/Annotations/AbstractAnnotation.php +++ b/src/Annotations/AbstractAnnotation.php @@ -3,18 +3,24 @@ namespace Torchlight\Engine\Annotations; use Closure; +use Phiki\Highlighting\Highlighter; +use ReflectionClass; +use Torchlight\Engine\Annotations\Contracts\AnnotationDescriptor; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +use Torchlight\Engine\Annotations\Ranges\AnnotationRange; use Torchlight\Engine\Annotations\Ranges\ImpactedRange; use Torchlight\Engine\Annotations\Ranges\RangeType; -use Torchlight\Engine\Generators\Concerns\InteractsWithHtmlRenderer; +use Torchlight\Engine\Generators\Gutters\AbstractGutter; use Torchlight\Engine\Generators\Gutters\CollapseGutter; +use Torchlight\Engine\Generators\Gutters\CustomContentGutter; use Torchlight\Engine\Generators\Gutters\DiffGutter; use Torchlight\Engine\Generators\Gutters\LineNumbersGutter; +use Torchlight\Engine\Generators\ThemeStyleResolver; use Torchlight\Engine\Options; -abstract class AbstractAnnotation +abstract class AbstractAnnotation implements AnnotationDescriptor { - use InteractsWithHtmlRenderer; + protected ?ThemeStyleResolver $themeResolver = null; protected Options $options; @@ -24,10 +30,83 @@ abstract class AbstractAnnotation public static string $name = ''; + /** @var list */ public static array $aliases = []; + public static ?string $prefix = null; + + /** + * @var array + */ + private static array $attributeCache = []; + + protected static function resolveAttribute(): ?Annotation + { + $class = static::class; + + if (! array_key_exists($class, self::$attributeCache)) { + $ref = new ReflectionClass($class); + $attrs = $ref->getAttributes(Annotation::class); + self::$attributeCache[$class] = ! empty($attrs) ? $attrs[0]->newInstance() : false; + } + + $cached = self::$attributeCache[$class]; + + return $cached === false ? null : $cached; + } + + public static function getName(): string + { + $attribute = static::resolveAttribute(); + + return $attribute !== null ? $attribute->name : static::$name; + } + + /** @return list */ + public static function getAliases(): array + { + $attribute = static::resolveAttribute(); + + return $attribute !== null ? $attribute->aliases : static::$aliases; + } + + public static function getPrefix(): ?string + { + $attr = static::resolveAttribute(); + + return $attr !== null ? $attr->prefix : static::$prefix; + } + + public static function acceptsMethodArgs(): bool + { + $attribute = static::resolveAttribute(); + + return $attribute !== null ? $attribute->methodArgs : true; + } + + public static function acceptsOptions(): bool + { + $attribute = static::resolveAttribute(); + + return $attribute !== null ? $attribute->options : true; + } + + public static function supportsCharacterRanges(): bool + { + $attribute = static::resolveAttribute(); + + return $attribute !== null && $attribute->charRanges; + } + + public static function supportsLineRanges(): bool + { + $attribute = static::resolveAttribute(); + + return $attribute !== null ? $attribute->lineRanges : true; + } + public function __construct( - protected Processor $processor, + protected AnnotationEngine $annotationEngine, ) { $this->options = Options::default(); } @@ -45,6 +124,44 @@ public function setTorchlightOptions(Options $options): static return $this; } + public function setThemeResolver(ThemeStyleResolver $resolver): static + { + $this->themeResolver = $resolver; + + return $this; + } + + public function setHighlighter(Highlighter $highlighter): static + { + if ($this->themeResolver !== null) { + $this->themeResolver->setHighlighter($highlighter); + } + + return $this; + } + + protected function getLineNumberColorStyles(): string + { + return $this->themeResolver()->getThemeValueStylesString( + 'color', + ['torchlight.lineNumberColor', 'editorLineNumber.foreground'] + ); + } + + protected function getThemeStyle(string $key): string + { + if ($this->themeResolver === null) { + return ''; + } + + return $this->themeResolver()->toStyleString( + $this->themeResolver()->getStyle($key) + ); + } + + /** + * @param string|list $class + */ protected function addBlockClass(array|string $class): static { if (! is_array($class)) { @@ -52,45 +169,50 @@ protected function addBlockClass(array|string $class): static } foreach ($class as $className) { - $this->processor->addBlockClass($className); + $this->annotationEngine->addBlockClass($className); } return $this; } - private function eachLine(callable|Closure $callback): static + protected function eachLine(callable|Closure $callback): static { - for ($i = $this->range->startLine; $i <= $this->range->endLine; $i++) { + $range = $this->activeRange(); + + for ($i = $range->startLine; $i <= $range->endLine; $i++) { call_user_func_array($callback, [$i]); } return $this; } + /** + * @param string|list $scope + */ protected function addLineScope(array|string $scope): static { if (! is_array($scope)) { $scope = [$scope]; } - return $this->eachLine(function ($i) use ($scope) { - $this->processor->addScopeToLine($i, $scope); + return $this->eachLine(function (int $i) use ($scope): void { + $this->annotationEngine->addScopeToLine($i, $scope); }); } protected function markLinesHighlighted(): static { - return $this->eachLine(function ($i) { + return $this->eachLine(function (int $i): void { $this->lineNumbersGutter()->markLineHighlighted($i); }); } protected function addClassToCharacterRange(string $class): static { - $this->processor->addClassToCharacterRange( - $this->range->startLine, - intval($this->parsedAnnotation->range->start), - intval($this->parsedAnnotation->range->end), + $this->annotationEngine->addClassToCharacterRange( + $this->activeRange()->startLine, + intval($this->annotationRange()->start), + intval($this->annotationRange()->end), $class ); @@ -99,115 +221,167 @@ protected function addClassToCharacterRange(string $class): static protected function addIdToCharacterRange(string $id): static { - $this->processor->addIdToCharacterRange( - $this->range->startLine, - intval($this->parsedAnnotation->range->start), - intval($this->parsedAnnotation->range->end), + $this->annotationEngine->addIdToCharacterRange( + $this->activeRange()->startLine, + intval($this->annotationRange()->start), + intval($this->annotationRange()->end), $id ); return $this; } + /** + * @param array $attributes + */ + protected function addAttributesToCharacterRange(array $attributes): static + { + $this->annotationEngine->addAttributesToCharacterRange( + $this->activeRange()->startLine, + intval($this->annotationRange()->start), + intval($this->annotationRange()->end), + $attributes + ); + + return $this; + } + + protected function addStyledCharacterRange(string $class, string $styleKey): static + { + $styles = $this->themeResolver()->getStyle($styleKey); + $styleString = $this->themeResolver()->toStyleString($styles); + + $attributes = ['class' => $class]; + + if ($styleString !== '') { + $attributes['style'] = $styleString; + } + + return $this->addAttributesToCharacterRange($attributes); + } + + protected function removeLine(int $line): static + { + $this->annotationEngine->removeLine($line); + + return $this; + } + + /** + * @param string|list $class + */ protected function addLineClass(array|string $class): static { if (! is_array($class)) { $class = [$class]; } - return $this->eachLine(function ($i) use ($class) { + return $this->eachLine(function (int $i) use ($class): void { foreach ($class as $className) { - $this->processor->addLineClass($i, $className); + $this->annotationEngine->addLineClass($i, $className); } }); } protected function addLineAttribute(string $name, string $value): static { - return $this->eachLine(function ($i) use ($name, $value) { - $this->processor->addAttributeToLine($i, $name, $value); + return $this->eachLine(function (int $i) use ($name, $value): void { + $this->annotationEngine->addAttributeToLine($i, $name, $value); }); } + /** + * @param list $scopes + */ protected function replaceLineMarker(string $marker, array $scopes = []): static { - return $this->eachLine(function ($i) use ($marker, $scopes) { + return $this->eachLine(function (int $i) use ($marker, $scopes): void { $this->lineNumbersGutter()->replaceLineMarker($i, [$marker, $scopes]); }); } + /** + * @param list $scopes + */ protected function setLineScopes(array $scopes): static { - return $this->eachLine(function ($i) use ($scopes) { + return $this->eachLine(function (int $i) use ($scopes): void { $this->lineNumbersGutter()->setLineScopes($i, $scopes); }); } protected function setDiffLineMarker(string $marker): static { - return $this->eachLine(function ($i) use ($marker) { + return $this->eachLine(function (int $i) use ($marker): void { $this->diffGutter()->setLineMarker($i, $marker); }); } + protected function setGutterLineContent(string $content): static + { + return $this->eachLine(function (int $i) use ($content): void { + $this->customContentGutter()->setLineContent($i, $content); + }); + } + protected function prependLine(int $line, string $content): static { - $this->processor->prependLine($line, $content); + $this->annotationEngine->prependLine($line, $content); return $this; } protected function appendLine(int $line, string $content): static { - $this->processor->appendLine($line, $content); + $this->annotationEngine->appendLine($line, $content); return $this; } protected function reindexLine(int $originalLine, ?int $newLine, ?int $relativeOffset = null): static { - $this->processor->reindexLine($originalLine, $newLine, $relativeOffset); + $this->annotationEngine->reindexLine($originalLine, $newLine, $relativeOffset); return $this; } protected function forceDisplayLine(int $originalLine, int $newLine): static { - $this->processor->lineNumbersGutter()->forceLineDisplay($originalLine, $newLine); + $this->annotationEngine->lineNumbersGutter()->forceLineDisplay($originalLine, $newLine); return $this; } protected function surroundStartLine(string $prefix, string $suffix): static { - return $this->prependLine($this->range->startLine, $prefix) - ->appendLine($this->range->startLine, $suffix); + return $this->prependLine($this->activeRange()->startLine, $prefix) + ->appendLine($this->activeRange()->startLine, $suffix); } protected function surroundEndLine(string $prefix, string $suffix): static { - return $this->prependLine($this->range->endLine, $prefix) - ->appendLine($this->range->endLine, $suffix); + return $this->prependLine($this->activeRange()->endLine, $prefix) + ->appendLine($this->activeRange()->endLine, $suffix); } protected function surroundLine(int $line, string $prefix, string $suffix): static { - $this->processor->surroundLine($line, $prefix, $suffix); + $this->annotationEngine->surroundLine($line, $prefix, $suffix); return $this; } protected function surroundRange(string $prefix, string $suffix): static { - $this->processor->surroundRange($this->range, $prefix, $suffix); + $this->annotationEngine->surroundRange($this->activeRange(), $prefix, $suffix); return $this; } protected function modifyRangeTokens(callable|Closure $callback): static { - $this->eachLine(function ($line) use ($callback) { - $this->processor->modifyLineTokens($line, $callback); + $this->eachLine(function (int $line) use ($callback): void { + $this->annotationEngine->modifyLineTokens($line, $callback); }); return $this; @@ -215,7 +389,7 @@ protected function modifyRangeTokens(callable|Closure $callback): static protected function modifyRangeContents(callable|Closure $callback): static { - $this->eachLine(function ($line) use ($callback) { + $this->eachLine(function (int $line) use ($callback): void { $this->modifyLineContents($line, $callback); }); @@ -224,19 +398,19 @@ protected function modifyRangeContents(callable|Closure $callback): static public function safeReplace(string $search, string $replace, string $subject): string { - return $this->processor->safeReplace($search, $replace, $subject); + return $this->annotationEngine->safeReplace($search, $replace, $subject); } protected function modifyLineContents(int $line, callable|Closure $callback): static { - $this->processor->modifyLineContents($line, $callback); + $this->annotationEngine->modifyLineContents($line, $callback); return $this; } protected function modifyStartLineContents(callable|Closure $callback): static { - return $this->modifyLineContents($this->range->startLine, $callback); + return $this->modifyLineContents($this->activeRange()->startLine, $callback); } public function setParsedAnnotation(ParsedAnnotation $annotation): static @@ -255,29 +429,91 @@ public function setActiveRange(ImpactedRange $range): static protected function isCharacterRange(): bool { - if ($this->parsedAnnotation === null || $this->parsedAnnotation->range == null) { + if ($this->parsedAnnotation === null || $this->parsedAnnotation->range === null) { return false; } return $this->parsedAnnotation->range->type == RangeType::Character; } + protected function themeResolver(): ThemeStyleResolver + { + if ($this->themeResolver === null) { + throw new \LogicException('Theme resolver has not been configured.'); + } + + return $this->themeResolver; + } + + protected function activeRange(): ImpactedRange + { + if ($this->range === null) { + throw new \LogicException('Active annotation range has not been configured.'); + } + + return $this->range; + } + + protected function activeAnnotation(): ParsedAnnotation + { + if ($this->parsedAnnotation === null) { + throw new \LogicException('Parsed annotation has not been configured.'); + } + + return $this->parsedAnnotation; + } + + protected function annotationRange(): AnnotationRange + { + $range = $this->activeAnnotation()->range; + + if ($range === null) { + throw new \LogicException('Annotation range is required for this operation.'); + } + + return $range; + } + protected function lineNumbersGutter(): LineNumbersGutter { - return $this->processor->lineNumbersGutter(); + return $this->annotationEngine->lineNumbersGutter(); } protected function diffGutter(): DiffGutter { - return $this->processor->diffGutter(); + return $this->annotationEngine->diffGutter(); } protected function collapseGutter(): CollapseGutter { - return $this->processor->collapseGutter(); + return $this->annotationEngine->collapseGutter(); + } + + protected function customContentGutter(): CustomContentGutter + { + return $this->annotationEngine->customContentGutter(); + } + + protected function getGutter(string $name): ?AbstractGutter + { + return $this->annotationEngine->getGenerationOptions()->gutters[$name] ?? null; } - abstract public function process(ParsedAnnotation $annotation): void; + protected function onLine(ParsedAnnotation $annotation): void {} + + protected function onCharacterRange(ParsedAnnotation $annotation): void + { + $this->onLine($annotation); + } + + public function process(ParsedAnnotation $annotation): void + { + if ($this->isCharacterRange()) { + $this->onCharacterRange($annotation); + } else { + $this->onLine($annotation); + } + } public function beforeProcess(): void {} diff --git a/src/Annotations/Annotation.php b/src/Annotations/Annotation.php new file mode 100644 index 0000000..cd31748 --- /dev/null +++ b/src/Annotations/Annotation.php @@ -0,0 +1,22 @@ + $aliases + */ + public function __construct( + public string $name, + public array $aliases = [], + public ?string $prefix = null, + public bool $charRanges = false, + public bool $lineRanges = true, + public bool $methodArgs = true, + public bool $options = true, + ) {} +} diff --git a/src/Annotations/AnnotationContext.php b/src/Annotations/AnnotationContext.php new file mode 100644 index 0000000..ce5acee --- /dev/null +++ b/src/Annotations/AnnotationContext.php @@ -0,0 +1,111 @@ +annotation->methodArgs; + } + + /** @return array */ + public function getOptions(): array + { + return $this->annotation->options; + } + + public function isCharacterRange(): bool + { + if ($this->annotation->range === null) { + return false; + } + + return $this->annotation->range->type === RangeType::Character; + } + + public function addBlockClass(string $class): static + { + $this->annotationEngine->addBlockClass($class); + + return $this; + } + + /** + * @param string|list $class + */ + public function addLineClass(array|string $class): static + { + if (! is_array($class)) { + $class = [$class]; + } + + for ($i = $this->range->startLine; $i <= $this->range->endLine; $i++) { + foreach ($class as $className) { + $this->annotationEngine->addLineClass($i, $className); + } + } + + return $this; + } + + public function addLineAttribute(string $name, string $value): static + { + for ($i = $this->range->startLine; $i <= $this->range->endLine; $i++) { + $this->annotationEngine->addAttributeToLine($i, $name, $value); + } + + return $this; + } + + /** + * @param array $attributes + */ + public function addAttributesToCharacterRange(array $attributes): static + { + $range = $this->annotation->range; + + if ($range === null) { + throw new \LogicException('Character-range annotation is required.'); + } + + $this->annotationEngine->addAttributesToCharacterRange( + $this->range->startLine, + intval($range->start), + intval($range->end), + $attributes + ); + + return $this; + } + + public function addClassToCharacterRange(string $class): static + { + return $this->addAttributesToCharacterRange(['class' => $class]); + } + + public function getLineText(int $line): ?string + { + return $this->annotationEngine->getLineText($line); + } + + public function getStartLine(): int + { + return $this->range->startLine; + } + + public function getEndLine(): int + { + return $this->range->endLine; + } +} diff --git a/src/Annotations/Processor.php b/src/Annotations/AnnotationEngine.php similarity index 54% rename from src/Annotations/Processor.php rename to src/Annotations/AnnotationEngine.php index 487cb3e..3849c97 100644 --- a/src/Annotations/Processor.php +++ b/src/Annotations/AnnotationEngine.php @@ -3,49 +3,36 @@ namespace Torchlight\Engine\Annotations; use Closure; -use Phiki\Highlighter; -use Torchlight\Engine\Annotations\Attributes\CssClassAnnotation; -use Torchlight\Engine\Annotations\Attributes\IdAnnotation; -use Torchlight\Engine\Annotations\Parser\AnnotationType; +use LogicException; +use Phiki\Highlighting\Highlighter; +use Phiki\Token\Token; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; use Torchlight\Engine\Annotations\Ranges\ImpactedRange; use Torchlight\Engine\Annotations\Ranges\RangeResolver; use Torchlight\Engine\Annotations\Ranges\RangeType; -use Torchlight\Engine\Generators\Concerns\AddsScopesToTokens; use Torchlight\Engine\Generators\GenerationOptions; use Torchlight\Engine\Generators\Gutters\AbstractGutter; use Torchlight\Engine\Generators\Gutters\CollapseGutter; +use Torchlight\Engine\Generators\Gutters\CustomContentGutter; use Torchlight\Engine\Generators\Gutters\DiffGutter; use Torchlight\Engine\Generators\Gutters\LineNumbersGutter; use Torchlight\Engine\Options; use Torchlight\Engine\Support\Str; -class Processor +class AnnotationEngine { - use AddsScopesToTokens; - - const ANNOTATION_HTML_CSS_CLASS = '*html-css-class'; - - const ANNOTATION_HTML_ID_ATTRIBUTE = '*html-id-attribute'; - protected GenerationOptions $generationOptions; - /** - * @var AbstractAnnotation[] - */ - protected array $annotations = []; - - protected ?ImpactedRange $activeRange = null; - protected RangeResolver $rangeResolver; protected Options $options; + /** @var array> */ protected array $currentTokens = []; protected ?Highlighter $highlighter = null; - public function __construct() + public function __construct(protected AnnotationRegistry $registry = new AnnotationRegistry) { $this->rangeResolver = new RangeResolver; $this->generationOptions = new GenerationOptions; @@ -53,67 +40,134 @@ public function __construct() $this->addDefaultGutters(); $this->options = Options::default(); - - $this->addAnnotation(static::ANNOTATION_HTML_CSS_CLASS, new CssClassAnnotation($this)) - ->addAnnotation(static::ANNOTATION_HTML_ID_ATTRIBUTE, new IdAnnotation($this)); } public function setHighlighter(Highlighter $highlighter): static { $this->highlighter = $highlighter; - foreach ($this->generationOptions->gutters as $gutter) { - $gutter->setHighlighter($this->highlighter); - } - - foreach ($this->annotations as $annotation) { - $annotation->setHighlighter($this->highlighter); - } + $this->registry->setHighlighter($highlighter); return $this; } protected function addDefaultGutters(): void { - $this->addGutter('line-numbers', new LineNumbersGutter($this)) - ->addGutter('diff', new DiffGutter($this)) - ->addGutter('collapse', new CollapseGutter($this)); + $this->addGutter('line-numbers', (new LineNumbersGutter($this))->setPriority(100)) + ->addGutter('diff', (new DiffGutter($this))->setPriority(200)) + ->addGutter('custom-content', (new CustomContentGutter($this))->setPriority(300)) + ->addGutter('collapse', (new CollapseGutter($this))->setPriority(400)); } public function collapseGutter(): CollapseGutter { - return $this->generationOptions->gutters['collapse']; + $gutter = $this->generationOptions->gutters['collapse'] ?? null; + + if (! $gutter instanceof CollapseGutter) { + throw new LogicException('Collapse gutter is not configured.'); + } + + return $gutter; } public function lineNumbersGutter(): LineNumbersGutter { - return $this->generationOptions->gutters['line-numbers']; + $gutter = $this->generationOptions->gutters['line-numbers'] ?? null; + + if (! $gutter instanceof LineNumbersGutter) { + throw new LogicException('Line numbers gutter is not configured.'); + } + + return $gutter; } public function diffGutter(): DiffGutter { - return $this->generationOptions->gutters['diff']; + $gutter = $this->generationOptions->gutters['diff'] ?? null; + + if (! $gutter instanceof DiffGutter) { + throw new LogicException('Diff gutter is not configured.'); + } + + return $gutter; + } + + public function customContentGutter(): CustomContentGutter + { + $gutter = $this->generationOptions->gutters['custom-content'] ?? null; + + if (! $gutter instanceof CustomContentGutter) { + throw new LogicException('Custom content gutter is not configured.'); + } + + return $gutter; } public function addGutter(string $name, AbstractGutter $gutter): static { $this->generationOptions->gutters[$name] = $gutter; - if ($this->highlighter) { - $gutter->setHighlighter($this->highlighter); + if ($this->generationOptions->gutterServices !== null) { + $gutter + ->setServices($this->generationOptions->gutterServices) + ->setGenerationOptions($this->generationOptions); } return $this; } /** - * @return \Torchlight\Engine\Generators\Gutters\AbstractGutter[] + * Remove a gutter by name. + */ + public function removeGutter(string $name): static + { + unset($this->generationOptions->gutters[$name]); + + return $this; + } + + public function hasGutter(string $name): bool + { + return isset($this->generationOptions->gutters[$name]); + } + + /** + * @return AbstractGutter[] */ public function getGutters(): array { return $this->generationOptions->gutters; } + public function setGutterPriority(string $name, int $priority): static + { + if (isset($this->generationOptions->gutters[$name])) { + $this->generationOptions->gutters[$name]->setPriority($priority); + } + + return $this; + } + + public function placeGutterAfter(string $gutter, string $afterGutter): static + { + if (isset($this->generationOptions->gutters[$afterGutter])) { + $refPriority = $this->generationOptions->gutters[$afterGutter]->getPriority(); + $this->setGutterPriority($gutter, $refPriority + 1); + } + + return $this; + } + + public function placeGutterBefore(string $gutter, string $beforeGutter): static + { + if (isset($this->generationOptions->gutters[$beforeGutter])) { + $refPriority = $this->generationOptions->gutters[$beforeGutter]->getPriority(); + $this->setGutterPriority($gutter, $refPriority - 1); + } + + return $this; + } + public function reindexLine(int $originalLine, ?int $newLine, ?int $relativeOffset = null): static { $this->lineNumbersGutter()->reindexLine($originalLine, $newLine, $relativeOffset); @@ -149,7 +203,10 @@ public function addIdToCharacterRange(int $line, int $start, int $end, string $i ]); } - protected function addAttributesToCharacterRange(int $line, int $start, int $end, array $attributes): static + /** + * @param array $attributes + */ + public function addAttributesToCharacterRange(int $line, int $start, int $end, array $attributes): static { $line -= 1; @@ -176,11 +233,12 @@ public function addLineClass(int $line, string $class): static return $this; } + /** + * @param array $attributes + */ public function addAttributesToLine(int $line, array $attributes): static { - foreach ($attributes as $attribute) { - [$name, $value] = $attribute; - + foreach ($attributes as $name => $value) { $this->addAttributeToLine($line, $name, $value); } @@ -198,6 +256,9 @@ public function addAttributeToLine(int $line, string $name, string $value): stat return $this; } + /** + * @param string|list $scope + */ public function addScopeToLine(int $line, array|string $scope): static { $line -= 1; @@ -206,11 +267,37 @@ public function addScopeToLine(int $line, array|string $scope): static $scope = [$scope]; } + $scope = array_values(array_filter($scope, is_string(...))); + $this->currentTokens = $this->addScopesToTokens($line, $this->currentTokens, $scope); return $this; } + /** + * @param array> $tokens + * @param list $scopes + * @return array> + */ + private function addScopesToTokens(int $line, array $tokens, array $scopes): array + { + if (! array_key_exists($line, $tokens)) { + return $tokens; + } + + $lineTokens = $tokens[$line]; + + for ($i = 0; $i < count($lineTokens); $i++) { + foreach ($scopes as $scope) { + $lineTokens[$i]->scopes[] = $scope; + } + } + + $tokens[$line] = $lineTokens; + + return $tokens; + } + public function prependLine(int $line, string $content): static { if (! array_key_exists($line, $this->generationOptions->linePrepends)) { @@ -258,6 +345,13 @@ public function safeReplace(string $search, string $replace, string $subject): s return str_replace($search, $replacement, $subject); } + public function removeLine(int $line): static + { + $this->generationOptions->removedLines[$line] = true; + + return $this; + } + public function modifyLineContents(int $line, callable|Closure $callback): static { if (! array_key_exists($line, $this->generationOptions->lineContentCallbacks)) { @@ -280,9 +374,40 @@ public function modifyLineTokens(int $line, callable|Closure $callback): static return $this; } + /** + * Get the text content of a specific line from the current tokens. + * Line is 1-based. + */ + public function getLineText(int $line): ?string + { + $index = $line - 1; + + if (! array_key_exists($index, $this->currentTokens)) { + return null; + } + + $text = ''; + /** @var Token $token */ + foreach ($this->currentTokens[$index] as $token) { + $text .= $token->text; + } + + return $text; + } + + public function getLineCount(): int + { + return count($this->currentTokens); + } + + public function getRegistry(): AnnotationRegistry + { + return $this->registry; + } + public function addAnnotation(string $name, AbstractAnnotation $annotation): static { - $this->annotations[$name] = $annotation; + $this->registry->register($name, $annotation); if ($this->highlighter) { $annotation->setHighlighter($this->highlighter); @@ -296,7 +421,7 @@ public function addAnnotation(string $name, AbstractAnnotation $annotation): sta */ public function getAnnotations(): array { - return $this->annotations; + return $this->registry->all(); } public function getGenerationOptions(): GenerationOptions @@ -315,17 +440,16 @@ public function setTorchlightOptions(Options $options): static return $this; } - protected function getAnnotationName(ParsedAnnotation $annotation): string + protected function resolveAnnotationHandler(ParsedAnnotation $annotation): ?AbstractAnnotation { - if ($annotation->type == AnnotationType::ClassName) { - return static::ANNOTATION_HTML_CSS_CLASS; - } elseif ($annotation->type == AnnotationType::IdAttribute) { - return static::ANNOTATION_HTML_ID_ATTRIBUTE; - } - - return $annotation->name; + return $this->registry->resolve($annotation->name); } + /** + * @param array $parsedAnnotations + * @param array> $tokens + * @return array> + */ public function process(array $parsedAnnotations, array $tokens): array { $this->currentTokens = $tokens; @@ -339,39 +463,48 @@ public function process(array $parsedAnnotations, array $tokens): array $this->lineNumbersGutter()->setMaxLineCount($lineCount); - foreach ($this->annotations as $annotation) { + foreach ($this->registry->allIncludingPrefixHandlers() as $annotation) { $annotation->beforeProcess(); } - /** @var \Torchlight\Engine\Annotations\Parser\ParsedAnnotation $annotation */ - foreach ($parsedAnnotations as $annotation) { - if ($annotation->range && $annotation->range->type == RangeType::OpenEndedEnd) { + /** @var ParsedAnnotation $parsedAnnotation */ + foreach ($parsedAnnotations as $parsedAnnotation) { + if ($parsedAnnotation->range && $parsedAnnotation->range->type == RangeType::OpenEndedEnd) { continue; } - $annotationName = $this->getAnnotationName($annotation); + $handler = $this->resolveAnnotationHandler($parsedAnnotation); - if (! array_key_exists($annotationName, $this->annotations)) { + if ($handler === null) { continue; } - $this->activeRange = $this->rangeResolver->resolve($annotation); + $activeRange = $this->rangeResolver->resolve($parsedAnnotation); - if ($this->activeRange === null) { + if ($activeRange === null) { continue; } - $this->annotations[$annotationName] + $handler ->setTorchlightOptions($this->options) - ->setActiveRange($this->activeRange) - ->setParsedAnnotation($annotation) - ->process($annotation); + ->setActiveRange($activeRange) + ->setParsedAnnotation($parsedAnnotation) + ->process($parsedAnnotation); } - foreach ($this->annotations as $annotation) { + foreach ($this->registry->allIncludingPrefixHandlers() as $annotation) { $annotation->afterProcess(); } + if ($this->options->diffIndicatorsInPlaceOfNumbers === false && $this->diffGutter()->hasMarkers()) { + $this->generationOptions->hasSeparatePaddingGutter = true; + } + + $removedLines = array_keys($this->generationOptions->removedLines); + if (! empty($removedLines)) { + $this->lineNumbersGutter()->adjustForRemovedLines($removedLines, $lineCount); + } + $tokens = $this->currentTokens; $this->currentTokens = []; diff --git a/src/Annotations/AnnotationRegistry.php b/src/Annotations/AnnotationRegistry.php new file mode 100644 index 0000000..27df80d --- /dev/null +++ b/src/Annotations/AnnotationRegistry.php @@ -0,0 +1,178 @@ + + */ + protected array $prefixHandlers = []; + + /** + * @var array + */ + protected array $nameToAnnotation = []; + + /** + * @return string[] + */ + protected function getPrefixesBySpecificity(): array + { + $prefixes = array_keys($this->prefixHandlers); + + usort($prefixes, function (string $left, string $right): int { + $lengthCompare = mb_strlen($right) <=> mb_strlen($left); + + return $lengthCompare !== 0 ? $lengthCompare : strcmp($left, $right); + }); + + return $prefixes; + } + + public function register(string $name, AbstractAnnotation $annotation): static + { + $this->annotations[$name] = $annotation; + $this->nameToAnnotation[mb_strtolower($name)] = $annotation; + + return $this; + } + + public function registerAnnotation(AbstractAnnotation $annotation): static + { + $name = $annotation::getName(); + + $this->annotations[$name] = $annotation; + $this->nameToAnnotation[mb_strtolower($name)] = $annotation; + + foreach ($annotation::getAliases() as $alias) { + $this->nameToAnnotation[mb_strtolower($alias)] = $annotation; + } + + $prefix = $annotation::getPrefix(); + if ($prefix !== null) { + $this->prefixHandlers[$prefix] = $annotation; + } + + return $this; + } + + public function registerPrefixHandler(string $prefix, AbstractAnnotation $annotation): static + { + $this->prefixHandlers[$prefix] = $annotation; + + return $this; + } + + public function unregister(string $name): static + { + $annotation = $this->annotations[$name] ?? null; + + if ($annotation === null) { + return $this; + } + + unset($this->annotations[$name]); + + $this->nameToAnnotation = array_filter( + $this->nameToAnnotation, + fn ($a) => $a !== $annotation + ); + + $this->prefixHandlers = array_filter( + $this->prefixHandlers, + fn ($a) => $a !== $annotation + ); + + return $this; + } + + public function get(string $name): ?AbstractAnnotation + { + return $this->annotations[$name] ?? null; + } + + public function resolve(string $nameOrPrefixedValue): ?AbstractAnnotation + { + foreach ($this->getPrefixesBySpecificity() as $prefix) { + if (str_starts_with($nameOrPrefixedValue, $prefix)) { + return $this->prefixHandlers[$prefix]; + } + } + + return $this->nameToAnnotation[mb_strtolower($nameOrPrefixedValue)] ?? null; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->annotations); + } + + /** + * @return string[] + */ + public function getRegisteredNames(): array + { + return array_keys($this->nameToAnnotation); + } + + /** + * @return string[] + */ + public function getRegisteredPrefixes(): array + { + return array_keys($this->prefixHandlers); + } + + /** + * @return AbstractAnnotation[] + */ + public function all(): array + { + return $this->annotations; + } + + /** + * @return AbstractAnnotation[] + */ + public function allIncludingPrefixHandlers(): array + { + return array_merge($this->annotations, $this->prefixHandlers); + } + + public function setHighlighter(Highlighter $highlighter): static + { + foreach ($this->allIncludingPrefixHandlers() as $annotation) { + $annotation->setHighlighter($highlighter); + } + + return $this; + } + + public function setTorchlightOptions(Options $options): static + { + foreach ($this->allIncludingPrefixHandlers() as $annotation) { + $annotation->setTorchlightOptions($options); + } + + return $this; + } + + public function setThemeResolver(ThemeStyleResolver $resolver): static + { + foreach ($this->allIncludingPrefixHandlers() as $annotation) { + $annotation->setThemeResolver($resolver); + } + + return $this; + } +} diff --git a/src/Annotations/Attributes/CssClassAnnotation.php b/src/Annotations/Attributes/CssClassAnnotation.php index 08cb690..405472e 100644 --- a/src/Annotations/Attributes/CssClassAnnotation.php +++ b/src/Annotations/Attributes/CssClassAnnotation.php @@ -3,12 +3,12 @@ namespace Torchlight\Engine\Annotations\Attributes; use Torchlight\Engine\Annotations\AbstractAnnotation; +use Torchlight\Engine\Annotations\Annotation; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +#[Annotation(name: 'html-css-class', prefix: '.', charRanges: true)] class CssClassAnnotation extends AbstractAnnotation { - public static string $name = 'html-css-class'; - public function process(ParsedAnnotation $annotation): void { $className = ltrim($annotation->name, '.'); diff --git a/src/Annotations/Attributes/IdAnnotation.php b/src/Annotations/Attributes/IdAnnotation.php index e0532bf..84f0e81 100644 --- a/src/Annotations/Attributes/IdAnnotation.php +++ b/src/Annotations/Attributes/IdAnnotation.php @@ -3,12 +3,12 @@ namespace Torchlight\Engine\Annotations\Attributes; use Torchlight\Engine\Annotations\AbstractAnnotation; +use Torchlight\Engine\Annotations\Annotation; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +#[Annotation(name: 'html-id', prefix: '#', charRanges: true)] class IdAnnotation extends AbstractAnnotation { - public static string $name = 'html-id'; - public function process(ParsedAnnotation $annotation): void { $id = ltrim($annotation->name, '#'); diff --git a/src/Annotations/AutoLinkAnnotation.php b/src/Annotations/AutoLinkAnnotation.php index 09e398e..5d22913 100644 --- a/src/Annotations/AutoLinkAnnotation.php +++ b/src/Annotations/AutoLinkAnnotation.php @@ -2,39 +2,50 @@ namespace Torchlight\Engine\Annotations; -use Phiki\Token\HighlightedToken; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +use Torchlight\Engine\Generators\RenderableToken; +#[Annotation(name: 'autolink')] class AutoLinkAnnotation extends AbstractAnnotation { const PATTERN_URL = '/\b((?:https?:\/\/|www\.)[^\s]+?)(?=[.,!?;:\]"\']?(?:\s|$))/i'; - public static string $name = 'autolink'; - public function process(ParsedAnnotation $annotation): void { - $this->modifyRangeTokens(function ($tokens) { - return $this->injectLinksIntoContent($tokens); - }); + $this->modifyRangeTokens( + function (array $tokens): array { + /** @var array $tokens */ + return $this->injectLinksIntoContent($tokens); + } + ); } + /** + * @param array $line + * @return array + */ protected function injectLinksIntoContent(array $line): array { - /** @var HighlightedToken $token */ + /** @var RenderableToken $token */ foreach ($line as $token) { - preg_match_all(static::PATTERN_URL, $token->token->text, $links); + $tokenText = $token->highlighted->token->text; + + preg_match_all(self::PATTERN_URL, $tokenText, $links); if (empty($links[0])) { continue; } - $styles = implode('', $this->getTokenStyles($token)); + $styles = implode('', $this->themeResolver()->getTokenStyles($token->highlighted)); foreach ($links[0] as $href) { $link = ''.htmlspecialchars($href).''; - $token->token->text = $this->safeReplace($href, $link, $token->token->text); + $tokenText = $this->safeReplace($href, $link, $tokenText); } + + $token->highlighted->token->text = $tokenText; + $token->metadata->setRawContent($tokenText); } return $line; diff --git a/src/Annotations/ClosureAnnotation.php b/src/Annotations/ClosureAnnotation.php new file mode 100644 index 0000000..5a5d2f9 --- /dev/null +++ b/src/Annotations/ClosureAnnotation.php @@ -0,0 +1,41 @@ +annotationEngine, + $annotation, + $this->activeRange(), + ); + + ($this->callback)($context); + } +} diff --git a/src/Annotations/CodeLensAnnotation.php b/src/Annotations/CodeLensAnnotation.php new file mode 100644 index 0000000..046975b --- /dev/null +++ b/src/Annotations/CodeLensAnnotation.php @@ -0,0 +1,204 @@ +}> */ + private array $pendingLens = []; + + protected function onLine(ParsedAnnotation $annotation): void + { + $text = $annotation->rawMethodArgs ?? ''; + + if ($text === '') { + return; + } + + $items = $this->parseItems($text); + $line = $this->activeRange()->startLine; + + $this->pendingLens[] = ['line' => $line, 'items' => $items]; + + $this->addBlockClass('has-codelens'); + } + + public function beforeProcess(): void + { + $this->pendingLens = []; + } + + public function afterProcess(): void + { + if (empty($this->pendingLens)) { + return; + } + + $spacer = $this->buildGutterSpacer(); + $removedLines = $this->annotationEngine->getGenerationOptions()->removedLines; + + foreach ($this->pendingLens as $entry) { + $line = $entry['line']; + + // If the target line was removed (e.g., by word-diff merging), + // we need to move the lens to the previous visible line. + while (isset($removedLines[$line]) && $line > 1) { + $line--; + } + + $indent = $this->buildIndent($line); + $html = $this->buildCodelensHtml($entry['items'], $spacer, $indent); + $this->prependLine($line, $html); + } + } + + private function buildGutterSpacer(): string + { + if (! $this->options->withGutter) { + return ''; + } + + $spacer = ''; + + foreach ($this->annotationEngine->getGenerationOptions()->getSortedGutters() as $gutter) { + if ($gutter->shouldRender()) { + $spacer .= $gutter->renderSpacer(); + } + } + + return $spacer; + } + + private function buildIndent(int $line): string + { + // When indent guides are active, emit a placeholder that + // HtmlGenerator will replace with guide spans after the + // IndentGuideTransformer has run to ensure widths match + if ($this->options->indentGuides !== false) { + $placeholder = ''; + $this->annotationEngine->getGenerationOptions()->codelensIndentPlaceholders[$placeholder] = $line; + + return $placeholder; + } + + $lineText = $this->annotationEngine->getLineText($line); + + if ($lineText === null) { + return ''; + } + + $indent = 0; + + for ($i = 0; $i < mb_strlen($lineText); $i++) { + $char = mb_substr($lineText, $i, 1); + + if ($char === ' ') { + $indent++; + } elseif ($char === "\t") { + $indent += 4; + } else { + break; + } + } + + if ($indent === 0) { + return ''; + } + + return str_repeat(' ', $indent); + } + + /** + * @return list + */ + private function parseItems(string $text): array + { + $items = []; + $current = ''; + $quote = null; + $len = mb_strlen($text); + + for ($i = 0; $i < $len; $i++) { + $char = mb_substr($text, $i, 1); + + if ($quote !== null) { + if ($char === '\\' && $i + 1 < $len) { + $next = mb_substr($text, $i + 1, 1); + if ($next === $quote || $next === '\\') { + $current .= $next; + $i++; + + continue; + } + } + + if ($char === $quote) { + $quote = null; + + continue; + } + + $current .= $char; + } else { + if ($char === '"' || $char === "'") { + $quote = $char; + + continue; + } + + if ($char === ',' && $i + 1 < $len && mb_substr($text, $i + 1, 1) === ' ') { + $items[] = trim($current); + $current = ''; + $i++; + + continue; + } + + $current .= $char; + } + } + + $trimmed = trim($current); + + if ($trimmed !== '') { + $items[] = $trimmed; + } + + return $items; + } + + /** + * @param list $items + */ + private function buildCodelensHtml(array $items, string $spacer = '', string $indent = ''): string + { + $spans = []; + + foreach ($items as $item) { + $spans[] = $this->buildItemHtml($item); + } + + $separator = " | "; + $inner = implode($separator, $spans); + + return "
{$spacer}{$indent}{$inner}
"; + } + + private function buildItemHtml(string $item): string + { + if (str_contains($item, ': ')) { + [$key, $value] = explode(': ', $item, 2); + + $key = htmlspecialchars($key); + $value = htmlspecialchars($value); + + return "{$key}: {$value}"; + } + + return "".htmlspecialchars($item).''; + } +} diff --git a/src/Annotations/CollapseAnnotation.php b/src/Annotations/CollapseAnnotation.php index c99c9a2..292273e 100644 --- a/src/Annotations/CollapseAnnotation.php +++ b/src/Annotations/CollapseAnnotation.php @@ -46,10 +46,8 @@ public function process(ParsedAnnotation $annotation): void ->addBlockClass('has-summaries') ->surroundRange("", '') ->surroundStartLine('', '') - ->modifyStartLineContents(function ($content) { - return $this->insertBeforeFinalSpanIfNewline($content, $this->getCollapseIndicator()); - }); + ->modifyStartLineContents(fn (string $content): string => $this->insertBeforeFinalSpanIfNewline($content, $this->getCollapseIndicator())); - $this->collapseGutter()->markRange($this->range); + $this->collapseGutter()->markRange($this->activeRange()); } } diff --git a/src/Annotations/Contracts/AnnotationDescriptor.php b/src/Annotations/Contracts/AnnotationDescriptor.php new file mode 100644 index 0000000..420dc8a --- /dev/null +++ b/src/Annotations/Contracts/AnnotationDescriptor.php @@ -0,0 +1,43 @@ + + */ + public static function getAliases(): array; + + /** + * Prefix this annotation responds to (e.g., "." for classes, "#" for IDs). + */ + public static function getPrefix(): ?string; + + /** + * Whether this annotation accepts method arguments: annotation(args) + */ + public static function acceptsMethodArgs(): bool; + + /** + * Whether this annotation accepts options after the name: annotation opt1 opt2 + */ + public static function acceptsOptions(): bool; + + /** + * Whether this annotation supports character ranges: annotation:c1,5 + */ + public static function supportsCharacterRanges(): bool; + + /** + * Whether this annotation supports line ranges: annotation:1,5 or annotation:start/end + */ + public static function supportsLineRanges(): bool; +} diff --git a/src/Annotations/Diff/AbstractDiffAnnotation.php b/src/Annotations/Diff/AbstractDiffAnnotation.php new file mode 100644 index 0000000..0729fd4 --- /dev/null +++ b/src/Annotations/Diff/AbstractDiffAnnotation.php @@ -0,0 +1,55 @@ +classPrefix(); + $scopes = $this->diffScopes(); + + if ($this->isCharacterRange()) { + $this->addBlockClass("has-{$prefix}-lines") + ->addStyledCharacterRange("char-{$prefix}", "line-{$prefix}"); + + return; + } + + $this->addBlockClass("has-{$prefix}-lines") + ->addLineClass(["line-{$prefix}", 'line-has-background']); + + if (! $this->options->diffPreserveSyntaxColors) { + $scopePrefix = $this->scopePrefix(); + $this->addLineScope(["markup.{$scopePrefix}", "torchlight.markup.{$scopePrefix}"]); + } + + if ($this->options->diffIndicatorsEnabled) { + if ($this->options->diffIndicatorsInPlaceOfNumbers) { + $this->replaceLineMarker($this->marker(), $scopes); + } else { + $this->setDiffLineMarker($this->marker()) + ->setLineScopes($scopes); + } + } else { + $this->setLineScopes($scopes); + } + } + + /** @return list */ + protected function diffScopes(): array + { + $prefix = $this->scopePrefix(); + + return ["markup.{$prefix}", "torchlight.markup.{$prefix}", "torchlight.markup.{$prefix}.foreground"]; + } +} diff --git a/src/Annotations/Diff/DiffAddAnnotation.php b/src/Annotations/Diff/DiffAddAnnotation.php index cd7b838..c561c4b 100644 --- a/src/Annotations/Diff/DiffAddAnnotation.php +++ b/src/Annotations/Diff/DiffAddAnnotation.php @@ -2,35 +2,29 @@ namespace Torchlight\Engine\Annotations\Diff; -use Torchlight\Engine\Annotations\AbstractAnnotation; -use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +use Torchlight\Engine\Annotations\Annotation; -class DiffAddAnnotation extends AbstractAnnotation +#[Annotation(name: 'add', aliases: ['++'], charRanges: true)] +class DiffAddAnnotation extends AbstractDiffAnnotation { - public static string $name = 'add'; + public const DIFF_ADD_SCOPES = [ + 'markup.inserted', + 'torchlight.markup.inserted', + 'torchlight.markup.inserted.foreground', + ]; - public static array $aliases = ['++']; - - public const DIFF_ADD_SCOPES = ['markup.inserted', 'torchlight.markup.inserted', 'torchlight.markup.inserted.foreground']; - - public function process(ParsedAnnotation $annotation): void + protected function marker(): string { - $this->addBlockClass('has-add-lines') - ->addLineClass(['line-add', 'line-has-background']); + return '+'; + } - if (! $this->options->diffPreserveSyntaxColors) { - $this->addLineScope(['markup.inserted', 'torchlight.markup.inserted']); - } + protected function classPrefix(): string + { + return 'add'; + } - if ($this->options->diffIndicatorsEnabled) { - if ($this->options->diffIndicatorsInPlaceOfNumbers) { - $this->replaceLineMarker('+', self::DIFF_ADD_SCOPES); - } else { - $this->setDiffLineMarker('+') - ->setLineScopes(self::DIFF_ADD_SCOPES); - } - } else { - $this->setLineScopes(self::DIFF_ADD_SCOPES); - } + protected function scopePrefix(): string + { + return 'inserted'; } } diff --git a/src/Annotations/Diff/DiffRemoveAnnotation.php b/src/Annotations/Diff/DiffRemoveAnnotation.php index e9990d5..4217e3a 100644 --- a/src/Annotations/Diff/DiffRemoveAnnotation.php +++ b/src/Annotations/Diff/DiffRemoveAnnotation.php @@ -2,36 +2,29 @@ namespace Torchlight\Engine\Annotations\Diff; -use Torchlight\Engine\Annotations\AbstractAnnotation; -use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +use Torchlight\Engine\Annotations\Annotation; -class DiffRemoveAnnotation extends AbstractAnnotation +#[Annotation(name: 'remove', aliases: ['--'], charRanges: true)] +class DiffRemoveAnnotation extends AbstractDiffAnnotation { - public static string $name = 'remove'; + public const DIFF_REMOVE_SCOPES = [ + 'markup.deleted', + 'torchlight.markup.deleted', + 'torchlight.markup.deleted.foreground', + ]; - public static array $aliases = ['--']; - - public const DIFF_REMOVE_SCOPES = ['markup.deleted', 'torchlight.markup.deleted', 'torchlight.markup.deleted.foreground']; - - public function process(ParsedAnnotation $annotation): void + protected function marker(): string { - $this->addBlockClass('has-remove-lines') - ->addLineClass(['line-remove', 'line-has-background']); + return '-'; + } - if (! $this->options->diffPreserveSyntaxColors) { - $this->addLineScope(['markup.deleted', 'torchlight.markup.deleted']); - } + protected function classPrefix(): string + { + return 'remove'; + } - if ($this->options->diffIndicatorsEnabled) { - if ($this->options->diffIndicatorsInPlaceOfNumbers) { - $this->replaceLineMarker('-', self::DIFF_REMOVE_SCOPES); - } else { - $this - ->setDiffLineMarker('-') - ->setLineScopes(self::DIFF_REMOVE_SCOPES); - } - } else { - $this->setLineScopes(self::DIFF_REMOVE_SCOPES); - } + protected function scopePrefix(): string + { + return 'deleted'; } } diff --git a/src/Annotations/Diff/WordDiffAnnotation.php b/src/Annotations/Diff/WordDiffAnnotation.php new file mode 100644 index 0000000..72e129e --- /dev/null +++ b/src/Annotations/Diff/WordDiffAnnotation.php @@ -0,0 +1,333 @@ +wdLines[] = $this->activeRange()->startLine; + } + + public function afterProcess(): void + { + foreach ($this->wdLines as $newLine) { + $oldLine = $newLine - 1; + + if ($oldLine >= 1) { + $this->processWordDiffPair($oldLine, $newLine); + } + } + } + + protected function processWordDiffPair(int $oldLine, int $newLine): void + { + $oldText = $this->annotationEngine->getLineText($oldLine); + $newText = $this->annotationEngine->getLineText($newLine); + + if ($oldText === null || $newText === null) { + return; + } + + $oldText = rtrim($oldText); + $newText = rtrim($newText); + + if ($oldText === $newText) { + return; + } + + $regions = $this->computeMultiRegionDiff($oldText, $newText); + + if (empty($regions)) { + return; + } + + $this->addBlockClass('has-word-diff'); + + $this->annotationEngine->removeLine($newLine); + + $this->annotationEngine->addLineClass($oldLine, 'line-word-diff'); + + $delStyle = $this->getThemeStyle('line-remove'); + $insStyle = $this->getThemeStyle('line-add'); + + $this->annotationEngine->modifyLineContents( + $oldLine, + fn (string $html) => $this->injectInlineDiff($html, $regions, $delStyle, $insStyle) + ); + } + + /** + * @param array $regions + */ + protected function injectInlineDiff( + string $html, + array $regions, + string $delStyle = '', + string $insStyle = '', + ): string { + $tokens = preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + if ($tokens === false) { + return $html; + } + + // Decode HTML entities in text nodes for accurate character counting. + for ($i = 0; $i < count($tokens); $i++) { + if (! str_starts_with($tokens[$i], '<')) { + $tokens[$i] = html_entity_decode($tokens[$i]); + } + } + + $delStyleAttr = $delStyle !== '' ? " style=\"{$delStyle}\"" : ''; + $insStyleAttr = $insStyle !== '' ? " style=\"{$insStyle}\"" : ''; + $delOpen = ''; + $insOpen = ''; + + $tagStack = []; + $visCount = 0; + $output = []; + $regionIndex = 0; + $inDel = false; + + // Handle pure insertions at position 0, before any visible characters. + while ($regionIndex < count($regions)) { + $region = $regions[$regionIndex]; + + if ($region['oldEnd'] < $region['oldStart'] && $region['oldStart'] <= 1) { + $output[] = $insOpen.htmlspecialchars((string) $region['newText']).''; + $regionIndex++; + } else { + break; + } + } + + foreach ($tokens as $token) { + if (str_starts_with($token, ''; + } + + foreach ($tagStack as $item) { + $output[] = $item; + } + + $inDel = false; + $regionIndex++; + } + + // Handle pure insertion + if (! $hasDel && $hasIns && $visCount === ($region['oldStart'] - 1)) { + foreach ($tagStack as $item) { + $output[] = ''; + } + $output[] = $insOpen.htmlspecialchars((string) $region['newText']).'
'; + foreach ($tagStack as $item) { + $output[] = $item; + } + $regionIndex++; + } + } else { + $output[] = htmlentities($char); + } + } + } + + $result = implode('', $output); + + // Remove empty spans produced when diff boundaries align with token boundaries. + return preg_replace('/]*><\/span>/', '', $result) ?? $result; + } + + /** + * @return list + */ + protected function tokenizeForDiff(string $text): array + { + preg_match_all('/\w+|\s+|[^\w\s]/u', $text, $matches); + + return $matches[0]; + } + + /** + * @return array + */ + protected function computeMultiRegionDiff(string $oldText, string $newText): array + { + $oldTokens = $this->tokenizeForDiff($oldText); + $newTokens = $this->tokenizeForDiff($newText); + $oldLen = count($oldTokens); + $newLen = count($newTokens); + + $oldOffsets = []; + $pos = 1; + for ($i = 0; $i < $oldLen; $i++) { + $oldOffsets[$i] = $pos; + $pos += mb_strlen($oldTokens[$i]); + } + + $newOffsets = []; + $pos = 1; + for ($i = 0; $i < $newLen; $i++) { + $newOffsets[$i] = $pos; + $pos += mb_strlen($newTokens[$i]); + } + + // Build LCS DP table on tokens. + /** @var array> $dp */ + $dp = []; + for ($i = 0; $i <= $oldLen; $i++) { + for ($j = 0; $j <= $newLen; $j++) { + if ($i === 0 || $j === 0) { + $dp[$i][$j] = 0; + } elseif ($oldTokens[$i - 1] === $newTokens[$j - 1]) { + $dp[$i][$j] = $dp[$i - 1][$j - 1] + 1; + } else { + $dp[$i][$j] = max($dp[$i - 1][$j] ?? 0, $dp[$i][$j - 1] ?? 0); + } + } + } + + // Backtrack to produce token-level edit operations. + /** @var list $ops */ + $ops = []; + $i = $oldLen; + $j = $newLen; + + while ($i > 0 || $j > 0) { + if ($i > 0 && $j > 0 && $oldTokens[$i - 1] === $newTokens[$j - 1]) { + $ops[] = ['type' => 'equal', 'oldIdx' => $i - 1, 'newIdx' => $j - 1]; + $i--; + $j--; + } elseif ($j > 0 && ($i === 0 || (($dp[$i][$j - 1] ?? 0) >= ($dp[$i - 1][$j] ?? 0)))) { + $ops[] = ['type' => 'insert', 'oldIdx' => null, 'newIdx' => $j - 1]; + $j--; + } else { + $ops[] = ['type' => 'delete', 'oldIdx' => $i - 1, 'newIdx' => null]; + $i--; + } + } + + $ops = array_reverse($ops); + + // Group consecutive non-equal ops into regions with character positions. + $regions = []; + $k = 0; + $opCount = count($ops); + + while ($k < $opCount) { + if ($ops[$k]['type'] === 'equal') { + $k++; + + continue; + } + + // Collect consecutive deletes and inserts. + $deletedIndices = []; + $insertedText = ''; + + while ($k < $opCount && $ops[$k]['type'] !== 'equal') { + if ($ops[$k]['type'] === 'delete') { + $oldIdx = $ops[$k]['oldIdx']; + if ($oldIdx !== null) { + $deletedIndices[] = $oldIdx; + } + } elseif ($ops[$k]['type'] === 'insert') { + $newIdx = $ops[$k]['newIdx']; + if ($newIdx !== null) { + $insertedText .= $newTokens[$newIdx]; + } + } + $k++; + } + + if (! empty($deletedIndices)) { + $firstIdx = min($deletedIndices); + $lastIdx = max($deletedIndices); + $oldStart = $oldOffsets[$firstIdx]; + $oldEnd = $oldOffsets[$lastIdx] + mb_strlen($oldTokens[$lastIdx]) - 1; + + $regions[] = [ + 'oldStart' => $oldStart, + 'oldEnd' => $oldEnd, + 'newText' => $insertedText, + ]; + } elseif ($insertedText !== '') { + // Pure insertion. + $anchorPos = 0; + for ($p = $k - 1; $p >= 0; $p--) { + if ($ops[$p]['type'] === 'equal' || $ops[$p]['type'] === 'delete') { + $idx = $ops[$p]['oldIdx']; + if ($idx !== null) { + $anchorPos = $oldOffsets[$idx] + mb_strlen($oldTokens[$idx]) - 1; + break; + } + } + } + + $regions[] = [ + 'oldStart' => $anchorPos + 1, + 'oldEnd' => $anchorPos, + 'newText' => $insertedText, + ]; + } + } + + return $regions; + } + + public function reset(): void + { + parent::reset(); + $this->wdLines = []; + } +} diff --git a/src/Annotations/FocusAnnotation.php b/src/Annotations/FocusAnnotation.php index 29297ca..02fa2bb 100644 --- a/src/Annotations/FocusAnnotation.php +++ b/src/Annotations/FocusAnnotation.php @@ -4,15 +4,18 @@ use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +#[Annotation(name: 'focus', aliases: ['**'], charRanges: true)] class FocusAnnotation extends AbstractAnnotation { - public static string $name = 'focus'; - - public static array $aliases = ['**']; - - public function process(ParsedAnnotation $annotation): void + protected function onLine(ParsedAnnotation $annotation): void { $this->addBlockClass('has-focus-lines') ->addLineClass('line-focus'); } + + protected function onCharacterRange(ParsedAnnotation $annotation): void + { + $this->addBlockClass('has-focus-lines') + ->addAttributesToCharacterRange(['class' => 'char-focus']); + } } diff --git a/src/Annotations/GutterContentAnnotation.php b/src/Annotations/GutterContentAnnotation.php new file mode 100644 index 0000000..d423839 --- /dev/null +++ b/src/Annotations/GutterContentAnnotation.php @@ -0,0 +1,62 @@ +parseGutterArgs($annotation); + + $this->addBlockClass('has-custom-gutter'); + + if ($content !== '') { + $gutter = $this->resolveGutter($gutterName); + + $this->eachLine(function (int $i) use ($gutter, $content): void { + $gutter->setLineContent($i, $content); + }); + } + } + + private function parseGutterArgs(ParsedAnnotation $annotation): array + { + $raw = $annotation->rawMethodArgs; + + if ($raw === null) { + return ['', 'custom-content']; + } + + $parts = array_map( + static fn (?string $part): string => trim((string) $part), + str_getcsv($raw, ',', '"', '') + ); + + $content = isset($parts[0]) ? trim($parts[0], '"\'') : ''; + $name = isset($parts[1]) ? trim($parts[1], '"\'') : 'custom-content'; + + return [$content, $name]; + } + + private function resolveGutter(string $name): CustomContentGutter + { + if (! $this->annotationEngine->hasGutter($name)) { + $this->annotationEngine->addGutter( + $name, + (new CustomContentGutter)->setPriority(300) + ); + } + + $gutter = $this->annotationEngine->getGenerationOptions()->gutters[$name]; + + if (! $gutter instanceof CustomContentGutter) { + throw new \LogicException("Gutter [{$name}] is not a custom content gutter."); + } + + return $gutter; + } +} diff --git a/src/Annotations/HideAnnotation.php b/src/Annotations/HideAnnotation.php new file mode 100644 index 0000000..0d5ff0e --- /dev/null +++ b/src/Annotations/HideAnnotation.php @@ -0,0 +1,55 @@ + */ + protected array $hiddenRanges = []; + + public function process(ParsedAnnotation $annotation): void + { + $placeholder = $annotation->methodArgs ?? '...'; + $range = $this->activeRange(); + + $this->hiddenRanges[] = [ + 'start' => $range->startLine, + 'end' => $range->endLine, + 'placeholder' => $placeholder, + ]; + } + + public function afterProcess(): void + { + if (empty($this->hiddenRanges)) { + return; + } + + $this->addBlockClass('has-hidden-lines'); + + foreach ($this->hiddenRanges as $range) { + $count = $range['end'] - $range['start'] + 1; + $placeholder = htmlspecialchars($range['placeholder']); + + $this->annotationEngine->addLineClass($range['start'], 'line-elided'); + $this->annotationEngine->addAttributeToLine($range['start'], 'data-hidden-count', (string) $count); + + $this->annotationEngine->modifyLineContents($range['start'], fn (string $content): string => ''.$placeholder.''); + + for ($i = $range['start'] + 1; $i <= $range['end']; $i++) { + $this->annotationEngine->addLineClass($i, 'line-hidden'); + } + } + + $this->hiddenRanges = []; + } + + public function reset(): void + { + parent::reset(); + $this->hiddenRanges = []; + } +} diff --git a/src/Annotations/HighlightAnnotation.php b/src/Annotations/HighlightAnnotation.php index d458750..926315d 100644 --- a/src/Annotations/HighlightAnnotation.php +++ b/src/Annotations/HighlightAnnotation.php @@ -4,16 +4,19 @@ use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +#[Annotation(name: 'highlight', aliases: ['~~'], charRanges: true)] class HighlightAnnotation extends AbstractAnnotation { - public static string $name = 'highlight'; - - public static array $aliases = ['~~']; - - public function process(ParsedAnnotation $annotation): void + protected function onLine(ParsedAnnotation $annotation): void { $this->addBlockClass('has-highlight-lines') ->addLineClass(['line-highlight', 'line-has-background']) ->markLinesHighlighted(); } + + protected function onCharacterRange(ParsedAnnotation $annotation): void + { + $this->addBlockClass('has-highlight-lines') + ->addStyledCharacterRange('char-highlight', 'line-highlight'); + } } diff --git a/src/Annotations/LinkAnnotation.php b/src/Annotations/LinkAnnotation.php new file mode 100644 index 0000000..77389c4 --- /dev/null +++ b/src/Annotations/LinkAnnotation.php @@ -0,0 +1,39 @@ +methodArgs ?? ''; + + if ($href === '') { + return; + } + + $escapedHref = htmlspecialchars($href); + + $this->addBlockClass('has-links'); + + $this->modifyRangeContents(fn (string $content): string => ''.$content.''); + } + + protected function onCharacterRange(ParsedAnnotation $annotation): void + { + $href = $annotation->methodArgs ?? ''; + + if ($href === '') { + return; + } + + $this->addBlockClass('has-links') + ->addAttributesToCharacterRange([ + 'class' => 'tl-link', + 'data-href' => $href, + ]); + } +} diff --git a/src/Annotations/MacroAnnotation.php b/src/Annotations/MacroAnnotation.php new file mode 100644 index 0000000..85c5fc2 --- /dev/null +++ b/src/Annotations/MacroAnnotation.php @@ -0,0 +1,55 @@ +annotationEngine->getRegistry(); + + foreach ($this->componentNames as $component) { + $handler = $registry->resolve($component); + + if ($handler === null) { + continue; + } + + $componentAnnotation = new ParsedAnnotation; + $componentAnnotation->index = $annotation->index; + $componentAnnotation->sourceLine = $annotation->sourceLine; + $componentAnnotation->name = $component; + $componentAnnotation->text = $component; + $componentAnnotation->methodArgs = $annotation->methodArgs; + $componentAnnotation->rawMethodArgs = $annotation->rawMethodArgs; + $componentAnnotation->options = $annotation->options; + $componentAnnotation->range = $annotation->range; + + $componentAnnotation->type = AnnotationType::Named; + foreach ($registry->getRegisteredPrefixes() as $prefix) { + if (str_starts_with($component, $prefix)) { + $componentAnnotation->type = AnnotationType::Prefixed; + $componentAnnotation->prefix = $prefix; + break; + } + } + + $handler + ->setTorchlightOptions($this->options) + ->setActiveRange($this->activeRange()) + ->setParsedAnnotation($componentAnnotation) + ->process($componentAnnotation); + } + } +} diff --git a/src/Annotations/MarkAnnotation.php b/src/Annotations/MarkAnnotation.php new file mode 100644 index 0000000..8e50615 --- /dev/null +++ b/src/Annotations/MarkAnnotation.php @@ -0,0 +1,51 @@ +methodArgs ?? ''; + + if ($needle === '') { + return; + } + + $lineText = $this->annotationEngine->getLineText($this->activeRange()->startLine); + + if ($lineText === null) { + return; + } + + $matchAll = in_array('all', $annotation->options); + $offset = 0; + $found = false; + + while (($pos = mb_strpos($lineText, $needle, $offset)) !== false) { + $start = $pos + 1; + $end = $pos + mb_strlen($needle); + $found = true; + + $this->annotationEngine->addAttributesToCharacterRange( + $this->activeRange()->startLine, + $start, + $end, + ['class' => 'char-mark'] + ); + + if (! $matchAll) { + break; + } + + $offset = $pos + mb_strlen($needle); + } + + if ($found) { + $this->addBlockClass('has-mark-lines'); + } + } +} diff --git a/src/Annotations/MonoAnnotation.php b/src/Annotations/MonoAnnotation.php index 8c935d7..b38902a 100644 --- a/src/Annotations/MonoAnnotation.php +++ b/src/Annotations/MonoAnnotation.php @@ -4,15 +4,18 @@ use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; +#[Annotation(name: 'mono', charRanges: true)] class MonoAnnotation extends AbstractAnnotation { - public static string $name = 'mono'; - - public static array $aliases = []; - - public function process(ParsedAnnotation $annotation): void + protected function onLine(ParsedAnnotation $annotation): void { $this->addBlockClass('has-mono-lines') ->addLineClass(['line-mono']); } + + protected function onCharacterRange(ParsedAnnotation $annotation): void + { + $this->addBlockClass('has-mono-lines') + ->addAttributesToCharacterRange(['class' => 'char-mono']); + } } diff --git a/src/Annotations/Parser/AnnotationTokenParser.php b/src/Annotations/Parser/AnnotationTokenParser.php index 6f43562..401e7b6 100644 --- a/src/Annotations/Parser/AnnotationTokenParser.php +++ b/src/Annotations/Parser/AnnotationTokenParser.php @@ -8,13 +8,21 @@ class AnnotationTokenParser { - public const ANNOTATION_PATTERN = '/\[tl!([^]]*(?:\][^]]*)*)\]/'; + public const ANNOTATION_PATTERN = '/\[tl!([^]]*(?:\](?!\s*\[tl!)[^]]*)*)\]/'; /** * @var string[] */ protected array $annotationNames = []; + /** + * Registered prefixes for prefix-based annotations. + * + * @var string[] + */ + protected array $registeredPrefixes = ['.', '#']; + + /** @var list */ protected array $annotations = []; protected int $annotationIndex = 0; @@ -27,6 +35,7 @@ public function reset(): static return $this; } + /** @param list $names */ public function setAnnotationNames(array $names): static { $this->annotationNames = $names; @@ -41,31 +50,73 @@ public function addAnnotationName(string $name): static return $this; } + /** @param list $prefixes */ + public function setRegisteredPrefixes(array $prefixes): static + { + $this->registeredPrefixes = array_values(array_unique($prefixes)); + + return $this; + } + + public function addRegisteredPrefix(string $prefix): static + { + if (! in_array($prefix, $this->registeredPrefixes, true)) { + $this->registeredPrefixes[] = $prefix; + } + + return $this; + } + + /** + * @return string[] + */ + protected function getPrefixesBySpecificity(): array + { + $prefixes = $this->registeredPrefixes; + + usort($prefixes, function (string $left, string $right): int { + $lengthCompare = mb_strlen($right) <=> mb_strlen($left); + + return $lengthCompare !== 0 ? $lengthCompare : strcmp($left, $right); + }); + + return $prefixes; + } + + protected function matchesPrefix(string $text): ?string + { + foreach ($this->getPrefixesBySpecificity() as $prefix) { + if (str_starts_with($text, $prefix)) { + return $prefix; + } + } + + return null; + } + protected function extractAnnotationName(string $text): string { $lastColonText = Str::afterLast($text, ':'); $colonPos = strrpos($text, ':'); + $colonPos = $colonPos === false ? PHP_INT_MAX : $colonPos; - if (! $this->isValidRange($lastColonText ?? '')) { - $colonPos = INF; + if (! $this->isValidRange($lastColonText)) { + $colonPos = PHP_INT_MAX; } - $leftParenPos = INF; + $leftParenPos = PHP_INT_MAX; - if (! str_starts_with($text, '.')) { + // Only check for parentheses if this is not a prefix-based annotation + if ($this->matchesPrefix($text) === null) { $leftParenPos = mb_strpos($text, '('); - - if (! $colonPos) { - $colonPos = INF; - } - if (! $leftParenPos) { - $leftParenPos = INF; + if ($leftParenPos === false) { + $leftParenPos = PHP_INT_MAX; } } $loc = min($colonPos, $leftParenPos); - if (! is_infinite($loc)) { + if ($loc !== PHP_INT_MAX) { $text = mb_substr($text, 0, $loc); } @@ -74,24 +125,25 @@ protected function extractAnnotationName(string $text): string protected function isAnnotation(string $text): bool { - if (str_starts_with($text, '.') || str_starts_with($text, '#')) { + if ($this->matchesPrefix($text) !== null) { return true; } return in_array(mb_strtolower($text), $this->annotationNames); } - protected function getAnnotationType(string $name): AnnotationType + /** + * @return array{AnnotationType, string|null} [type, matched_prefix] + */ + protected function getAnnotationTypeAndPrefix(string $name): array { - if (str_starts_with($name, '.')) { - return AnnotationType::ClassName; - } + $matchedPrefix = $this->matchesPrefix($name); - if (str_starts_with($name, '#')) { - return AnnotationType::IdAttribute; + if ($matchedPrefix !== null) { + return [AnnotationType::Prefixed, $matchedPrefix]; } - return AnnotationType::Named; + return [AnnotationType::Named, null]; } protected function parseRange(string $text): ?AnnotationRange @@ -137,7 +189,7 @@ protected function parseRange(string $text): ?AnnotationRange return $range; } - protected function parseMethodArgs(string $text): ?string + protected function extractMethodArgs(string $text): ?string { if (! str_contains($text, ')')) { return null; @@ -149,6 +201,22 @@ protected function parseMethodArgs(string $text): ?string ); } + protected function parseMethodArgs(string $text): ?string + { + $args = $this->extractMethodArgs($text); + + if ($args === null) { + return null; + } + + if ((str_starts_with($args, '"') && str_ends_with($args, '"')) || + (str_starts_with($args, "'") && str_ends_with($args, "'"))) { + $args = mb_substr($args, 1, -1); + } + + return $args; + } + private function isValidRange(string $text): bool { return str_contains($text, ',') || @@ -156,6 +224,10 @@ private function isValidRange(string $text): bool is_numeric($text); } + /** + * @param list> $annotations + * @return list + */ protected function convertAnnotations(array $annotations, int $sourceLine): array { $results = []; @@ -168,19 +240,36 @@ protected function convertAnnotations(array $annotations, int $sourceLine): arra $annotation->index = $this->annotationIndex; $tmpName = array_shift($tmpAnnotation); + if ($tmpName === null) { + continue; + } $name = $this->extractAnnotationName($tmpName); $annotation->text = $annotationText; - $annotation->type = $this->getAnnotationType($name); + + [$type, $prefix] = $this->getAnnotationTypeAndPrefix($name); + $annotation->type = $type; + $annotation->prefix = $prefix; $annotation->name = $name; - if (str_contains($tmpName, ':') && $this->isValidRange(Str::afterLast($tmpName, ':'))) { + if (str_contains((string) $tmpName, ':') && $this->isValidRange(Str::afterLast($tmpName, ':'))) { $annotation->range = $this->parseRange($tmpName); } - if (str_contains($tmpName, '(')) { - $annotation->methodArgs = $this->parseMethodArgs($tmpName); + if (str_contains($annotationText, '(')) { + $annotation->rawMethodArgs = $this->extractMethodArgs($annotationText); + $annotation->methodArgs = $this->parseMethodArgs($annotationText); + + // When method args span spaces, the range suffix may be after the closing + // paren in the full annotation text rather than in $tmpName alone. + if ($annotation->range === null && str_contains($annotationText, ')')) { + $afterParen = Str::after($annotationText, ')'); + + if (str_starts_with($afterParen, ':') && $this->isValidRange(Str::afterLast($afterParen, ':'))) { + $annotation->range = $this->parseRange($afterParen); + } + } } $annotation->options = $tmpAnnotation; @@ -193,20 +282,36 @@ protected function convertAnnotations(array $annotations, int $sourceLine): arra return $results; } + /** @return list */ protected function parseAnnotations(string $text, int $sourceLine): array { $parts = explode(' ', trim($text)); $tmpAnnotations = []; $annotationParts = []; $annotationPart = null; - $annotationName = null; + $insideParens = false; foreach ($parts as $part) { + // Track whether we're inside method args parentheses. + // We don't allow annotations to start inside args. + if ($insideParens) { + $annotationParts[] = $part; + if (str_contains($part, ')')) { + $insideParens = false; + } + + continue; + } + + if (str_contains($part, '(') && ! str_contains($part, ')')) { + $insideParens = true; + } + $checkName = $this->extractAnnotationName($part); if ($this->isAnnotation($checkName)) { if ( - ($annotationName != null && $checkName != $annotationName) || + $annotationPart != null || count($annotationParts) > 0 ) { if ($annotationPart != null) { @@ -218,7 +323,6 @@ protected function parseAnnotations(string $text, int $sourceLine): array $annotationParts = []; } - $annotationName = $checkName; $annotationPart = $part; continue; @@ -238,8 +342,9 @@ protected function parseAnnotations(string $text, int $sourceLine): array $finalAnnotations = []; foreach ($tmpAnnotations as $tmpAnnotation) { - if (str_starts_with($tmpAnnotation[0], '.') || str_starts_with($tmpAnnotation[0], '#')) { - foreach ($this->parseCombinedClassIdAnnotations($tmpAnnotation) as $annotation) { + // Check if this is a prefix-based annotation that may be combined + if ($this->matchesPrefix($tmpAnnotation[0]) !== null) { + foreach ($this->parseCombinedPrefixAnnotations($tmpAnnotation) as $annotation) { $finalAnnotations[] = $annotation; } @@ -252,14 +357,22 @@ protected function parseAnnotations(string $text, int $sourceLine): array return $this->convertAnnotations($finalAnnotations, $sourceLine); } - protected function parseCombinedClassIdAnnotations(array $annotation): array + /** + * @param list $annotation + * @return list> + */ + protected function parseCombinedPrefixAnnotations(array $annotation): array { $results = []; $value = array_shift($annotation); + $prefixes = $this->getPrefixesBySpecificity(); + + $escapedPrefixes = array_map(fn ($p) => preg_quote($p, '/'), $prefixes); + $prefixPattern = implode('|', $escapedPrefixes); - $pattern = '/([.#])([^.#]+)/'; + $pattern = '/('.$prefixPattern.')([^'.preg_quote(implode('', $prefixes), '/').']+)/'; - preg_match_all($pattern, $value, $matches, PREG_SET_ORDER); + preg_match_all($pattern, (string) $value, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $results[] = array_merge( @@ -275,7 +388,7 @@ public function parseText(string $text, int $sourceLine): ParseResult { $parseResult = new ParseResult; - $parseResult->text = preg_replace(static::ANNOTATION_PATTERN, '', $text); + $parseResult->text = preg_replace(self::ANNOTATION_PATTERN, '', $text) ?? $text; preg_match_all(self::ANNOTATION_PATTERN, $text, $matches); @@ -289,6 +402,7 @@ public function parseText(string $text, int $sourceLine): ParseResult return $parseResult; } + /** @return list */ public function getAnnotations(): array { return $this->annotations; diff --git a/src/Annotations/Parser/AnnotationType.php b/src/Annotations/Parser/AnnotationType.php index 6597cd6..8c5bd40 100644 --- a/src/Annotations/Parser/AnnotationType.php +++ b/src/Annotations/Parser/AnnotationType.php @@ -4,7 +4,6 @@ enum AnnotationType: int { - case Named = 1; - case ClassName = 2; - case IdAttribute = 3; + case Named = 1; // Standard named annotations: highlight, focus, etc. + case Prefixed = 2; // Any prefix-based annotation: .class, #id, etc. } diff --git a/src/Annotations/Parser/ParsedAnnotation.php b/src/Annotations/Parser/ParsedAnnotation.php index 4f228df..616ef8d 100644 --- a/src/Annotations/Parser/ParsedAnnotation.php +++ b/src/Annotations/Parser/ParsedAnnotation.php @@ -16,9 +16,14 @@ class ParsedAnnotation public ?string $methodArgs = null; + public ?string $rawMethodArgs = null; + + /** @var array */ public array $options = []; public ?AnnotationRange $range = null; public AnnotationType $type = AnnotationType::Named; + + public ?string $prefix = null; } diff --git a/src/Annotations/Ranges/RangeResolver.php b/src/Annotations/Ranges/RangeResolver.php index 57dfa61..245d5dd 100644 --- a/src/Annotations/Ranges/RangeResolver.php +++ b/src/Annotations/Ranges/RangeResolver.php @@ -13,6 +13,7 @@ class RangeResolver protected int $maxLine = 0; + /** @var array */ protected array $annotationRanges = []; public function reset(): static @@ -31,6 +32,9 @@ public function setMaxLine(int $maxLine): static return $this; } + /** + * @param array $annotations + */ public function setAnnotations(array $annotations): static { $this->annotations = $annotations; @@ -45,7 +49,12 @@ protected function makeSingleLineRange(int $lineNumber): ImpactedRange protected function makeRelativeRange(ParsedAnnotation $annotation): ?ImpactedRange { - $range = clone $annotation->range; + $annotationRange = $annotation->range; + if ($annotationRange === null) { + return null; + } + + $range = clone $annotationRange; $startingLine = $annotation->sourceLine; @@ -85,7 +94,7 @@ protected function makeRelativeRange(ParsedAnnotation $annotation): ?ImpactedRan return new ImpactedRange($startingLine == $endingLine, $startingLine, $endingLine); } - if ($annotation->range->start === null) { + if ($annotationRange->start === null) { return new ImpactedRange($startingLine === $endingLine, $endingLine, $startingLine); } diff --git a/src/Annotations/RegionAnnotation.php b/src/Annotations/RegionAnnotation.php new file mode 100644 index 0000000..47287bd --- /dev/null +++ b/src/Annotations/RegionAnnotation.php @@ -0,0 +1,56 @@ + */ + protected array $activeRegions = []; + + public function process(ParsedAnnotation $annotation): void + { + $regionName = $annotation->methodArgs ?? 'default'; + + // Check for end modifier in options + $isEnd = in_array('end', $annotation->options); + + if ($isEnd) { + // Close the region + if (isset($this->activeRegions[$regionName])) { + $startLine = $this->activeRegions[$regionName]; + $endLine = $this->activeRange()->startLine; + + $this->annotationEngine->prependLine($startLine, "
"); + $this->annotationEngine->appendLine($endLine, '
'); + + $this->addBlockClass('has-regions'); + + unset($this->activeRegions[$regionName]); + } + + return; + } + + $this->activeRegions[$regionName] = $this->activeRange()->startLine; + + $this->addLineAttribute('data-region', $regionName); + } + + public function afterProcess(): void + { + foreach ($this->activeRegions as $regionName => $startLine) { + $this->annotationEngine->addAttributeToLine($startLine, 'data-region', $regionName); + } + + $this->activeRegions = []; + } + + public function reset(): void + { + parent::reset(); + $this->activeRegions = []; + } +} diff --git a/src/Annotations/ReindexAnnotation.php b/src/Annotations/ReindexAnnotation.php index 1e97c09..0aeb1a1 100644 --- a/src/Annotations/ReindexAnnotation.php +++ b/src/Annotations/ReindexAnnotation.php @@ -10,25 +10,27 @@ class ReindexAnnotation extends AbstractAnnotation public function process(ParsedAnnotation $annotation): void { + $range = $this->activeRange(); + if ($annotation->methodArgs != 'null' && ! in_array($annotation->methodArgs, ['vim.relative', 'vim.preserve']) && intval($annotation->methodArgs) != $annotation->methodArgs) { return; } if ($annotation->methodArgs === 'vim.relative' || $annotation->methodArgs === 'vim.preserve') { $backwardsCount = 1; - for ($i = $this->range->startLine - 2; $i >= 0; $i--) { + for ($i = $range->startLine - 2; $i >= 0; $i--) { $this->forceDisplayLine($i, $backwardsCount); $backwardsCount++; } if ($annotation->methodArgs === 'vim.preserve') { - $this->reindexLine($this->range->startLine - 1, $this->range->startLine); + $this->reindexLine($range->startLine - 1, $range->startLine); } else { - $this->reindexLine($this->range->startLine - 1, 0); + $this->reindexLine($range->startLine - 1, 0); } - for ($i = $this->range->startLine; $i < $this->lineNumbersGutter()->getMaxLineCount(); $i++) { - $this->reindexLine($i, abs($this->range->startLine - $i) + 1); + for ($i = $range->startLine; $i < $this->lineNumbersGutter()->getMaxLineCount(); $i++) { + $this->reindexLine($i, abs($range->startLine - $i) + 1); } return; @@ -37,34 +39,34 @@ public function process(ParsedAnnotation $annotation): void $offset = $annotation->methodArgs == 'null' ? null : intval($annotation->methodArgs); $wasRelative = false; - if (str_starts_with($annotation->methodArgs, '-') || str_starts_with($annotation->methodArgs, '+')) { + if (str_starts_with((string) $annotation->methodArgs, '-') || str_starts_with((string) $annotation->methodArgs, '+')) { $wasRelative = true; - $offset += $this->range->startLine; + $offset += $range->startLine; } if ($wasRelative && $annotation->range === null) { - $this->reindexLine($this->range->startLine - 1, $offset, intval($annotation->methodArgs)); + $this->reindexLine($range->startLine - 1, $offset, intval($annotation->methodArgs)); return; } - if ($wasRelative && $this->range->isSingleLine) { - $this->reindexLine($this->range->startLine - 1, $offset); - $this->reindexLine($this->range->endLine, $this->range->startLine + 1); + if ($wasRelative && $range->isSingleLine) { + $this->reindexLine($range->startLine - 1, $offset); + $this->reindexLine($range->endLine, $range->startLine + 1); return; } if ($offset == null) { - for ($i = $this->range->startLine - 1; $i < $this->range->endLine; $i++) { + for ($i = $range->startLine - 1; $i < $range->endLine; $i++) { $this->reindexLine($i, null); } - $this->reindexLine($this->range->endLine, $this->range->startLine); // Restart the counting. + $this->reindexLine($range->endLine, $range->startLine); // Restart the counting. return; } - $this->reindexLine($this->range->startLine - 1, $offset); + $this->reindexLine($range->startLine - 1, $offset); } } diff --git a/src/CommonMark/CodeBlockRenderer.php b/src/CommonMark/CodeBlockRenderer.php index 2653d47..32a50da 100644 --- a/src/CommonMark/CodeBlockRenderer.php +++ b/src/CommonMark/CodeBlockRenderer.php @@ -9,8 +9,11 @@ use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; use Phiki\Grammar\Grammar; +use Phiki\Theme\ParsedTheme; use Phiki\Theme\Theme; +use Stringable; use Torchlight\Engine\Engine; +use Torchlight\Engine\Theme\Theme as TorchlightTheme; class CodeBlockRenderer implements NodeRendererInterface { @@ -18,12 +21,15 @@ class CodeBlockRenderer implements NodeRendererInterface protected string $defaultGrammar = 'txt'; + /** + * @param string|Theme|TorchlightTheme|ParsedTheme|array $theme + */ public function __construct( - private string|array|Theme $theme, - private Engine $engine = new Engine, - private bool $withGutter = false, + private readonly string|array|Theme|TorchlightTheme|ParsedTheme $theme, + private readonly Engine $engine = new Engine, ) {} + /** @var list */ protected array $renderCallbacks = []; public function setBlockCache(?BlockCache $cache): static @@ -40,6 +46,7 @@ public function setDefaultGrammar(string $grammar): static return $this; } + /** @param Closure(string): string $callback */ public function addRenderCallback(Closure $callback): static { $this->renderCallbacks[] = $callback; @@ -54,7 +61,7 @@ public function clearRenderCallbacks(): static return $this; } - public function render(Node $node, ChildNodeRendererInterface $childRenderer) + public function render(Node $node, ChildNodeRendererInterface $childRenderer): string|Stringable|null { if (! $node instanceof FencedCode) { throw new InvalidArgumentException('Block must be instance of '.FencedCode::class); @@ -65,9 +72,9 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer) } $code = rtrim($node->getLiteral(), "\n"); - $grammar = $this->detectGrammar($node, $code); + $grammar = $this->detectGrammar($node); - $result = $this->engine->codeToHtml($code, $grammar, $this->theme, $this->withGutter, false); + $result = $this->engine->codeToHtml($code, $grammar, $this->theme); foreach ($this->renderCallbacks as $callback) { $result = $callback($result); @@ -80,10 +87,10 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer) return $result; } - protected function detectGrammar(FencedCode $node, string $code): Grammar|string + protected function detectGrammar(FencedCode $node): Grammar|string { if (! isset($node->getInfoWords()[0]) || $node->getInfoWords()[0] === '') { - return $this->engine->detectGrammar($code) ?? $this->defaultGrammar; + return $this->defaultGrammar; } return $node->getInfoWords()[0]; diff --git a/src/CommonMark/Extension.php b/src/CommonMark/Extension.php index dd27e86..aa92634 100644 --- a/src/CommonMark/Extension.php +++ b/src/CommonMark/Extension.php @@ -6,27 +6,40 @@ use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\ExtensionInterface; +use Phiki\Theme\ParsedTheme; use Phiki\Theme\Theme; +use Torchlight\Engine\Contracts\Preprocessor; use Torchlight\Engine\Engine; +use Torchlight\Engine\Options; +use Torchlight\Engine\Theme\Theme as TorchlightTheme; class Extension implements ExtensionInterface { + /** @var Closure(): (Theme|TorchlightTheme|ParsedTheme|array|string|null)|null */ protected static ?Closure $themeResolver = null; - private Engine $engine; + private readonly Engine $engine; - private CodeBlockRenderer $renderer; + private readonly CodeBlockRenderer $renderer; + /** @param Closure(): (Theme|TorchlightTheme|ParsedTheme|array|string|null) $resolver */ public static function setThemeResolver(Closure $resolver): void { static::$themeResolver = $resolver; } + /** + * @param Theme|TorchlightTheme|ParsedTheme|array|string|null $theme + * @param list|array $preprocessors + * @param Closure(string): string|list $renderCallbacks + * @param array $replacers + */ public function __construct( - private Theme|array|string|null $theme = null, - private bool $withGutter = true, + private Theme|TorchlightTheme|ParsedTheme|array|string|null $theme = null, + bool $withGutter = true, array $preprocessors = [], Closure|array $renderCallbacks = [], + array $replacers = [], ) { if (! $this->theme && static::$themeResolver) { $callback = static::$themeResolver; @@ -34,6 +47,9 @@ public function __construct( } $this->engine = new Engine; + $this->engine->setTorchlightOptions( + Options::default()->mergeWith(['withGutter' => $withGutter]) + ); foreach ($preprocessors as $lang => $preprocessor) { $this->engine->registerPreprocessor( @@ -42,7 +58,9 @@ public function __construct( ); } - $this->renderer = new CodeBlockRenderer($this->theme, $this->engine, $this->withGutter); + $this->engine->addReplacers($replacers); + + $this->renderer = new CodeBlockRenderer($this->theme ?? 'github-light', $this->engine); if (! is_array($renderCallbacks)) { $renderCallbacks = [$renderCallbacks]; @@ -58,6 +76,7 @@ public function renderer(): CodeBlockRenderer return $this->renderer; } + /** @param Closure(string): string $callback */ public function addRenderCallback(Closure $callback): static { $this->renderer->addRenderCallback($callback); diff --git a/src/Concerns/LoadsGrammars.php b/src/Concerns/LoadsGrammars.php deleted file mode 100644 index 7c58641..0000000 --- a/src/Concerns/LoadsGrammars.php +++ /dev/null @@ -1,47 +0,0 @@ - __DIR__.'/../../resources/languages/alpine.tmLanguage.json', - 'curl' => __DIR__.'/../../resources/languages/curl.tmLanguage.json', - 'env' => __DIR__.'/../../resources/languages/env.tmLanguage.json', - 'files' => __DIR__.'/../../resources/languages/files.tmLanguage.json', - 'git-ignore' => __DIR__.'/../../resources/languages/ignore.tmLanguage.json', - 'mysql-explain' => __DIR__.'/../../resources/languages/mysql-explain.tmLanguage.json', - 'php-html' => __DIR__.'/../../resources/languages/php-html.tmLanguage.json', - 'shell' => __DIR__.'/../../resources/languages/shell.tmLanguage.json', - 'makefile' => __DIR__.'/../../resources/languages/make.tmLanguage.json', - // TODO: Remove this custom grammar. This modified grammar just updates - // some custom HTML rules to help with syntax highlighting for - // some languages, such as Blade. It is just a workaround. - // https://github.com/phikiphp/phiki/issues/58 - 'html' => __DIR__.'/../../resources/languages/html.tmLanguage.json', - ]; - - public static array $aliases = [ - 'alpinejs' => 'alpine', - 'shellscript' => 'shell', - 'gitignore' => 'git-ignore', - 'pls' => 'plsql', - 'html-ruby-erb' => 'erb', - 'actionscript' => 'actionscript-3', - 'dockerfile' => 'docker', - 'make' => 'makefile', - ]; - - protected function loadGrammars(): static - { - foreach ($this->extraGrammars as $grammar => $file) { - $this->environment->getGrammarRepository()->register($grammar, $file); - } - - foreach (self::$aliases as $alias => $target) { - $this->environment->getGrammarRepository()->alias($alias, $target); - } - - return $this; - } -} diff --git a/src/Concerns/ManagesCommentTokens.php b/src/Concerns/ManagesCommentTokens.php index d563f49..cbf767d 100644 --- a/src/Concerns/ManagesCommentTokens.php +++ b/src/Concerns/ManagesCommentTokens.php @@ -6,15 +6,19 @@ trait ManagesCommentTokens { - private string $commonChars = " \t-#/"; + protected string $commonChars = " \t-#/"; - private array $trailingCommentChars = [ + /** @var array */ + protected array $trailingCommentChars = [ 'source.coq' => '*)', 'text.html.statamic' => '#}}', + 'text.html.jinja' => '#}', ]; - private array $leadingCommentChars = [ + /** @var array */ + protected array $leadingCommentChars = [ 'text.html.statamic' => '{{#', + 'text.html.jinja' => '{#', 'source.coq' => '(*', 'source.abap' => '"', 'source.actionscript.3' => '/*', @@ -26,6 +30,54 @@ trait ManagesCommentTokens 'source.cobol' => '*>', ]; + /** + * @param string $scope The TextMate scope name (e.g., 'source.mylang') + * @param string|null $leadingChars Characters to trim from the start of comments + * @param string|null $trailingChars Characters to trim from the end of comments + */ + public function registerCommentPattern( + string $scope, + ?string $leadingChars = null, + ?string $trailingChars = null + ): static { + if ($leadingChars !== null) { + $this->leadingCommentChars[$scope] = $leadingChars; + } + + if ($trailingChars !== null) { + $this->trailingCommentChars[$scope] = $trailingChars; + } + + return $this; + } + + /** + * @param array $patterns + */ + public function registerCommentPatterns(array $patterns): static + { + foreach ($patterns as $scope => $config) { + $this->registerCommentPattern( + $scope, + $config['leading'] ?? null, + $config['trailing'] ?? null + ); + } + + return $this; + } + + /** + * @return array{leading: array, trailing: array} + */ + public function getCommentPatterns(): array + { + return [ + 'leading' => $this->leadingCommentChars, + 'trailing' => $this->trailingCommentChars, + ]; + } + protected function isComment(Token $token): bool { $scopes = implode(' ', $token->scopes); diff --git a/src/Concerns/ManagesPreprocessors.php b/src/Concerns/ManagesPreprocessors.php index 32798a2..47b3cb6 100644 --- a/src/Concerns/ManagesPreprocessors.php +++ b/src/Concerns/ManagesPreprocessors.php @@ -4,15 +4,23 @@ use Closure; use Phiki\Grammar\Grammar; +use Phiki\Grammar\ParsedGrammar; +use Phiki\Token\Token; use Torchlight\Engine\Contracts\Preprocessor; use Torchlight\Engine\Preprocessors\PreprocessorArgs; trait ManagesPreprocessors { + /** @var list */ protected array $preprocessors = []; + /** @var array> */ protected array $languageSpecificPreprocessors = []; + /** + * @param string $languageName The grammar name to filter on + * @param Closure|Preprocessor $preprocessor The preprocessor to register + */ public function registerPreprocessorForLanguage(string $languageName, Closure|Preprocessor $preprocessor): static { if (! array_key_exists($languageName, $this->languageSpecificPreprocessors)) { @@ -24,6 +32,10 @@ public function registerPreprocessorForLanguage(string $languageName, Closure|Pr return $this; } + /** + * @param Closure|Preprocessor $preprocessor The preprocessor to register + * @param string|null $languageName Optional: restrict to a specific language + */ public function registerPreprocessor(Closure|Preprocessor $preprocessor, ?string $languageName = null): static { if ($languageName) { @@ -35,7 +47,12 @@ public function registerPreprocessor(Closure|Preprocessor $preprocessor, ?string return $this; } - protected function runPreprocessors(array $tokens, string $originalCode, string|Grammar $grammar, ?string $languageName, array $preprocessors): array + /** + * @param array> $tokens + * @param list $preprocessors + * @return array> + */ + protected function runPreprocessors(array $tokens, string $originalCode, string|Grammar|ParsedGrammar $grammar, ?string $languageName, array $preprocessors): array { $args = new PreprocessorArgs( $tokens, @@ -46,16 +63,25 @@ protected function runPreprocessors(array $tokens, string $originalCode, string| foreach ($preprocessors as $preprocessor) { if ($preprocessor instanceof Preprocessor) { + if (! $preprocessor->supports($languageName)) { + continue; + } + $tokens = $preprocessor->process($args, $this); } elseif (is_callable($preprocessor)) { $tokens = $preprocessor($args, $this); } } + /** @var array> $tokens */ return $tokens; } - protected function preprocess(array $tokens, string $originalCode, string|Grammar $grammar, ?string $languageName): array + /** + * @param array> $tokens + * @return array> + */ + protected function preprocess(array $tokens, string $originalCode, string|Grammar|ParsedGrammar $grammar, ?string $languageName): array { if (count($this->preprocessors) > 0) { $tokens = $this->runPreprocessors($tokens, $originalCode, $grammar, $languageName, $this->preprocessors); diff --git a/src/Concerns/ManagesReplacers.php b/src/Concerns/ManagesReplacers.php new file mode 100644 index 0000000..3d2992e --- /dev/null +++ b/src/Concerns/ManagesReplacers.php @@ -0,0 +1,65 @@ + */ + protected array $stringReplacers = []; + + /** @var list */ + protected array $callableReplacers = []; + + /** + * @param string|callable $search String to find, or a callable replacer + * @param string|null $replace Replacement string (required when $search is a string) + */ + public function addReplacer(string|callable $search, ?string $replace = null): static + { + if (is_string($search)) { + $this->stringReplacers[$search] = $replace ?? ''; + + return $this; + } + + $this->callableReplacers[] = $search; + + return $this; + } + + /** @param array|list $replacers */ + public function addReplacers(array $replacers): static + { + foreach ($replacers as $key => $value) { + if (is_string($key)) { + $this->addReplacer($key, is_string($value) ? $value : null); + } elseif (is_callable($value)) { + /** @var callable(string): string $value */ + $this->addReplacer($value); + } + } + + return $this; + } + + public function clearReplacers(): static + { + $this->stringReplacers = []; + $this->callableReplacers = []; + + return $this; + } + + protected function applyReplacers(string $html): string + { + if (count($this->stringReplacers) > 0) { + $html = strtr($html, $this->stringReplacers); + } + + foreach ($this->callableReplacers as $replacer) { + $html = $replacer($html); + } + + return $html; + } +} diff --git a/src/Concerns/ManagesThemes.php b/src/Concerns/ManagesThemes.php index 768223f..0fff62a 100644 --- a/src/Concerns/ManagesThemes.php +++ b/src/Concerns/ManagesThemes.php @@ -2,19 +2,58 @@ namespace Torchlight\Engine\Concerns; +use Phiki\Theme\ParsedTheme; +use Torchlight\Engine\Theme\Parser; + trait ManagesThemes { protected function loadThemes(): static { - $manifest = json_decode(file_get_contents(__DIR__.'/../../resources/themes/themes.json'), true); + $manifestJson = file_get_contents(__DIR__.'/../../resources/themes/themes.json'); + $decodedManifest = $manifestJson === false ? [] : json_decode($manifestJson, true); + /** @var array $manifest */ + $manifest = is_array($decodedManifest) ? $decodedManifest : []; + $parser = new Parser; foreach ($manifest as $name => $path) { - $this->environment->getThemeRepository()->register( - $name, - __DIR__.'/../../resources/themes/normalized/'.$path - ); + $themePath = __DIR__.'/../../resources/themes/normalized/'.$path; + $themeJson = file_get_contents($themePath); + $decodedTheme = $themeJson === false ? [] : json_decode($themeJson, true); + /** @var array $themeData */ + $themeData = is_array($decodedTheme) ? $decodedTheme : []; + $parsedTheme = $parser->parse($themeData); + + $this->environment->themes->register($name, $parsedTheme); + } + + return $this; + } + + /** + * @param string $name The name to register the theme under + * @param string|array|ParsedTheme $theme A file path, theme array, or ParsedTheme + */ + public function registerTheme(string $name, string|array|ParsedTheme $theme): static + { + if (is_string($theme)) { + $themeJson = file_get_contents($theme); + $decodedTheme = $themeJson === false ? [] : json_decode($themeJson, true); + /** @var array $data */ + $data = is_array($decodedTheme) ? $decodedTheme : []; + $parser = new Parser; + $theme = $parser->parse($data); + } elseif (is_array($theme)) { + $parser = new Parser; + $theme = $parser->parse($theme); } + $this->environment->themes->register($name, $theme); + return $this; } + + public function hasTheme(string $name): bool + { + return $this->environment->themes->has($name); + } } diff --git a/src/Concerns/ProcessesLanguages.php b/src/Concerns/ProcessesLanguages.php index dd7f7f0..2072b62 100644 --- a/src/Concerns/ProcessesLanguages.php +++ b/src/Concerns/ProcessesLanguages.php @@ -2,131 +2,301 @@ namespace Torchlight\Engine\Concerns; +use Phiki\Token\Token; +use Torchlight\Engine\Annotations\Parser\AnnotationTokenParser; +use Torchlight\Engine\Exceptions\InvalidJsonException; + trait ProcessesLanguages { + /** + * @param array> $tokens + * @return array> + */ protected function processLanguage(array $tokens): array { - $newTokens = []; - $currentLine = 1; + $result = []; + + foreach ($tokens as $lineIndex => $lineTokens) { + $lineNumber = $lineIndex + 1; - /** @var Token[] $line */ - foreach ($tokens as $line) { - if (! $this->annotationsEnabled) { - $newTokens[] = $line; - $currentLine++; + // If annotations are disabled, pass through unchanged + if (! $this->state->annotationsEnabled) { + $result[] = $lineTokens; continue; } - $newLineTokens = []; - $tokenLen = count($line); - $skipLine = false; + $processed = $this->processLanguageLine($lineTokens, $lineNumber); + + if ($processed !== null) { + $result[] = $processed; + } + } + + return $result; + } + + /** + * @param array $lineTokens Tokens for this line + * @param int $lineNumber 1-based line number + * @return array|null Processed tokens or null to skip this line + * + * @throws InvalidJsonException + */ + protected function processLanguageLine(array $lineTokens, int $lineNumber): ?array + { + $lineText = $this->getLineText($lineTokens); + + if ($lineNumber === 1 && str_contains((string) $lineText, $this->blockOptionsKeyword)) { + $this->parseBlockOptions($lineText); + + return null; + } + + $annotationCountBefore = count($this->state->parsedAnnotations); + + $result = $this->extractAnnotationsFromLine($lineTokens, $lineNumber); + + $normalFoundAnnotations = count($this->state->parsedAnnotations) > $annotationCountBefore; + + if ($result !== null && ! $normalFoundAnnotations) { + $result = $this->handleUniversalAnnotation($result, $lineText, $lineNumber); + } + + return $result; + } + + /** + * @param array $tokens + */ + protected function getLineText(array $tokens): string + { + $text = ''; + foreach ($tokens as $token) { + $text .= $token->text; + } + + return $text; + } + + /** + * @param array $lineTokens + * @return array|null + */ + protected function extractAnnotationsFromLine(array $lineTokens, int $lineNumber): ?array + { + $result = []; + $tokenCount = count($lineTokens); - $lineText = ''; + for ($i = 0; $i < $tokenCount; $i++) { + $token = $lineTokens[$i]; + $result[] = $token; - foreach ($line as $token) { - $lineText .= $token->text; + if (! $this->isComment($token)) { + continue; } - for ($i = 0; $i < $tokenLen; $i++) { - $token = $line[$i]; + $annotationResult = $this->findAnnotationInCommentSequence($lineTokens, $i, $tokenCount); - $newLineTokens[] = $token; + if ($annotationResult === null) { + continue; + } - if ($currentLine === 1 && str_contains($lineText, 'torchlight! ')) { - $newLineTokens = []; - $skipLine = true; + [$foundToken, $forwardedTo, $extraTokens] = $annotationResult; - $this->parseBlockOptions($lineText); - break; - } + foreach ($extraTokens as $extraToken) { + $result[] = $extraToken; + } - if (! $this->isComment($token)) { - continue; - } + $processResult = $this->processFoundAnnotation($foundToken, $result, $lineNumber, $tokenCount); - $foundToken = null; - $forwardedTo = null; - - if ($this->containsTorchlightAnnotation($token)) { - $foundToken = $token; - $forwardedTo = $i; - } else { - if ($i + 1 > $tokenLen) { - break; - } - - for ($j = $i + 1; $j < $tokenLen; $j++) { - $commentToken = $line[$j]; - - if ($this->containsTorchlightAnnotation($commentToken)) { - $foundToken = $commentToken; - $forwardedTo = $j; - break; - } - - $forwardedTo = $j; - $newLineTokens[] = $commentToken; - } - } + if ($processResult === null) { + return null; + } - if ($foundToken != null) { - if ($tokenLen === 1) { - array_pop($newLineTokens); - } - - if (! $this->beginsWithAnnotation($foundToken)) { - [$text, $newToken] = $this->removeAnnotationFromToken($foundToken); - - $this->parseAnnotationsInText($text, $currentLine); - - if (empty($newLineTokens) || $newLineTokens[array_key_last($newLineTokens)] != $newToken) { - $newLineTokens[] = $newToken; - } - $lastToken = $newLineTokens[array_key_last($newLineTokens)]; - - $cleanedText = $this->cleanCommentText($lastToken->text, $this->activeScopeName); - - if (mb_strlen($cleanedText) === 0) { - array_pop($newLineTokens); - } else { - if ($forwardedTo && $forwardedTo + 1 < $tokenLen && $this->isComment($line[$forwardedTo + 1])) { - $newLineTokens[] = $line[$forwardedTo + 1]; - $forwardedTo = null; - } - } - - if (count($newLineTokens) === 1 && mb_strlen(trim($lastToken->text)) === 0) { - array_pop($newLineTokens); - } - } else { - $this->parseAnnotationsInText($foundToken->text, $currentLine); - array_pop($newLineTokens); - - if (count($newLineTokens) === 0) { - $skipLine = true; - } - } - - unset($foundToken); - break; - } + $result = $processResult['tokens']; - // Prevent duplicating contents if we've already added it. - if ($forwardedTo) { - $i = $forwardedTo; + if ($processResult['addTrailing'] && $forwardedTo !== null && $forwardedTo + 1 < $tokenCount) { + $nextToken = $lineTokens[$forwardedTo + 1]; + if ($this->isComment($nextToken)) { + $result[] = $nextToken; } } - $currentLine++; + break; + } + + return $result; + } + + /** + * @param array $lineTokens + * @return array{0:Token, 1:int, 2:array}|null + */ + protected function findAnnotationInCommentSequence(array $lineTokens, int $startIndex, int $tokenCount): ?array + { + $token = $lineTokens[$startIndex]; + + if ($this->containsTorchlightAnnotation($token)) { + return [$token, $startIndex, []]; + } + + $extraTokens = []; + for ($j = $startIndex + 1; $j < $tokenCount; $j++) { + $nextToken = $lineTokens[$j]; + + if ($this->containsTorchlightAnnotation($nextToken)) { + return [$nextToken, $j, $extraTokens]; + } + + $extraTokens[] = $nextToken; + } + + return null; + } + + /** + * @param array $resultTokens + * @return array|null + */ + protected function handleUniversalAnnotation(array $resultTokens, string $lineText, int $lineNumber): ?array + { + $tlPos = mb_strpos($lineText, '[tl!'); + + if ($tlPos === false) { + return $resultTokens; + } + + $beforeTl = mb_substr($lineText, 0, $tlPos); + $slashSlashPos = mb_strrpos($beforeTl, '//'); + + if ($slashSlashPos === false) { + return $resultTokens; + } + + // Ensure the // is preceded by whitespace or is at line start + // Prevents matching :// in URLs like http://... and making a mess. + if ($slashSlashPos > 0) { + $charBefore = mb_substr($lineText, $slashSlashPos - 1, 1); + + if ($charBefore !== ' ' && $charBefore !== "\t") { + return $resultTokens; + } + } + + $annotationSegment = mb_substr($lineText, $slashSlashPos); + + if (! preg_match('/^\/\/\s*\[tl!/', $annotationSegment)) { + return $resultTokens; + } + + if (! preg_match(AnnotationTokenParser::ANNOTATION_PATTERN, $annotationSegment)) { + return $resultTokens; + } + + $this->parseAnnotationsInText($annotationSegment, $lineNumber); + + $stripFrom = mb_strlen(rtrim(mb_substr($lineText, 0, $slashSlashPos))); + $strippedTokens = $this->stripTextFromTokens($resultTokens, $stripFrom); + + if (empty($strippedTokens)) { + return null; + } + + $hasContent = false; + + foreach ($strippedTokens as $token) { + if (trim((string) $token->text) !== '') { + $hasContent = true; + + break; + } + } + + if (! $hasContent) { + return null; + } - if ($skipLine) { + return $strippedTokens; + } + + /** + * @param array $tokens + * @return array + */ + protected function stripTextFromTokens(array $tokens, int $stripFromPos): array + { + $result = []; + $currentPos = 0; + + foreach ($tokens as $token) { + $tokenLen = mb_strlen((string) $token->text); + $tokenEnd = $currentPos + $tokenLen; + + if ($tokenEnd <= $stripFromPos) { + $result[] = $token; + } elseif ($currentPos >= $stripFromPos) { continue; + } else { + $token->text = mb_substr((string) $token->text, 0, $stripFromPos - $currentPos); + + if (mb_strlen($token->text) > 0) { + $result[] = $token; + } } - $newTokens[] = $newLineTokens; + $currentPos = $tokenEnd; + } + + return $result; + } + + /** + * @param array $resultTokens + * @return array{tokens: array, addTrailing: bool}|null + */ + protected function processFoundAnnotation(Token $foundToken, array $resultTokens, int $lineNumber, int $tokenCount): ?array + { + if ($tokenCount === 1) { + array_pop($resultTokens); + } + + // Case 1: Annotation is the entire token + if ($this->beginsWithAnnotation($foundToken)) { + $this->parseAnnotationsInText($foundToken->text, $lineNumber); + array_pop($resultTokens); + + if (count($resultTokens) === 0) { + return null; // Skip line - it was only an annotation + } + + return ['tokens' => $resultTokens, 'addTrailing' => false]; + } + + // Case 2: Annotation is embedded in a comment + [$originalText, $cleanedToken] = $this->removeAnnotationFromToken($foundToken); + $this->parseAnnotationsInText($originalText, $lineNumber); + + // Add the cleaned token if not already there + if (empty($resultTokens) || $resultTokens[array_key_last($resultTokens)] !== $cleanedToken) { + $resultTokens[] = $cleanedToken; + } + + $lastToken = $resultTokens[array_key_last($resultTokens)]; + $cleanedText = $this->cleanCommentText($lastToken->text, $this->state->activeScopeName); + + // Remove empty comment tokens + if (mb_strlen((string) $cleanedText) === 0) { + array_pop($resultTokens); + + return ['tokens' => $resultTokens, 'addTrailing' => false]; + } + + // Remove whitespace-only single tokens + if (count($resultTokens) === 1 && mb_strlen(trim((string) $lastToken->text)) === 0) { + array_pop($resultTokens); } - return $newTokens; + return ['tokens' => $resultTokens, 'addTrailing' => true]; } } diff --git a/src/Concerns/ProcessesTextJson.php b/src/Concerns/ProcessesTextJson.php index 77922c3..f23509a 100644 --- a/src/Concerns/ProcessesTextJson.php +++ b/src/Concerns/ProcessesTextJson.php @@ -2,21 +2,26 @@ namespace Torchlight\Engine\Concerns; +use Phiki\Token\Token; use Torchlight\Engine\Exceptions\InvalidJsonException; trait ProcessesTextJson { /** + * @param array> $tokens + * @return array> + * * @throws InvalidJsonException */ protected function processTextOrJson(array $tokens): array { + /** @var array> $newTokens */ $newTokens = []; $currentLine = 1; /** @var Token[] $line */ foreach ($tokens as $line) { - if (! $this->annotationsEnabled) { + if (! $this->state->annotationsEnabled) { $newTokens[] = $line; $currentLine++; @@ -32,12 +37,12 @@ protected function processTextOrJson(array $tokens): array $token = $line[$i]; if ($currentLine === 1) { - if ($this->isPlainText() && str_contains($token->text, 'torchlight! ')) { + if ($this->isPlainText() && str_contains($token->text, $this->blockOptionsKeyword)) { $this->parseBlockOptions($token->text); $skipLine = true; break; - } elseif ($this->isComment($token) && $i + 1 < $tokenLen && str_contains($line[$i + 1]->text, 'torchlight! ')) { + } elseif ($this->isComment($token) && $i + 1 < $tokenLen && str_contains($line[$i + 1]->text, $this->blockOptionsKeyword)) { $this->parseBlockOptions($line[$i + 1]->text); $skipLine = true; @@ -54,7 +59,7 @@ protected function processTextOrJson(array $tokens): array [$annotationText, $newToken] = $this->removeAnnotationFromToken($token); if ($this->isPlainText()) { - $trimmedText = rtrim($newToken->text); + $trimmedText = rtrim((string) $newToken->text); // Clean up danging comments if (str_ends_with($trimmedText, '//')) { @@ -85,6 +90,7 @@ protected function processTextOrJson(array $tokens): array $newTokens[] = $newLineTokens; } + /** @var array> $newTokens */ return $newTokens; } } diff --git a/src/Contracts/BlockDecorator.php b/src/Contracts/BlockDecorator.php new file mode 100644 index 0000000..bc187d1 --- /dev/null +++ b/src/Contracts/BlockDecorator.php @@ -0,0 +1,22 @@ + $tokens + */ + public function renderLine(int $relativeLine, int $index, array $tokens): string; + + public function renderSpacer(): string; + + public function shouldRender(): bool; + + public function reset(): void; + + public function getPriority(): int; + + public function decorateLine(int $relativeLine, int $index, GenerationOptions $options): void; +} diff --git a/src/Contracts/Preprocessor.php b/src/Contracts/Preprocessor.php index adf2f49..28f078b 100644 --- a/src/Contracts/Preprocessor.php +++ b/src/Contracts/Preprocessor.php @@ -2,10 +2,18 @@ namespace Torchlight\Engine\Contracts; +use Phiki\Token\Token; use Torchlight\Engine\Engine; use Torchlight\Engine\Preprocessors\PreprocessorArgs; interface Preprocessor { + /** + * @param PreprocessorArgs $args Arguments containing tokens, code, grammar, etc. + * @param Engine $engine The engine instance + * @return array> Modified tokens + */ public function process(PreprocessorArgs $args, Engine $engine): array; + + public function supports(?string $grammarName): bool; } diff --git a/src/Contracts/TokenTransformer.php b/src/Contracts/TokenTransformer.php new file mode 100644 index 0000000..7e52380 --- /dev/null +++ b/src/Contracts/TokenTransformer.php @@ -0,0 +1,20 @@ +> $tokens + * @return array> + */ + public function transform(RenderContext $context, array $tokens): array; + + /** + * @param string $grammarName The grammar name being rendered + */ + public function supports(string $grammarName): bool; +} diff --git a/src/Engine.php b/src/Engine.php index b39f2f8..99e41dd 100644 --- a/src/Engine.php +++ b/src/Engine.php @@ -3,106 +3,176 @@ namespace Torchlight\Engine; use InvalidArgumentException; -use Phiki\Environment\Environment; +use Phiki\Environment; use Phiki\Grammar\Grammar; -use Phiki\Grammar\GrammarRepository; -use Phiki\Phiki as BasePhiki; -use Phiki\Theme\Theme; +use Phiki\Grammar\ParsedGrammar; +use Phiki\Highlighting\Highlighter; +use Phiki\TextMate\Tokenizer; +use Phiki\Theme\ParsedTheme; +use Phiki\Theme\Theme as PhikiTheme; +use Phiki\Token\HighlightedToken; use Phiki\Token\Token; -use Phiki\Tokenizer; use Torchlight\Engine\Annotations\AbstractAnnotation; +use Torchlight\Engine\Annotations\AnnotationEngine; +use Torchlight\Engine\Annotations\Attributes\CssClassAnnotation; +use Torchlight\Engine\Annotations\Attributes\IdAnnotation; use Torchlight\Engine\Annotations\AutoLinkAnnotation; +use Torchlight\Engine\Annotations\CodeLensAnnotation; use Torchlight\Engine\Annotations\CollapseAnnotation; use Torchlight\Engine\Annotations\Diff\DiffAddAnnotation; use Torchlight\Engine\Annotations\Diff\DiffRemoveAnnotation; +use Torchlight\Engine\Annotations\Diff\WordDiffAnnotation; use Torchlight\Engine\Annotations\FocusAnnotation; +use Torchlight\Engine\Annotations\GutterContentAnnotation; +use Torchlight\Engine\Annotations\HideAnnotation; use Torchlight\Engine\Annotations\HighlightAnnotation; +use Torchlight\Engine\Annotations\LinkAnnotation; +use Torchlight\Engine\Annotations\MacroAnnotation; +use Torchlight\Engine\Annotations\MarkAnnotation; use Torchlight\Engine\Annotations\MonoAnnotation; use Torchlight\Engine\Annotations\Parser\AnnotationTokenParser; use Torchlight\Engine\Annotations\Parser\AnnotationType; use Torchlight\Engine\Annotations\Parser\ParsedAnnotation; -use Torchlight\Engine\Annotations\Processor; use Torchlight\Engine\Annotations\Ranges\AnnotationRange; use Torchlight\Engine\Annotations\Ranges\RangeType; +use Torchlight\Engine\Annotations\RegionAnnotation; use Torchlight\Engine\Annotations\ReindexAnnotation; -use Torchlight\Engine\Concerns\LoadsGrammars; use Torchlight\Engine\Concerns\ManagesCommentTokens; use Torchlight\Engine\Concerns\ManagesPreprocessors; +use Torchlight\Engine\Concerns\ManagesReplacers; use Torchlight\Engine\Concerns\ManagesThemes; -use Torchlight\Engine\Concerns\MergesTokens; use Torchlight\Engine\Concerns\ProcessesLanguages; use Torchlight\Engine\Concerns\ProcessesTextJson; +use Torchlight\Engine\Contracts\BlockDecorator; +use Torchlight\Engine\Contracts\TokenTransformer; use Torchlight\Engine\Exceptions\InvalidJsonException; +use Torchlight\Engine\Generators\BlockDecorators\CopyTargetDecorator; +use Torchlight\Engine\Generators\GeneratorFactory; use Torchlight\Engine\Generators\HtmlGenerator; use Torchlight\Engine\Generators\RenderedBlock; +use Torchlight\Engine\Generators\TokenTransformers\FileTreeTransformer; +use Torchlight\Engine\Generators\TokenTransformers\IndentGuideTransformer; +use Torchlight\Engine\Pipeline\ProcessedTokens; +use Torchlight\Engine\Pipeline\RenderState; use Torchlight\Engine\Support\Str; -use Torchlight\Engine\Theme\Highlighting\Highlighter; -use Torchlight\Engine\Theme\ThemeRepository; +use Torchlight\Engine\Support\TokenMerger; +use Torchlight\Engine\Theme\Theme; -class Engine extends BasePhiki +class Engine { public const VERSION = '0.1.0'; - use LoadsGrammars, - ManagesCommentTokens, + use ManagesCommentTokens, ManagesPreprocessors, + ManagesReplacers, ManagesThemes, - MergesTokens, ProcessesLanguages, ProcessesTextJson; + /** + * @var array + */ + protected array $extraGrammars = [ + 'alpine' => __DIR__.'/../resources/languages/alpine.tmLanguage.json', + 'curl' => __DIR__.'/../resources/languages/curl.tmLanguage.json', + 'env' => __DIR__.'/../resources/languages/env.tmLanguage.json', + 'files' => __DIR__.'/../resources/languages/files.tmLanguage.json', + 'git-ignore' => __DIR__.'/../resources/languages/ignore.tmLanguage.json', + 'mysql-explain' => __DIR__.'/../resources/languages/mysql-explain.tmLanguage.json', + 'php-html' => __DIR__.'/../resources/languages/php-html.tmLanguage.json', + 'shell' => __DIR__.'/../resources/languages/shell.tmLanguage.json', + 'makefile' => __DIR__.'/../resources/languages/make.tmLanguage.json', + 'jinja-html' => __DIR__.'/../vendor/phiki/phiki/resources/grammars/jinja-html.json', + ]; + + /** + * Grammar aliases for common alternative names. + * + * @var array + */ + public static array $grammarAliases = [ + 'alpinejs' => 'alpine', + 'shellscript' => 'shell', + 'gitignore' => 'git-ignore', + 'pls' => 'plsql', + 'html-ruby-erb' => 'erb', + 'actionscript' => 'actionscript-3', + 'dockerfile' => 'docker', + 'make' => 'makefile', + ]; + + /** @var list */ protected array $plainTextScopes = [ 'text.txt', + 'text.plain', 'text.bibtex', 'text.csv', 'text.tsv', ]; + /** @var array */ protected array $commonVanityLabels = [ 'php-html' => 'php', ]; - protected string $activeScopeName = ''; - protected AnnotationTokenParser $annotationParser; - /** - * @var \Torchlight\Engine\Annotations\Parser\ParsedAnnotation[] - */ - protected array $parsedAnnotations = []; - - protected Processor $annotationEngine; + protected AnnotationEngine $annotationEngine; protected Options $torchlightOptions; - protected int $sourceLineOffset = 0; + protected ?Options $userBaseOptions = null; - protected bool $annotationsEnabled = true; + protected RenderState $state; - protected string $cleanedText = ''; + protected string $blockOptionsKeyword = 'torchlight! '; - protected ?array $overrideThemes = null; + /** + * @var array> + */ + protected array $grammarTransformers = []; - protected string $languageVanityLabel = ''; + /** + * @var list + */ + protected array $tokenTransformerFactories = []; - public function __construct(?Environment $environment = null) - { - if ($environment === null) { - $environment = new Environment; - $environment->disableStrictMode() - ->useGrammarRepository(new GrammarRepository) - ->useThemeRepository(new ThemeRepository); - } + /** + * @var list + */ + protected array $blockDecoratorFactories = []; - parent::__construct($environment); + protected GeneratorFactory $generatorFactory; + public function __construct(protected Environment $environment = new Environment) + { $this->torchlightOptions = Options::default(); + $this->state = new RenderState; - $this->annotationEngine = new Processor; + $this->annotationEngine = new AnnotationEngine; $this->annotationParser = new AnnotationTokenParser; + $this->generatorFactory = new GeneratorFactory; + + $this->annotationEngine->getRegistry() + ->registerAnnotation(new CssClassAnnotation($this->annotationEngine)) + ->registerAnnotation(new IdAnnotation($this->annotationEngine)); + + $this->syncAnnotationParser(); + + $this->registerGrammarTransformer('php', function (string $code): ?string { + if (str_contains($code, 'registerTokenTransformerFactory(fn () => new FileTreeTransformer) + ->registerTokenTransformerFactory(fn () => new IndentGuideTransformer) + ->registerBlockDecoratorFactory(fn () => new CopyTargetDecorator) ->loadThemes() ->loadGrammars() ->addDefaultAnnotations(); @@ -119,14 +189,37 @@ protected function addDefaultAnnotations(): static DiffRemoveAnnotation::class, CollapseAnnotation::class, MonoAnnotation::class, + WordDiffAnnotation::class, + RegionAnnotation::class, + GutterContentAnnotation::class, + MarkAnnotation::class, + HideAnnotation::class, + LinkAnnotation::class, + CodeLensAnnotation::class, ]); } + protected function loadGrammars(): static + { + foreach ($this->extraGrammars as $grammar => $file) { + $this->environment->grammars->register($grammar, $file); + } + + foreach (self::$grammarAliases as $alias => $target) { + $this->environment->grammars->alias($alias, $target); + } + + return $this; + } + public function getEnvironment(): Environment { return $this->environment; } + /** + * @param list> $classNames + */ public function addAnnotations(array $classNames): static { foreach ($classNames as $className) { @@ -145,64 +238,120 @@ public function addAnnotation(string $className): static /** @var AbstractAnnotation $instance */ $instance = new $className($this->annotationEngine); - $this->annotationEngine->addAnnotation($instance::$name, $instance); - $this->annotationParser->addAnnotationName($instance::$name); + $this->annotationEngine->getRegistry()->registerAnnotation($instance); - foreach ($instance::$aliases as $alias) { - $this->annotationEngine->addAnnotation($alias, $instance); - $this->annotationParser->addAnnotationName($alias); - } + $this->syncAnnotationParser(); return $this; } - protected function reset(): void + /** + * @param list $components + */ + public function registerAnnotationMacro(string $name, array $components): static { - $this->languageVanityLabel = ''; - $this->overrideThemes = null; - $this->annotationsEnabled = true; - $this->sourceLineOffset = 0; - $this->annotationEngine->reset(); - $this->annotationParser->reset(); - $this->parsedAnnotations = []; + $macro = new MacroAnnotation($this->annotationEngine, $name, $components); + + $this->annotationEngine->getRegistry()->register($name, $macro); + + $this->syncAnnotationParser(); + + return $this; } - protected function makeGenerator(?string $grammarName, array $themes, bool $withGutter = false): HtmlGenerator + public function registerAnnotation(string $name, \Closure $callback, bool $charRanges = false): static { - $generator = new HtmlGenerator( - $grammarName, - $themes, - $withGutter + $annotation = new Annotations\ClosureAnnotation( + $this->annotationEngine, + $name, + $callback, + $charRanges, ); - $generator->setHighlighter($this->makeHighlighter($themes)); + $this->annotationEngine->getRegistry()->register($name, $annotation); - $options = $this->annotationEngine->getGenerationOptions(); + $this->syncAnnotationParser(); + + return $this; + } + + public function removeAnnotation(string $name): static + { + $this->annotationEngine->getRegistry()->unregister($name); + + $this->syncAnnotationParser(); + + return $this; + } - foreach ($options->gutters as $gutter) { - $gutter->setHtmlGenerator($generator); + protected function beginNewRender(): void + { + $this->state = new RenderState; + $this->annotationEngine->reset(); + $this->annotationParser->reset(); + } + + private function syncAnnotationParser(): void + { + $registry = $this->annotationEngine->getRegistry(); + $this->annotationParser + ->setAnnotationNames(array_values($registry->getRegisteredNames())) + ->setRegisteredPrefixes(array_values($registry->getRegisteredPrefixes())); + } + + /** + * @param array $themes + */ + protected function makeGenerator(?string $grammarName, array $themes, Highlighter $highlighter): HtmlGenerator + { + return $this->generatorFactory + ->setTokenTransformerFactories($this->tokenTransformerFactories) + ->setBlockDecoratorFactories($this->blockDecoratorFactories) + ->create( + $grammarName, + $themes, + $highlighter, + $this->annotationEngine, + $this->torchlightOptions, + $this->state->cleanedText, + $this->state->languageVanityLabel, + ); + } + + protected function prepareGrammar(string $code, Grammar|string $grammar): PreparedGrammar + { + $vanityLabel = ''; + + if (is_string($grammar) && isset($this->grammarTransformers[$grammar])) { + foreach ($this->grammarTransformers[$grammar] as $transformer) { + $result = $transformer($code, $grammar); + if ($result !== null) { + $grammar = $result; + break; + } + } } - foreach ($this->annotationEngine->getAnnotations() as $annotation) { - $annotation->setHtmlGenerator($generator); + if ((is_string($grammar) && mb_strlen($grammar) > 0) && ! $this->environment->grammars->has($grammar) && $this->torchlightOptions->fallbackOnUnknownGrammar) { + $vanityLabel = $grammar; + $grammar = 'plaintext'; } - $generator - ->setGenerationOptions($options) - ->setCleanedText($this->cleanedText) - ->setLanguageVanityLabel($this->languageVanityLabel); + if (is_string($grammar) && array_key_exists($grammar, $this->commonVanityLabels)) { + $vanityLabel = $this->commonVanityLabels[$grammar]; + } - return $generator; + return new PreparedGrammar($grammar, $vanityLabel); } protected function isPlainText(): bool { - return in_array($this->activeScopeName, $this->plainTextScopes); + return in_array($this->state->activeScopeName, $this->plainTextScopes); } protected function isJson(): bool { - return $this->activeScopeName === 'source.json'; + return $this->state->activeScopeName === 'source.json'; } protected function beginsWithAnnotation(Token $token): bool @@ -214,7 +363,7 @@ protected function containsTorchlightAnnotation(Token $token): bool { preg_match_all(AnnotationTokenParser::ANNOTATION_PATTERN, $token->text, $matches); - if (empty($matches) || empty($matches[0])) { + if (empty($matches[0])) { return false; } @@ -229,25 +378,28 @@ protected function containsTorchlightAnnotation(Token $token): bool return false; } + /** + * @return array{0:string, 1:Token} + */ protected function removeAnnotationFromToken(Token $token): array { $originalText = $token->text; - $token->text = preg_replace(AnnotationTokenParser::ANNOTATION_PATTERN, '', $token->text); - $token->text = $this->cleanCommentText($token->text, $this->activeScopeName); + $token->text = preg_replace(AnnotationTokenParser::ANNOTATION_PATTERN, '', $token->text) ?? $token->text; + $token->text = $this->cleanCommentText($token->text, $this->state->activeScopeName); return [$originalText, $token]; } protected function parseAnnotationsInText(string $text, int $line): void { - foreach ($this->annotationParser->parseText($text, $line - $this->sourceLineOffset)->annotations as $annotation) { - $this->parsedAnnotations[] = $annotation; + foreach ($this->annotationParser->parseText($text, $line - $this->state->sourceLineOffset)->annotations as $annotation) { + $this->state->parsedAnnotations[] = $annotation; } } protected function parseBlockOptions(string $text): void { - $this->sourceLineOffset = 1; + $this->state->sourceLineOffset = 1; $text = Str::after($text, '{'); $text = Str::beforeLast($text, '}'); @@ -258,25 +410,21 @@ protected function parseBlockOptions(string $text): void throw new InvalidJsonException("{$jsonError} when parsing options [{$text}]."); } - $this->torchlightOptions = Options::fromArray(array_merge($this->torchlightOptions->toArray(), $jsonResult)); + /** @var array $blockOptions */ + $blockOptions = is_array($jsonResult) ? $jsonResult : []; + $this->torchlightOptions = $this->torchlightOptions->mergeWith($blockOptions); if (count($this->torchlightOptions->themes) > 0) { - $this->overrideThemes = $this->torchlightOptions->themes; - - // TODO: Review. - if (count($this->overrideThemes) === 1 && ! is_string(array_keys($this->overrideThemes)[0])) { - $this->overrideThemes = [ - 'light' => $this->overrideThemes[0], - ]; - } + $this->state->overrideThemes = $this->torchlightOptions->themes; } - $this->annotationsEnabled = $this->torchlightOptions->annotationsEnabled; + $this->state->annotationsEnabled = $this->torchlightOptions->annotationsEnabled; } public function setTorchlightOptions(Options $options): static { $this->torchlightOptions = $options; + $this->userBaseOptions = $options; return $this; } @@ -286,6 +434,231 @@ public function getTorchlightOptions(): Options return $this->torchlightOptions; } + public function registerVanityLabel(string $grammarName, string $displayLabel): static + { + $this->commonVanityLabels[$grammarName] = $displayLabel; + + return $this; + } + + /** + * @return array + */ + public function getVanityLabels(): array + { + return $this->commonVanityLabels; + } + + public function registerPlainTextScope(string $scope): static + { + if (! in_array($scope, $this->plainTextScopes)) { + $this->plainTextScopes[] = $scope; + } + + return $this; + } + + public function unregisterPlainTextScope(string $scope): static + { + $this->plainTextScopes = array_values(array_filter( + $this->plainTextScopes, + fn (string $s): bool => $s !== $scope + )); + + return $this; + } + + /** + * @return string[] + */ + public function getPlainTextScopes(): array + { + return $this->plainTextScopes; + } + + public function setBlockOptionsKeyword(string $keyword): static + { + $this->blockOptionsKeyword = $keyword; + + return $this; + } + + public function getBlockOptionsKeyword(): string + { + return $this->blockOptionsKeyword; + } + + public function addGutter(string $name, Generators\Gutters\AbstractGutter $gutter): static + { + $this->annotationEngine->addGutter($name, $gutter); + + return $this; + } + + public function removeGutter(string $name): static + { + $this->annotationEngine->removeGutter($name); + + return $this; + } + + public function hasGutter(string $name): bool + { + return $this->annotationEngine->hasGutter($name); + } + + /** + * @return Generators\Gutters\AbstractGutter[] + */ + public function getGutters(): array + { + return $this->annotationEngine->getGutters(); + } + + public function setGutterPriority(string $name, int $priority): static + { + $this->annotationEngine->setGutterPriority($name, $priority); + + return $this; + } + + public function placeGutterAfter(string $gutter, string $afterGutter): static + { + $this->annotationEngine->placeGutterAfter($gutter, $afterGutter); + + return $this; + } + + public function placeGutterBefore(string $gutter, string $beforeGutter): static + { + $this->annotationEngine->placeGutterBefore($gutter, $beforeGutter); + + return $this; + } + + public function getAnnotationEngine(): AnnotationEngine + { + return $this->annotationEngine; + } + + /** @param callable(string, string): ?string $transformer */ + public function registerGrammarTransformer(string $grammar, callable $transformer): static + { + if (! isset($this->grammarTransformers[$grammar])) { + $this->grammarTransformers[$grammar] = []; + } + + $this->grammarTransformers[$grammar][] = $transformer; + + return $this; + } + + public function removeGrammarTransformer(string $grammar, int $index): static + { + if (isset($this->grammarTransformers[$grammar][$index])) { + array_splice($this->grammarTransformers[$grammar], $index, 1); + + // Clean up empty arrays + if (empty($this->grammarTransformers[$grammar])) { + unset($this->grammarTransformers[$grammar]); + } + } + + return $this; + } + + public function removeGrammarTransformers(string $grammar): static + { + unset($this->grammarTransformers[$grammar]); + + return $this; + } + + /** + * @return array> + */ + public function getGrammarTransformers(): array + { + return $this->grammarTransformers; + } + + /** @param callable(): TokenTransformer $factory */ + public function registerTokenTransformerFactory(callable $factory): static + { + $this->tokenTransformerFactories[] = $factory; + + return $this; + } + + public function registerTokenTransformer(TokenTransformer $transformer): static + { + return $this->registerTokenTransformerFactory(fn () => $transformer); + } + + public function removeTokenTransformerFactory(int $index): static + { + if (isset($this->tokenTransformerFactories[$index])) { + array_splice($this->tokenTransformerFactories, $index, 1); + } + + return $this; + } + + public function clearTokenTransformerFactories(): static + { + $this->tokenTransformerFactories = []; + + return $this; + } + + /** + * @return list + */ + public function getTokenTransformerFactories(): array + { + return $this->tokenTransformerFactories; + } + + /** @param callable(): BlockDecorator $factory */ + public function registerBlockDecoratorFactory(callable $factory): static + { + $this->blockDecoratorFactories[] = $factory; + + return $this; + } + + public function registerBlockDecorator(BlockDecorator $decorator): static + { + return $this->registerBlockDecoratorFactory(fn () => $decorator); + } + + public function removeBlockDecoratorFactory(int $index): static + { + if (isset($this->blockDecoratorFactories[$index])) { + array_splice($this->blockDecoratorFactories, $index, 1); + } + + return $this; + } + + public function clearBlockDecoratorFactories(): static + { + $this->blockDecoratorFactories = []; + + return $this; + } + + /** + * @return list + */ + public function getBlockDecoratorFactories(): array + { + return $this->blockDecoratorFactories; + } + + /** + * @param array> $lines + */ protected function extractText(array $lines): string { $text = ''; @@ -304,6 +677,13 @@ protected function extractText(array $lines): string return $text; } + /** + * @throws InvalidJsonException + */ + /** + * @param array> $tokens + * @return array> + */ protected function parseAnnotationTokens(array $tokens): array { if ($this->isJson() || $this->isPlainText()) { @@ -312,33 +692,50 @@ protected function parseAnnotationTokens(array $tokens): array $processedTokens = $this->processLanguage($tokens); } - $this->cleanedText = $this->extractText($processedTokens); + /** @var array> $processedTokens */ + $this->state->cleanedText = $this->extractText($processedTokens); return $processedTokens; } - public function getTokens(string $code, string|Grammar $grammar): array + /** + * @internal + */ + /** + * @return array> + */ + public function getTokens(string $code, string|Grammar|ParsedGrammar $grammar): array { $languageName = is_string($grammar) ? $grammar : null; - $grammar = $this->environment->resolveGrammar($grammar); + /** @var ParsedGrammar $grammar */ + $grammar = $this->environment->grammars->resolve($grammar); - if (! $languageName) { + if ($languageName === null) { $languageName = $grammar->name; } - if (property_exists($grammar, 'scopeName')) { - $this->activeScopeName = $grammar->scopeName; - } else { - $this->activeScopeName = 'text.txt'; + if ($languageName === null) { + throw new InvalidArgumentException('Unable to resolve language name.'); } + $this->state->resolvedGrammar = $grammar; + $this->state->resolvedLanguageName = $languageName; + + $this->state->activeScopeName = $grammar->scopeName; + $tokenizer = new Tokenizer($grammar, $this->environment); - return $tokenizer->tokenize($code); + /** @var array> $tokenLines */ + $tokenLines = $tokenizer->tokenize($code); + + return $tokenLines; } - public function codeToTokens(string $code, string|Grammar $grammar): array + /** + * @throws InvalidJsonException + */ + public function processCode(string $code, string|Grammar|ParsedGrammar $grammar): ProcessedTokens { if (is_string($grammar) && ! $grammar) { $grammar = 'text'; @@ -346,32 +743,45 @@ public function codeToTokens(string $code, string|Grammar $grammar): array $tokens = $this->getTokens($code, $grammar); - $languageName = is_string($grammar) ? $grammar : null; - $grammar = $this->environment->resolveGrammar($grammar); + /** @var array> $tokens */ + $tokens = TokenMerger::merge($tokens); - if (! $languageName) { - $languageName = $grammar->name; + $resolvedGrammar = $this->state->resolvedGrammar; + if ($resolvedGrammar === null) { + throw new InvalidArgumentException('Resolved grammar is required before preprocessing.'); } - $tokens = $this->mergeTokens($tokens); + $tokens = $this->preprocess($tokens, $code, $resolvedGrammar, $this->state->resolvedLanguageName); - $tokens = $this->preprocess($tokens, $code, $grammar, $languageName); + $tokens = $this->parseAnnotationTokens($tokens); - return $this->parseAnnotationTokens($tokens); + return new ProcessedTokens( + tokens: $tokens, + cleanedText: $this->state->cleanedText, + grammar: $this->state->resolvedGrammar, + languageName: $this->state->resolvedLanguageName, + scopeName: $this->state->activeScopeName, + ); } - private function makeHighlighter($themes): Highlighter + /** + * @param array $themes + */ + private function makeHighlighter(array $themes): Highlighter { return new Highlighter($themes); } + /** + * @param list $ranges + */ protected function createAnnotationsFromOptions(string $annotationName, array $ranges): static { foreach ($ranges as $range) { [$start, $end] = $range; $annotation = new ParsedAnnotation; - $annotation->index = count($this->parsedAnnotations); + $annotation->index = count($this->state->parsedAnnotations); $annotation->sourceLine = $start; $annotation->name = $annotationName; $annotation->type = AnnotationType::Named; @@ -383,7 +793,7 @@ protected function createAnnotationsFromOptions(string $annotationName, array $r $annotation->range = $annotationRange; - $this->parsedAnnotations[] = $annotation; + $this->state->parsedAnnotations[] = $annotation; } return $this; @@ -397,88 +807,131 @@ private function createAllAnnotationsFromOptions(): void ->createAnnotationsFromOptions('remove', $this->torchlightOptions->removeLines) ->createAnnotationsFromOptions('focus', $this->torchlightOptions->focusLines) ->createAnnotationsFromOptions('autolink', $this->torchlightOptions->autolinkLines) - ->createAnnotationsFromOptions('mono', $this->torchlightOptions->monoLines); + ->createAnnotationsFromOptions('mono', $this->torchlightOptions->monoLines) + ->createAnnotationsFromOptions('hide', $this->torchlightOptions->hideLines); } - private function getHtmlGeneratorForCode(string $code, Grammar|string $grammar, Theme|array|string $theme, bool $withGutter = false, bool $withWrapper = false): array + /** + * @param string|PhikiTheme|ParsedTheme|Theme|array $theme + */ + public function renderCode(string $code, Grammar|string $grammar, PhikiTheme|ParsedTheme|Theme|array|string $theme): RenderedBlock { - if ($grammar === 'php') { - if (str_contains($code, 'buildRenderedBlock($code, $grammar, $theme); + $block->code = $this->applyReplacers($block->code); - $this->torchlightOptions = Options::default(); + return $block; + } - $this->reset(); + /** + * @param string|PhikiTheme|ParsedTheme|Theme|array $theme + */ + public function codeToHtml(string $code, Grammar|string $grammar, PhikiTheme|ParsedTheme|Theme|array|string $theme): string + { + return $this->applyReplacers( + $this->buildRenderedBlock($code, $grammar, $theme)->toHtml() + ); + } - if ((is_string($grammar) && mb_strlen($grammar) > 0) && ! $this->environment->getGrammarRepository()->has($grammar) && $this->torchlightOptions->fallbackOnUnknownGrammar) { - $this->languageVanityLabel = $grammar; - $grammar = 'plaintext'; + /** + * @throws InvalidJsonException + */ + /** + * @param string|PhikiTheme|ParsedTheme|Theme|array $theme + */ + private function buildRenderedBlock(string $code, Grammar|string $grammar, PhikiTheme|ParsedTheme|Theme|array|string $theme, bool $withGutter = false): RenderedBlock + { + $this->torchlightOptions = $this->userBaseOptions ?? Options::default(); + + if ($withGutter) { + /** @var array $gutterOptions */ + $gutterOptions = ['withGutter' => true]; + $this->torchlightOptions = $this->torchlightOptions->mergeWith($gutterOptions); } + $this->beginNewRender(); + + $prepared = $this->prepareGrammar($code, $grammar); + $grammar = $prepared->grammar; + $this->state->languageVanityLabel = $prepared->vanityLabel; + if (is_string($theme) && str_contains($theme, ':')) { $theme = Options::adjustOptionThemes([$theme]); } - if (is_string($grammar) && array_key_exists($grammar, $this->commonVanityLabels)) { - $this->languageVanityLabel = $this->commonVanityLabels[$grammar]; - } - - // Remove trailing whitespace. $code = rtrim($code); - // We need to tokenize the code first as this will - // retrieve the annotation information which we - // need to have for the remaining processes - $tokens = $this->codeToTokens($code, $grammar); - $themes = $this->wrapThemes($this->overrideThemes ?? $theme); + $tokens = $this->processCode($code, $grammar)->tokens; + $themes = $this->wrapThemes($this->state->overrideThemes ?? $theme); + + $this->createAllAnnotationsFromOptions(); + $highlighter = $this->makeHighlighter($themes); $generator = $this->makeGenerator( - match (true) { - is_string($grammar) => $grammar, - default => $this->environment->resolveGrammar($grammar)->name, - }, - $this->wrapThemes($this->overrideThemes ?? $theme), - $withGutter + $prepared->getName(), + $themes, + $highlighter, ); - $this->createAllAnnotationsFromOptions(); - $highlighter = $this->makeHighlighter($themes); + $parsedAnnotations = array_values($this->state->parsedAnnotations); + /** @var array> $annotatableTokens */ + $annotatableTokens = $tokens; $tokens = $this->annotationEngine ->setHighlighter($highlighter) ->setTorchlightOptions($this->torchlightOptions) ->process( - $this->parsedAnnotations, - $tokens, + $parsedAnnotations, + $annotatableTokens, ); $generator ->setTorchlightOptions($this->torchlightOptions); - return [$generator, $highlighter->highlight($tokens)]; - } - - public function renderCode(string $code, Grammar|string $grammar, Theme|array|string $theme): RenderedBlock - { - [$generator, $highlightedTokens] = $this->getHtmlGeneratorForCode($code, $grammar, $theme, true, false); + /** @var array> $highlightedTokens */ + $highlightedTokens = $highlighter->highlight($tokens); return $generator->renderBlock($highlightedTokens); } - public function codeToHtml(string $code, Grammar|string $grammar, Theme|array|string $theme, bool $withGutter = false, bool $withWrapper = false): string + /** + * @return ParsedAnnotation[] + */ + public function getParsedAnnotations(): array { - [$generator, $highlightedTokens] = $this->getHtmlGeneratorForCode($code, $grammar, $theme, $withGutter, $withWrapper); - - return $generator->generate($highlightedTokens); + return $this->state->parsedAnnotations; } /** - * @return ParsedAnnotation[] + * @param string|PhikiTheme|ParsedTheme|Theme|array $themes + * @return array */ - public function getParsedAnnotations(): array + protected function wrapThemes(string|array|PhikiTheme|ParsedTheme|Theme $themes): array + { + if (! is_array($themes)) { + $themes = ['default' => $themes]; + } + + if (count($themes) === 1 && ! is_string(array_keys($themes)[0])) { + $themes = ['light' => $themes[0]]; + } + + $wrappedThemes = []; + + foreach ($themes as $themeId => $theme) { + $wrappedThemes[(string) $themeId] = $this->resolveTheme($theme); + } + + return $wrappedThemes; + } + + protected function resolveTheme(string|PhikiTheme|ParsedTheme|Theme $theme): ParsedTheme { - return $this->parsedAnnotations; + if ($theme instanceof Theme) { + return $theme->resolve( + fn (string|PhikiTheme $themeName): ParsedTheme => $this->environment->themes->resolve($themeName) + ); + } + + return $this->environment->themes->resolve($theme); } } diff --git a/src/Generators/BlockDecorators/CopyTargetDecorator.php b/src/Generators/BlockDecorators/CopyTargetDecorator.php new file mode 100644 index 0000000..03a8e68 --- /dev/null +++ b/src/Generators/BlockDecorators/CopyTargetDecorator.php @@ -0,0 +1,123 @@ + */ + protected array $attributes = [ + 'aria-hidden' => 'true', + 'hidden' => true, + 'tabindex' => '-1', + 'style' => 'display: none;', + ]; + + protected int $priority = 100; + + public function setCssClass(string $class): static + { + $this->cssClass = $class; + + return $this; + } + + public function getCssClass(): string + { + return $this->cssClass; + } + + public function setTagName(string $tag): static + { + $this->tagName = $tag; + + return $this; + } + + public function getTagName(): string + { + return $this->tagName; + } + + /** @param array $attributes */ + public function setAttributes(array $attributes): static + { + $this->attributes = $attributes; + + return $this; + } + + /** + * @param string $name Attribute name + * @param string|bool $value Attribute value (true for valueless) + */ + public function setAttribute(string $name, string|bool $value): static + { + $this->attributes[$name] = $value; + + return $this; + } + + public function removeAttribute(string $name): static + { + unset($this->attributes[$name]); + + return $this; + } + + /** @return array */ + public function getAttributes(): array + { + return $this->attributes; + } + + public function setPriority(int $priority): static + { + $this->priority = $priority; + + return $this; + } + + public function shouldRender(RenderContext $context): bool + { + return $context->options->copyable; + } + + public function render(RenderContext $context, string $cleanedText): string + { + $content = htmlspecialchars($cleanedText); + $attrs = $this->buildAttributeString(); + + return "<{$this->tagName} {$attrs}class='{$this->cssClass}'>{$content}tagName}>"; + } + + public function getPriority(): int + { + return $this->priority; + } + + protected function buildAttributeString(): string + { + $parts = []; + + foreach ($this->attributes as $name => $value) { + if ($value === true) { + $parts[] = $name; + } elseif ($value !== false && $value !== null) { + $parts[] = $name."='".$value."'"; + } + } + + if (empty($parts)) { + return ''; + } + + return implode(' ', $parts).' '; + } +} diff --git a/src/Generators/CharacterRangeDecorator.php b/src/Generators/CharacterRangeDecorator.php index 75eee15..47cc5dd 100644 --- a/src/Generators/CharacterRangeDecorator.php +++ b/src/Generators/CharacterRangeDecorator.php @@ -2,15 +2,18 @@ namespace Torchlight\Engine\Generators; -use Torchlight\Engine\Generators\Concerns\ManagesStyles; - class CharacterRangeDecorator { - use ManagesStyles; - + /** + * @param list> $ranges + */ public function decorateCharacterRanges(string $html, array $ranges): string { $tokens = preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + if ($tokens === false) { + return $html; + } + $output = []; for ($i = 0; $i < count($tokens); $i++) { @@ -25,6 +28,14 @@ public function decorateCharacterRanges(string $html, array $ranges): string $visCount = 0; $foundAnyRanges = false; + foreach ($ranges as $range) { + if ($range['start'] === 0) { + $attributes = ThemeStyleResolver::toAttributeString($this->stringAttributes($range)); + $output[] = ""; + $foundAnyRanges = true; + } + } + foreach ($tokens as $token) { if (str_starts_with($token, ' 0) { - usort($rangesStarting, function ($a, $b) { - return $b['end'] <=> $a['end']; - }); + usort($rangesStarting, fn ($a, $b) => $b['end'] <=> $a['end']); foreach ($tagStack as $item) { $output[] = ''; } foreach ($rangesStarting as $range) { - $attributes = $this->toAttributeString(array_diff_key($range, array_flip(['start', 'end']))); + $attributes = ThemeStyleResolver::toAttributeString($this->stringAttributes($range)); $output[] = ""; } @@ -95,6 +104,30 @@ public function decorateCharacterRanges(string $html, array $ranges): string return $html; } - return implode('', $output); + $result = implode('', $output); + + // Remove empty spans produced when range boundaries align with token boundaries. + // The close-reopen cycle for the tag stack can leave behind + // with no content when the boundary falls exactly at a token edge. + return preg_replace('/]*><\/span>/', '', $result) ?? $result; + } + + /** + * @param array $range + * @return array + */ + private function stringAttributes(array $range): array + { + $attributes = []; + + foreach ($range as $name => $value) { + if ($name === 'start' || $name === 'end' || ! is_string($value)) { + continue; + } + + $attributes[$name] = $value; + } + + return $attributes; } } diff --git a/src/Generators/ColumnGuideApplicator.php b/src/Generators/ColumnGuideApplicator.php new file mode 100644 index 0000000..54216a5 --- /dev/null +++ b/src/Generators/ColumnGuideApplicator.php @@ -0,0 +1,35 @@ + $columns + * @return list + */ + public static function computeLineClasses(array $columns): array + { + $classes = []; + + foreach ($columns as $col) { + $classes[] = 'torchlight-colguide-'.$col; + } + + return $classes; + } + + /** + * @param list $columns + */ + public static function computeGuideHtml(array $columns): string + { + $html = ''; + + foreach ($columns as $col) { + $html .= ""; + } + + return $html; + } +} diff --git a/src/Generators/Concerns/AddsScopesToTokens.php b/src/Generators/Concerns/AddsScopesToTokens.php deleted file mode 100644 index 4ce8552..0000000 --- a/src/Generators/Concerns/AddsScopesToTokens.php +++ /dev/null @@ -1,25 +0,0 @@ -scopes[] = $scope; - } - } - - return $tokens; - } -} diff --git a/src/Generators/Concerns/InteractsWithHtmlRenderer.php b/src/Generators/Concerns/InteractsWithHtmlRenderer.php deleted file mode 100644 index 4d76da6..0000000 --- a/src/Generators/Concerns/InteractsWithHtmlRenderer.php +++ /dev/null @@ -1,78 +0,0 @@ -htmlGenerator = $generator; - - return $this; - } - - protected function getThemeValueStyles(string $propertyName, string $themeProp, ?string $default = null): array - { - return $this->htmlGenerator->getThemeValueStyles($propertyName, $themeProp, $default); - } - - protected function getLineNumberColorStyles(): string - { - return $this->getThemeValueStylesString('color', ['torchlight.lineNumberColor', 'editorLineNumber.foreground']); - } - - protected function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string - { - return $this->htmlGenerator->getThemeValueStylesString($propertyName, $themeProp, $default); - } - - protected function getTokenStyles($token): array - { - return $this->htmlGenerator->getTokenStyles($token); - } - - protected function makeToken(string $text, array $scopes): object - { - $token = new Token($scopes, $text, 0, 0); - - return $this->highlighter->highlight([[$token]])[0][0]; - } - - protected function getScopeSettings(array $scopes): array - { - /** @var HighlightedToken $highlightedToken */ - $highlightedToken = $this->makeToken('*', $scopes); - - return $highlightedToken->settings; - } - - protected function getScopeStyles(array $scopes): string - { - return $this->htmlGenerator->getSettingsStyleString($this->getScopeSettings($scopes)); - } - - protected function renderText(string $text, array $scopes, array $classes = [], array $styles = []): string - { - return $this->htmlGenerator->buildToken( - $this->makeToken($text, $scopes), - $classes, - $styles - ); - } - - public function setHighlighter(Highlighter $highlighter): static - { - $this->highlighter = $highlighter; - - return $this; - } -} diff --git a/src/Generators/Concerns/ManagesStyles.php b/src/Generators/Concerns/ManagesStyles.php deleted file mode 100644 index 4af54eb..0000000 --- a/src/Generators/Concerns/ManagesStyles.php +++ /dev/null @@ -1,136 +0,0 @@ - [ - [ - ['editor.lineHighlightBackground', 'editor.selectionHighlightBackground', 'theme::background'], - 'background', - '#00000050', - ], - ], - 'line-add' => [ - [ - ['torchlight.markupInsertedBackground', 'diffEditor.insertedTextBackground'], - 'background', - '#89DDFF20', - ], - ], - 'line-remove' => [ - [ - ['torchlight.markupDeletedBackground', 'diffEditor.removedTextBackground'], - 'background', - '#ff9cac20', - ], - ], - ]; - - protected function getPhikiPropertyName(string $prefix, string $property): string - { - return "--phiki-{$prefix}-{$property}"; - } - - protected function getStyle(string $class): array - { - $styles = []; - - if (isset($this->styles[$class])) { - foreach ($this->styles[$class] as $classProps) { - [$themeProp, $propertyName, $defaultValue] = $classProps; - - /** - * @var string $id - * @var ParsedTheme $theme - */ - foreach ($this->themes as $id => $theme) { - $propName = $propertyName; - - if (is_array($themeProp)) { - foreach ($themeProp as $tryPropName) { - $themeValue = $this->getValueFromTheme($theme, $tryPropName); - - if ($themeValue) { - break; - } - } - } else { - $themeValue = $this->getValueFromTheme($theme, $themeProp); - } - - $themeValue ??= $defaultValue; - - if ($id != $this->getDefaultThemeId()) { - $propName = $this->getPhikiPropertyName($id, $propertyName); - } - - $styles[$propName] = $themeValue; - } - } - } - - return $styles; - } - - protected function getValueFromTheme(ParsedTheme $theme, string $propName): ?string - { - if ($propName === 'theme::background') { - return $theme->base()->background; - } elseif ($propName === 'theme::foreground') { - return $theme->base()->foreground; - } elseif ($propName === 'theme::fontStyle') { - return $theme->base()->fontStyle; - } - - return $theme->colors[$propName] ?? null; - } - - protected function getLineStyles(array $classes): array - { - $styles = []; - - foreach ($classes as $class) { - foreach ($this->getStyle($class) as $k => $v) { - $styles[$k] = $v; - } - } - - return $styles; - } - - protected function toAttributeString(array $attributes): string - { - $attributeParts = []; - - foreach ($attributes as $k => $v) { - $attributeParts[] = "{$k}='{$v}'"; - } - - return implode(' ', $attributeParts); - } - - protected function toStyleString(array $styles): string - { - $styleParts = []; - - foreach ($styles as $k => $v) { - if (! is_string($k)) { - $styleParts[] = $v; - - continue; - } - - $styleParts[] = "{$k}: {$v}"; - } - - if (count($styleParts) === 0) { - return ''; - } - - return implode(';', $styleParts); - } -} diff --git a/src/Generators/Concerns/ManagesThemeHooks.php b/src/Generators/Concerns/ManagesThemeHooks.php index d7ffceb..c1f2bd1 100644 --- a/src/Generators/Concerns/ManagesThemeHooks.php +++ b/src/Generators/Concerns/ManagesThemeHooks.php @@ -4,9 +4,13 @@ use Closure; use Torchlight\Engine\Options; +use Torchlight\Engine\Theme\Hooks\Fortnite; +use Torchlight\Engine\Theme\Hooks\Moonlight; +use Torchlight\Engine\Theme\Hooks\Synthwave84; trait ManagesThemeHooks { + /** @var array>> */ protected array $themeHooks = []; protected function runAfterRenderHooks(string $theme, string $output, Options $torchlightOptions, string $propertyPrefix, string $themeName): string @@ -38,6 +42,9 @@ public function registerThemeHook(string $theme, string $hook, Closure $callback return $this; } + /** + * @return list + */ public function getThemeHooks(string $theme, string $hook): array { return $this->themeHooks[$theme][$hook] ?? []; @@ -46,8 +53,8 @@ public function getThemeHooks(string $theme, string $hook): array protected function loadDefaultThemeHooks(): static { return $this - ->registerAfterRenderHook('moonlight-ii', fn ($html, $options, $propertyPrefix, $themeId) => \Torchlight\Engine\Theme\Hooks\Moonlight::replaceColors($html, $options, $propertyPrefix, $themeId)) - ->registerAfterRenderHook('fortnite', fn ($html, $options, $propertyPrefix, $themeId) => \Torchlight\Engine\Theme\Hooks\Fortnite::replaceColors($html, $options, $propertyPrefix, $themeId)) - ->registerAfterRenderHook('synthwave-84', fn ($html, $options, $propertyPrefix, $themeId) => \Torchlight\Engine\Theme\Hooks\Synthwave84::replaceColors($html, $options, $propertyPrefix, $themeId)); + ->registerAfterRenderHook('moonlight-ii', fn (string $html, Options $options, string $propertyPrefix, string $themeId): string => Moonlight::replaceColors($html, $options, $propertyPrefix, $themeId)) + ->registerAfterRenderHook('fortnite', fn (string $html, Options $options, string $propertyPrefix, string $themeId): string => Fortnite::replaceColors($html, $options, $propertyPrefix, $themeId)) + ->registerAfterRenderHook('synthwave-84', fn (string $html, Options $options, string $propertyPrefix, string $themeId): string => Synthwave84::replaceColors($html, $options, $propertyPrefix, $themeId)); } } diff --git a/src/Generators/Concerns/MergesHighlightedTokens.php b/src/Generators/Concerns/MergesHighlightedTokens.php new file mode 100644 index 0000000..c02083f --- /dev/null +++ b/src/Generators/Concerns/MergesHighlightedTokens.php @@ -0,0 +1,126 @@ + $renderableLines + * @return array + */ + protected function mergeHighlightedTokens(array $renderableLines): array + { + foreach ($renderableLines as $lineIndex => $tokens) { + $renderableLines[$lineIndex] = $this->mergeHighlightedLineTokens($tokens); + } + + return $renderableLines; + } + + /** + * @param RenderableToken[] $tokens + * @return RenderableToken[] + */ + protected function mergeHighlightedLineTokens(array $tokens): array + { + if (empty($tokens)) { + return $tokens; + } + + $merged = []; + $currentToken = null; + + foreach ($tokens as $token) { + if ($currentToken === null) { + $currentToken = $this->cloneRenderableToken($token); + + continue; + } + + // Only merge if: same visual settings AND neither has custom metadata + if ($this->tokensHaveSameVisualSettings($currentToken->highlighted, $token->highlighted) + && ! $this->tokenHasCustomMetadata($currentToken) + && ! $this->tokenHasCustomMetadata($token)) { + // Merge: append text and update end position + $currentToken->highlighted->token->text .= $token->highlighted->token->text; + $currentToken->highlighted->token->end = $token->highlighted->token->end; + } else { + $merged[] = $currentToken; + $currentToken = $this->cloneRenderableToken($token); + } + } + $merged[] = $currentToken; + + return $merged; + } + + protected function tokensHaveSameVisualSettings(HighlightedToken $a, HighlightedToken $b): bool + { + $settingsA = $a->settings; + $settingsB = $b->settings; + + if (array_keys($settingsA) !== array_keys($settingsB)) { + return false; + } + + foreach ($settingsA as $themeId => $tokenSettingsA) { + if (! isset($settingsB[$themeId])) { + return false; + } + + if (! $this->tokenSettingsAreEqual($tokenSettingsA, $settingsB[$themeId])) { + return false; + } + } + + return true; + } + + protected function tokenSettingsAreEqual(?TokenSettings $a, ?TokenSettings $b): bool + { + if ($a === null && $b === null) { + return true; + } + + if ($a === null || $b === null) { + return false; + } + + return $a->foreground === $b->foreground + && $a->background === $b->background + && $a->fontStyle === $b->fontStyle; + } + + protected function cloneRenderableToken(RenderableToken $token): RenderableToken + { + $newToken = new Token( + $token->highlighted->token->scopes, + $token->highlighted->token->text, + $token->highlighted->token->start, + $token->highlighted->token->end + ); + + $newHighlighted = new HighlightedToken($newToken, $token->highlighted->settings); + + $newMetadata = new TokenMetadata( + $token->metadata->classes, + $token->metadata->attributes, + $token->metadata->rawContent, + ); + + return new RenderableToken($newHighlighted, $newMetadata); + } + + protected function tokenHasCustomMetadata(RenderableToken $token): bool + { + return $token->metadata->hasClasses() + || $token->metadata->hasAttributes() + || $token->metadata->isRaw(); + } +} diff --git a/src/Generators/Concerns/ProcessesFileLanguage.php b/src/Generators/Concerns/ProcessesFileLanguage.php deleted file mode 100644 index 4782980..0000000 --- a/src/Generators/Concerns/ProcessesFileLanguage.php +++ /dev/null @@ -1,416 +0,0 @@ - ┬ - case self::FULL_HORIZONTAL + self::HALF_VERTICAL_DOWN: - return '┬'; - - // Combining full horizontal + half vertical => ┴ - case self::FULL_HORIZONTAL + self::HALF_VERTICAL_UP: - return '┴'; - - // Combining half horizontal + full vertical => ┤ or ├ - case self::HALF_HORIZONTAL_LEFT + self::FULL_VERTICAL: - return '┤'; - - case self::HALF_HORIZONTAL_RIGHT + self::FULL_VERTICAL: - return '├'; - - // Corners - case self::TR_CORNER: - return '┐'; - - case self::TL_CORNER: - return '┌'; - - case self::BL_CORNER: - return '└'; - - case self::BR_CORNER: - return '┘'; - } - - return ' '; - } - - protected function processFileLanguage(array $lines): array - { - $info = []; - - $commentSettings = $this->getScopeSettings([ - 'source.files', - 'comment.line.number-sign.yaml', - 'punctuation.definition.comment.yaml', - ]); - - foreach ($lines as $lineIndex => $tokens) { - $info[$lineIndex] = [ - 'depth' => 0, - 'isCommentOnly' => false, - 'isDirectory' => false, - 'content' => '', - ]; - - if (empty($tokens)) { - continue; - } - - /** - * @var int $i - * @var HighlightedToken $token - */ - foreach ($tokens as $i => $token) { - $tokenText = $token->token->text; - - if (trim($tokenText) == '') { - $info[$lineIndex]['depth'] = mb_strlen($tokenText); - - continue; - } - - if (str_starts_with($tokenText, '#')) { - $info[$lineIndex]['isCommentOnly'] = true; - break; // Stop scanning tokens on this line. - } - - $isDirectory = str_ends_with($tokenText, '/'); - $info[$lineIndex]['isDirectory'] = $isDirectory; - $info[$lineIndex]['content'] = $tokenText; - - $attributes = []; - - if (! $isDirectory) { - $attributes['tl-file-extension'] = htmlspecialchars(pathinfo($token->token->text, PATHINFO_EXTENSION)); - } - - $this->tokenOptions[$token] = [ - 'classes' => [ - $isDirectory ? 'tl-files-folder' : 'tl-files-file', - 'tl-files-name', - ], - 'attributes' => $attributes, - ]; - - break; - } - } - - // We need to figure out how many unique depths there are so that we can - // add a space between the horizontal connector and the word. We can't - // just add one, because it's additive. If we add one to a depth of e.g. - // 2, then we need to add one to the depth of 4 *just to account* - // for the padding at depth 2. So we figure out how many unique - // depths there are, and then add one per depth. - $allDepths = array_map(function ($i) { - return $i['depth']; - }, $info); - - $uniqueDepths = array_unique($allDepths); - sort($uniqueDepths); - - // At this point we have an array like [0,2,4,6,8] of all the depths. - // We want to turn it into a mapping like [0,0; 2,1; 4,2; 6,3; 8,4] - // as this will guide how many spaces to add to each depth. - $levels = []; - - foreach ($uniqueDepths as $i => $level) { - $levels[$level] = $i; - } - - // Go ahead and add the spaces. - $info = array_map(function ($line) use ($levels) { - $levelIndex = $levels[$line['depth']]; - // We normalize everything to two spaces per level. Having 4 - // spaces looks weird, and so does one. - $line['depth'] = $levelIndex * 3; - - return $line; - }, $info); - - // This is going to hold all of our vertical and horizontal connectors. - $rows = []; - - // For every row, add spaces out to the content. - for ($r = 0; $r < count($info); $r++) { - $rows[$r] = []; - for ($c = 0; $c < $info[$r]['depth']; $c++) { - $rows[$r][$c] = ' '; // placeholder - } - } - - // Now we go row by row and draw the lines. Each row has a sub-loop - // that will draw all of the vertical connectors where necessary. - $infoLen = count($info); - for ($r = 0; $r < $infoLen - 1; $r++) { - $line = $info[$r]; - - $childDepth = false; - - // Look at the next line and see if it's depth is deeper than ours. If - // so, that's the child depth we're looking for. This lets us not - // prescribe indentation size of e.g. 2 or 4, and it also - // covers mistakes the developer might make. - if (isset($info[$r + 1]) && $info[$r + 1]['depth'] > $line['depth']) { - $childDepth = $info[$r + 1]['depth']; - } - - // No children, no problem. - if (! $childDepth) { - continue; - } - - // We need to find the last child that this row has, so that we - // don't draw lines beyond it unnecessarily. - $lastChild = 0; - $lastPhantom = $infoLen - 1; - - // r' starts at the next row and goes to the end. - for ($r1 = $r + 1; $r1 < $infoLen; $r1++) { - // If r' has the same depth as the parent's children, then - // it's a child. We'll store this index and then keep - // looking for more. - if ($info[$r1]['depth'] === $childDepth) { - $lastChild = $r1; - } - - // If r' is *shallower* than the parent's children then it's a - // sibling or ancestor, so we stop looking for more children - // because we are out of our family tree. - if ($info[$r1]['depth'] < $childDepth) { - $lastPhantom = $r1 - 1; - break; - } - } - - // r' again is going to be the next row. This is where we start drawing lines. - for ($r1 = $r + 1; $r1 <= $lastPhantom; $r1++) { - $prime = $info[$r1]; - - // Run the vertical connector directly below the first character. - $verticalIndex = $line['depth']; - - // If there is a line above the prime line and it's not the line - // we're operating on, then we need to draw the second half of - // the vertical connector. We can't do this when r'-1 = r - // because that space is occupied by the name of the - // previous file or folder. - // resources/ - // └─ blueprints/ <--- This only has a half vertical up, set by itself. - // ├─ collections/ <--- This has both. The half vertical down was set by the row below it. - // │ └─ blog/ - // │ └─ art_directed_post.yaml - // ├─ taxonomies/ - // │ └─ tags/ - // │ └─ tag.yaml - - if ($r1 <= $lastChild) { - // If there's a line above and it's > r - if (isset($rows[$r1 - 1][$verticalIndex]) && ($r1 - 1) > $r) { - $rows[$r1 - 1][$verticalIndex] = - $this->addBit($rows[$r1 - 1][$verticalIndex], static::HALF_VERTICAL_DOWN); - } - } - - if ($r1 <= $lastChild) { - // Add our own half vertical up. - $rows[$r1][$verticalIndex] = - $this->addBit($rows[$r1][$verticalIndex], static::HALF_VERTICAL_UP); - } else { - $rows[$r1][$verticalIndex] = - $this->addBit($rows[$r1][$verticalIndex], static::PHANTOM); - } - - // If prime is a direct child and there is content (e.g. - // not a comment) then add a horizontal connector. - if ($prime['depth'] === $childDepth && ! empty($prime['content'])) { - // The half bit to make the vertical connector an intersection. - $rows[$r1][$verticalIndex] = - $this->addBit($rows[$r1][$verticalIndex], static::HALF_HORIZONTAL_RIGHT); - - // The full bit to make the horizontal connector more prominent. - if (isset($rows[$r1][$verticalIndex + 1])) { - $rows[$r1][$verticalIndex + 1] = - $this->addBit($rows[$r1][$verticalIndex + 1], static::FULL_HORIZONTAL); - } - } - } - } - - if ($this->torchlightOptions->fileStyle !== 'ascii') { - foreach ($rows as $rowIndex => &$row) { - foreach ($row as $charIndex => &$char) { - if ($char === ' ') { - $char = ""; - - continue; - } - - $horizontal = ['tl-connect', 'tl-connect-h']; - $vertical = ['tl-connect', 'tl-connect-v']; - $wrapper = ['tl-connect-wrap']; - - $char = (int) $char; - - if (($char & self::HALF_HORIZONTAL_LEFT) === self::HALF_HORIZONTAL_LEFT) { - $horizontal[] = 'tl-connect-left'; - } - - if (($char & self::HALF_HORIZONTAL_RIGHT) === self::HALF_HORIZONTAL_RIGHT) { - $horizontal[] = 'tl-connect-right'; - } - - if (($char & self::HALF_VERTICAL_DOWN) === self::HALF_VERTICAL_DOWN) { - $vertical[] = 'tl-connect-down'; - $wrapper[] = 'tl-connect-x-adjust'; - } - - if (($char & self::HALF_VERTICAL_UP) === self::HALF_VERTICAL_UP) { - $vertical[] = 'tl-connect-up'; - $wrapper[] = 'tl-connect-x-adjust'; - } - - if (($char & self::PHANTOM) === self::PHANTOM) { - $wrapper[] = 'tl-connect-x-adjust'; - } - - $horizontal = array_unique($horizontal); - $vertical = array_unique($vertical); - $wrapper = array_unique($wrapper); - - $inner = sprintf( - "", - implode(' ', $horizontal), - implode(' ', $vertical) - ); - - $char = sprintf(" %s", implode(' ', $wrapper), $inner); - } - } - - // Map back to lines and tokens. - foreach ($lines as $lineIndex => $tokens) { - if (empty($token) || empty($rows[$lineIndex])) { - continue; - } - - /** @var HighlightedToken $token */ - foreach ($tokens as $tokenIndex => $token) { - if (trim($token->token->text) !== '') { - continue; - } - - // Swap out the token to apply the desired settings. - $lines[$lineIndex][$tokenIndex] = new HighlightedToken( - $token->token, - $commentSettings, - ); - - $this->setRawContent( - $token->token, - implode('', $rows[$lineIndex]) - ); - - break; - } - } - - return $lines; - } - - foreach ($rows as $rIndex => &$row) { - foreach ($row as $cIndex => &$char) { - $char = $this->characterForMask($char); - } - } - - // Insert ASCII connectors into the indentation token - foreach ($lines as $lineIndex => $tokens) { - if (empty($token) || empty($rows[$lineIndex])) { - continue; - } - - /** @var HighlightedToken $token */ - foreach ($tokens as $tokenIndex => $token) { - if (trim($token->token->text) !== '') { - continue; - } - - $asciiContent = $rows[$lineIndex]; - - $token->token->text = implode('', $asciiContent); - - // Swap out the token to apply the desired settings. - $lines[$lineIndex][$tokenIndex] = new HighlightedToken( - $token->token, - $commentSettings, - ); - - break; - } - } - - return $lines; - } -} diff --git a/src/Generators/GenerationOptions.php b/src/Generators/GenerationOptions.php index 3901be1..a92b1d2 100644 --- a/src/Generators/GenerationOptions.php +++ b/src/Generators/GenerationOptions.php @@ -11,24 +11,59 @@ class GenerationOptions */ public array $gutters = []; + /** @var array */ public array $blockClasses = []; + /** @var array> */ public array $lineClasses = []; + /** @var array> */ public array $lineAttributes = []; + /** @var array> */ public array $linePrepends = []; + /** @var array> */ public array $lineAppends = []; + /** @var array): string>> */ public array $lineContentCallbacks = []; + /** @var array): array>> */ public array $lineTokenCallbacks = []; + /** @var array */ public array $textReplacements = []; + /** @var array>> */ public array $characterDecorators = []; + /** @var array */ + public array $removedLines = []; + + /** @var list */ + public array $globalLineClasses = []; + + public string $columnGuideHtml = ''; + + /** @var array */ + public array $codelensIndentPlaceholders = []; + + public bool $hasSeparatePaddingGutter = false; + + public ?GutterServices $gutterServices = null; + + /** + * @return AbstractGutter[] + */ + public function getSortedGutters(): array + { + $sorted = array_values($this->gutters); + usort($sorted, fn (AbstractGutter $a, AbstractGutter $b) => $a->getPriority() <=> $b->getPriority()); + + return $sorted; + } + public function reset(): void { foreach ($this->gutters as $gutter) { @@ -44,5 +79,10 @@ public function reset(): void $this->lineContentCallbacks = []; $this->lineTokenCallbacks = []; $this->textReplacements = []; + $this->removedLines = []; + $this->hasSeparatePaddingGutter = false; + $this->globalLineClasses = []; + $this->columnGuideHtml = ''; + $this->codelensIndentPlaceholders = []; } } diff --git a/src/Generators/GeneratorFactory.php b/src/Generators/GeneratorFactory.php new file mode 100644 index 0000000..997d353 --- /dev/null +++ b/src/Generators/GeneratorFactory.php @@ -0,0 +1,91 @@ + $tokenTransformerFactories + * @param list $blockDecoratorFactories + */ + public function __construct( + protected array $tokenTransformerFactories = [], + protected array $blockDecoratorFactories = [], + ) {} + + /** @param list $factories */ + public function setTokenTransformerFactories(array $factories): static + { + $this->tokenTransformerFactories = $factories; + + return $this; + } + + /** @param list $factories */ + public function setBlockDecoratorFactories(array $factories): static + { + $this->blockDecoratorFactories = $factories; + + return $this; + } + + /** + * @param array $themes + */ + public function create( + ?string $grammarName, + array $themes, + Highlighter $highlighter, + AnnotationEngine $annotationProcessor, + Options $options, + string $cleanedText, + string $languageVanityLabel, + ): HtmlGenerator { + $generator = new HtmlGenerator( + $grammarName, + $themes, + ); + + $themeResolver = new ThemeStyleResolver($themes, $highlighter, $options); + + $generator + ->setHighlighter($highlighter) + ->setThemeResolver($themeResolver); + + $generationOptions = $annotationProcessor->getGenerationOptions(); + + $gutterServices = new GutterServices($generator, $themeResolver, $highlighter); + + $generationOptions->gutterServices = $gutterServices; + + foreach ($generationOptions->gutters as $gutter) { + $gutter->setServices($gutterServices); + $gutter->setGenerationOptions($generationOptions); + } + + $annotationProcessor->getRegistry() + ->setThemeResolver($themeResolver); + + $generator + ->setGenerationOptions($generationOptions) + ->setCleanedText($cleanedText) + ->setLanguageVanityLabel($languageVanityLabel); + + foreach ($this->tokenTransformerFactories as $factory) { + $generator->registerTokenTransformer($factory()); + } + + foreach ($this->blockDecoratorFactories as $factory) { + $generator->registerBlockDecorator($factory()); + } + + return $generator; + } +} diff --git a/src/Generators/GutterServices.php b/src/Generators/GutterServices.php new file mode 100644 index 0000000..43577ce --- /dev/null +++ b/src/Generators/GutterServices.php @@ -0,0 +1,120 @@ + + */ + public function getThemeValueStyles(string $propertyName, string $themeProp, ?string $default = null): array + { + return $this->resolver->getThemeValueStyles( + $propertyName, + $themeProp, + $default + ); + } + + /** @param list|string $themeProp */ + public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string + { + return $this->resolver->getThemeValueStylesString( + $propertyName, + $themeProp, + $default + ); + } + + public function getLineNumberColorStyles(): string + { + return $this->getThemeValueStylesString( + 'color', + [ + 'torchlight.lineNumberColor', + 'editorLineNumber.foreground', + ] + ); + } + + /** + * @return list + */ + public function getTokenStyles(HighlightedToken|RenderableToken $token): array + { + $highlighted = $token instanceof RenderableToken ? $token->highlighted : $token; + + return $this->resolver->getTokenStyles($highlighted); + } + + /** @param list $scopes */ + public function makeToken(string $text, array $scopes): HighlightedToken + { + return $this->resolver->makeToken($text, $scopes); + } + + /** + * @param list $scopes + * @return array + */ + public function getScopeSettings(array $scopes): array + { + return $this->resolver->getScopeSettings($scopes); + } + + /** @param list $scopes */ + public function getScopeStyles(array $scopes): string + { + return $this->resolver->getScopeStyles($scopes); + } + + /** + * @param array $settings + */ + public function getSettingsStyleString(array $settings): string + { + return $this->resolver->getSettingsStyleString($settings); + } + + /** + * @param list $scopes + * @param list $classes + * @param array $styles + */ + public function renderText(string $text, array $scopes, array $classes = [], array $styles = []): string + { + return $this->generator->buildToken( + RenderableToken::from($this->makeToken($text, $scopes)), + $classes, + $styles + ); + } + + /** @param list|string $themeProp */ + public function registerLineStyle(string $class, array|string $themeProp, string $cssProperty, ?string $default = null): static + { + $this->resolver->registerLineStyle( + $class, + $themeProp, + $cssProperty, + $default + ); + + return $this; + } + + public function getResolver(): ThemeStyleResolver + { + return $this->resolver; + } +} diff --git a/src/Generators/Gutters/AbstractGutter.php b/src/Generators/Gutters/AbstractGutter.php index dea4013..11757cf 100644 --- a/src/Generators/Gutters/AbstractGutter.php +++ b/src/Generators/Gutters/AbstractGutter.php @@ -2,28 +2,62 @@ namespace Torchlight\Engine\Generators\Gutters; -use Torchlight\Engine\Annotations\Processor; -use Torchlight\Engine\Generators\Concerns\InteractsWithHtmlRenderer; -use Torchlight\Engine\Generators\HtmlGenerator; +use Phiki\Theme\TokenSettings; +use Phiki\Token\HighlightedToken; +use Torchlight\Engine\Annotations\AnnotationEngine; +use Torchlight\Engine\Contracts\Gutter; +use Torchlight\Engine\Generators\GenerationOptions; +use Torchlight\Engine\Generators\GutterServices; +use Torchlight\Engine\Generators\RenderableToken; use Torchlight\Engine\Options; -abstract class AbstractGutter +abstract class AbstractGutter implements Gutter { - use InteractsWithHtmlRenderer; - protected Options $options; - protected ?HtmlGenerator $htmlGenerator = null; + protected ?GutterServices $services = null; + + protected ?GenerationOptions $generationOptions = null; + + protected int $priority = 100; + + protected string $cssClass = ''; + + protected bool $userSelectable = false; public function __construct( - protected Processor $engine, + protected ?AnnotationEngine $engine = null, ) { $this->options = Options::default(); } - public function setHtmlGenerator(HtmlGenerator $generator): static + public function getCssClass(): string + { + return $this->cssClass; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): static + { + $this->priority = $priority; + + return $this; + } + + public function setGenerationOptions(GenerationOptions $generationOptions): static { - $this->htmlGenerator = $generator; + $this->generationOptions = $generationOptions; + + return $this; + } + + public function setServices(GutterServices $services): static + { + $this->services = $services; return $this; } @@ -35,12 +69,121 @@ public function setTorchlightOptions(Options $options): static return $this; } + protected function services(): GutterServices + { + if ($this->services === null) { + throw new \LogicException('Gutter services have not been configured.'); + } + + return $this->services; + } + public function reset(): void {} + /** + * @param array $tokens + */ abstract public function renderLine(int $relativeLine, int $index, array $tokens): string; + public function renderSpacer(): string + { + return ''; + } + public function shouldRender(): bool { return true; } + + public function decorateLine(int $relativeLine, int $index, GenerationOptions $options): void + { + // Override in subclasses to add line classes, styles, or attributes. + } + + /** + * @param list $extraClasses + */ + protected function renderGutterSpan( + string $content, + ?string $class = null, + string $colorStyles = '', + array $extraClasses = [], + ): string { + $class ??= $this->cssClass; + + $allClasses = array_filter(array_merge([$class], $extraClasses)); + $classAttr = implode(' ', $allClasses); + + $styles = ''; + if (! $this->userSelectable) { + $styles .= 'user-select: none;'; + } + $styles .= $colorStyles; + + return ''.$content.''; + } + + /** + * @return array + */ + protected function getThemeValueStyles(string $propertyName, string $themeProp, ?string $default = null): array + { + return $this->services()->getThemeValueStyles($propertyName, $themeProp, $default); + } + + /** @param list|string $themeProp */ + protected function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string + { + return $this->services()->getThemeValueStylesString($propertyName, $themeProp, $default); + } + + protected function getLineNumberColorStyles(): string + { + return $this->services()->getLineNumberColorStyles(); + } + + /** + * @return list + */ + protected function getTokenStyles(HighlightedToken|RenderableToken $token): array + { + return $this->services()->getTokenStyles($token); + } + + /** @param list $scopes */ + protected function makeToken(string $text, array $scopes): HighlightedToken + { + return $this->services()->makeToken($text, $scopes); + } + + /** + * @param list $scopes + * @return array + */ + protected function getScopeSettings(array $scopes): array + { + return $this->services()->getScopeSettings($scopes); + } + + /** @param list $scopes */ + protected function getScopeStyles(array $scopes): string + { + return $this->services()->getScopeStyles($scopes); + } + + /** + * @param list $scopes + * @param list $classes + * @param array $styles + */ + protected function renderText(string $text, array $scopes, array $classes = [], array $styles = []): string + { + return $this->services()->renderText($text, $scopes, $classes, $styles); + } + + /** @param list|string $themeProp */ + protected function registerLineStyle(string $class, array|string $themeProp, string $cssProperty, ?string $default = null): void + { + $this->services()->registerLineStyle($class, $themeProp, $cssProperty, $default); + } } diff --git a/src/Generators/Gutters/CollapseGutter.php b/src/Generators/Gutters/CollapseGutter.php index 25133a5..7035c77 100644 --- a/src/Generators/Gutters/CollapseGutter.php +++ b/src/Generators/Gutters/CollapseGutter.php @@ -3,9 +3,13 @@ namespace Torchlight\Engine\Generators\Gutters; use Torchlight\Engine\Annotations\Ranges\ImpactedRange; +use Torchlight\Engine\Generators\RenderableToken; class CollapseGutter extends AbstractGutter { + protected string $cssClass = 'summary-caret'; + + /** @var array */ protected array $lineMarkers = []; public function reset(): void @@ -15,22 +19,22 @@ public function reset(): void protected function getStartIndicator(string $styles): string { - return ''; + return $this->renderGutterSpan('', extraClasses: ['summary-caret-start'], colorStyles: $styles); } protected function getMiddleIndicator(string $styles): string { - return ''; + return $this->renderGutterSpan('', extraClasses: ['summary-caret-middle'], colorStyles: $styles); } protected function getEndIndicator(string $styles): string { - return ''; + return $this->renderGutterSpan('', extraClasses: ['summary-caret-end'], colorStyles: $styles); } protected function getEmptyIndicator(string $styles): string { - return ''; + return $this->renderGutterSpan('', extraClasses: ['summary-caret-empty'], colorStyles: $styles); } public function markRange(ImpactedRange $range): static @@ -55,12 +59,28 @@ public function markRange(ImpactedRange $range): static return $this; } + public function renderSpacer(): string + { + if (empty($this->lineMarkers) || ! $this->options->showSummaryCarets) { + return ''; + } + + return $this->getEmptyIndicator($this->getLineNumberColorStyles()); + } + + /** + * @param array $tokens + */ public function renderLine(int $relativeLine, int $index, array $tokens): string { if (empty($this->lineMarkers) || ! $this->options->showSummaryCarets) { return ''; } - return $this->lineMarkers[$index] ?? $this->getEmptyIndicator($this->getLineNumberColorStyles()); + $marker = $this->lineMarkers[$index] ?? null; + + return is_string($marker) + ? $marker + : $this->getEmptyIndicator($this->getLineNumberColorStyles()); } } diff --git a/src/Generators/Gutters/CustomContentGutter.php b/src/Generators/Gutters/CustomContentGutter.php new file mode 100644 index 0000000..832e1f0 --- /dev/null +++ b/src/Generators/Gutters/CustomContentGutter.php @@ -0,0 +1,87 @@ + */ + protected array $lineContent = []; + + public function reset(): void + { + $this->lineContent = []; + } + + public function setLineContent(int $line, string $content): static + { + $this->lineContent[$line - 1] = $content; + + return $this; + } + + public function hasContent(): bool + { + return count($this->lineContent) > 0; + } + + public function shouldRender(): bool + { + return $this->hasContent(); + } + + /** + * @param array $tokens + */ + public function renderLine(int $relativeLine, int $index, array $tokens): string + { + if (! $this->hasContent()) { + return ''; + } + + $content = $this->lineContent[$index] ?? ''; + $maxWidth = $this->getMaxContentWidth(); + $padded = $content.str_repeat(' ', max(0, $maxWidth - mb_strwidth($content))); + + $widthCss = "display:inline-block;width:{$maxWidth}ch;"; + + return $this->renderGutterSpan( + htmlspecialchars($padded), + colorStyles: $widthCss.$this->getLineNumberColorStyles(), + ); + } + + public function renderSpacer(): string + { + if (! $this->hasContent()) { + return ''; + } + + $maxWidth = $this->getMaxContentWidth(); + $widthCss = "display:inline-block;width:{$maxWidth}ch;"; + + return $this->renderGutterSpan( + str_repeat(' ', $maxWidth), + colorStyles: $widthCss.$this->getLineNumberColorStyles(), + ); + } + + private function getMaxContentWidth(): int + { + if (empty($this->lineContent)) { + return 0; + } + + $widths = array_map( + mb_strwidth(...), + $this->lineContent + ); + + return max($widths); + } +} diff --git a/src/Generators/Gutters/DiffGutter.php b/src/Generators/Gutters/DiffGutter.php index 2d57f6b..ccc29f1 100644 --- a/src/Generators/Gutters/DiffGutter.php +++ b/src/Generators/Gutters/DiffGutter.php @@ -4,9 +4,13 @@ use Torchlight\Engine\Annotations\Diff\DiffAddAnnotation; use Torchlight\Engine\Annotations\Diff\DiffRemoveAnnotation; +use Torchlight\Engine\Generators\RenderableToken; class DiffGutter extends AbstractGutter { + protected string $cssClass = 'diff-indicator'; + + /** @var array */ protected array $lineMarkers = []; public function reset(): void @@ -26,6 +30,9 @@ private function shouldRenderPadding(): bool $this->options->diffIndicatorsInPlaceOfNumbers == false; } + /** + * @param array $tokens + */ public function renderLine(int $relativeLine, int $index, array $tokens): string { if (count($this->lineMarkers) === 0) { @@ -33,14 +40,7 @@ public function renderLine(int $relativeLine, int $index, array $tokens): string } if (! array_key_exists($index, $this->lineMarkers)) { - $styles = $this->getThemeValueStylesString('color', 'editorLineNumber.foreground'); - $contentLen = 1; - - if ($this->shouldRenderPadding()) { - $contentLen += $this->options->lineNumberAndDiffIndicatorRightPadding; - } - - return ''.str_repeat(' ', $contentLen).''; + return $this->renderEmptyIndicator(); } $marker = $this->lineMarkers[$index]; @@ -63,6 +63,30 @@ public function renderLine(int $relativeLine, int $index, array $tokens): string return $this->renderText($marker, $scopes, ['diff-indicator', $className], ['user-select' => 'none']); } + public function renderSpacer(): string + { + if (count($this->lineMarkers) === 0) { + return ''; + } + + return $this->renderEmptyIndicator(); + } + + private function renderEmptyIndicator(): string + { + $contentLen = 1; + + if ($this->shouldRenderPadding()) { + $contentLen += $this->options->lineNumberAndDiffIndicatorRightPadding; + } + + return $this->renderGutterSpan( + str_repeat(' ', $contentLen), + class: 'diff-indicator diff-indicator-empty', + colorStyles: $this->getThemeValueStylesString('color', 'editorLineNumber.foreground'), + ); + } + public function setLineMarker(int $line, string $marker): static { $this->lineMarkers[$line - 1] = $marker; diff --git a/src/Generators/Gutters/LineNumbersGutter.php b/src/Generators/Gutters/LineNumbersGutter.php index b342f8c..32278e7 100644 --- a/src/Generators/Gutters/LineNumbersGutter.php +++ b/src/Generators/Gutters/LineNumbersGutter.php @@ -3,22 +3,30 @@ namespace Torchlight\Engine\Generators\Gutters; use Phiki\Theme\TokenSettings; +use Torchlight\Engine\Generators\RenderableToken; use Torchlight\Engine\Options; class LineNumbersGutter extends AbstractGutter { + protected string $cssClass = 'line-number'; + + /** @var array> */ protected array $lineNumberScopes = []; + /** @var array}> */ protected array $lineMarkerReplacements = []; protected int $maxLineCount = 0; + /** @var array */ protected array $reindexedLines = []; + /** @var array */ protected array $forcedDisplayLine = []; protected int $startLineOffset = 0; + /** @var array */ protected array $highlightedLines = []; public function reset(): void @@ -32,9 +40,10 @@ public function reset(): void $this->startLineOffset = 0; } + /** @param list $scopes */ public function setLineScopes(int $line, array $scopes): static { - $this->lineNumberScopes[$line] = $scopes; + $this->lineNumberScopes[$line] = array_values($scopes); return $this; } @@ -110,7 +119,8 @@ protected function isHighlighted(int $line): bool } /** - * @param TokenSettings[] $settings + * @param array $settings + * @return array */ private function removeBackground(array $settings): array { @@ -127,31 +137,16 @@ private function removeBackground(array $settings): array return $newSettings; } + /** + * @param array $tokens + */ public function renderLine(int $relativeLine, int $index, array $tokens): string { - if (! $this->options->lineNumbersEnabled && count($this->lineMarkerReplacements) === 0) { + if (! $this->isEnabled()) { return ''; } - $longestLineNumberLen = mb_strlen(strval($this->maxLineCount)); - - $colorStyles = $this->isHighlighted($relativeLine) - ? $this->htmlGenerator->getThemeValueStylesString('color', ['torchlight.activeLineNumberColor', 'editorLineNumber.foreground']) - : $this->getLineNumberColorStyles(); - - if (array_key_exists($relativeLine, $this->lineNumberScopes)) { - $settings = $this->removeBackground( - $this->getScopeSettings($this->lineNumberScopes[$relativeLine]) - ); - - $colorStyles = $this->htmlGenerator->getSettingsStyleString($settings); - } - - $lineNumberStyles = ''; - - if ($this->options->lineNumbersStyle != '') { - $lineNumberStyles .= $this->options->lineNumbersStyle; - } + $colorStyles = $this->resolveColorStyles($relativeLine); $resetLine = false; @@ -171,15 +166,11 @@ public function renderLine(int $relativeLine, int $index, array $tokens): string $displayLine = $this->forcedDisplayLine[$index]; } - $lineNumberText = $displayLine; + $lineNumberText = (string) $displayLine; $textLen = mb_strlen($lineNumberText); if (! $this->options->lineNumbersEnabled || $resetLine) { - if (count($this->lineMarkerReplacements) > 0) { - $lineNumberText = ' '; - } else { - $lineNumberText = ''; - } + $lineNumberText = count($this->lineMarkerReplacements) > 0 ? ' ' : ''; } if (array_key_exists($index, $this->lineMarkerReplacements)) { @@ -188,29 +179,66 @@ public function renderLine(int $relativeLine, int $index, array $tokens): string $textLen = mb_strlen($lineNumberText); if (! empty($marker[1])) { - // Apply styles from specified scopes. - $colorStyles = implode('', $this->htmlGenerator->getTokenStyles($this->makeToken($lineNumberText, $marker[1]))); + $colorStyles = implode('', $this->getTokenStyles($this->makeToken($lineNumberText, $marker[1]))); } } + return $this->buildLineNumberSpan($colorStyles, $lineNumberText, $textLen); + } + + public function renderSpacer(): string + { + if (! $this->isEnabled()) { + return ''; + } + + $content = str_repeat(' ', mb_strlen(strval($this->maxLineCount))); + + return $this->buildLineNumberSpan($this->getLineNumberColorStyles(), $content); + } + + private function isEnabled(): bool + { + return $this->options->lineNumbersEnabled || count($this->lineMarkerReplacements) > 0; + } + + private function resolveColorStyles(int $relativeLine): string + { + $colorStyles = $this->isHighlighted($relativeLine) + ? $this->getThemeValueStylesString('color', ['torchlight.activeLineNumberColor', 'editorLineNumber.foreground']) + : $this->getLineNumberColorStyles(); + + if (array_key_exists($relativeLine, $this->lineNumberScopes)) { + $settings = $this->removeBackground( + $this->getScopeSettings($this->lineNumberScopes[$relativeLine]) + ); + + $colorStyles = $this->services()->getSettingsStyleString($settings); + } + + return $colorStyles; + } + + private function buildLineNumberSpan(string $colorStyles, string $text, ?int $textLen = null): string + { + $textLen ??= mb_strlen($text); + $longestLineNumberLen = mb_strlen(strval($this->maxLineCount)); + + $lineNumberStyles = ''; + + if ($this->options->lineNumbersStyle != '') { + $lineNumberStyles .= $this->options->lineNumbersStyle; + } + if (mb_strlen(trim($colorStyles)) > 0 && ! str_ends_with($colorStyles, ';')) { $colorStyles .= '; '; } - $lineNumberStyles = $colorStyles.$lineNumberStyles; + $styles = $colorStyles.$lineNumberStyles; if ($this->options->lineNumberAndDiffIndicatorRightPadding > 0) { - $shouldAdd = true; - - // If the user wants to render diff indicators separately, - // we will skip adding the padding here, and take care - // of it inside the diff gutter to avoid pushing +/- - if ($this->options->diffIndicatorsInPlaceOfNumbers === false && $this->engine->diffGutter()->hasMarkers()) { - $shouldAdd = false; - } - - if ($shouldAdd) { - $lineNumberText .= str_repeat(' ', $this->options->lineNumberAndDiffIndicatorRightPadding); + if (! $this->generationOptions?->hasSeparatePaddingGutter) { + $text .= str_repeat(' ', $this->options->lineNumberAndDiffIndicatorRightPadding); } } @@ -220,13 +248,40 @@ public function renderLine(int $relativeLine, int $index, array $tokens): string $leadingPadding = str_repeat(' ', $longestLineNumberLen - $textLen); } - return implode('', [ - '', - $leadingPadding.$lineNumberText, - '', - ]); + return ''.$leadingPadding.$text.''; + } + + /** @param list $removedLineNumbers */ + public function adjustForRemovedLines(array $removedLineNumbers, int $originalLineCount): void + { + if (empty($removedLineNumbers)) { + return; + } + + sort($removedLineNumbers); + $offset = 0; + + for ($line = 1; $line <= $originalLineCount; $line++) { + $index = $line - 1; + + if (in_array($line, $removedLineNumbers, true)) { + $offset++; + + continue; + } + + // Only adjust lines that haven't been explicitly reindexed + if ($offset > 0 && ! array_key_exists($index, $this->reindexedLines)) { + $this->reindexedLines[$index] = $line - $offset; + } + } + + $this->maxLineCount = $originalLineCount - count($removedLineNumbers); } + /** + * @param array{0:string, 1:list} $marker + */ public function replaceLineMarker(int $line, array $marker): static { $this->lineMarkerReplacements[$line - 1] = $marker; diff --git a/src/Generators/HtmlGenerator.php b/src/Generators/HtmlGenerator.php index 9efcd98..67bbc4a 100644 --- a/src/Generators/HtmlGenerator.php +++ b/src/Generators/HtmlGenerator.php @@ -2,32 +2,32 @@ namespace Torchlight\Engine\Generators; -use Phiki\Generators\HtmlGenerator as BaseHtmlGenerator; -use Phiki\Support\Arr; +use Phiki\Highlighting\Highlighter; use Phiki\Theme\ParsedTheme; -use Phiki\Theme\TokenSettings; use Phiki\Token\HighlightedToken; -use Phiki\Token\Token; -use Torchlight\Engine\Generators\Concerns\InteractsWithHtmlRenderer; -use Torchlight\Engine\Generators\Concerns\ManagesStyles; +use Torchlight\Engine\Contracts\BlockDecorator; +use Torchlight\Engine\Contracts\TokenTransformer; use Torchlight\Engine\Generators\Concerns\ManagesThemeHooks; -use Torchlight\Engine\Generators\Concerns\ProcessesFileLanguage; +use Torchlight\Engine\Generators\Concerns\MergesHighlightedTokens; +use Torchlight\Engine\Generators\TokenTransformers\IndentGuideTransformer; use Torchlight\Engine\Options; -use Torchlight\Engine\Theme\FallbackColors; -use WeakMap; -class HtmlGenerator extends BaseHtmlGenerator +class HtmlGenerator { - use InteractsWithHtmlRenderer, - ManagesStyles, - ManagesThemeHooks, - ProcessesFileLanguage; + use ManagesThemeHooks, + MergesHighlightedTokens; - protected string $vanityLabel = ''; + /** + * @var TokenTransformer[] + */ + protected array $tokenTransformers = []; - private WeakMap $tokenOptions; + /** + * @var BlockDecorator[] + */ + protected array $blockDecorators = []; - private WeakMap $rawTokenContent; + protected string $vanityLabel = ''; protected ?GenerationOptions $generationOptions = null; @@ -37,20 +37,33 @@ class HtmlGenerator extends BaseHtmlGenerator protected CharacterRangeDecorator $characterRangeDecorator; + protected ?ThemeStyleResolver $themeResolver = null; + + /** + * @param array $themes + */ public function __construct( - ?string $grammarName, - array $themes, - bool $withGutter = false) + protected ?string $grammarName, + /** @var array */ + protected array $themes, + ) { + $this->characterRangeDecorator = new CharacterRangeDecorator; + + $this->loadDefaultThemeHooks(); + } + + public function setThemeResolver(ThemeStyleResolver $resolver): static { - parent::__construct($grammarName, $themes, $withGutter); + $this->themeResolver = $resolver; - $this->characterRangeDecorator = new CharacterRangeDecorator; + return $this; + } - $this->setHtmlGenerator($this); - $this->tokenOptions = new WeakMap; - $this->rawTokenContent = new WeakMap; + public function setHighlighter(Highlighter $highlighter): static + { + $this->themeResolver?->setHighlighter($highlighter); - $this->loadDefaultThemeHooks(); + return $this; } public function setLanguageVanityLabel(string $vanityLabel): static @@ -67,102 +80,390 @@ public function setTorchlightOptions(Options $options): static return $this; } - protected function setRawContent(Token $token, string $content): static + public function setCleanedText(string $text): static { - $token->text = $content; - $this->rawTokenContent[$token] = true; + $this->cleanedText = $text; return $this; } - public function setCleanedText(string $text): static + public function setGenerationOptions(GenerationOptions $generationOptions): static { - $this->cleanedText = $text; + $this->generationOptions = $generationOptions; return $this; } - public function setGenerationOptions(GenerationOptions $generationOptions): static + /** + * Register a token transformer. + */ + public function registerTokenTransformer(TokenTransformer $transformer): static { - $this->generationOptions = $generationOptions; + $this->tokenTransformers[] = $transformer; return $this; } - public function renderBlock(array $tokens): RenderedBlock + /** + * Get all registered token transformers. + * + * @return TokenTransformer[] + */ + public function getTokenTransformers(): array + { + return $this->tokenTransformers; + } + + /** + * Register a block decorator. + * Decorators are sorted by priority (lower = earlier). + */ + public function registerBlockDecorator(BlockDecorator $decorator): static + { + $this->blockDecorators[] = $decorator; + + usort($this->blockDecorators, fn ($a, $b) => $a->getPriority() <=> $b->getPriority()); + + return $this; + } + + /** + * Get all registered block decorators. + * + * @return BlockDecorator[] + */ + public function getBlockDecorators(): array + { + return $this->blockDecorators; + } + + /** + * Apply all block decorators that should render. + * + * @param RenderContext $context The render context + * @param string $cleanedText Plain text content for decorators + * @return string[] Array of HTML strings to append + */ + protected function applyBlockDecorators(RenderContext $context, string $cleanedText): array + { + $output = []; + + foreach ($this->blockDecorators as $decorator) { + if ($decorator->shouldRender($context)) { + $output[] = $decorator->render($context, $cleanedText); + } + } + + return $output; + } + + /** + * Apply all token transformers that support the current grammar. + */ + /** + * @param array> $tokens + * @return array> + */ + protected function applyTokenTransformers(array $tokens): array { - if ($this->grammarName === 'files') { - $tokens = $this->processFileLanguage($tokens); + $context = new RenderContext( + $this->torchlightOptions(), + $this->themes, + $this->grammarName ?? '', + $this->themeResolver(), + $this, + ); + + foreach ($this->tokenTransformers as $transformer) { + if ($transformer->supports($this->grammarName ?? '')) { + $tokens = $transformer->transform($context, $tokens); + } } - $block = new RenderedBlock; + /** @var array> $tokens */ + return $tokens; + } + + /** + * Convert HighlightedToken[][] to RenderableToken[][] for processing. + * + * @param array> $tokens + * @return array> + */ + protected function wrapTokens(array $tokens): array + { + $wrapped = []; + + foreach ($tokens as $lineIndex => $lineTokens) { + $wrapped[$lineIndex] = []; + + foreach ($lineTokens as $token) { + $wrapped[$lineIndex][] = RenderableToken::from($token); + } + } + + return $wrapped; + } + + /** + * @param array> $tokens + */ + public function renderBlock(array $tokens): RenderedBlock + { + // Resolve codelens indent guide placeholders BEFORE transformation, + $this->resolveCodelensIndentGuides($tokens); + + $renderableTokens = $this->prepareTokensForRendering($tokens); + + $this->applyColumnGuideClasses(); + + if ($this->torchlightOptions()->indentGuides !== false) { + $generationOptions = $this->generationOptions(); + $generationOptions->blockClasses['has-indent-guides'] = true; + $generationOptions->blockClasses['indent-guides-'.$this->torchlightOptions()->indentGuides] = true; + } $output = []; - $attrs = $this->makeThemeAttributes(); + $removedLines = $this->generationOptions()->removedLines; - $label = null; + foreach ($renderableTokens as $i => $line) { + if (isset($removedLines[$i + 1])) { + continue; + } - if ($this->vanityLabel) { - $label = $this->vanityLabel; - } elseif ($this->grammarName) { - $label = $this->grammarName; + $output[] = $this->buildLine($line, $i); } + $context = $this->createRenderContext(); + + $block = new RenderedBlock; + $this->buildBlockMetadata($block); + $this->buildWrapperMetadata($block); + + $beforeClosing = $this->applyBlockDecorators($context, $this->cleanedText); + + $result = implode('', $output); + $result = strtr($result, $this->generationOptions()->textReplacements); + $result = $this->applyPostRenderHooks($result); + + $block->code = implode('', [ + '', + $result, + ...$beforeClosing, + ]); + + return $block; + } + + /** + * @param array> $tokens + * @return array> + */ + protected function prepareTokensForRendering(array $tokens): array + { + $renderableTokens = $this->wrapTokens($tokens); + + $renderableTokens = $this->applyTokenTransformers($renderableTokens); + + /** @var array> $mergedTokens */ + $mergedTokens = $this->mergeHighlightedTokens($renderableTokens); + + return $mergedTokens; + } + + protected function createRenderContext(): RenderContext + { + return new RenderContext( + $this->torchlightOptions(), + $this->themes, + $this->grammarName ?? '', + $this->themeResolver(), + $this, + ); + } + + protected function buildBlockMetadata(RenderedBlock $block): void + { + $attrs = $this->makeThemeAttributes(); + + $label = $this->vanityLabel ?: $this->grammarName; if ($label) { $attrs['data-lang'] = htmlspecialchars($label); } - foreach ($tokens as $i => $line) { - $output[] = $this->buildLine($line, $i); + if ($this->torchlightOptions()->ariaEnabled) { + $attrs['role'] = 'region'; + $attrs['tabindex'] = '0'; + $ariaLabel = $label ? "Code block: {$label}" : 'Code block'; + $attrs['aria-label'] = htmlspecialchars($ariaLabel); } - $beforeClosing = []; + $classes = array_values(array_filter(array_merge([ + 'torchlight', + trim($this->torchlightOptions()->classes), + ], array_keys($this->generationOptions()->blockClasses)))); + + $styles = $this->buildSelectionBackgroundStyles(); - if ($this->torchlightOptions->copyable) { - $beforeClosing[] = $this->makeCopyTarget(); + $columns = $this->torchlightOptions()->columnGuides; + if ($columns !== []) { + $styles['--tl-colguide-max'] = (string) max($columns); } - $classes = array_filter(array_merge([ - 'torchlight', - trim($this->torchlightOptions->classes), - ], array_keys($this->generationOptions?->blockClasses ?? []))); + $block->attributes = $attrs; + $block->styles = $styles; + $block->classes = $classes; + $block->attributeString = $this->themeResolver()->toAttributeString($attrs); + $block->classString = implode(' ', $block->classes); + $block->styleString = $this->themeResolver()->toStyleString($block->styles); + } + + /** @return array */ + protected function buildSelectionBackgroundStyles(): array + { $styles = []; foreach ($this->themes as $id => $theme) { - $themeSelectionBackground = $this->getValueFromTheme($theme, 'torchlight.selectionBackgroundColor'); + $themeSelectionBackground = $this->themeResolver()->getValueFromTheme($theme, 'torchlight.selectionBackgroundColor'); if (! $themeSelectionBackground) { continue; } $property = '--theme-selection-background'; - if ($id != $this->getDefaultThemeId()) { - $property = $this->getPhikiPropertyName($id, 'theme-selection-background'); + if ($id != $this->themeResolver()->getDefaultThemeId()) { + $property = $this->themeResolver()->getPhikiPropertyName($id, 'theme-selection-background'); } $styles[$property] = $themeSelectionBackground; } - $block->attributes = $attrs; - $block->styles = $styles; - $block->classes = $classes; + return $styles; + } - $block->attributeString = $this->toAttributeString($attrs); - $block->classString = implode(' ', $block->classes); - $block->styleString = $this->toStyleString($block->styles); + protected function applyColumnGuideClasses(): void + { + $columns = $this->torchlightOptions()->columnGuides; + + if ($columns === []) { + return; + } + + $genOptions = $this->generationOptions(); + $genOptions->globalLineClasses = ColumnGuideApplicator::computeLineClasses($columns); + $genOptions->columnGuideHtml = ColumnGuideApplicator::computeGuideHtml($columns); + $genOptions->blockClasses['has-column-guides'] = true; + } + + /** + * @param array> $originalTokens + */ + private function resolveCodelensIndentGuides(array $originalTokens): void + { + if (empty($this->generationOptions()->codelensIndentPlaceholders)) { + return; + } + + $mode = $this->torchlightOptions()->indentGuides; + + if ($mode === false) { + return; + } + + $tabWidth = $this->torchlightOptions()->indentGuidesTabWidth + ?? $this->detectTabWidthFromTokens($originalTokens); + + if ($tabWidth < 1) { + $tabWidth = 4; + } + + foreach ($this->generationOptions()->codelensIndentPlaceholders as $placeholder => $line) { + $index = $line - 1; + $indent = $this->measureLineIndent($originalTokens[$index] ?? [], $tabWidth); + + if ($indent === 0) { + $this->generationOptions()->textReplacements[$placeholder] = ''; + + continue; + } + + $levels = intdiv($indent, $tabWidth); + + $this->generationOptions()->textReplacements[$placeholder] = IndentGuideTransformer::renderGuideSpans( + $levels, $tabWidth, $indent, $mode + ); + } + } + + /** + * @param array> $tokens + */ + private function detectTabWidthFromTokens(array $tokens): int + { + $indentSizes = []; - // Wrapper styles/classes/etc. + foreach ($tokens as $lineTokens) { + if (empty($lineTokens)) { + continue; + } + + /** @var HighlightedToken $firstToken */ + $firstToken = $lineTokens[0]; + $text = $firstToken->token->text; - $wrapperClasses = array_filter([ + // Only measure whitespace-only tokens for indent detection. + if ($text === '' || trim((string) $text) !== '') { + continue; + } + + $size = strlen(str_replace("\t", ' ', $text)); + + if ($size > 0) { + $indentSizes[] = $size; + } + } + + return IndentGuideTransformer::computeTabWidth($indentSizes); + } + + /** + * @param HighlightedToken[] $lineTokens + */ + private function measureLineIndent(array $lineTokens, int $tabWidth): int + { + $columns = 0; + + foreach ($lineTokens as $token) { + $text = $token->token->text; + + if (trim($text) !== '') { + break; + } + + for ($i = 0; $i < strlen($text); $i++) { + if ($text[$i] === "\t") { + $columns += $tabWidth - ($columns % $tabWidth); + } else { + $columns++; + } + } + } + + return $columns; + } + + protected function buildWrapperMetadata(RenderedBlock $block): void + { + $wrapperClasses = array_values(array_filter([ 'phiki', $this->grammarName ? "language-$this->grammarName" : null, - $this->getDefaultTheme()->name, + $this->themeResolver()->getDefaultTheme()->name, count($this->themes) > 1 ? 'phiki-themes' : null, - ]); + ])); foreach ($this->themes as $theme) { - if ($theme !== $this->getDefaultTheme()) { + if ($theme !== $this->themeResolver()->getDefaultTheme()) { $wrapperClasses[] = $theme->name; } } @@ -170,10 +471,10 @@ public function renderBlock(array $tokens): RenderedBlock $block->wrapperClasses = $wrapperClasses; $block->wrapperClassString = implode(' ', $wrapperClasses); - $wrapperStyles = [$this->getDefaultTheme()->base()->toStyleString()]; + $wrapperStyles = [$this->themeResolver()->getDefaultTheme()->base()->toStyleString()]; foreach ($this->themes as $id => $theme) { - if ($id !== $this->getDefaultThemeId()) { + if ($id !== $this->themeResolver()->getDefaultThemeId()) { $wrapperStyles[] = $theme->base()->toCssVarString($id); } } @@ -183,69 +484,48 @@ public function renderBlock(array $tokens): RenderedBlock if (count($wrapperStyles) > 0) { $block->wrapperStyleString = implode(';', $wrapperStyles); } + } - $result = implode('', $output); - $result = strtr($result, $this->generationOptions?->textReplacements ?? []); + protected function applyPostRenderHooks(string $result): string + { + if (! $this->torchlightOptions()->outputTextShadows) { + return $result; + } foreach ($this->themes as $id => $theme) { $propertyPrefix = ''; - if ($id !== $this->getDefaultThemeId()) { - $propertyPrefix = $this->getPhikiPropertyName($id, ''); + if ($id !== $this->themeResolver()->getDefaultThemeId()) { + $propertyPrefix = $this->themeResolver()->getPhikiPropertyName($id, ''); } - // Currently the render hooks apply the text shadow/glow. - // If those have been disabled, we can skip the hooks - if ($this->torchlightOptions->outputTextShadows) { - $result = $this->runAfterRenderHooks( - $theme->name, - $result, - $this->torchlightOptions, - $propertyPrefix, - $id, - ); - } + $result = $this->runAfterRenderHooks( + $theme->name, + $result, + $this->torchlightOptions(), + $propertyPrefix, + $id, + ); } - $block->code = implode('', [ - '', - $result, - ...$beforeClosing, - ]); - - return $block; - } - - public function generate(array $tokens): string - { - return $this->buildPre( - $this->renderBlock($tokens) - ); - } - - private function buildPre(RenderedBlock $block): string - { - return implode('', [ - '
',
-            $this->buildCode($block),
-            '
', - ]); + return $result; } + /** @return array */ protected function makeThemeAttributes(): array { $attributes = []; foreach ($this->themes as $id => $theme) { - $name = htmlspecialchars($theme->name); + $name = htmlspecialchars((string) $theme->name); - if ($id === $this->getDefaultThemeId()) { + if ($id === $this->themeResolver()->getDefaultThemeId()) { $attributes['data-theme'] = $name; continue; } - $id = htmlspecialchars($id); + $id = htmlspecialchars((string) $id); $attributes["data-theme:{$id}"] = $name; } @@ -253,284 +533,251 @@ protected function makeThemeAttributes(): array return $attributes; } - private function buildCode(RenderedBlock $block): string - { - return implode('', [ - "attributeString} class='{$block->allClassesToString()}' style='{$block->allStylesToString()}'>", - $block->code, - '', - ]); - } - - private function makeCopyTarget(): string - { - $content = htmlspecialchars($this->cleanedText); - - return ""; - } - private function buildLinePrepend(int $line): string { - return implode('', $this->generationOptions?->linePrepends[$line] ?? []); + return implode('', $this->generationOptions()->linePrepends[$line] ?? []); } private function buildLineAppend(int $line): string { - return implode('', $this->generationOptions?->lineAppends[$line] ?? []); + return implode('', $this->generationOptions()->lineAppends[$line] ?? []); } + /** + * @param array $line + */ private function buildLine(array $line, int $index): string { $currentLine = $index + 1; - $output = []; - if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineTokenCallbacks)) { - foreach ($this->generationOptions->lineTokenCallbacks[$currentLine] as $tokenCallback) { - $line = call_user_func_array($tokenCallback, [$line]); - } - } + $line = $this->applyLineTokenCallbacks($line, $currentLine); - $classes = [ - 'line', - ]; + $this->applyGutterLineDecorations($index); - if ($this->generationOptions) { - if (array_key_exists($currentLine, $this->generationOptions->lineClasses)) { - $classes = array_merge($classes, array_unique($this->generationOptions->lineClasses[$currentLine])); - } - } + $classes = $this->buildLineClasses($currentLine); - if ($this->withGutter && $this->generationOptions) { - foreach ($this->generationOptions->gutters as $gutter) { - if (! $gutter->shouldRender()) { - continue; - } + $innerContent = $this->buildInnerLineContent($line, $index); + $innerContent = $this->applyLineContentCallbacks($innerContent, $line, $currentLine); - $output[] = $gutter->renderLine($index + 1, $index, $line); - } - } + $gutterContent = $this->buildGutterContent($index, $line); + $guideHtml = $this->generationOptions()->columnGuideHtml; + $lineInnerContent = $guideHtml.implode('', array_merge($gutterContent, [$innerContent])); - // Store the inner content separately to make it easier to apply character ranges if we have any. - $innerLineOutput = []; + $lineElement = $this->buildLineElement($classes, $lineInnerContent, $currentLine); - foreach ($line as $token) { - $innerLineOutput[] = $this->buildToken($token); - } + return implode('', [ + $this->buildLinePrepend($currentLine), + $lineElement, + $this->buildLineAppend($currentLine), + ]); + } - $innerContent = implode('', $innerLineOutput); + /** + * @param array $line + * @return array + */ + private function applyLineTokenCallbacks(array $line, int $currentLine): array + { + $generationOptions = $this->generationOptions; + if ($generationOptions === null || ! array_key_exists($currentLine, $generationOptions->lineTokenCallbacks)) { + return $line; + } - if (array_key_exists($index, $this->generationOptions->characterDecorators)) { - $innerContent = $this->characterRangeDecorator->decorateCharacterRanges( - $innerContent, - $this->generationOptions->characterDecorators[$index] - ); + foreach ($generationOptions->lineTokenCallbacks[$currentLine] as $tokenCallback) { + $line = $tokenCallback($line); } - $output[] = $innerContent; + return $line; + } + + /** @return list */ + private function buildLineClasses(int $currentLine): array + { + $classes = ['line']; - $styles = $this->toStyleString($this->getLineStyles($classes)); + if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineClasses)) { + $classes = array_merge($classes, array_unique($this->generationOptions->lineClasses[$currentLine])); + } - if ($styles != '') { - $styles = "style=\"{$styles}\""; + if ($this->generationOptions && $this->generationOptions->globalLineClasses !== []) { + $classes = array_merge($classes, $this->generationOptions->globalLineClasses); } - $attributes = $this->generationOptions?->lineAttributes[$currentLine] ?? []; - $attributeString = ''; + return $classes; + } - if (! empty($attributes)) { - $attributeString = ' '.$this->toAttributeString($attributes).' '; + private function applyGutterLineDecorations(int $index): void + { + if (! $this->torchlightOptions?->withGutter || ! $this->generationOptions) { + return; } - $lineInnerContent = implode($output); - - if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineContentCallbacks)) { - foreach ($this->generationOptions->lineContentCallbacks[$currentLine] as $callback) { - $lineInnerContent = call_user_func_array($callback, [$lineInnerContent, $line]); + foreach ($this->generationOptions->getSortedGutters() as $gutter) { + if ($gutter->shouldRender()) { + $gutter->decorateLine($index + 1, $index, $this->generationOptions); } } - - $lineContent = '
'.$lineInnerContent.'
'; - - return implode('', [ - $this->buildLinePrepend($currentLine), - $lineContent, - $this->buildLineAppend($currentLine), - ]); } - public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array + /** + * @param array $line + * @return list + */ + private function buildGutterContent(int $index, array $line): array { - $styles = []; - - foreach ($this->themes as $id => $theme) { - $propName = $propertyName; - - if (is_array($themeProp)) { - foreach ($themeProp as $tryPropName) { - $themeValue = $this->getValueFromTheme($theme, $tryPropName); + $output = []; - if ($themeValue) { - break; - } - } - } else { - $themeValue = $this->getValueFromTheme($theme, $themeProp); - } + if (! $this->torchlightOptions?->withGutter || ! $this->generationOptions) { + return $output; + } - $themeValue ??= $default; + $gutters = $this->generationOptions->getSortedGutters(); - if ($id != $this->getDefaultThemeId()) { - $propName = $this->getPhikiPropertyName($id, $propertyName); + foreach ($gutters as $gutter) { + if ($gutter->shouldRender()) { + $output[] = $gutter->renderLine($index + 1, $index, $line); } - - $styles[$propName] = $themeValue; } - return $styles; + return $output; } - public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string + /** + * @param array $line + */ + private function buildInnerLineContent(array $line, int $index): string { - return $this->toStyleString($this->getThemeValueStyles($propertyName, $themeProp, $default)); - } - - private function adjustTokenStyles(HighlightedToken $token): HighlightedToken - { - $newSettings = []; - $currentSettings = $token->settings; - - foreach ($this->themes as $id => $theme) { - if (array_key_exists($id, $currentSettings)) { - continue; - } + $innerLineOutput = []; - $currentSettings[$id] = new TokenSettings(null, null, null); + foreach ($line as $token) { + $innerLineOutput[] = $this->buildToken($token); } - foreach ($currentSettings as $id => $settings) { - $foreground = $settings->foreground; - $themeName = $this->themes[$id]?->name ?? ''; + $innerContent = implode('', $innerLineOutput); - if (mb_strlen(trim($token->token->text)) > 0) { - if ($foreground === null) { - $foreground = FallbackColors::getDefaultForeground($themeName); - } - } + $generationOptions = $this->generationOptions(); - $newSettings[$id] = new TokenSettings( - null, - $foreground, - $this->torchlightOptions->outputFontStyles ? $settings->fontStyle : null + if (array_key_exists($index, $generationOptions->characterDecorators)) { + $innerContent = $this->characterRangeDecorator->decorateCharacterRanges( + $innerContent, + $generationOptions->characterDecorators[$index] ); } - return new HighlightedToken( - $token->token, - $newSettings - ); - } - - public function getTokenStyles(object $token): array - { - return $this->getSettingsStylesArray( - $this->adjustTokenStyles($token)->settings - ); + return $innerContent; } - public function getSettingsStylesArray(array $tokenSettings): array + /** + * @param array $line + */ + private function applyLineContentCallbacks(string $content, array $line, int $currentLine): string { - $defaultThemeId = $this->getDefaultThemeId(); - - $tokenStyles = [($tokenSettings[$defaultThemeId] ?? null)?->toStyleString()]; + $generationOptions = $this->generationOptions; + if ($generationOptions === null || ! array_key_exists($currentLine, $generationOptions->lineContentCallbacks)) { + return $content; + } - foreach ($tokenSettings as $id => $settings) { - if ($id !== $defaultThemeId) { - $tokenStyles[] = $settings->toCssVarString($id); - } + foreach ($generationOptions->lineContentCallbacks[$currentLine] as $callback) { + $content = $callback($content, $line); } - return $tokenStyles; + return $content; } - public function getSettingsStyleString(array $tokenSettings): string + /** + * @param list $classes + */ + private function buildLineElement(array $classes, string $content, int $currentLine): string { - return $this->toStyleString($this->getSettingsStylesArray($tokenSettings)); - } + $lineStyles = $this->themeResolver()->getLineStyles($classes); + $styles = $this->themeResolver()->toStyleString($lineStyles); + $styleAttr = $styles !== '' ? "style=\"{$styles}\"" : ''; - public function buildToken(object $token, array $classes = [], array $styles = []): string - { - $tokenStyles = $this->getTokenStyles($token); - $tokenStyles = array_filter($tokenStyles); - $styleString = ''; + $attributes = $this->generationOptions()->lineAttributes[$currentLine] ?? []; + $attributeString = ! empty($attributes) + ? ' '.$this->themeResolver()->toAttributeString($attributes).' ' + : ''; - if (count($tokenStyles) > 0) { - $styleString = implode(';', $tokenStyles); - } + return '
'.$content.'
'; + } - if (! empty($styles)) { - $incomingStyles = $this->toStyleString($styles); + /** + * @param list $classes + * @param array $styles + */ + public function buildToken(RenderableToken $token, array $classes = [], array $styles = []): string + { + $highlighted = $token->highlighted; + $metadata = $token->metadata; - if (! str_ends_with($incomingStyles, ';')) { - $incomingStyles .= ';'; - } + $tokenStyles = array_filter($this->themeResolver()->getTokenStyles($highlighted)); + $styleString = $this->themeResolver()->toStyleString(array_merge($styles, $tokenStyles)); - $styleString = $incomingStyles.$styleString; + if ($styleString !== '' && ! str_ends_with($styleString, ';')) { + $styleString .= ';'; } + $styleString = str_replace(';;', ';', $styleString); if (empty($classes)) { $classes = ['token']; } $attributes = []; - - if (isset($this->tokenOptions[$token])) { - $options = $this->tokenOptions[$token]; - - if (isset($options['classes'])) { - $classes = array_merge($classes, $options['classes']); - } - - if (isset($options['attributes'])) { - $attributes = array_merge($attributes, $options['attributes']); - } + if ($metadata->hasClasses()) { + $classes = array_merge($classes, $metadata->classes); + } + if ($metadata->hasAttributes()) { + $attributes = array_merge($attributes, $metadata->attributes); } - $attributeString = $this->toAttributeString($attributes); + $attributeString = $this->themeResolver()->toAttributeString($attributes); if ($attributeString != '') { $attributeString = ' '.$attributeString; } - if (mb_strlen(trim($styleString)) > 0 && ! str_ends_with($styleString, ';')) { - $styleString .= ';'; - } - - $styleString = str_replace(';;', ';', $styleString); - return sprintf( '%s', $styleString ? " style=\"$styleString\"" : null, $attributeString, - $this->getTokenContent($token->token) + $this->getTokenContent($highlighted, $metadata) ); } - private function getTokenContent(Token $token): string + private function getTokenContent(HighlightedToken $token, ?TokenMetadata $metadata = null): string { - if (! isset($this->rawTokenContent[$token])) { - return htmlspecialchars($token->text); + // If metadata indicates raw content, don't escape + if ($metadata !== null && $metadata->isRaw()) { + return $token->token->text; } - return $token->text; + return htmlspecialchars($token->token->text); } - public function getDefaultTheme(): ParsedTheme + private function torchlightOptions(): Options { - return Arr::first($this->themes); + if ($this->torchlightOptions === null) { + throw new \LogicException('Torchlight options have not been configured.'); + } + + return $this->torchlightOptions; } - public function getDefaultThemeId(): string + private function generationOptions(): GenerationOptions { - return Arr::firstKey($this->themes); + if ($this->generationOptions === null) { + throw new \LogicException('Generation options have not been configured.'); + } + + return $this->generationOptions; + } + + private function themeResolver(): ThemeStyleResolver + { + if ($this->themeResolver === null) { + throw new \LogicException('Theme resolver has not been configured.'); + } + + return $this->themeResolver; } } diff --git a/src/Generators/RenderContext.php b/src/Generators/RenderContext.php new file mode 100644 index 0000000..44a29ad --- /dev/null +++ b/src/Generators/RenderContext.php @@ -0,0 +1,82 @@ + $themes + */ + public function __construct( + public readonly Options $options, + public readonly array $themes, + public readonly string $grammarName, + private readonly ThemeStyleResolver $themeResolver, + private readonly HtmlGenerator $generator, + ) {} + + /** + * @param list $scopes + * @return array + */ + public function getScopeSettings(array $scopes): array + { + return $this->themeResolver->getScopeSettings($scopes); + } + + /** @param list $scopes */ + public function getScopeStyles(array $scopes): string + { + return $this->themeResolver->getScopeStyles($scopes); + } + + /** + * @param list|string $themeProp + * @return array + */ + public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array + { + return $this->themeResolver->getThemeValueStyles($propertyName, $themeProp, $default); + } + + /** @param list|string $themeProp */ + public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string + { + return $this->themeResolver->getThemeValueStylesString($propertyName, $themeProp, $default); + } + + /** + * @param list $classes + * @param array $styles + */ + public function buildToken(RenderableToken $token, array $classes = [], array $styles = []): string + { + return $this->generator->buildToken($token, $classes, $styles); + } + + public function getDefaultTheme(): ParsedTheme + { + return $this->themeResolver->getDefaultTheme(); + } + + public function getDefaultThemeId(): string + { + return $this->themeResolver->getDefaultThemeId(); + } + + /** @param array $attributes */ + public function toAttributeString(array $attributes): string + { + return $this->themeResolver->toAttributeString($attributes); + } + + /** @param array $styles */ + public function toStyleString(array $styles): string + { + return $this->themeResolver->toStyleString($styles); + } +} diff --git a/src/Generators/RenderableToken.php b/src/Generators/RenderableToken.php new file mode 100644 index 0000000..f0071b6 --- /dev/null +++ b/src/Generators/RenderableToken.php @@ -0,0 +1,31 @@ +setRawContent($rawContent); + } + + return new self($token, $metadata); + } + + public static function raw(HighlightedToken $token, string $content): self + { + $token->token->text = $content; + + return static::from($token, $content); + } +} diff --git a/src/Generators/RenderedBlock.php b/src/Generators/RenderedBlock.php index 5fdb899..514214a 100644 --- a/src/Generators/RenderedBlock.php +++ b/src/Generators/RenderedBlock.php @@ -6,22 +6,27 @@ class RenderedBlock { public string $code = ''; + /** @var array */ public array $attributes = []; public string $attributeString = ''; + /** @var array */ public array $styles = []; public string $styleString = ''; + /** @var list */ public array $wrapperStyles = []; public string $wrapperStyleString = ''; + /** @var list */ public array $wrapperClasses = []; public string $wrapperClassString = ''; + /** @var list */ public array $classes = []; public string $classString = ''; @@ -37,4 +42,13 @@ public function allStylesToString(): string { return $this->wrapperStyleString.$this->styleString; } + + public function toHtml(): string + { + $code = "attributeString} class='{$this->allClassesToString()}' style='{$this->allStylesToString()}'>" + .$this->code + .''; + + return '
'.$code.'
'; + } } diff --git a/src/Generators/ThemeStyleResolver.php b/src/Generators/ThemeStyleResolver.php new file mode 100644 index 0000000..df37d20 --- /dev/null +++ b/src/Generators/ThemeStyleResolver.php @@ -0,0 +1,379 @@ +, 1:string, 2:string|null}>> */ + protected array $styles = [ + 'line-highlight' => [ + [ + ['editor.lineHighlightBackground', 'editor.selectionHighlightBackground', 'theme::background'], + 'background', + '#00000050', + ], + ], + 'line-add' => [ + [ + ['torchlight.markupInsertedBackground', 'diffEditor.insertedTextBackground'], + 'background', + '#89DDFF20', + ], + ], + 'line-remove' => [ + [ + ['torchlight.markupDeletedBackground', 'diffEditor.removedTextBackground'], + 'background', + '#ff9cac20', + ], + ], + ]; + + /** + * @param array $themes + */ + public function __construct( + /** @var array */ + protected array $themes, + protected ?Highlighter $highlighter = null, + protected ?Options $options = null, + ) {} + + public function setHighlighter(Highlighter $highlighter): static + { + $this->highlighter = $highlighter; + + return $this; + } + + public function setOptions(Options $options): static + { + $this->options = $options; + + return $this; + } + + /** + * @param list|string $themeProp + * @return array + */ + public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array + { + return $this->resolveThemeProperty($propertyName, $themeProp, $default); + } + + /** @param list|string $themeProp */ + public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string + { + return $this->toStyleString($this->getThemeValueStyles($propertyName, $themeProp, $default)); + } + + /** + * @param list|string $themeProp + * @return array + */ + private function resolveThemeProperty(string $propertyName, array|string $themeProp, ?string $default = null): array + { + $styles = []; + + foreach ($this->themes as $id => $theme) { + $propName = $propertyName; + $themeValue = null; + + if (is_array($themeProp)) { + foreach ($themeProp as $tryPropName) { + $themeValue = $this->getValueFromTheme($theme, $tryPropName); + + if ($themeValue) { + break; + } + } + } else { + $themeValue = $this->getValueFromTheme($theme, $themeProp); + } + + $themeValue ??= $default; + + if ($id != $this->getDefaultThemeId()) { + $propName = $this->getPhikiPropertyName($id, $propertyName); + } + + $styles[$propName] = $themeValue; + } + + return $styles; + } + + public function getValueFromTheme(ParsedTheme $theme, string $propName): ?string + { + if ($propName === 'theme::background') { + return $theme->base()->background; + } elseif ($propName === 'theme::foreground') { + return $theme->base()->foreground; + } elseif ($propName === 'theme::fontStyle') { + return $theme->base()->fontStyle; + } + + return $theme->colors[$propName] ?? null; + } + + /** @return list */ + public function getTokenStyles(HighlightedToken $token): array + { + return $this->getSettingsStylesArray( + $this->adjustTokenStyles($token)->settings + ); + } + + /** + * @param array $tokenSettings + * @return list + */ + public function getSettingsStylesArray(array $tokenSettings): array + { + $defaultThemeId = $this->getDefaultThemeId(); + + $tokenStyles = []; + + $defaultSettings = $tokenSettings[$defaultThemeId] ?? null; + if ($defaultSettings !== null) { + $tokenStyles[] = $defaultSettings->toStyleString(); + } + + foreach ($tokenSettings as $id => $settings) { + if ($id !== $defaultThemeId) { + $tokenStyles[] = $settings->toCssVarString($id); + } + } + + return $tokenStyles; + } + + /** + * @param array $tokenSettings + */ + public function getSettingsStyleString(array $tokenSettings): string + { + return $this->toStyleString($this->getSettingsStylesArray($tokenSettings)); + } + + protected function adjustTokenStyles(HighlightedToken $token): HighlightedToken + { + /** @var array $newSettings */ + $newSettings = []; + /** @var array $currentSettings */ + $currentSettings = $token->settings; + + foreach ($this->themes as $id => $theme) { + if (array_key_exists($id, $currentSettings)) { + continue; + } + + $currentSettings[$id] = new TokenSettings(null, null, null); + } + + foreach ($currentSettings as $id => $settings) { + $foreground = $settings->foreground; + $theme = $this->themes[$id] ?? null; + $themeName = $theme === null ? '' : $theme->name; + + if (mb_strlen(trim($token->token->text)) > 0) { + if ($foreground === null) { + $foreground = FallbackColors::getDefaultForeground($themeName); + } + } + + $outputFontStyles = $this->options === null ? true : $this->options->outputFontStyles; + + $newSettings[$id] = new TokenSettings( + null, + $foreground, + $outputFontStyles ? $settings->fontStyle : null + ); + } + + return new HighlightedToken( + $token->token, + $newSettings + ); + } + + /** + * @param list $scopes + * @return array + */ + public function getScopeSettings(array $scopes): array + { + $highlightedToken = $this->makeToken('*', $scopes); + + return $highlightedToken->settings; + } + + /** @param list $scopes */ + public function getScopeStyles(array $scopes): string + { + return $this->getSettingsStyleString($this->getScopeSettings($scopes)); + } + + /** @param list $scopes */ + public function makeToken(string $text, array $scopes): HighlightedToken + { + $token = new Token($scopes, $text, 0, 0); + /** @var array> $highlightedLines */ + $highlightedLines = $this->highlighter()->highlight([[$token]]); + if (! isset($highlightedLines[0][0]) || ! $highlightedLines[0][0] instanceof HighlightedToken) { + throw new LogicException('Unable to generate highlighted token.'); + } + + return $highlightedLines[0][0]; + } + + /** @return array */ + public function getStyle(string $class): array + { + $styles = []; + + if (isset($this->styles[$class])) { + foreach ($this->styles[$class] as $classProps) { + [$themeProp, $propertyName, $defaultValue] = $classProps; + + foreach ($this->resolveThemeProperty($propertyName, $themeProp, $defaultValue) as $k => $v) { + $styles[$k] = $v; + } + } + } + + return $styles; + } + + /** + * @param list $classes + * @return array + */ + public function getLineStyles(array $classes): array + { + $styles = []; + + foreach ($classes as $class) { + foreach ($this->getStyle($class) as $k => $v) { + $styles[$k] = $v; + } + } + + return $styles; + } + + /** + * @param list|string $themeProp + */ + public function registerLineStyle(string $class, array|string $themeProp, string $cssProperty, ?string $default = null): static + { + $this->styles[$class] ??= []; + + $themeProperties = is_array($themeProp) ? array_values($themeProp) : [$themeProp]; + + $this->styles[$class][] = [ + $themeProperties, + $cssProperty, + $default, + ]; + + return $this; + } + + public function getDefaultTheme(): ParsedTheme + { + $firstKey = array_key_first($this->themes); + + if ($firstKey === null) { + throw new LogicException('At least one theme is required.'); + } + + /** @var ParsedTheme $theme */ + $theme = $this->themes[$firstKey]; + + return $theme; + } + + public function getDefaultThemeId(): string + { + $firstKey = array_key_first($this->themes); + + if (! is_string($firstKey)) { + throw new LogicException('At least one theme id is required.'); + } + + return $firstKey; + } + + /** @return array */ + public function getThemes(): array + { + return $this->themes; + } + + public function getPhikiPropertyName(string $prefix, string $property): string + { + return "--phiki-{$prefix}-{$property}"; + } + + /** + * @param array $attributes + */ + public static function toAttributeString(array $attributes): string + { + $attributeParts = []; + + foreach ($attributes as $k => $v) { + $attributeParts[] = "{$k}='{$v}'"; + } + + return implode(' ', $attributeParts); + } + + /** + * @param array $styles + */ + public function toStyleString(array $styles): string + { + $styleParts = []; + + foreach ($styles as $k => $v) { + if ($v === null) { + continue; + } + + if (! is_string($k)) { + $styleParts[] = $v; + + continue; + } + + $styleParts[] = "{$k}: {$v}"; + } + + if (count($styleParts) === 0) { + return ''; + } + + return implode(';', $styleParts); + } + + private function highlighter(): Highlighter + { + if ($this->highlighter === null) { + throw new LogicException('Theme highlighter has not been configured.'); + } + + return $this->highlighter; + } +} diff --git a/src/Generators/TokenMetadata.php b/src/Generators/TokenMetadata.php new file mode 100644 index 0000000..9dc7a8c --- /dev/null +++ b/src/Generators/TokenMetadata.php @@ -0,0 +1,64 @@ + $classes + * @param array $attributes + */ + public function __construct( + public array $classes = [], + public array $attributes = [], + public ?string $rawContent = null, + ) {} + + public function hasClasses(): bool + { + return ! empty($this->classes); + } + + public function hasAttributes(): bool + { + return ! empty($this->attributes); + } + + public function isRaw(): bool + { + return $this->rawContent !== null; + } + + public function addClass(string $class): static + { + $this->classes[] = $class; + + return $this; + } + + public function addAttribute(string $name, string $value): static + { + $this->attributes[$name] = $value; + + return $this; + } + + public function setRawContent(string $content): static + { + $this->rawContent = $content; + + return $this; + } + + public function merge(TokenMetadata $other): static + { + $this->classes = array_merge($this->classes, $other->classes); + $this->attributes = array_merge($this->attributes, $other->attributes); + + if ($other->rawContent !== null) { + $this->rawContent = $other->rawContent; + } + + return $this; + } +} diff --git a/src/Generators/TokenTransformers/FileTreeTransformer.php b/src/Generators/TokenTransformers/FileTreeTransformer.php new file mode 100644 index 0000000..1c2b87a --- /dev/null +++ b/src/Generators/TokenTransformers/FileTreeTransformer.php @@ -0,0 +1,285 @@ + */ + protected array $commentScopes = [ + 'source.files', + 'comment.line.number-sign.yaml', + 'punctuation.definition.comment.yaml', + ]; + + public function __construct(protected TreeConnectorGrid $connectorGrid = new TreeConnectorGrid) {} + + public function setFileClassPrefix(string $prefix): static + { + $this->fileClassPrefix = $prefix; + + return $this; + } + + public function getFileClassPrefix(): string + { + return $this->fileClassPrefix; + } + + public function setConnectorClassPrefix(string $prefix): static + { + $this->connectorClassPrefix = $prefix; + + return $this; + } + + public function getConnectorClassPrefix(): string + { + return $this->connectorClassPrefix; + } + + public function setGrammarName(string $name): static + { + $this->grammarName = $name; + + return $this; + } + + public function getGrammarName(): string + { + return $this->grammarName; + } + + /** @param list $scopes */ + public function setCommentScopes(array $scopes): static + { + $this->commentScopes = $scopes; + + return $this; + } + + /** @return list */ + public function getCommentScopes(): array + { + return $this->commentScopes; + } + + public function supports(string $grammarName): bool + { + return $grammarName === $this->grammarName; + } + + /** + * @param array> $tokens + * @return array> + */ + public function transform(RenderContext $context, array $tokens): array + { + $lineInfo = $this->extractLineMetadata($tokens); + $lineInfo = $this->normalizeDepths($lineInfo); + + $grid = $this->connectorGrid->build($lineInfo); + + $commentSettings = $context->getScopeSettings($this->commentScopes); + + $style = $context->options->fileStyle ?? 'ascii'; + + if ($style === 'ascii') { + return $this->renderAsciiConnectors($tokens, $grid, $lineInfo, $commentSettings); + } + + return $this->renderHtmlConnectors($tokens, $grid, $lineInfo, $commentSettings); + } + + /** + * @param array> $lines + * @return array + */ + protected function extractLineMetadata(array $lines): array + { + $info = []; + + foreach ($lines as $lineIndex => $tokens) { + $info[$lineIndex] = [ + 'depth' => 0, + 'isCommentOnly' => false, + 'isDirectory' => false, + 'content' => '', + ]; + + if (empty($tokens)) { + continue; + } + + /** @var RenderableToken $token */ + foreach ($tokens as $token) { + $tokenText = $token->highlighted->token->text; + + // Whitespace token determines depth + if (trim($tokenText) == '') { + $info[$lineIndex]['depth'] = mb_strlen($tokenText); + + continue; + } + + // Comment lines start with # + if (str_starts_with($tokenText, '#')) { + $info[$lineIndex]['isCommentOnly'] = true; + break; + } + + // Directory entries end with / + $isDirectory = str_ends_with($tokenText, '/'); + $info[$lineIndex]['isDirectory'] = $isDirectory; + $info[$lineIndex]['content'] = $tokenText; + + // Set metadata on the token + $attributes = []; + + if (! $isDirectory) { + $attributes['tl-file-extension'] = htmlspecialchars( + pathinfo($token->highlighted->token->text, PATHINFO_EXTENSION) + ); + } + + $token->metadata->classes = [ + $isDirectory + ? "{$this->fileClassPrefix}-folder" + : "{$this->fileClassPrefix}-file", + "{$this->fileClassPrefix}-name", + ]; + $token->metadata->attributes = $attributes; + + break; + } + } + + return $info; + } + + /** + * @param array $info + * @return array + */ + protected function normalizeDepths(array $info): array + { + $allDepths = array_map(fn ($i) => $i['depth'], $info); + $uniqueDepths = array_unique($allDepths); + sort($uniqueDepths); + + $levels = []; + foreach ($uniqueDepths as $i => $level) { + $levels[$level] = $i; + } + + return array_map(function ($line) use ($levels) { + $levelIndex = $levels[$line['depth']]; + $line['depth'] = $levelIndex * 3; + + return $line; + }, $info); + } + + /** + * @param array> $lines + * @param array> $grid + * @param array $lineInfo + * @param array $commentSettings + * @return array> + */ + protected function renderAsciiConnectors(array $lines, array $grid, array $lineInfo, array $commentSettings): array + { + foreach ($lines as $lineIndex => $tokens) { + if (empty($tokens) || empty($grid[$lineIndex])) { + continue; + } + + /** @var RenderableToken $token */ + foreach ($tokens as $tokenIndex => $token) { + if (trim($token->highlighted->token->text) !== '') { + continue; + } + + // Convert bitmask grid row to ASCII characters + $asciiContent = $this->connectorGrid->rowToAscii($grid[$lineIndex]); + + // Update the token text + $token->highlighted->token->text = $asciiContent; + + // Create new HighlightedToken with comment settings wrapped in RenderableToken + $newHighlighted = new HighlightedToken( + $token->highlighted->token, + $commentSettings, + ); + + $lines[$lineIndex][$tokenIndex] = new RenderableToken( + $newHighlighted, + $token->metadata, + ); + + break; + } + } + + return $lines; + } + + /** + * @param array> $lines + * @param array> $grid + * @param array $lineInfo + * @param array $commentSettings + * @return array> + */ + protected function renderHtmlConnectors(array $lines, array $grid, array $lineInfo, array $commentSettings): array + { + foreach ($lines as $lineIndex => $tokens) { + if (empty($tokens) || empty($grid[$lineIndex])) { + continue; + } + + /** @var RenderableToken $token */ + foreach ($tokens as $tokenIndex => $token) { + if (trim($token->highlighted->token->text) !== '') { + continue; + } + + // Convert bitmask grid row to HTML + $rawContent = $this->connectorGrid->rowToHtml( + $grid[$lineIndex], + $this->connectorClassPrefix + ); + + // Update the token text + $token->highlighted->token->text = $rawContent; + + // Create new HighlightedToken with comment settings + $newHighlighted = new HighlightedToken( + $token->highlighted->token, + $commentSettings, + ); + + // Replace with new RenderableToken with raw content metadata + $lines[$lineIndex][$tokenIndex] = new RenderableToken( + $newHighlighted, + $token->metadata->setRawContent($rawContent), + ); + + break; + } + } + + return $lines; + } +} diff --git a/src/Generators/TokenTransformers/IndentGuideTransformer.php b/src/Generators/TokenTransformers/IndentGuideTransformer.php new file mode 100644 index 0000000..bbb207c --- /dev/null +++ b/src/Generators/TokenTransformers/IndentGuideTransformer.php @@ -0,0 +1,275 @@ +classPrefix = $prefix; + + return $this; + } + + public function getClassPrefix(): string + { + return $this->classPrefix; + } + + public function supports(string $grammarName): bool + { + return $grammarName !== 'files'; + } + + /** + * @param array> $tokens + * @return array> + */ + public function transform(RenderContext $context, array $tokens): array + { + $mode = $context->options->indentGuides; + + if ($mode === false) { + return $tokens; + } + + $tabWidth = $context->options->indentGuidesTabWidth ?? $this->detectTabWidth($tokens); + + if ($tabWidth < 1) { + $tabWidth = 4; + } + + $indents = $this->analyzeIndentation($tokens, $tabWidth); + $indents = $this->fillBlankLines($indents); + + return $this->injectGuides($tokens, $indents, $tabWidth, $mode); + } + + /** + * @param array> $tokens + * @return array + */ + protected function analyzeIndentation(array $tokens, int $tabWidth): array + { + $indents = []; + + foreach ($tokens as $lineIndex => $lineTokens) { + $leadingColumns = 0; + $isEmpty = true; + + foreach ($lineTokens as $token) { + $text = $token->highlighted->token->text; + + if (trim((string) $text) === '') { + $leadingColumns += $this->countColumns($text, $tabWidth); + + continue; + } + + $isEmpty = false; + break; + } + + $indents[$lineIndex] = [ + 'columns' => $leadingColumns, + 'levels' => intdiv($leadingColumns, $tabWidth), + 'isEmpty' => $isEmpty, + ]; + } + + return $indents; + } + + protected function countColumns(string $text, int $tabWidth): int + { + $columns = 0; + + for ($i = 0; $i < strlen($text); $i++) { + if ($text[$i] === "\t") { + $columns += $tabWidth - ($columns % $tabWidth); + } else { + $columns++; + } + } + + return $columns; + } + + /** @param array> $tokens */ + protected function detectTabWidth(array $tokens): int + { + $indentSizes = []; + + foreach ($tokens as $lineTokens) { + if (empty($lineTokens)) { + continue; + } + + $text = $lineTokens[0]->highlighted->token->text; + + if ($text === '' || trim((string) $text) !== '') { + continue; + } + + $size = strlen(str_replace("\t", ' ', $text)); + + if ($size > 0) { + $indentSizes[] = $size; + } + } + + return self::computeTabWidth($indentSizes); + } + + /** @param list $indentSizes */ + public static function computeTabWidth(array $indentSizes): int + { + if (empty($indentSizes)) { + return 4; + } + + $gcd = $indentSizes[0]; + + foreach ($indentSizes as $size) { + $gcd = self::gcd($gcd, $size); + } + + return max(1, min(8, $gcd)); + } + + protected static function gcd(int $a, int $b): int + { + while ($b !== 0) { + [$a, $b] = [$b, $a % $b]; + } + + return $a; + } + + /** + * @param array $indents + * @return array + */ + protected function fillBlankLines(array $indents): array + { + $lineCount = count($indents); + + for ($i = 0; $i < $lineCount; $i++) { + if (! $indents[$i]['isEmpty']) { + continue; + } + + $prevLevel = $this->findNearestNonEmptyLevel($indents, $i, -1); + $nextLevel = $this->findNearestNonEmptyLevel($indents, $i, 1); + + $indents[$i]['levels'] = min($prevLevel, $nextLevel); + } + + return $indents; + } + + /** + * @param array $indents + */ + protected function findNearestNonEmptyLevel(array $indents, int $from, int $direction): int + { + $i = $from + $direction; + + while (isset($indents[$i])) { + if (! $indents[$i]['isEmpty']) { + return $indents[$i]['levels']; + } + + $i += $direction; + } + + return 0; + } + + /** + * @param array> $tokens + * @param array $indents + * @return array> + */ + protected function injectGuides(array $tokens, array $indents, int $tabWidth, string $mode): array + { + foreach ($tokens as $lineIndex => $lineTokens) { + $levels = $indents[$lineIndex]['levels']; + + if ($levels <= 0) { + continue; + } + + foreach ($lineTokens as $tokenIndex => $token) { + $text = $token->highlighted->token->text; + + if (trim((string) $text) !== '') { + break; + } + + $rawContent = self::renderGuideSpans($levels, $tabWidth, strlen((string) $text), $mode, $this->classPrefix); + + $token->highlighted->token->text = $rawContent; + + $tokens[$lineIndex][$tokenIndex] = new RenderableToken( + $token->highlighted, + (new TokenMetadata)->setRawContent($rawContent), + ); + + break; + } + } + + return $tokens; + } + + public static function renderGuideSpans( + int $levels, + int $tabWidth, + int $totalChars, + string $mode, + string $classPrefix = 'tl-guide', + ): string { + $html = ''; + $charsUsed = 0; + + for ($depth = 0; $depth < $levels; $depth++) { + $charsForLevel = min($tabWidth, $totalChars - $charsUsed); + + if ($charsForLevel <= 0) { + break; + } + + $depthNum = $depth + 1; + + if ($mode === 'ascii') { + $content = '│'.str_repeat(' ', max(0, $charsForLevel - 1)); + } else { + $content = str_repeat(' ', $charsForLevel); + } + + $html .= sprintf( + '%s', + $classPrefix, + $classPrefix, + $depthNum, + $content + ); + + $charsUsed += $charsForLevel; + } + + if ($charsUsed < $totalChars) { + $html .= str_repeat(' ', $totalChars - $charsUsed); + } + + return $html; + } +} diff --git a/src/Generators/TokenTransformers/TreeConnectorGrid.php b/src/Generators/TokenTransformers/TreeConnectorGrid.php new file mode 100644 index 0000000..842e851 --- /dev/null +++ b/src/Generators/TokenTransformers/TreeConnectorGrid.php @@ -0,0 +1,268 @@ + $lines + * @return array> + */ + public function build(array $lines): array + { + $rows = []; + $lineCount = count($lines); + + // Initialize grid with spaces for each row up to content depth + for ($r = 0; $r < $lineCount; $r++) { + $rows[$r] = []; + for ($c = 0; $c < $lines[$r]['depth']; $c++) { + $rows[$r][$c] = ' '; // placeholder + } + } + + // Draw connection lines row by row + for ($r = 0; $r < $lineCount - 1; $r++) { + $line = $lines[$r]; + + $childDepth = null; + + if (isset($lines[$r + 1]) && $lines[$r + 1]['depth'] > $line['depth']) { + $childDepth = $lines[$r + 1]['depth']; + } + + // No children, no connectors needed + if ($childDepth === null) { + continue; + } + + // Find the last child and last phantom line for this parent + $lastChild = 0; + $lastPhantom = $lineCount - 1; + + for ($r1 = $r + 1; $r1 < $lineCount; $r1++) { + // Direct child at same child depth + if ($lines[$r1]['depth'] === $childDepth) { + $lastChild = $r1; + } + + // Shallower depth means we've left this family tree + if ($lines[$r1]['depth'] < $childDepth) { + $lastPhantom = $r1 - 1; + break; + } + } + + // Draw vertical and horizontal connectors for child lines + for ($r1 = $r + 1; $r1 <= $lastPhantom; $r1++) { + $prime = $lines[$r1]; + $verticalIndex = $line['depth']; + + // Add vertical down connector to the line above (if not the parent line) + if ($r1 <= $lastChild) { + if (isset($rows[$r1 - 1][$verticalIndex]) && ($r1 - 1) > $r) { + $rows[$r1 - 1][$verticalIndex] = $this->addBit( + $rows[$r1 - 1][$verticalIndex], + self::HALF_VERTICAL_DOWN + ); + } + } + + // Add vertical up connector (or phantom for alignment) + if ($r1 <= $lastChild) { + $rows[$r1][$verticalIndex] = $this->addBit( + $rows[$r1][$verticalIndex], + self::HALF_VERTICAL_UP + ); + } else { + $rows[$r1][$verticalIndex] = $this->addBit( + $rows[$r1][$verticalIndex], + self::PHANTOM + ); + } + + // Add horizontal connector for direct children with content + if ($prime['depth'] === $childDepth && ! empty($prime['content'])) { + // The half bit for intersection + $rows[$r1][$verticalIndex] = $this->addBit( + $rows[$r1][$verticalIndex], + self::HALF_HORIZONTAL_RIGHT + ); + + // The full horizontal for the connector line + if (isset($rows[$r1][$verticalIndex + 1])) { + $rows[$r1][$verticalIndex + 1] = $this->addBit( + $rows[$r1][$verticalIndex + 1], + self::FULL_HORIZONTAL + ); + } + } + } + } + + /** @var array> $normalizedRows */ + $normalizedRows = array_map( + array_values(...), + $rows + ); + + return $normalizedRows; + } + + public function addBit(int|string $char, int $bit): int + { + $char = $char === ' ' ? 0 : (int) $char; + + return $char | $bit; + } + + public function maskToCharacter(int|string $mask): string + { + // If $mask isn't a number, just return it. + if (! is_numeric($mask)) { + return $mask; + } + + return match ($mask) { + // Half bars become full bars + self::HALF_HORIZONTAL_LEFT, + self::HALF_HORIZONTAL_RIGHT, + self::FULL_HORIZONTAL => '─', + + // Vertical bars + self::HALF_VERTICAL_DOWN, + self::HALF_VERTICAL_UP, + self::FULL_VERTICAL => '│', + + // T-junctions + self::FULL_HORIZONTAL + self::HALF_VERTICAL_DOWN => '┬', + self::FULL_HORIZONTAL + self::HALF_VERTICAL_UP => '┴', + self::HALF_HORIZONTAL_LEFT + self::FULL_VERTICAL => '┤', + self::HALF_HORIZONTAL_RIGHT + self::FULL_VERTICAL => '├', + + // Corners + self::TR_CORNER => '┐', + self::TL_CORNER => '┌', + self::BL_CORNER => '└', + self::BR_CORNER => '┘', + + default => ' ', + }; + } + + /** + * @return array{wrapper:list, horizontal:list, vertical:list} + */ + public function maskToClasses(int $mask, string $prefix = 'tl-connect'): array + { + $horizontal = [$prefix, "{$prefix}-h"]; + $vertical = [$prefix, "{$prefix}-v"]; + $wrapper = ["{$prefix}-wrap"]; + + if (($mask & self::HALF_HORIZONTAL_LEFT) === self::HALF_HORIZONTAL_LEFT) { + $horizontal[] = "{$prefix}-left"; + } + + if (($mask & self::HALF_HORIZONTAL_RIGHT) === self::HALF_HORIZONTAL_RIGHT) { + $horizontal[] = "{$prefix}-right"; + } + + if (($mask & self::HALF_VERTICAL_DOWN) === self::HALF_VERTICAL_DOWN) { + $vertical[] = "{$prefix}-down"; + $wrapper[] = "{$prefix}-x-adjust"; + } + + if (($mask & self::HALF_VERTICAL_UP) === self::HALF_VERTICAL_UP) { + $vertical[] = "{$prefix}-up"; + $wrapper[] = "{$prefix}-x-adjust"; + } + + if (($mask & self::PHANTOM) === self::PHANTOM) { + $wrapper[] = "{$prefix}-x-adjust"; + } + + return [ + 'wrapper' => array_values(array_unique($wrapper)), + 'horizontal' => array_values(array_unique($horizontal)), + 'vertical' => array_values(array_unique($vertical)), + ]; + } + + public function renderHtmlConnector(int|string $mask, string $prefix = 'tl-connect'): string + { + if ($mask === ' ') { + return sprintf( + "", + $prefix, + $prefix, + $prefix, + $prefix, + $prefix, + $prefix + ); + } + + $classes = $this->maskToClasses((int) $mask, $prefix); + + $inner = sprintf( + "", + implode(' ', $classes['horizontal']), + implode(' ', $classes['vertical']) + ); + + return sprintf(" %s", implode(' ', $classes['wrapper']), $inner); + } + + /** @param list $row */ + public function rowToAscii(array $row): string + { + $chars = []; + + foreach ($row as $mask) { + $chars[] = $this->maskToCharacter($mask); + } + + return implode('', $chars); + } + + /** @param list $row */ + public function rowToHtml(array $row, string $prefix = 'tl-connect'): string + { + $html = []; + + foreach ($row as $mask) { + $html[] = $this->renderHtmlConnector($mask, $prefix); + } + + return implode('', $html); + } +} diff --git a/src/Options.php b/src/Options.php index 0fb52f8..a264e00 100644 --- a/src/Options.php +++ b/src/Options.php @@ -3,7 +3,6 @@ namespace Torchlight\Engine; use Closure; -use Phiki\Support\Arr; class Options { @@ -35,6 +34,21 @@ class Options private const COPYABLE = false; + private const ARIA_ENABLED = false; + + private const WITH_GUTTER = true; + + /** + * @param list $highlightLines + * @param list $addLines + * @param list $removeLines + * @param list $focusLines + * @param list $autolinkLines + * @param list $monoLines + * @param list $hideLines + * @param array $themes + * @param list $columnGuides + */ public function __construct( public bool $lineNumbersEnabled = self::LINE_NUMBERS_ENABLED, public int $lineNumbersStart = self::LINE_NUMBERS_START, @@ -48,17 +62,23 @@ public function __construct( public bool $annotationsEnabled = self::ANNOTATIONS_ENABLED, public string $fileStyle = self::FILE_STYLE, public bool $copyable = self::COPYABLE, + public bool $ariaEnabled = self::ARIA_ENABLED, + public bool $withGutter = self::WITH_GUTTER, public array $highlightLines = [], public array $addLines = [], public array $removeLines = [], public array $focusLines = [], public array $autolinkLines = [], public array $monoLines = [], + public array $hideLines = [], public array $themes = [], public string $classes = '', public bool $fallbackOnUnknownGrammar = true, public bool $outputFontStyles = false, // Default consistent with current API behavior public bool $outputTextShadows = true, + public string|false $indentGuides = false, + public ?int $indentGuidesTabWidth = null, + public array $columnGuides = [], ) {} public static function setDefaultOptionsBuilder(?Closure $builder): void @@ -72,15 +92,25 @@ public static function default(): Options if (! static::$default) { if (static::$defaultOptionsBuilder != null) { $callback = static::$defaultOptionsBuilder; - static::$default = $callback(); + $default = $callback(); + + if (! $default instanceof Options) { + throw new \LogicException('Default options builder must return an Options instance.'); + } + + static::$default = $default; } else { static::$default = new Options; } } - return self::$default; + return self::$default ?? new Options; } + /** + * @param list $ranges + * @return list + */ protected static function parseConfigRanges(array $ranges): array { $processedRanges = []; @@ -88,20 +118,24 @@ protected static function parseConfigRanges(array $ranges): array foreach ($ranges as $range) { if (is_string($range) && str_contains($range, '-')) { $parts = explode('-', $range, 2); - $start = intval($parts[0]); - $end = intval($parts[1]); + $start = (int) $parts[0]; + $end = (int) $parts[1]; $processedRanges[] = [$start, $end]; continue; } - $processedRanges[] = [$range, $range]; + $processedRanges[] = [(int) $range, (int) $range]; } return $processedRanges; } + /** + * @param string|array $optionThemes + * @return array + */ public static function adjustOptionThemes(string|array $optionThemes): array { $themes = []; @@ -111,8 +145,8 @@ public static function adjustOptionThemes(string|array $optionThemes): array } foreach ($optionThemes as $theme) { - if (str_contains($theme, ':')) { - $parts = explode(':', $theme, 2); + if (str_contains((string) $theme, ':')) { + $parts = explode(':', (string) $theme, 2); $themes[$parts[0]] = $parts[1]; @@ -125,6 +159,7 @@ public static function adjustOptionThemes(string|array $optionThemes): array return $themes; } + /** @return array|null> */ public function toArray(): array { return [ @@ -142,39 +177,225 @@ public function toArray(): array 'fileStyle' => $this->fileStyle, 'copyable' => $this->copyable, + 'withGutter' => $this->withGutter, 'classes' => $this->classes, + + 'indentGuides' => $this->indentGuides, + 'indentGuidesTabWidth' => $this->indentGuidesTabWidth, + 'columnGuides' => $this->columnGuides, ]; } + /** + * @param array $options + */ public static function fromArray(array $options): Options { + $themeOption = self::themeOption($options, 'theme'); + return new Options( - lineNumbersEnabled: $options['lineNumbers'] ?? self::LINE_NUMBERS_ENABLED, - lineNumbersStart: $options['lineNumbersStart'] ?? self::LINE_NUMBERS_START, - lineNumbersStyle: $options['lineNumbersStyle'] ?? self::LINE_NUMBERS_STYLE, - - lineNumberAndDiffIndicatorRightPadding: $options['lineNumberAndDiffIndicatorRightPadding'] ?? self::LINE_NUMBER_AND_DIFF_INDICATOR_RIGHT_PADDING, - diffIndicatorsEnabled: $options['diffIndicators'] ?? self::DIFF_INDICATORS_ENABLED, - diffIndicatorsInPlaceOfNumbers: $options['diffIndicatorsInPlaceOfLineNumbers'] ?? self::DIFF_INDICATORS_IN_PLACE_OF_NUMBERS, - diffPreserveSyntaxColors: $options['diffPreserveSyntaxColors'] ?? self::DIFF_PRESERVE_SYNTAX_COLORS, - - showSummaryCarets: $options['showSummaryCarets'] ?? self::SHOW_SUMMARY_CARETS, - summaryCollapsedIndicator: $options['summaryCollapsedIndicator'] ?? self::SUMMARY_COLLAPSED_INDICATOR, - - annotationsEnabled: $options['torchlightAnnotations'] ?? self::ANNOTATIONS_ENABLED, - fileStyle: $options['fileStyle'] ?? self::FILE_STYLE, - copyable: $options['copyable'] ?? self::COPYABLE, - - highlightLines: self::parseConfigRanges($options['highlightLines'] ?? []), - addLines: self::parseConfigRanges($options['addLines'] ?? []), - removeLines: self::parseConfigRanges($options['removeLines'] ?? []), - focusLines: self::parseConfigRanges($options['focusLines'] ?? []), - autolinkLines: self::parseConfigRanges($options['autolinkLines'] ?? []), - monoLines: self::parseConfigRanges($options['monoLines'] ?? []), - themes: self::adjustOptionThemes(Arr::wrap($options['theme'] ?? [])), - - classes: $options['classes'] ?? '', + lineNumbersEnabled: self::boolOption($options, 'lineNumbers', self::LINE_NUMBERS_ENABLED), + lineNumbersStart: self::intOption($options, 'lineNumbersStart', self::LINE_NUMBERS_START), + lineNumbersStyle: self::stringOption($options, 'lineNumbersStyle', self::LINE_NUMBERS_STYLE), + + lineNumberAndDiffIndicatorRightPadding: self::intOption($options, 'lineNumberAndDiffIndicatorRightPadding', self::LINE_NUMBER_AND_DIFF_INDICATOR_RIGHT_PADDING), + diffIndicatorsEnabled: self::boolOption($options, 'diffIndicators', self::DIFF_INDICATORS_ENABLED), + diffIndicatorsInPlaceOfNumbers: self::boolOption($options, 'diffIndicatorsInPlaceOfLineNumbers', self::DIFF_INDICATORS_IN_PLACE_OF_NUMBERS), + diffPreserveSyntaxColors: self::boolOption($options, 'diffPreserveSyntaxColors', self::DIFF_PRESERVE_SYNTAX_COLORS), + + showSummaryCarets: self::boolOption($options, 'showSummaryCarets', self::SHOW_SUMMARY_CARETS), + summaryCollapsedIndicator: self::stringOption($options, 'summaryCollapsedIndicator', self::SUMMARY_COLLAPSED_INDICATOR), + + annotationsEnabled: self::boolOption($options, 'torchlightAnnotations', self::ANNOTATIONS_ENABLED), + fileStyle: self::stringOption($options, 'fileStyle', self::FILE_STYLE), + copyable: self::boolOption($options, 'copyable', self::COPYABLE), + ariaEnabled: self::boolOption($options, 'ariaEnabled', self::ARIA_ENABLED), + withGutter: self::boolOption($options, 'withGutter', self::WITH_GUTTER), + + highlightLines: self::parseConfigRanges(self::configRangeOption($options, 'highlightLines')), + addLines: self::parseConfigRanges(self::configRangeOption($options, 'addLines')), + removeLines: self::parseConfigRanges(self::configRangeOption($options, 'removeLines')), + focusLines: self::parseConfigRanges(self::configRangeOption($options, 'focusLines')), + autolinkLines: self::parseConfigRanges(self::configRangeOption($options, 'autolinkLines')), + monoLines: self::parseConfigRanges(self::configRangeOption($options, 'monoLines')), + hideLines: self::parseConfigRanges(self::configRangeOption($options, 'hideLines')), + themes: self::adjustOptionThemes($themeOption ?? []), + + classes: self::stringOption($options, 'classes', ''), + + indentGuides: self::indentGuidesOption($options, 'indentGuides', false), + indentGuidesTabWidth: self::nullableIntOption($options, 'indentGuidesTabWidth'), + columnGuides: self::intListOption($options, 'columnGuides'), + ); + } + + /** + * @param array $overrides + */ + public function mergeWith(array $overrides): Options + { + $themeOverride = self::themeOption($overrides, 'theme'); + + return new Options( + lineNumbersEnabled: self::boolOption($overrides, 'lineNumbers', $this->lineNumbersEnabled), + lineNumbersStart: self::intOption($overrides, 'lineNumbersStart', $this->lineNumbersStart), + lineNumbersStyle: self::stringOption($overrides, 'lineNumbersStyle', $this->lineNumbersStyle), + + lineNumberAndDiffIndicatorRightPadding: self::intOption($overrides, 'lineNumberAndDiffIndicatorRightPadding', $this->lineNumberAndDiffIndicatorRightPadding), + diffIndicatorsEnabled: self::boolOption($overrides, 'diffIndicators', $this->diffIndicatorsEnabled), + diffIndicatorsInPlaceOfNumbers: self::boolOption($overrides, 'diffIndicatorsInPlaceOfLineNumbers', $this->diffIndicatorsInPlaceOfNumbers), + diffPreserveSyntaxColors: self::boolOption($overrides, 'diffPreserveSyntaxColors', $this->diffPreserveSyntaxColors), + + showSummaryCarets: self::boolOption($overrides, 'showSummaryCarets', $this->showSummaryCarets), + summaryCollapsedIndicator: self::stringOption($overrides, 'summaryCollapsedIndicator', $this->summaryCollapsedIndicator), + + annotationsEnabled: self::boolOption($overrides, 'torchlightAnnotations', $this->annotationsEnabled), + fileStyle: self::stringOption($overrides, 'fileStyle', $this->fileStyle), + copyable: self::boolOption($overrides, 'copyable', $this->copyable), + ariaEnabled: self::boolOption($overrides, 'ariaEnabled', $this->ariaEnabled), + withGutter: self::boolOption($overrides, 'withGutter', $this->withGutter), + + highlightLines: array_key_exists('highlightLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'highlightLines')) + : $this->highlightLines, + addLines: array_key_exists('addLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'addLines')) + : $this->addLines, + removeLines: array_key_exists('removeLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'removeLines')) + : $this->removeLines, + focusLines: array_key_exists('focusLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'focusLines')) + : $this->focusLines, + autolinkLines: array_key_exists('autolinkLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'autolinkLines')) + : $this->autolinkLines, + monoLines: array_key_exists('monoLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'monoLines')) + : $this->monoLines, + hideLines: array_key_exists('hideLines', $overrides) + ? self::parseConfigRanges(self::configRangeOption($overrides, 'hideLines')) + : $this->hideLines, + themes: array_key_exists('theme', $overrides) + ? self::adjustOptionThemes($themeOverride ?? []) + : $this->themes, + + classes: self::stringOption($overrides, 'classes', $this->classes), + fallbackOnUnknownGrammar: $this->fallbackOnUnknownGrammar, + outputFontStyles: $this->outputFontStyles, + outputTextShadows: $this->outputTextShadows, + + indentGuides: self::indentGuidesOption($overrides, 'indentGuides', $this->indentGuides), + indentGuidesTabWidth: self::nullableIntOption($overrides, 'indentGuidesTabWidth') ?? $this->indentGuidesTabWidth, + columnGuides: array_key_exists('columnGuides', $overrides) + ? self::intListOption($overrides, 'columnGuides') + : $this->columnGuides, + ); + } + + /** + * @param array $options + */ + private static function boolOption(array $options, string $key, bool $default): bool + { + $value = $options[$key] ?? $default; + + return is_bool($value) ? $value : $default; + } + + /** + * @param array $options + */ + private static function intOption(array $options, string $key, int $default): int + { + $value = $options[$key] ?? $default; + + return is_int($value) ? $value : $default; + } + + /** + * @param array $options + */ + private static function nullableIntOption(array $options, string $key): ?int + { + $value = $options[$key] ?? null; + + return is_int($value) ? $value : null; + } + + /** + * @param array $options + */ + private static function stringOption(array $options, string $key, string $default): string + { + $value = $options[$key] ?? $default; + + return is_string($value) ? $value : $default; + } + + /** + * @param array $options + * @return list + */ + private static function configRangeOption(array $options, string $key): array + { + $value = $options[$key] ?? []; + + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + $value, + static fn (mixed $range): bool => is_int($range) || is_string($range), + )); + } + + /** + * @param array $options + */ + private static function indentGuidesOption(array $options, string $key, string|false $default): string|false + { + $value = $options[$key] ?? $default; + + return is_string($value) || $value === false ? $value : $default; + } + + /** + * @param array $options + * @return list + */ + private static function intListOption(array $options, string $key): array + { + $value = $options[$key] ?? []; + + if (! is_array($value)) { + return []; + } + + return array_values(array_filter($value, is_int(...))); + } + + /** + * @param array $options + * @return string|array|null + */ + private static function themeOption(array $options, string $key): string|array|null + { + $value = $options[$key] ?? null; + + if (is_string($value)) { + return $value; + } + + if (! is_array($value)) { + return null; + } + + return array_filter( + $value, + is_string(...), ); } } diff --git a/src/Pipeline/ProcessedTokens.php b/src/Pipeline/ProcessedTokens.php new file mode 100644 index 0000000..7891e4d --- /dev/null +++ b/src/Pipeline/ProcessedTokens.php @@ -0,0 +1,25 @@ + + */ + public readonly array $tokens, + + public readonly string $cleanedText, + + public readonly Grammar|ParsedGrammar|null $grammar, + + public readonly string $languageName, + + public readonly string $scopeName, + ) {} +} diff --git a/src/Pipeline/RenderState.php b/src/Pipeline/RenderState.php new file mode 100644 index 0000000..653bbec --- /dev/null +++ b/src/Pipeline/RenderState.php @@ -0,0 +1,32 @@ +|null */ + public ?array $overrideThemes = null; + + public string $languageVanityLabel = ''; +} diff --git a/src/PreparedGrammar.php b/src/PreparedGrammar.php new file mode 100644 index 0000000..7f35165 --- /dev/null +++ b/src/PreparedGrammar.php @@ -0,0 +1,23 @@ +grammar)) { + return $this->grammar; + } + + return $this->grammar->name ?? ''; + } +} diff --git a/src/Preprocessors/PreprocessorArgs.php b/src/Preprocessors/PreprocessorArgs.php index 7aa660c..195509c 100644 --- a/src/Preprocessors/PreprocessorArgs.php +++ b/src/Preprocessors/PreprocessorArgs.php @@ -3,13 +3,18 @@ namespace Torchlight\Engine\Preprocessors; use Phiki\Grammar\Grammar; +use Phiki\Grammar\ParsedGrammar; +use Phiki\Token\Token; class PreprocessorArgs { + /** + * @param array> $tokens + */ public function __construct( public array $tokens, public string $originalText, - public string|Grammar $grammar, + public string|Grammar|ParsedGrammar $grammar, public ?string $languageName ) {} } diff --git a/src/Support/Str.php b/src/Support/Str.php index ff618ad..0d799c6 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -4,31 +4,39 @@ class Str { + /** @var non-empty-string */ const CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; public static function random(int $length = 16): string { $result = ''; - $charLen = strlen(static::CHARS); + $maxIndex = strlen(static::CHARS) - 1; + + if ($maxIndex < 0) { + return $result; + } for ($i = 0; $i < $length; $i++) { - $result .= static::CHARS[random_int(0, $charLen - 1)]; + $result .= static::CHARS[random_int(0, $maxIndex)]; } return $result; } - public static function substr($string, $start, $length = null, $encoding = 'UTF-8') + public static function substr(string|\Stringable|int|float|bool $string, int $start, ?int $length = null, ?string $encoding = 'UTF-8'): string { - return mb_substr($string, $start, $length, $encoding); + return mb_substr((string) $string, $start, $length, $encoding); } - public static function beforeLast($subject, $search) + public static function beforeLast(string|\Stringable|int|float|bool $subject, string|\Stringable|int|float|bool $search): string { + $search = (string) $search; + if ($search === '') { - return $subject; + return (string) $subject; } + $subject = (string) $subject; $pos = mb_strrpos($subject, $search); if ($pos === false) { @@ -38,18 +46,28 @@ public static function beforeLast($subject, $search) return static::substr($subject, 0, $pos); } - public static function after($subject, $search) + public static function after(string|\Stringable|int|float|bool $subject, string|\Stringable|int|float|bool $search): string { - return $search === '' ? $subject : array_reverse(explode($search, $subject, 2))[0]; + $subject = (string) $subject; + $search = (string) $search; + + if ($search === '') { + return $subject; + } + + return array_reverse(explode($search, $subject, 2))[0]; } - public static function afterLast($subject, $search) + public static function afterLast(string|\Stringable|int|float|bool $subject, string|\Stringable|int|float|bool $search): string { + $subject = (string) $subject; + $search = (string) $search; + if ($search === '') { return $subject; } - $position = strrpos($subject, (string) $search); + $position = strrpos($subject, $search); if ($position === false) { return $subject; @@ -58,13 +76,23 @@ public static function afterLast($subject, $search) return substr($subject, $position + strlen($search)); } + /** + * @return list + */ public static function nlSplit(string $subject): array { - return preg_split('/\r\n|\r|\n/', $subject); + return preg_split('/\r\n|\r|\n/', $subject) ?: []; } - public static function substrReplace($string, $replace, $offset = 0, $length = null) - { + public static function substrReplace( + string|\Stringable|int|float|bool $string, + string|\Stringable|int|float|bool $replace, + int $offset = 0, + ?int $length = null, + ): string { + $string = (string) $string; + $replace = (string) $replace; + if ($length === null) { $length = strlen($string); } diff --git a/src/Concerns/MergesTokens.php b/src/Support/TokenMerger.php similarity index 72% rename from src/Concerns/MergesTokens.php rename to src/Support/TokenMerger.php index 0022263..d472daa 100644 --- a/src/Concerns/MergesTokens.php +++ b/src/Support/TokenMerger.php @@ -1,10 +1,16 @@ $lines + * @return array + */ + public static function merge(array $lines): array { foreach ($lines as $i => $tokens) { $merged = []; diff --git a/src/Theme/FallbackColors.php b/src/Theme/FallbackColors.php index cde2143..f53b741 100644 --- a/src/Theme/FallbackColors.php +++ b/src/Theme/FallbackColors.php @@ -2,9 +2,9 @@ namespace Torchlight\Engine\Theme; -// TODO: Work to not need this anymore :) class FallbackColors { + /** @var array>|null */ protected static ?array $loadedSettings = null; protected static function loadSettings(): void @@ -13,10 +13,37 @@ protected static function loadSettings(): void return; } - static::$loadedSettings = json_decode( - file_get_contents(__DIR__.'/../../resources/themes/settings.json'), - true - ); + $json = file_get_contents(__DIR__.'/../../resources/themes/settings.json'); + if ($json === false) { + static::$loadedSettings = []; + + return; + } + + $decoded = json_decode($json, true); + if (! is_array($decoded)) { + static::$loadedSettings = []; + + return; + } + + $loadedSettings = []; + + foreach ($decoded as $theme => $themeSettings) { + if (! is_string($theme) || ! is_array($themeSettings)) { + continue; + } + + foreach ($themeSettings as $settingName => $settingValue) { + if (! is_string($settingName) || ! is_string($settingValue)) { + continue; + } + + $loadedSettings[$theme][$settingName] = $settingValue; + } + } + + static::$loadedSettings = $loadedSettings; } public static function getDefaultForeground(string $theme): ?string diff --git a/src/Theme/Highlighting/Highlighter.php b/src/Theme/Highlighting/Highlighter.php deleted file mode 100644 index 2eff1e2..0000000 --- a/src/Theme/Highlighting/Highlighter.php +++ /dev/null @@ -1,55 +0,0 @@ -resolver = new SettingsResolver; - } - - public function highlight(array $tokens): array - { - $highlightedTokens = []; - - foreach ($tokens as $i => $line) { - foreach ($line as $token) { - if (mb_strlen(trim($token->text)) === 0) { - $highlightedTokens[$i][] = new HighlightedToken($token, []); - - continue; - } - - $scopes = array_reverse($token->scopes); - $settings = []; - - foreach ($this->themes as $id => $theme) { - foreach ($scopes as $scope) { - $resolved = $this->resolver->resolve($theme, $scope); - // Commented out and left here to make it easier to compare with Phiki results. - // $resolved = $theme->resolve($scope); - - if ($resolved !== null) { - $settings[$id] = $resolved; - - break; - } - - } - } - - $highlightedTokens[$i][] = new HighlightedToken($token, $settings); - } - } - - return $highlightedTokens; - } -} diff --git a/src/Theme/Highlighting/SettingsResolver.php b/src/Theme/Highlighting/SettingsResolver.php deleted file mode 100644 index adae2ad..0000000 --- a/src/Theme/Highlighting/SettingsResolver.php +++ /dev/null @@ -1,139 +0,0 @@ -tokenColors as $tokenColor) { - $settings = $tokenColor->settings; - - foreach ($tokenColor->scopes as $scope) { - $parts = explode('.', $scope); - $current = &$tokenColors; - - foreach ($parts as $part) { - if (! isset($current[$part])) { - $current[$part] = []; - } - - $current = &$current[$part]; - } - - $current['*'] = $settings; - } - } - - $this->indexedThemes[$theme->name] = $tokenColors; - } - - protected function resolveStyles(array $settings): ?TokenSettings - { - if (count($settings) === 1) { - return $settings[0]; - } - - $currentBackground = null; - $currentForeground = null; - $currentFontStyle = null; - - foreach ($settings as $setting) { - if ($setting->background !== null) { - $currentBackground = $setting->background; - } - - if ($setting->foreground !== null) { - $currentForeground = $setting->foreground; - } - - if ($setting->fontStyle !== null) { - $currentFontStyle = $setting->fontStyle; - } - } - - if ( - $currentBackground === null && - $currentFontStyle === null && - $currentForeground === null - ) { - return null; - } - - return new TokenSettings( - $currentBackground, - $currentForeground, - $currentFontStyle - ); - } - - public function resolve(ParsedTheme $theme, $scope) - { - // TODO: Review all of this logic, make less awful, and improve compatibility with all themes. - if (! isset($this->indexedThemes[$theme->name])) { - $this->indexTheme($theme); - } - - $scopeParts = explode('.', $scope); - $scopeLevels = []; - $partLen = count($scopeParts); - - for ($i = $partLen; $i > 0; $i--) { - $scopeLevels[] = implode('.', array_slice($scopeParts, 0, $i)); - } - - $current = $this->indexedThemes[$theme->name]; - $currentBackground = null; - $currentForeground = null; - $currentFontStyle = null; - - $settings = null; - - foreach ($scopeLevels as $level) { - $parts = explode('.', $level); - - foreach ($parts as $part) { - // Can't find the right part here, break. - if (! isset($current[$part])) { - break; - } - - $current = $current[$part]; - - if (isset($current['*'])) { - /** @var TokenSettings $settings */ - $settings = $current['*']; - - if ($settings->background !== null) { - $currentBackground = $settings->background; - } - - if ($settings->foreground !== null) { - $currentForeground = $settings->foreground; - } - - if ($settings->fontStyle !== null) { - $currentFontStyle = $settings->fontStyle; - } - } - } - } - - if (! $settings) { - return null; - } - - return new TokenSettings( - $currentBackground, - $currentForeground, - $currentFontStyle - ); - } -} diff --git a/src/Theme/Hooks/Fortnite.php b/src/Theme/Hooks/Fortnite.php index ff40f62..9372c30 100644 --- a/src/Theme/Hooks/Fortnite.php +++ b/src/Theme/Hooks/Fortnite.php @@ -32,7 +32,7 @@ public static function replaceColors(string $html, Options $options, string $pro ]; foreach ($replacements as $replacement) { - $html = preg_replace($replacement[0], $replacement[1], $html); + $html = preg_replace($replacement[0], $replacement[1], $html) ?? $html; } return $html; diff --git a/src/Theme/Hooks/Moonlight.php b/src/Theme/Hooks/Moonlight.php index 9eb63b4..30500fe 100644 --- a/src/Theme/Hooks/Moonlight.php +++ b/src/Theme/Hooks/Moonlight.php @@ -20,7 +20,7 @@ public static function replaceColors(string $html, Options $options, string $pro ]; foreach ($replacements as $replacement) { - $html = preg_replace($replacement[0], $replacement[1], $html); + $html = preg_replace($replacement[0], $replacement[1], $html) ?? $html; } return $html; diff --git a/src/Theme/Hooks/Synthwave84.php b/src/Theme/Hooks/Synthwave84.php index 57635ed..181a266 100644 --- a/src/Theme/Hooks/Synthwave84.php +++ b/src/Theme/Hooks/Synthwave84.php @@ -6,6 +6,7 @@ class Synthwave84 { + /** @var array */ protected static array $opacities = [ 100 => 'FF', 95 => 'F2', @@ -38,28 +39,27 @@ public static function replaceColors(string $html, Options $options, string $pro $replacement = $propertyPrefix.'color: #fff5f6; '.$propertyPrefix.'text-shadow: 0 0 2px #000, 0 0 10px #fc1f2c'.$opacity. ', 0 0 5px #fc1f2c'.$opacity. ', 0 0 25px #fc1f2c'.$opacity.';'; - $html = preg_replace($pattern, $replacement, $html); + $html = preg_replace($pattern, $replacement, $html) ?? $html; $pattern = '/'.$propertyPrefix.'color:\s*#ff7edb;/i'; $replacement = $propertyPrefix.'color: #f92aad; '.$propertyPrefix.'text-shadow: 0 0 2px #100c0f, 0 0 5px #dc078e33, 0 0 10px #fff3;'; - $html = preg_replace($pattern, $replacement, $html); + $html = preg_replace($pattern, $replacement, $html) ?? $html; $pattern = '/'.$propertyPrefix.'color:\s*#fede5d;/i'; $replacement = $propertyPrefix.'color: #f4eee4; '.$propertyPrefix.'text-shadow: 0 0 2px #393a33, 0 0 8px #f39f05'.$opacity. ', 0 0 2px #f39f05'.$opacity.';'; - $html = preg_replace($pattern, $replacement, $html); + $html = preg_replace($pattern, $replacement, $html) ?? $html; $pattern = '/'.$propertyPrefix.'color:\s*#72f1b8;/i'; $replacement = $propertyPrefix.'color: #72f1b8; '.$propertyPrefix.'text-shadow: 0 0 2px #100c0f, 0 0 10px #257c55'.$opacity. ', 0 0 35px #212724'.$opacity.';'; - $html = preg_replace($pattern, $replacement, $html); + $html = preg_replace($pattern, $replacement, $html) ?? $html; $pattern = '/'.$propertyPrefix.'color:\s*#36f9f6;/i'; $replacement = $propertyPrefix.'color: #fdfdfd; '.$propertyPrefix.'text-shadow: 0 0 2px #001716, 0 0 3px #03edf9'.$opacity. ', 0 0 5px #03edf9'.$opacity. ', 0 0 8px #03edf9'.$opacity.';'; - $html = preg_replace($pattern, $replacement, $html); - return $html; + return preg_replace($pattern, $replacement, $html) ?? $html; } } diff --git a/src/Theme/Parser.php b/src/Theme/Parser.php index 6f8d2be..c11e6d2 100644 --- a/src/Theme/Parser.php +++ b/src/Theme/Parser.php @@ -4,6 +4,7 @@ use Phiki\Support\Arr; use Phiki\Theme\ParsedTheme; +use Phiki\Theme\Scope; use Phiki\Theme\TokenColor; use Phiki\Theme\TokenSettings; @@ -11,7 +12,6 @@ class Parser { private function adjustScope(string $scope): string { - // TODO: Remove this quick workaround to better support/have compatibility with themes. if (str_starts_with($scope, 'source.') && str_contains($scope, ' ')) { $parts = explode(' ', $scope, 2); $suffix = mb_substr($parts[0], 6); @@ -22,15 +22,40 @@ private function adjustScope(string $scope): string return $scope; } + private function createScope(string $scopeStr): Scope + { + if (str_contains($scopeStr, ' ')) { + return new Scope(array_map(trim(...), explode(' ', $scopeStr))); + } + + return new Scope([$scopeStr]); + } + + /** + * @param array $theme + */ public function parse(array $theme): ParsedTheme { - $name = $theme['name']; - $colors = $theme['colors']; + $name = is_string($theme['name'] ?? null) ? $theme['name'] : 'theme'; + /** @var array $colors */ + $colors = []; + if (is_array($theme['colors'] ?? null)) { + foreach ($theme['colors'] as $colorName => $colorValue) { + if (! is_string($colorName) || ! is_string($colorValue)) { + continue; + } + $colors[$colorName] = $colorValue; + } + } + $settings = is_array($theme['settings'] ?? null) ? $theme['settings'] : []; + $tokenColorsConfig = is_array($theme['tokenColors'] ?? null) ? $theme['tokenColors'] : []; + + /** @var array $tokenColors */ $tokenColors = Arr::filterMap( array_merge_recursive( - $theme['settings'] ?? [], // TODO: Review this. - $theme['tokenColors'] ?? [] + $settings, + $tokenColorsConfig ), function (array $tokenColor) { if (! isset($tokenColor['scope'])) { return null; @@ -40,23 +65,35 @@ public function parse(array $theme): ParsedTheme $scopes = []; foreach ($tmpScopes as $scope) { + if (! is_string($scope)) { + continue; + } + if (str_contains($scope, ',')) { $subScopes = explode(',', $scope); foreach ($subScopes as $subScope) { - $scopes[] = trim($this->adjustScope($subScope), " \n\r\t\v\0,"); + $trimmed = trim($this->adjustScope($subScope), " \n\r\t\v\0,"); + if ($trimmed !== '') { + $scopes[] = $this->createScope($trimmed); + } } continue; } - $scopes[] = trim($this->adjustScope($scope), " \n\r\t\v\0,"); + $trimmed = trim($this->adjustScope($scope), " \n\r\t\v\0,"); + if ($trimmed !== '') { + $scopes[] = $this->createScope($trimmed); + } } + $settings = is_array($tokenColor['settings'] ?? null) ? $tokenColor['settings'] : []; + return new TokenColor($scopes, new TokenSettings( - $tokenColor['settings']['background'] ?? null, - $tokenColor['settings']['foreground'] ?? null, - $tokenColor['settings']['fontStyle'] ?? null, + is_string($settings['background'] ?? null) ? $settings['background'] : null, + is_string($settings['foreground'] ?? null) ? $settings['foreground'] : null, + is_string($settings['fontStyle'] ?? null) ? $settings['fontStyle'] : null, )); }); diff --git a/src/Theme/Theme.php b/src/Theme/Theme.php new file mode 100644 index 0000000..b0cc243 --- /dev/null +++ b/src/Theme/Theme.php @@ -0,0 +1,78 @@ + $overrides + */ + public function __construct( + public readonly string|PhikiTheme|ParsedTheme $base, + public readonly array $overrides = [], + ) {} + + public static function fromFile(string $path, ?string $name = null): self + { + $json = file_get_contents($path); + $decoded = $json === false ? [] : json_decode($json, true); + /** @var array $data */ + $data = is_array($decoded) ? $decoded : []; + + if ($name !== null) { + $data['name'] = $name; + } + + return self::fromArray($data); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $parser = new Parser; + $parsed = $parser->parse($data); + + return new self($parsed); + } + + /** + * @param array $overrides + */ + public static function override(string|PhikiTheme|ParsedTheme $theme, array $overrides): self + { + return new self($theme, $overrides); + } + + /** + * @param array $overrides + */ + public function withOverrides(array $overrides): self + { + return new self($this->base, array_merge($this->overrides, $overrides)); + } + + /** + * @param callable(string|PhikiTheme): ParsedTheme $resolver + */ + public function resolve(callable $resolver): ParsedTheme + { + $parsed = $this->base instanceof ParsedTheme + ? $this->base + : $resolver($this->base); + + if (empty($this->overrides)) { + return $parsed; + } + + return new ParsedTheme( + $parsed->name, + array_merge($parsed->colors, $this->overrides), + $parsed->tokenColors + ); + } +} diff --git a/src/Theme/ThemeRepository.php b/src/Theme/ThemeRepository.php deleted file mode 100644 index 9780584..0000000 --- a/src/Theme/ThemeRepository.php +++ /dev/null @@ -1,44 +0,0 @@ -has($name)) { - throw UnrecognisedThemeException::make($name); - } - - $theme = $this->themes[$name]; - - if ($theme instanceof ParsedTheme) { - return $theme; - } - - $parser = new Parser; - - return $this->themes[$name] = $parser->parse(json_decode(file_get_contents($theme), true)); - } - - public function getThemesAndSettings(): array - { - $themes = []; - - foreach (array_keys($this->themes) as $id) { - $theme = $this->get($id); - - $themes[] = [ - 'name' => $theme->name, - 'background' => $theme->base()->background, - 'foreground' => $theme->base()->foreground, - ]; - } - - return $themes; - } -} diff --git a/src/TypeAliases.php b/src/TypeAliases.php new file mode 100644 index 0000000..792c13c --- /dev/null +++ b/src/TypeAliases.php @@ -0,0 +1,25 @@ + + * @phpstan-type TokenLine array + * @phpstan-type TokenLines array + * @phpstan-type HighlightedLine array + * @phpstan-type HighlightedLines array + * @phpstan-type RenderLine array + * @phpstan-type RenderLines array + * @phpstan-type AttributeMap array + * @phpstan-type TokenSettingsMap array + * @phpstan-type LineMetadata array{depth:int, isCommentOnly:bool, isDirectory:bool, content:string} + * @phpstan-type IndentInfo array{columns:int, levels:int, isEmpty:bool} + * @phpstan-type ConnectorClasses array{wrapper:list, horizontal:list, vertical:list} + */ +final class TypeAliases +{ + private function __construct() {} +} diff --git a/tests/AnnotationParser/BasicAnnotationsTest.php b/tests/AnnotationParser/BasicAnnotationsTest.php index 854a670..9986eaa 100644 --- a/tests/AnnotationParser/BasicAnnotationsTest.php +++ b/tests/AnnotationParser/BasicAnnotationsTest.php @@ -1,11 +1,12 @@ parseAnnotations('// [tl! add highlight]'); $this->assertSame('// ', $results->text); @@ -26,7 +27,7 @@ $this->assertEmpty($highlight->options); }); -test('it parses shorthand annotations', function () { +test('it parses shorthand annotations', function (): void { $results = $this->parseAnnotations('// [tl! ++]'); $this->assertSame('// ', $results->text); @@ -40,7 +41,7 @@ $this->assertEmpty($add->options); }); -test('it parses annotations with ranges', function () { +test('it parses annotations with ranges', function (): void { $results = $this->parseAnnotations('// [tl! add:3,2]'); $this->assertCount(1, $results->annotations); @@ -55,7 +56,7 @@ $this->assertEmpty($add->options); }); -test('it parses start of open ended range', function () { +test('it parses start of open ended range', function (): void { $results = $this->parseAnnotations('// [tl! add:start]'); $this->assertCount(1, $results->annotations); @@ -70,7 +71,7 @@ $this->assertEmpty($add->options); }); -test('it parses end of open ended range', function () { +test('it parses end of open ended range', function (): void { $results = $this->parseAnnotations('// [tl! add:end]'); $this->assertCount(1, $results->annotations); @@ -85,7 +86,7 @@ $this->assertEmpty($add->options); }); -test('it parses multiple annotations with ranges', function () { +test('it parses multiple annotations with ranges', function (): void { $results = $this->parseAnnotations('// [tl! add:3,2 highlight:-3,-2]'); $this->assertCount(2, $results->annotations); @@ -109,7 +110,7 @@ $this->assertEmpty($highlight->options); }); -test('it parses relative following line ranges', function () { +test('it parses relative following line ranges', function (): void { $results = $this->parseAnnotations('// [tl! add:3]'); $this->assertCount(1, $results->annotations); @@ -124,7 +125,7 @@ $this->assertEmpty($add->options); }); -test('it parses relative preceding line ranges', function () { +test('it parses relative preceding line ranges', function (): void { $results = $this->parseAnnotations('// [tl! add:-3]'); $this->assertCount(1, $results->annotations); @@ -139,7 +140,7 @@ $this->assertEmpty($add->options); }); -test('it parses annotations with parenthesis', function () { +test('it parses annotations with parenthesis', function (): void { $results = $this->parseAnnotations('// [tl! reindex(24)]'); $this->assertCount(1, $results->annotations); @@ -153,7 +154,7 @@ $this->assertEmpty($reindex->options); }); -test('it parses annotations with parenthesis and ranges', function () { +test('it parses annotations with parenthesis and ranges', function (): void { $results = $this->parseAnnotations('// [tl! reindex(+5):6,1]'); $this->assertCount(1, $results->annotations); @@ -171,7 +172,7 @@ $this->assertEmpty($reindex->options); }); -test('it parses relative following lines with parenthesis', function () { +test('it parses relative following lines with parenthesis', function (): void { $results = $this->parseAnnotations('// [tl! reindex(+5):6]'); @@ -190,7 +191,7 @@ $this->assertEmpty($reindex->options); }); -test('it parses relative preceding lines with parenthesis', function () { +test('it parses relative preceding lines with parenthesis', function (): void { $results = $this->parseAnnotations('// [tl! reindex(+5):-6]'); $this->assertCount(1, $results->annotations); @@ -208,7 +209,7 @@ $this->assertEmpty($reindex->options); }); -test('it parses different types of method args', function ($arg) { +test('it parses different types of method args', function ($arg): void { $results = $this->parseAnnotations("// [tl! reindex({$arg}):-6]"); $expectedText = "reindex({$arg}):-6"; @@ -234,7 +235,7 @@ '+1000', ]); -test('it parses annotations with extra options', function () { +test('it parses annotations with extra options', function (): void { $results = $this->parseAnnotations('// [tl! collapse:start open]'); $this->assertCount(1, $results->annotations); @@ -249,7 +250,7 @@ $this->assertSame(['open'], $collapse->options); }); -test('it parses annotations with extra options and ranges', function () { +test('it parses annotations with extra options and ranges', function (): void { $results = $this->parseAnnotations('// [tl! collapse:3,2 open]'); $this->assertCount(1, $results->annotations); @@ -264,7 +265,7 @@ $this->assertSame(['open'], $collapse->options); }); -test('it parses annotations with multiple options', function () { +test('it parses annotations with multiple options', function (): void { $results = $this->parseAnnotations('// [tl! collapse:start open something else here]'); $this->assertCount(1, $results->annotations); @@ -279,7 +280,7 @@ $this->assertSame(['open', 'something', 'else', 'here'], $collapse->options); }); -test('it parses multiple annotations with options', function () { +test('it parses multiple annotations with options', function (): void { $results = $this->parseAnnotations('// [tl! add highlight collapse:start open something else here]'); $this->assertCount(3, $results->annotations); diff --git a/tests/AnnotationParser/ClassAndIdAnnotationsTest.php b/tests/AnnotationParser/ClassAndIdAnnotationsTest.php index b16d8cd..2b6e772 100644 --- a/tests/AnnotationParser/ClassAndIdAnnotationsTest.php +++ b/tests/AnnotationParser/ClassAndIdAnnotationsTest.php @@ -1,11 +1,13 @@ parseAnnotations('// [tl! .font-bold .italic .animate-pulse]'); $this->assertCount(3, $results->annotations); @@ -15,24 +17,27 @@ $this->assertSame('.font-bold', $fontBold->text); $this->assertSame([], $fontBold->options); $this->assertNull($fontBold->range); - $this->assertSame(AnnotationType::ClassName, $fontBold->type); + $this->assertSame(AnnotationType::Prefixed, $fontBold->type); + $this->assertSame('.', $fontBold->prefix); $italic = $results->annotations[1]; $this->assertSame('.italic', $italic->name); $this->assertSame('.italic', $italic->text); $this->assertSame([], $italic->options); $this->assertNull($italic->range); - $this->assertSame(AnnotationType::ClassName, $italic->type); + $this->assertSame(AnnotationType::Prefixed, $italic->type); + $this->assertSame('.', $italic->prefix); $animatePulse = $results->annotations[2]; $this->assertSame('.animate-pulse', $animatePulse->name); $this->assertSame('.animate-pulse', $animatePulse->text); $this->assertSame([], $animatePulse->options); $this->assertNull($animatePulse->range); - $this->assertSame(AnnotationType::ClassName, $animatePulse->type); + $this->assertSame(AnnotationType::Prefixed, $animatePulse->type); + $this->assertSame('.', $animatePulse->prefix); }); -test('it parses class annotations with ranges', function () { +test('it parses class annotations with ranges', function (): void { $results = $this->parseAnnotations('// [tl! .font-bold:3,2]'); $this->assertCount(1, $results->annotations); @@ -41,14 +46,15 @@ $this->assertSame('.font-bold', $fontBold->name); $this->assertSame('.font-bold:3,2', $fontBold->text); $this->assertSame([], $fontBold->options); - $this->assertSame(AnnotationType::ClassName, $fontBold->type); + $this->assertSame(AnnotationType::Prefixed, $fontBold->type); + $this->assertSame('.', $fontBold->prefix); $this->assertNotNull($fontBold->range); $this->assertSame(RangeType::Relative, $fontBold->range->type); $this->assertSame('3', $fontBold->range->start); $this->assertSame('2', $fontBold->range->end); }); -test('it class and id annotations', function () { +test('it class and id annotations', function (): void { $results = $this->parseAnnotations('// [tl! .font-bold .italic .animate-pulse #pulse]'); $this->assertCount(4, $results->annotations); @@ -58,26 +64,43 @@ $this->assertSame('.font-bold', $fontBold->text); $this->assertSame([], $fontBold->options); $this->assertNull($fontBold->range); - $this->assertSame(AnnotationType::ClassName, $fontBold->type); + $this->assertSame(AnnotationType::Prefixed, $fontBold->type); + $this->assertSame('.', $fontBold->prefix); $italic = $results->annotations[1]; $this->assertSame('.italic', $italic->name); $this->assertSame('.italic', $italic->text); $this->assertSame([], $italic->options); $this->assertNull($italic->range); - $this->assertSame(AnnotationType::ClassName, $italic->type); + $this->assertSame(AnnotationType::Prefixed, $italic->type); + $this->assertSame('.', $italic->prefix); $animatePulse = $results->annotations[2]; $this->assertSame('.animate-pulse', $animatePulse->name); $this->assertSame('.animate-pulse', $animatePulse->text); $this->assertSame([], $animatePulse->options); $this->assertNull($animatePulse->range); - $this->assertSame(AnnotationType::ClassName, $animatePulse->type); + $this->assertSame(AnnotationType::Prefixed, $animatePulse->type); + $this->assertSame('.', $animatePulse->prefix); $pulseId = $results->annotations[3]; $this->assertSame('#pulse', $pulseId->name); $this->assertSame('#pulse', $pulseId->text); $this->assertSame([], $pulseId->options); $this->assertNull($pulseId->range); - $this->assertSame(AnnotationType::IdAttribute, $pulseId->type); + $this->assertSame(AnnotationType::Prefixed, $pulseId->type); + $this->assertSame('#', $pulseId->prefix); +}); + +test('it prefers the longest registered prefix when parsing', function (): void { + $parser = new AnnotationTokenParser; + $parser->setRegisteredPrefixes(['@', '@@']); + + $results = $parser->parseText('[tl! @@region @inline]', 1); + + $this->assertCount(2, $results->annotations); + $this->assertSame('@@', $results->annotations[0]->prefix); + $this->assertSame('@@region', $results->annotations[0]->name); + $this->assertSame('@', $results->annotations[1]->prefix); + $this->assertSame('@inline', $results->annotations[1]->name); }); diff --git a/tests/Annotations/AnnotationCleanupTest.php b/tests/Annotations/AnnotationCleanupTest.php index f5dff07..0db1cab 100644 --- a/tests/Annotations/AnnotationCleanupTest.php +++ b/tests/Annotations/AnnotationCleanupTest.php @@ -1,10 +1,12 @@ makeEngine(); $resultParser = new ResultParser; @@ -23,6 +25,4 @@ $this->assertFalse($plainResult->line($i)->isHighlighted()); $this->assertFalse($plainResult->line($i)->hasBackground()); } -})->with(function () { - return AnnotationCleanupLoader::load(); -}); +})->with(fn () => AnnotationCleanupLoader::load()); diff --git a/tests/Annotations/AnnotationRegistryTest.php b/tests/Annotations/AnnotationRegistryTest.php new file mode 100644 index 0000000..d037d7d --- /dev/null +++ b/tests/Annotations/AnnotationRegistryTest.php @@ -0,0 +1,282 @@ +highlighterWasSet = true; + + return parent::setHighlighter($highlighter); + } + + public function setTorchlightOptions(Options $options): static + { + $this->optionsWereSet = true; + + return parent::setTorchlightOptions($options); + } + + public function setThemeResolver(ThemeStyleResolver $resolver): static + { + $this->themeResolverWasSet = true; + + return parent::setThemeResolver($resolver); + } +} + +test('register stores annotation by name', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $registry->register('test', $annotation); + + $this->assertSame($annotation, $registry->get('test')); +}); + +test('get retrieves registered annotation', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $registry->register('myAnnotation', $annotation); + + $this->assertSame($annotation, $registry->get('myAnnotation')); +}); + +test('get returns null for unregistered', function (): void { + $registry = new AnnotationRegistry; + + $this->assertNull($registry->get('nonexistent')); +}); + +test('has returns true for registered annotation', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $registry->register('test', $annotation); + + $this->assertTrue($registry->has('test')); +}); + +test('has returns false for unregistered', function (): void { + $registry = new AnnotationRegistry; + + $this->assertFalse($registry->has('nonexistent')); +}); + +test('registerPrefixHandler stores handler', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $registry->registerPrefixHandler('.', $annotation); + + // Verify via resolve + $this->assertSame($annotation, $registry->resolve('.my-class')); +}); + +test('resolve returns prefix handler for matching prefix', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $classAnnotation = new TestAnnotation($processor); + $idAnnotation = new TestAnnotation($processor); + + $registry->registerPrefixHandler('.', $classAnnotation); + $registry->registerPrefixHandler('#', $idAnnotation); + + $this->assertSame($classAnnotation, $registry->resolve('.my-class')); + $this->assertSame($idAnnotation, $registry->resolve('#my-id')); +}); + +test('resolve prefers the longest matching prefix', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $shortPrefix = new TestAnnotation($processor); + $longPrefix = new TestAnnotation($processor); + + $registry->registerPrefixHandler('@', $shortPrefix); + $registry->registerPrefixHandler('@@', $longPrefix); + + $this->assertSame($longPrefix, $registry->resolve('@@region')); +}); + +test('resolve falls back to normal registry', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $registry->register('highlight', $annotation); + + $this->assertSame($annotation, $registry->resolve('highlight')); +}); + +test('resolve returns null when not found', function (): void { + $registry = new AnnotationRegistry; + + $this->assertNull($registry->resolve('nonexistent')); +}); + +test('all returns only named annotations', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation1 = new TestAnnotation($processor); + $annotation2 = new TestAnnotation($processor); + $prefixHandler = new TestAnnotation($processor); + + $registry->register('test1', $annotation1); + $registry->register('test2', $annotation2); + $registry->registerPrefixHandler('.', $prefixHandler); + + $all = $registry->all(); + + $this->assertCount(2, $all); + $this->assertSame($annotation1, $all['test1']); + $this->assertSame($annotation2, $all['test2']); + $this->assertArrayNotHasKey('.', $all); +}); + +test('allIncludingPrefixHandlers merges both', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + $prefixHandler = new TestAnnotation($processor); + + $registry->register('test', $annotation); + $registry->registerPrefixHandler('.', $prefixHandler); + + $all = $registry->allIncludingPrefixHandlers(); + + $this->assertCount(2, $all); + $this->assertContains($annotation, $all); + $this->assertContains($prefixHandler, $all); +}); + +test('setHighlighter propagates to all annotations', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation1 = new TestAnnotation($processor); + $annotation2 = new TestAnnotation($processor); + $prefixHandler = new TestAnnotation($processor); + + $registry->register('test1', $annotation1); + $registry->register('test2', $annotation2); + $registry->registerPrefixHandler('.', $prefixHandler); + + $highlighter = new Highlighter([]); + $registry->setHighlighter($highlighter); + + $this->assertTrue($annotation1->highlighterWasSet); + $this->assertTrue($annotation2->highlighterWasSet); + $this->assertTrue($prefixHandler->highlighterWasSet); +}); + +test('setTorchlightOptions propagates to all annotations', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation1 = new TestAnnotation($processor); + $annotation2 = new TestAnnotation($processor); + $prefixHandler = new TestAnnotation($processor); + + $registry->register('test1', $annotation1); + $registry->register('test2', $annotation2); + $registry->registerPrefixHandler('.', $prefixHandler); + + $options = new Options; + $registry->setTorchlightOptions($options); + + $this->assertTrue($annotation1->optionsWereSet); + $this->assertTrue($annotation2->optionsWereSet); + $this->assertTrue($prefixHandler->optionsWereSet); +}); + +test('setThemeResolver propagates to all annotations', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation1 = new TestAnnotation($processor); + $annotation2 = new TestAnnotation($processor); + $prefixHandler = new TestAnnotation($processor); + + $registry->register('test1', $annotation1); + $registry->register('test2', $annotation2); + $registry->registerPrefixHandler('.', $prefixHandler); + + $resolver = new ThemeStyleResolver([]); + $registry->setThemeResolver($resolver); + + $this->assertTrue($annotation1->themeResolverWasSet); + $this->assertTrue($annotation2->themeResolverWasSet); + $this->assertTrue($prefixHandler->themeResolverWasSet); +}); + +test('setThemeResolver returns static for chaining', function (): void { + $registry = new AnnotationRegistry; + $resolver = new ThemeStyleResolver([]); + + $result = $registry->setThemeResolver($resolver); + + $this->assertSame($registry, $result); +}); + +test('register returns static for chaining', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $result = $registry->register('test', $annotation); + + $this->assertSame($registry, $result); +}); + +test('registerPrefixHandler returns static for chaining', function (): void { + $registry = new AnnotationRegistry; + $processor = new AnnotationEngine($registry); + $annotation = new TestAnnotation($processor); + + $result = $registry->registerPrefixHandler('.', $annotation); + + $this->assertSame($registry, $result); +}); + +test('setHighlighter returns static for chaining', function (): void { + $registry = new AnnotationRegistry; + $highlighter = new Highlighter([]); + + $result = $registry->setHighlighter($highlighter); + + $this->assertSame($registry, $result); +}); + +test('setTorchlightOptions returns static for chaining', function (): void { + $registry = new AnnotationRegistry; + $options = new Options; + + $result = $registry->setTorchlightOptions($options); + + $this->assertSame($registry, $result); +}); diff --git a/tests/Annotations/AutolinkingAnnotationTest.php b/tests/Annotations/AutolinkingAnnotationTest.php index b36999d..184b2de 100644 --- a/tests/Annotations/AutolinkingAnnotationTest.php +++ b/tests/Annotations/AutolinkingAnnotationTest.php @@ -1,8 +1,10 @@ [ @@ -30,7 +32,7 @@ $this->assertTrue($results->line(7)->hasClass('animate-pulse')); }); -test('it supports tailwind JIT syntax', function () { +test('it supports tailwind JIT syntax', function (): void { $code = <<<'TEXT' ID only // [tl! #id] ID + Class // [tl! #id.pt-4] @@ -68,7 +70,7 @@ $this->assertTrue($results->line(8)->hasClass('pr-[8px]')); }); -test('tailwind classes with colons can be used with start/end ranges', function () { +test('tailwind classes with colons can be used with start/end ranges', function (): void { $code = <<<'TEXT' One // [tl! .sm:pb-8:start] Two @@ -93,7 +95,7 @@ } }); -test('add classes numeric ranges', function () { +test('add classes numeric ranges', function (): void { $code = <<<'TEXT' One Two diff --git a/tests/Annotations/CollapseAnnotationTest.php b/tests/Annotations/CollapseAnnotationTest.php index f5e6b56..d2db196 100644 --- a/tests/Annotations/CollapseAnnotationTest.php +++ b/tests/Annotations/CollapseAnnotationTest.php @@ -1,3 +1,5 @@ [ diff --git a/tests/Annotations/HighlightAnnotationTest.php b/tests/Annotations/HighlightAnnotationTest.php index 3303d8e..800e5c7 100644 --- a/tests/Annotations/HighlightAnnotationTest.php +++ b/tests/Annotations/HighlightAnnotationTest.php @@ -1,8 +1,10 @@ [ diff --git a/tests/Annotations/ReindexAnnotationTest.php b/tests/Annotations/ReindexAnnotationTest.php index 7e0e43a..c9be2e9 100644 --- a/tests/Annotations/ReindexAnnotationTest.php +++ b/tests/Annotations/ReindexAnnotationTest.php @@ -1,8 +1,10 @@ assertSame('26', $results->line(6)->lineNumberContent); }); -test('no number at all', function () { +test('no number at all', function (): void { $code = <<<'PHP' 'a'; 'b'; @@ -44,7 +46,7 @@ $this->assertSame('26', $results->line(7)->lineNumberContent); }); -test('not immediately reindexing after clearing a line number', function () { +test('not immediately reindexing after clearing a line number', function (): void { $code = <<<'PHP' 'a'; 'b'; @@ -66,7 +68,7 @@ $this->assertSame('6', $results->line(7)->lineNumberContent); }); -test('relative reindex changes', function () { +test('relative reindex changes', function (): void { $code = <<<'PHP' // torchlight! {"diffIndicatorsInPlaceOfLineNumbers": false} return [ @@ -95,7 +97,7 @@ $this->assertSame('9', $results->line(10)->lineNumberContent); }); -test('diff and reindex', function () { +test('diff and reindex', function (): void { $code = <<<'PHP' return [ 'extensions' => [ @@ -123,7 +125,7 @@ $this->assertSame('1010', $results->line(10)->lineNumberContent); }); -test('reindex with range modifiers', function () { +test('reindex with range modifiers', function (): void { $code = <<<'PHP' // This is a long bit of text, hard to reindex the middle. [tl! reindex(+5):6,1] return <<assertSame('14', $results->line(14)->lineNumberContent); }); -test('removing line numbers in the middle of code blocks', function () { +test('removing line numbers in the middle of code blocks', function (): void { $code = <<<'PHP' // This is a long bit of text, hard to reindex the middle. [tl! reindex(null):5,5] return <<setTorchlightOptions(new Options(withGutter: true, ariaEnabled: true)); + + $html = $engine->codeToHtml('echo "hello";', 'php', 'nord'); + + expect($html)->toContain("role='region'", "tabindex='0'", "aria-label='Code block: php'"); +}); + +test('ARIA attributes not present when disabled', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, ariaEnabled: false)); + + $html = $engine->codeToHtml('echo "hello";', 'php', 'nord'); + + expect($html)->not->toContain("role='region'", 'tabindex=', 'aria-label='); +}); + +test('ARIA disabled by default to preserve backward compatibility', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $html = $engine->codeToHtml('echo "hello";', 'php', 'nord'); + + expect($html)->not->toContain("role='region'"); +}); + +test('ARIA label uses vanity label when available', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, ariaEnabled: true)); + + // php-html has a vanity label of 'php' + $html = $engine->codeToHtml('', 'php', 'nord'); + + expect($html)->toContain("aria-label='Code block: php'"); +}); + +test('ARIA label uses grammar name when no vanity label', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, ariaEnabled: true)); + + $html = $engine->codeToHtml('const x = 1;', 'javascript', 'nord'); + + expect($html)->toContain("aria-label='Code block: javascript'"); +}); + +test('ARIA generic label for unknown grammar with fallback', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, ariaEnabled: true)); + + // Unknown grammar falls back to plaintext but preserves original as vanity + $html = $engine->codeToHtml('some code', 'fortnite', 'nord'); + + expect($html)->toContain("aria-label='Code block: fortnite'"); +}); + +test('ARIA can be enabled via block options', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $code = "// torchlight! {\"ariaEnabled\": true}\necho \"hello\";"; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain("role='region'", 'aria-label='); +}); + +test('ARIA with renderBlock returns attributes in block object', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, ariaEnabled: true)); + + $block = $engine->renderCode('echo "hello";', 'php', 'nord'); + + expect($block->attributes)->toHaveKey('role', 'region'); + expect($block->attributes)->toHaveKey('tabindex', '0'); + expect($block->attributes)->toHaveKey('aria-label'); +}); diff --git a/tests/BlockOptionsTest.php b/tests/BlockOptionsTest.php index d3bf11c..59c9e4f 100644 --- a/tests/BlockOptionsTest.php +++ b/tests/BlockOptionsTest.php @@ -1,8 +1,12 @@ assertFalse($options->lineNumbersEnabled); }); -test('it parses block options in text', function () { +test('it parses block options in text', function (): void { $code = <<<'TEXT' // torchlight! {"lineNumbers": false} just @@ -44,7 +48,7 @@ $this->assertFalse($options->lineNumbersEnabled); }); -test('it parses block options in json', function () { +test('it parses block options in json', function (): void { $code = <<<'JSON' // torchlight! {"lineNumbers": false} { @@ -61,7 +65,7 @@ $this->assertFalse($options->lineNumbersEnabled); }); -test('multiple block options can be set', function () { +test('multiple block options can be set', function (): void { $code = <<<'PHP' // torchlight! {"lineNumbers": false, "lineNumbersStart": 42, "lineNumbersStyle": "opacity: .5;", "diffIndicators": false, "diffIndicatorsInPlaceOfLineNumbers": false, "summaryCollapsedIndicator": "something new", "torchlightAnnotations": false} return [ @@ -89,7 +93,7 @@ $this->assertFalse($options->annotationsEnabled); }); -test('block options are not parsed if not the first line', function () { +test('block options are not parsed if not the first line', function (): void { $code = <<<'PHP' // torchlight! {"lineNumbers": false, "lineNumbersStart": 42, "lineNumbersStyle": "opacity: .5;", "diffIndicators": false, "diffIndicatorsInPlaceOfLineNumbers": false, "summaryCollapsedIndicator": "something new", "torchlightAnnotations": false} @@ -107,7 +111,7 @@ $this->assertStringContainsString('torchlight! ', $results->line(2)->text); }); -test('block options are not parsed if annotations disabled', function () { +test('block options are not parsed if annotations disabled', function (): void { $code = <<<'PHP' // torchlight! {"torchlightAnnotations": false} // torchlight! {"lineNumbers": false, "lineNumbersStart": 42, "lineNumbersStyle": "opacity: .5;", "diffIndicators": false, "diffIndicatorsInPlaceOfLineNumbers": false, "summaryCollapsedIndicator": "something new", "torchlightAnnotations": false} @@ -125,7 +129,34 @@ $this->assertStringContainsString('torchlight! ', $results->line(1)->text); }); -test('block options can be set using forward slashes in other languages', function () { +test('hideLines option hides lines', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, hideLines: [[2, 2]])); + + $html = $engine->codeToHtml("line 1\nline 2\nline 3", 'text', 'nord'); + + expect($html)->toContain('has-hidden-lines', 'line-elided'); +}); + +test('hideLines option via block options', function (): void { + $engine = new Engine; + + $code = "// torchlight! {\"hideLines\": [2]}\nline 1\nline 2\nline 3"; + $html = $engine->codeToHtml($code, 'text', 'nord'); + + expect($html)->toContain('has-hidden-lines'); +}); + +test('line range options support range syntax', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true, highlightLines: [[1, 3]])); + + $html = $engine->codeToHtml("a\nb\nc\nd", 'text', 'nord'); + + expect(substr_count($html, 'line-highlight'))->toBe(3); +}); + +test('block options can be set using forward slashes in other languages', function (): void { $code = <<<'HTML' // torchlight! {"lineNumbers": false}
diff --git a/tests/ClosureAnnotationTest.php b/tests/ClosureAnnotationTest.php new file mode 100644 index 0000000..aadac96 --- /dev/null +++ b/tests/ClosureAnnotationTest.php @@ -0,0 +1,113 @@ +setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('important', function ($ctx): void { + $ctx->addBlockClass('has-important-lines'); + $ctx->addLineClass('line-important'); + }); + + $code = '$x = 1; // [tl! important]'; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain('has-important-lines', 'line-important'); +}); + +test('closure annotation reads method args', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('badge', function ($ctx): void { + $label = $ctx->getMethodArgs() ?? 'default'; + $ctx->addBlockClass('has-badges'); + $ctx->addLineAttribute('data-badge', $label); + }); + + $code = '$x = 1; // [tl! badge("NEW")]'; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain('has-badges', "data-badge='NEW'"); +}); + +test('closure annotation reads options', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('custom', function ($ctx): void { + $options = $ctx->getOptions(); + if (in_array('bold', $options)) { + $ctx->addLineClass('line-bold'); + } + $ctx->addBlockClass('has-custom'); + }); + + $code = '$x = 1; // [tl! custom bold]'; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain('has-custom', 'line-bold'); +}); + +test('closure annotation with character range support', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('underline', function ($ctx): void { + if ($ctx->isCharacterRange()) { + $ctx->addAttributesToCharacterRange(['class' => 'char-underline']); + } else { + $ctx->addLineClass('line-underline'); + } + $ctx->addBlockClass('has-underlines'); + }, charRanges: true); + + $code = '$x = 1; // [tl! underline]'; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain('has-underlines', 'line-underline'); +}); + +test('closure annotation with range', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('review', function ($ctx): void { + $ctx->addBlockClass('has-review'); + $ctx->addLineClass('line-review'); + }); + + $code = <<<'CODE' + $a = 1; // [tl! review:2] + $b = 2; + $c = 3; + CODE; + + $html = $engine->codeToHtml($code, 'php', 'nord'); + + expect($html)->toContain('has-review') + ->and(substr_count($html, 'line-review'))->toBeGreaterThanOrEqual(2); +}); + +test('closure annotation in plain text', function (): void { + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: true)); + + $engine->registerAnnotation('flag', function ($ctx): void { + $ctx->addBlockClass('has-flags'); + $ctx->addLineAttribute('data-flag', 'true'); + }); + + $code = 'important line [tl! flag]'; + + $html = $engine->codeToHtml($code, 'text', 'nord'); + + expect($html)->toContain('has-flags', "data-flag='true'"); +}); diff --git a/tests/CodeLensAnnotationTest.php b/tests/CodeLensAnnotationTest.php new file mode 100644 index 0000000..cd6a48a --- /dev/null +++ b/tests/CodeLensAnnotationTest.php @@ -0,0 +1,415 @@ +setTorchlightOptions(new Options(withGutter: true)); + + return $engine->codeToHtml($code, $grammar, $theme); +} + +function lensHtmlNoGutter(string $code, string $grammar = 'php', string $theme = 'github-light'): string +{ + $engine = new Engine; + $engine->setTorchlightOptions(new Options(withGutter: false)); + + return $engine->codeToHtml($code, $grammar, $theme); +} + +test('simple text lens renders above line', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(First Line)] +$b = 2; +CODE; + + $html = lensHtml($code); + + expect($html)->toContain("class='codelens'", "First Line"); +}); + +test('lens content appears before the line div', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(Above)] +CODE; + + $html = lensHtml($code); + + $codelensPos = strpos($html, "class='codelens'"); + $linePos = strpos($html, "class='line'"); + + expect($codelensPos)->toBeLessThan($linePos); +}); + +test('key-value lens renders structured spans', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(Author: John)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain( + "Author: John", + "class='codelens-item'" + ); +}); + +test('key-value with url in value works correctly', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(Link: https://example.com)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain( + "Link: https://example.com" + ); +}); + +test('comma-separated items render with separators', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(item1, item2)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain( + "item1", + " | ", + "item2" + ); +}); + +test('mixed plain and key-value items', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(plain text, key: value)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain( + "plain text", + "key: value" + ); +}); + +test('single-quoted string with comma is one item', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens('hello, world')] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain("hello, world") + ->and($html)->not->toContain('codelens-separator'); +}); + +test('double-quoted string with comma is one item', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens("hello, world")] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain("hello, world") + ->and($html)->not->toContain('codelens-separator'); +}); + +test('mixed quoted and unquoted items', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens('first, item', second)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain( + "first, item", + "second", 'codelens-separator' + ); +}); + +test('escaped quote inside quoted string', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens('it\'s here')] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain('it's here'); +}); + +test('quoted key-value pair preserves colon parsing', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(Author: 'Jane, Doe')] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain("Author: Jane, Doe"); +}); + +test('multiple lens annotations on same line stack as separate rows', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(first) lens(second)] +CODE; + + $html = lensHtml($code); + + expect(substr_count($html, "class='codelens'"))->toBe(2) + ->and($html)->toContain( + "first", + "second" + ); +}); + +test('block gets has-codelens class', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens(test)] +CODE; + + $html = lensHtml($code); + + expect($html)->toContain('has-codelens'); +}); + +test('lens on different lines renders above correct lines', function (): void { + $code = <<<'CODE' +$a = 1; +$b = 2; // [tl! lens(Second)] +$c = 3; +CODE; + + $html = lensHtml($code); + + expect($html)->toContain("class='codelens'", "Second"); + + $firstLineContent = '$a = 1;'; + $firstLinePos = strpos($html, htmlspecialchars($firstLineContent)); + $codelensPos = strpos($html, "class='codelens'"); + + expect($codelensPos)->toBeGreaterThan($firstLinePos); +}); + +test('empty lens does nothing', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens()] +CODE; + + $html = lensHtml($code); + + expect($html)->not->toContain('codelens'); +}); + +test('lens content is html escaped', function (): void { + $code = <<<'CODE' +$a = 1; // [tl! lens()] +CODE; + + $html = lensHtml($code); + + expect($html)->not->toContain(' :::expectation -
1<div x-data="{ }" x-on:click="console.log('testing')"></div> +
1<div x-data="{ }" x-on:click="console.log('testing')"></div>
2 -
3<p x-html="{ -
4 prop: "Testing", -
5 init() { -
6 this.isPrettyNeatRight(); -
7 }, -
8 -
9 test() { -
10 console.log('testing'); -
11 }, -
12 -
13}"></p> +
3<p x-html="{ +
4 prop: "Testing", +
5 init() { +
6 this.isPrettyNeatRight(); +
7 }, +
8 +
9 test() { +
10 console.log('testing'); +
11 }, +
12 +
13}"></p>
14
15<script> -
16 document.addEventListener('alpine:init', () => { -
17 Alpine.data('dropdown', () => ({ -
18 open: false, -
19 -
20 toggle() { -
21 this.open != this.open -
22 } -
23 })) -
24 }) +
16 document.addEventListener('alpine:init', () => { +
17 Alpine.data('dropdown', () => ({ +
18 open: false, +
19 +
20 toggle() { +
21 this.open != this.open +
22 } +
23 })) +
24 })
25</script>
:::end -
1<div x-data="{ }" x-on:click="console.log('testing')"></div> +
1<div x-data="{ }" x-on:click="console.log('testing')"></div>
2 -
3<p x-html="{ -
4 prop: "Testing", -
5 init() { -
6 this.isPrettyNeatRight(); -
7 }, -
8 -
9 test() { -
10 console.log('testing'); -
11 }, -
12 -
13}"></p> +
3<p x-html="{ +
4 prop: "Testing", +
5 init() { +
6 this.isPrettyNeatRight(); +
7 }, +
8 +
9 test() { +
10 console.log('testing'); +
11 }, +
12 +
13}"></p>
14
15<script> -
16 document.addEventListener('alpine:init', () => { -
17 Alpine.data('dropdown', () => ({ -
18 open: false, -
19 -
20 toggle() { -
21 this.open != this.open -
22 } -
23 })) -
24 }) +
16 document.addEventListener('alpine:init', () => { +
17 Alpine.data('dropdown', () => ({ +
18 open: false, +
19 +
20 toggle() { +
21 this.open != this.open +
22 } +
23 })) +
24 })
25</script>
\ No newline at end of file diff --git a/tests/fixtures/tests/alpinejs.txt b/tests/fixtures/tests/alpinejs.txt index 967d87f..de4e6c6 100644 --- a/tests/fixtures/tests/alpinejs.txt +++ b/tests/fixtures/tests/alpinejs.txt @@ -13,32 +13,32 @@ }"

:::expectation -
1<div x-data="{ }" x-on:click="console.log('testing')"></div> +
1<div x-data="{ }" x-on:click="console.log('testing')"></div>
2 -
3<p x-html="{ -
4 prop: "Testing", -
5 init() { -
6 this.isPrettyNeatRight(); -
7 }, -
8 -
9 test() { -
10 console.log('testing'); -
11 }, -
12 -
13}"</p> +
3<p x-html="{ +
4 prop: "Testing", +
5 init() { +
6 this.isPrettyNeatRight(); +
7 }, +
8 +
9 test() { +
10 console.log('testing'); +
11 }, +
12 +
13}"</p>
:::end -
1<div x-data="{ }" x-on:click="console.log('testing')"></div> +
1<div x-data="{ }" x-on:click="console.log('testing')"></div>
2 -
3<p x-html="{ -
4 prop: "Testing", -
5 init() { -
6 this.isPrettyNeatRight(); -
7 }, -
8 -
9 test() { -
10 console.log('testing'); -
11 }, -
12 -
13}"</p> +
3<p x-html="{ +
4 prop: "Testing", +
5 init() { +
6 this.isPrettyNeatRight(); +
7 }, +
8 +
9 test() { +
10 console.log('testing'); +
11 }, +
12 +
13}"</p>
\ No newline at end of file diff --git a/tests/fixtures/tests/antlers-duplication.txt b/tests/fixtures/tests/antlers-duplication.txt new file mode 100644 index 0000000..4dab661 --- /dev/null +++ b/tests/fixtures/tests/antlers-duplication.txt @@ -0,0 +1,11 @@ +:::config { "language": "antlers", "theme":"moonlight" } +// The "pages" collection +{{ nav:collection:pages }} ... {{ /nav:collection:pages }} +:::expectation +
1// The "pages" collection +
2{{ nav:collection:pages }} ... {{ /nav:collection:pages }} +
+:::end +
1// The "pages" collection +
2{{ nav:collection:pages }} ... {{ /nav:collection:pages }} +
\ No newline at end of file diff --git a/tests/fixtures/tests/antlers.txt b/tests/fixtures/tests/antlers.txt index 4176d38..e355385 100644 --- a/tests/fixtures/tests/antlers.txt +++ b/tests/fixtures/tests/antlers.txt @@ -16,38 +16,38 @@

Street

:::expectation -
1{{ skaters }} -
2<div class="card"> -
3 <h2>{{ name }}</h2> -
4 <p>{{ style }}</p> -
5</div> -
6{{ /skaters }} -
7 +
1{{ skaters }} +
2<div class="card"> +
3 <h2>{{ name }}</h2> +
4 <p>{{ style }}</p> +
5</div> +
6{{ /skaters }} +
7
8// Output -
9<div class="card"> -
10 <h2>Tony Hawk</h2> -
11 <p>Vert</p> -
12</div> -
13<div class="card"> -
14 <h2>Rodney Mullen</h2> -
15 <p>Street</p> -
16</div> +
9<div class="card"> +
10 <h2>Tony Hawk</h2> +
11 <p>Vert</p> +
12</div> +
13<div class="card"> +
14 <h2>Rodney Mullen</h2> +
15 <p>Street</p> +
16</div>
:::end -
1{{ skaters }} -
2<div class="card"> -
3 <h2>{{ name }}</h2> -
4 <p>{{ style }}</p> -
5</div> -
6{{ /skaters }} -
7 +
1{{ skaters }} +
2<div class="card"> +
3 <h2>{{ name }}</h2> +
4 <p>{{ style }}</p> +
5</div> +
6{{ /skaters }} +
7
8// Output -
9<div class="card"> -
10 <h2>Tony Hawk</h2> -
11 <p>Vert</p> -
12</div> -
13<div class="card"> -
14 <h2>Rodney Mullen</h2> -
15 <p>Street</p> -
16</div> +
9<div class="card"> +
10 <h2>Tony Hawk</h2> +
11 <p>Vert</p> +
12</div> +
13<div class="card"> +
14 <h2>Rodney Mullen</h2> +
15 <p>Street</p> +
16</div>
\ No newline at end of file diff --git a/tests/fixtures/tests/autolink-within-code.txt b/tests/fixtures/tests/autolink-within-code.txt index d15b8e5..95e4c0d 100644 --- a/tests/fixtures/tests/autolink-within-code.txt +++ b/tests/fixtures/tests/autolink-within-code.txt @@ -1,6 +1,6 @@ :::config { "language": "php", "theme":"github-light" } $link = 'https://bit.ly/3z77lme' // [tl! autolink] :::expectation -
+
:::end -
\ No newline at end of file +
\ No newline at end of file diff --git a/tests/fixtures/tests/awsconf.txt b/tests/fixtures/tests/awsconf.txt index b23dcd7..82bc418 100644 --- a/tests/fixtures/tests/awsconf.txt +++ b/tests/fixtures/tests/awsconf.txt @@ -39,84 +39,84 @@ resource "aws_eip_association" "eip_assoc" { allocation_id = aws_eip.super_awesome_addr.id } :::expectation -
1resource "aws_instance" "a_web_instance" { -
2 ami = data.aws_ami.app.id -
3 instance_type = "t3.small" -
4 -
5 root_block_device { -
6 volume_size = 8 # GB -
7 volume_type = "gp3" -
8 } -
9 -
10 tags = { -
11 Name = "super-amazing-domain-name-staging-web" -
12 Project = "super-amazing-domain-name.io" -
13 Environment = "staging" -
14 ManagedBy = "terraform" -
15 } +
1resource "aws_instance" "a_web_instance" { +
2 ami = data.aws_ami.app.id +
3 instance_type = "t3.small" +
4 +
5 root_block_device { +
6 volume_size = 8 # GB +
7 volume_type = "gp3" +
8 } +
9 +
10 tags = { +
11 Name = "super-amazing-domain-name-staging-web" +
12 Project = "super-amazing-domain-name.io" +
13 Environment = "staging" +
14 ManagedBy = "terraform" +
15 }
16}
17 -
18resource "aws_eip" "super_awesome_addr" { -
19 ## Another fantastic comment. -
20 # instance = aws_instance.super_awesome.id -
21 -
22 vpc = true -
23 -
24 lifecycle { -
25 prevent_destroy = true -
26 } -
27 -
28 tags = { -
29 Name = "super_awesome-staging-web-address" -
30 Project = "super_awesome.io" -
31 Environment = "staging" -
32 ManagedBy = "terraform" -
33 } +
18resource "aws_eip" "super_awesome_addr" { +
19 ## Another fantastic comment. +
20 # instance = aws_instance.super_awesome.id +
21 +
22 vpc = true +
23 +
24 lifecycle { +
25 prevent_destroy = true +
26 } +
27 +
28 tags = { +
29 Name = "super_awesome-staging-web-address" +
30 Project = "super_awesome.io" +
31 Environment = "staging" +
32 ManagedBy = "terraform" +
33 }
34}
35 -
36resource "aws_eip_association" "eip_assoc" { -
37 instance_id = aws_instance.super_awesome_web.id -
38 allocation_id = aws_eip.super_awesome_addr.id +
36resource "aws_eip_association" "eip_assoc" { +
37 instance_id = aws_instance.super_awesome_web.id +
38 allocation_id = aws_eip.super_awesome_addr.id
39}
:::end -
1resource "aws_instance" "a_web_instance" { -
2 ami = data.aws_ami.app.id -
3 instance_type = "t3.small" -
4 -
5 root_block_device { -
6 volume_size = 8 # GB -
7 volume_type = "gp3" -
8 } -
9 -
10 tags = { -
11 Name = "super-amazing-domain-name-staging-web" -
12 Project = "super-amazing-domain-name.io" -
13 Environment = "staging" -
14 ManagedBy = "terraform" -
15 } +
1resource "aws_instance" "a_web_instance" { +
2 ami = data.aws_ami.app.id +
3 instance_type = "t3.small" +
4 +
5 root_block_device { +
6 volume_size = 8 # GB +
7 volume_type = "gp3" +
8 } +
9 +
10 tags = { +
11 Name = "super-amazing-domain-name-staging-web" +
12 Project = "super-amazing-domain-name.io" +
13 Environment = "staging" +
14 ManagedBy = "terraform" +
15 }
16}
17 -
18resource "aws_eip" "super_awesome_addr" { -
19 ## Another fantastic comment. -
20 # instance = aws_instance.super_awesome.id -
21 -
22 vpc = true -
23 -
24 lifecycle { -
25 prevent_destroy = true -
26 } -
27 -
28 tags = { -
29 Name = "super_awesome-staging-web-address" -
30 Project = "super_awesome.io" -
31 Environment = "staging" -
32 ManagedBy = "terraform" -
33 } +
18resource "aws_eip" "super_awesome_addr" { +
19 ## Another fantastic comment. +
20 # instance = aws_instance.super_awesome.id +
21 +
22 vpc = true +
23 +
24 lifecycle { +
25 prevent_destroy = true +
26 } +
27 +
28 tags = { +
29 Name = "super_awesome-staging-web-address" +
30 Project = "super_awesome.io" +
31 Environment = "staging" +
32 ManagedBy = "terraform" +
33 }
34}
35 -
36resource "aws_eip_association" "eip_assoc" { -
37 instance_id = aws_instance.super_awesome_web.id -
38 allocation_id = aws_eip.super_awesome_addr.id +
36resource "aws_eip_association" "eip_assoc" { +
37 instance_id = aws_instance.super_awesome_web.id +
38 allocation_id = aws_eip.super_awesome_addr.id
39}
\ No newline at end of file diff --git a/tests/fixtures/tests/bad-reindex-is-ok.txt b/tests/fixtures/tests/bad-reindex-is-ok.txt index 84f905b..7b38b11 100644 --- a/tests/fixtures/tests/bad-reindex-is-ok.txt +++ b/tests/fixtures/tests/bad-reindex-is-ok.txt @@ -3,8 +3,8 @@ 'b'; // [tl! reindex(foooooooo)] 'c'; // [tl! reindex(1.1)] :::expectation -
1'a'; -
2'b';
3'c';
+
1'a'; +
2'b';
3'c';
:::end -
1'a'; -
2'b';
3'c';
\ No newline at end of file +
1'a'; +
2'b';
3'c';
\ No newline at end of file diff --git a/tests/fixtures/tests/bash.txt b/tests/fixtures/tests/bash.txt new file mode 100644 index 0000000..2ab7b91 --- /dev/null +++ b/tests/fixtures/tests/bash.txt @@ -0,0 +1,8 @@ +:::config { "language": "bash" } +cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +:::expectation +
1cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +
+:::end +
1cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +
\ No newline at end of file diff --git a/tests/fixtures/tests/basic-theme-synth.txt b/tests/fixtures/tests/basic-theme-synth.txt index d3ad140..16074df 100644 --- a/tests/fixtures/tests/basic-theme-synth.txt +++ b/tests/fixtures/tests/basic-theme-synth.txt @@ -7,20 +7,20 @@ async function synthWave() { } } :::expectation -
1async function synthWave() { -
2 if (new Date().getFullYear() === 1984) { -
3 await theNight(); -
4 const SaxSolo = new Saxophone(); -
5 SaxSolo.play(); -
6 } -
7} +
1async function synthWave() { +
2 if (new Date().getFullYear() === 1984) { +
3 await theNight(); +
4 const SaxSolo = new Saxophone(); +
5 SaxSolo.play(); +
6 } +
7}
:::end -
1async function synthWave() { -
2 if (new Date().getFullYear() === 1984) { -
3 await theNight(); -
4 const SaxSolo = new Saxophone(); -
5 SaxSolo.play(); -
6 } -
7} +
1async function synthWave() { +
2 if (new Date().getFullYear() === 1984) { +
3 await theNight(); +
4 const SaxSolo = new Saxophone(); +
5 SaxSolo.play(); +
6 } +
7}
\ No newline at end of file diff --git a/tests/fixtures/tests/better-shell.txt b/tests/fixtures/tests/better-shell.txt new file mode 100644 index 0000000..8a59b4e --- /dev/null +++ b/tests/fixtures/tests/better-shell.txt @@ -0,0 +1,8 @@ +:::config { "language": "shell" } +cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +:::expectation +
1cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +
+:::end +
1cat filesss.txt | xargs -P 2 -I % http -f POST httpbin.org/post filename="%" token="secret" --ignore-stdin +
\ No newline at end of file diff --git a/tests/fixtures/tests/blade.txt b/tests/fixtures/tests/blade.txt index 6c10dd2..4b76493 100644 --- a/tests/fixtures/tests/blade.txt +++ b/tests/fixtures/tests/blade.txt @@ -4,14 +4,14 @@
Hey buddy
@endif :::expectation -
1@if($something) +
1@if($something)
2 {{ $something }} -
3 <div>Hey buddy</div> +
3 <div>Hey buddy</div>
4@endif
:::end -
1@if($something) +
1@if($something)
2 {{ $something }} -
3 <div>Hey buddy</div> +
3 <div>Hey buddy</div>
4@endif
\ No newline at end of file diff --git a/tests/fixtures/tests/class-character-ranges.txt b/tests/fixtures/tests/class-character-ranges.txt index c7e6085..72ca8ea 100644 --- a/tests/fixtures/tests/class-character-ranges.txt +++ b/tests/fixtures/tests/class-character-ranges.txt @@ -5,12 +5,12 @@

{{ title | ensure_right('rocks!') | upper }}

{{# [tl! .inner-highlight:c16,38] #}} :::expectation -
1<!-- AWESOME STUDIOS rocks! --> -
2<h1>{{ title | upper | ensure_right('rocks!') }}</h1>
3 -
4<!-- AWESOME STUDIOS ROCKS! (order matters) --> -
5<h1>{{ title | ensure_right('rocks!') | upper }}</h1>
+
1<!-- AWESOME STUDIOS rocks! --> +
2<h1>{{ title | upper | ensure_right('rocks!') }}</h1>
3 +
4<!-- AWESOME STUDIOS ROCKS! (order matters) --> +
5<h1>{{ title | ensure_right('rocks!') | upper }}</h1>
:::end -
1<!-- AWESOME STUDIOS rocks! --> -
2<h1>{{ title | upper | ensure_right('rocks!') }}</h1>
3 -
4<!-- AWESOME STUDIOS ROCKS! (order matters) --> -
5<h1>{{ title | ensure_right('rocks!') | upper }}</h1>
\ No newline at end of file +
1<!-- AWESOME STUDIOS rocks! --> +
2<h1>{{ title | upper | ensure_right('rocks!') }}</h1>
3 +
4<!-- AWESOME STUDIOS ROCKS! (order matters) --> +
5<h1>{{ title | ensure_right('rocks!') | upper }}</h1>
\ No newline at end of file diff --git a/tests/fixtures/tests/cobalt2.txt b/tests/fixtures/tests/cobalt2.txt index bac88f6..1894594 100644 --- a/tests/fixtures/tests/cobalt2.txt +++ b/tests/fixtures/tests/cobalt2.txt @@ -5,16 +5,16 @@ CustomRenderer.prototype.matchedRangeModifier = function (content, prefix = '') return content.match(pattern); } :::expectation -
1CustomRenderer.prototype.matchedRangeModifier = function (content, prefix = '') { -
2 let pattern = new RegExp(escapeRegex(prefix) + ':?((start|end|all|-?[0-9]+)(,-?[0-9]+)?)?', 'i'); -
3 -
4 return content.match(pattern); +
1CustomRenderer.prototype.matchedRangeModifier = function (content, prefix = '') { +
2 let pattern = new RegExp(escapeRegex(prefix) + ':?((start|end|all|-?[0-9]+)(,-?[0-9]+)?)?', 'i'); +
3 +
4 return content.match(pattern);
5}
:::end -
1CustomRenderer.prototype.matchedRangeModifier = function (content, prefix = '') { -
2 let pattern = new RegExp(escapeRegex(prefix) + ':?((start|end|all|-?[0-9]+)(,-?[0-9]+)?)?', 'i'); -
3 -
4 return content.match(pattern); +
1CustomRenderer.prototype.matchedRangeModifier = function (content, prefix = '') { +
2 let pattern = new RegExp(escapeRegex(prefix) + ':?((start|end|all|-?[0-9]+)(,-?[0-9]+)?)?', 'i'); +
3 +
4 return content.match(pattern);
5}
\ No newline at end of file diff --git a/tests/fixtures/tests/copyable.txt b/tests/fixtures/tests/copyable.txt index da2fbe7..d2afbaa 100644 --- a/tests/fixtures/tests/copyable.txt +++ b/tests/fixtures/tests/copyable.txt @@ -12,15 +12,15 @@ }">

:::expectation -
1<p x-html="{ -
2 prop: "Testing", -
3 init() { -
4 this.isPrettyNeatRight(); // actual comment
5 }, +
1<p x-html="{ +
2 prop: "Testing", +
3 init() { +
4 this.isPrettyNeatRight(); // actual comment
5 },
6 -
7 test() { -
8 console.log('testing');
9 }, +
7 test() { +
8 console.log('testing');
9 },
10 -
11}"></p> +
11}"></p>
:::end -
1<p x-html="{ -
2 prop: "Testing", -
3 init() { -
4 this.isPrettyNeatRight(); // actual comment
5 }, +
1<p x-html="{ +
2 prop: "Testing", +
3 init() { +
4 this.isPrettyNeatRight(); // actual comment
5 },
6 -
7 test() { -
8 console.log('testing');
9 }, +
7 test() { +
8 console.log('testing');
9 },
10 -
11}"></p> +
11}"></p>