diff --git a/CHANGELOG.md b/CHANGELOG.md
index d16e5d5c80..e554e03b9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added get_minimal_width to Visual protocol https://github.com/Textualize/textual/pull/5962
- Added `expand` and `shrink` attributes to GridLayout https://github.com/Textualize/textual/pull/5962
+- Added `Markdown.get_stream` https://github.com/Textualize/textual/pull/5966
+- Added `textual.highlight` module for syntax highlighting https://github.com/Textualize/textual/pull/5966
+- Added `MessagePump.wait_for_refresh` method https://github.com/Textualize/textual/pull/5966
### Changed
- Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection https://github.com/Textualize/textual/pull/5962
+- Change look of command palette, to drop accented borders https://github.com/Textualize/textual/pull/5966
## [4.0.0] - 2025-07-12
diff --git a/examples/code_browser.py b/examples/code_browser.py
index d693cd0e21..56382f1a60 100644
--- a/examples/code_browser.py
+++ b/examples/code_browser.py
@@ -9,12 +9,13 @@
from __future__ import annotations
import sys
+from pathlib import Path
-from rich.syntax import Syntax
from rich.traceback import Traceback
from textual.app import App, ComposeResult
from textual.containers import Container, VerticalScroll
+from textual.highlight import highlight
from textual.reactive import reactive, var
from textual.widgets import DirectoryTree, Footer, Header, Static
@@ -68,13 +69,8 @@ def watch_path(self, path: str | None) -> None:
code_view.update("")
return
try:
- syntax = Syntax.from_path(
- path,
- line_numbers=True,
- word_wrap=False,
- indent_guides=True,
- theme="github-dark" if self.current_theme.dark else "github-light",
- )
+ code = Path(path).read_text(encoding="utf-8")
+ syntax = highlight(code, path=path)
except Exception:
code_view.update(Traceback(theme="github-dark", width=None))
self.sub_title = "ERROR"
diff --git a/examples/code_browser.tcss b/examples/code_browser.tcss
index d3f1793304..7d2e5f6255 100644
--- a/examples/code_browser.tcss
+++ b/examples/code_browser.tcss
@@ -26,4 +26,6 @@ CodeBrowser.-show-tree #tree-view {
}
#code {
width: auto;
+ padding: 0 1;
+ background: $surface;
}
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index 3be43d7068..25cdd6fcbf 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -203,6 +203,7 @@ nav:
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/getters.md"
+ - "api/highlight.md"
- "api/layout.md"
- "api/lazy.md"
- "api/logger.md"
diff --git a/poetry.lock b/poetry.lock
index 653b4f1564..ce8f992898 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -2768,4 +2768,4 @@ syntax = ["tree-sitter", "tree-sitter-bash", "tree-sitter-css", "tree-sitter-go"
[metadata]
lock-version = "2.0"
python-versions = "^3.8.1"
-content-hash = "8e8a5322af7485eeb968afb3c82e6de12d3551fe9c956217419758283332c234"
+content-hash = "1876ad2e2a1494788a7a7c9a7f75d424c41502b520a7a00d253233cfd430cdc0"
diff --git a/pyproject.toml b/pyproject.toml
index e5e0b312bd..6b5b43890e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -71,6 +71,7 @@ tree-sitter-sql = { version = ">=0.3.0,<0.3.8", optional = true, python = ">=3.9
tree-sitter-java = { version = ">=0.23.0", optional = true, python = ">=3.9" }
tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.9" }
# end of [syntax] extras
+pygments = "^2.19.2"
[tool.poetry.extras]
syntax = [
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index 6941bf96c0..6137672ffa 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -606,7 +606,6 @@ def add_widget(
sub_clip = clip.intersection(child_region)
if widget._anchored and not widget._anchor_released:
- scroll_y = widget.scroll_y
new_scroll_y = (
arrange_result.spatial_map.total_region.bottom
- (
@@ -615,7 +614,7 @@ def add_widget(
)
)
widget.set_reactive(Widget.scroll_y, new_scroll_y)
- widget.watch_scroll_y(scroll_y, new_scroll_y)
+ widget.vertical_scrollbar._reactive_position = new_scroll_y
if visible_only:
placements = arrange_result.get_visible_placements(
diff --git a/src/textual/command.py b/src/textual/command.py
index 0d74f1993f..a05401e999 100644
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -442,7 +442,7 @@ class CommandList(OptionList, can_focus=False):
CommandList {
visibility: hidden;
border-top: blank;
- border-bottom: hkey $border;
+ border-bottom: hkey black;
border-left: none;
border-right: none;
height: auto;
@@ -586,7 +586,7 @@ class CommandPalette(SystemModalScreen[None]):
CommandPalette #--input {
height: auto;
visibility: visible;
- border: hkey $border;
+ border: hkey black 50%;
}
CommandPalette #--input.--list-visible {
diff --git a/src/textual/content.py b/src/textual/content.py
index 2cc37f2571..3abac71246 100644
--- a/src/textual/content.py
+++ b/src/textual/content.py
@@ -1226,6 +1226,9 @@ def divide(self, offsets: Sequence[int]) -> list[Content]:
_Span = Span
for span_start, span_end, style in self._spans:
+ if span_start >= text_length:
+ continue
+ span_end = min(text_length, span_end)
lower_bound = 0
upper_bound = line_count
start_line_no = (lower_bound + upper_bound) // 2
diff --git a/src/textual/highlight.py b/src/textual/highlight.py
new file mode 100644
index 0000000000..268b137ebf
--- /dev/null
+++ b/src/textual/highlight.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import os
+from typing import Tuple
+
+from pygments.lexer import Lexer
+from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename
+from pygments.token import Token
+from pygments.util import ClassNotFound
+
+from textual.content import Content, Span
+
+TokenType = Tuple[str, ...]
+
+
+class HighlightTheme:
+ """Contains the style definition for user with the highlight method."""
+
+ STYLES: dict[TokenType, str] = {
+ Token.Comment: "$text 60%",
+ Token.Error: "$error on $error-muted",
+ Token.Generic.Error: "$error on $error-muted",
+ Token.Generic.Heading: "$primary underline",
+ Token.Generic.Subheading: "$primary",
+ Token.Keyword: "$accent",
+ Token.Keyword.Constant: "bold $success 80%",
+ Token.Keyword.Namespace: "$error",
+ Token.Keyword.Type: "bold",
+ Token.Literal.Number: "$warning",
+ Token.Literal.String: "$success 90%",
+ Token.Literal.String.Doc: "$success 80% italic",
+ Token.Literal.String.Double: "$success 90%",
+ Token.Name: "$primary",
+ Token.Name.Attribute: "$warning",
+ Token.Name.Builtin: "$accent",
+ Token.Name.Builtin.Pseudo: "italic",
+ Token.Name.Class: "$warning bold",
+ Token.Name.Constant: "$error",
+ Token.Name.Decorator: "$primary bold",
+ Token.Name.Function: "$warning underline",
+ Token.Name.Function.Magic: "$warning underline",
+ Token.Name.Tag: "$primary bold",
+ Token.Name.Variable: "$secondary",
+ Token.Number: "$warning",
+ Token.Operator: "bold",
+ Token.Operator.Word: "bold $error",
+ Token.String: "$success",
+ Token.Whitespace: "",
+ }
+
+
+def guess_language(code: str, path: str) -> str:
+ """Guess the language based on the code and path.
+
+ Args:
+ code: The code to guess from.
+ path: A path to the code.
+
+ Returns:
+ The language, suitable for use with Pygments.
+ """
+
+ if path is not None and os.path.splitext(path)[-1] == ".tcss":
+ # A special case for TCSS files which aren't known outside of Textual
+ return "scss"
+
+ lexer: Lexer | None = None
+ lexer_name = "default"
+ if code:
+ try:
+ lexer = guess_lexer_for_filename(path, code)
+ except ClassNotFound:
+ pass
+
+ if not lexer:
+ try:
+ _, ext = os.path.splitext(path)
+ if ext:
+ extension = ext.lstrip(".").lower()
+ lexer = get_lexer_by_name(extension)
+ except ClassNotFound:
+ pass
+
+ if lexer:
+ if lexer.aliases:
+ lexer_name = lexer.aliases[0]
+ else:
+ lexer_name = lexer.name
+
+ return lexer_name
+
+
+def highlight(
+ code: str,
+ *,
+ language: str | None = None,
+ path: str | None = None,
+ theme: type[HighlightTheme] = HighlightTheme,
+ tab_size: int = 8,
+) -> Content:
+ """Apply syntax highlighting to a string.
+
+ Args:
+ code: A string to highlight.
+ language: The language to highlight.
+ theme: A HighlightTheme class (type not instance).
+ tab_size: Number of spaces in a tab. Defaults to 8.
+
+ Returns:
+ A Content instance which may be used in a widget.
+ """
+ if language is None:
+ if path is None:
+ raise RuntimeError("One of 'language' or 'path' must be supplied.")
+ language = guess_language(code, path)
+
+ assert language is not None
+ code = "\n".join(code.splitlines())
+ try:
+ lexer = get_lexer_by_name(
+ language,
+ stripnl=False,
+ ensurenl=True,
+ tabsize=tab_size,
+ )
+ except ClassNotFound:
+ lexer = get_lexer_by_name(
+ "text",
+ stripnl=False,
+ ensurenl=True,
+ tabsize=tab_size,
+ )
+
+ token_start = 0
+ spans: list[Span] = []
+ styles = theme.STYLES
+
+ for token_type, token in lexer.get_tokens(code):
+ token_end = token_start + len(token)
+ while True:
+ if style := styles.get(token_type):
+ spans.append(Span(token_start, token_end, style))
+ break
+ if (token_type := token_type.parent) is None:
+ break
+ token_start = token_end
+
+ highlighted_code = Content(code, spans=spans).stylize_before("$text")
+ return highlighted_code
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 19e37e4a19..dd2ec42270 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -450,6 +450,20 @@ def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> b
message = messages.InvokeLater(partial(callback, *args, **kwargs))
return self.post_message(message)
+ async def wait_for_refresh(self) -> None:
+ """Wait for the next refresh.
+
+ This method should only be called from a task other than the one running this widget.
+ If called from the same task, it will return immediately to avoid blocking the event loop.
+
+ """
+
+ if self._task is None or asyncio.current_task() is not self._task:
+ return
+ refreshed_event = asyncio.Event()
+ self.call_after_refresh(refreshed_event.set)
+ await refreshed_event.wait()
+
def call_later(self, callback: Callback, *args: Any, **kwargs: Any) -> bool:
"""Schedule a callback to run after all messages are processed in this object.
Positional and keywords arguments are passed to the callable.
diff --git a/src/textual/selection.py b/src/textual/selection.py
index 94166838aa..0b12fc27ba 100644
--- a/src/textual/selection.py
+++ b/src/textual/selection.py
@@ -50,6 +50,7 @@ def extract(self, text: str) -> str:
end_offset = len(lines[end_line])
else:
end_line, end_offset = self.end.transpose
+ end_line = min(len(lines), end_line)
if start_line == end_line:
return lines[start_line][start_offset:end_offset]
diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py
index 2fa5948cac..4e4eb9f994 100644
--- a/src/textual/widgets/_markdown.py
+++ b/src/textual/widgets/_markdown.py
@@ -10,17 +10,17 @@
from markdown_it import MarkdownIt
from markdown_it.token import Token
-from rich.syntax import Syntax
from rich.text import Text
from typing_extensions import TypeAlias
-from textual._slug import TrackedSlugs
+from textual._slug import TrackedSlugs, slug
from textual.app import ComposeResult
from textual.await_complete import AwaitComplete
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.content import Content
from textual.css.query import NoMatches
from textual.events import Mount
+from textual.highlight import highlight
from textual.layout import Layout
from textual.layouts.grid import GridLayout
from textual.message import Message
@@ -36,6 +36,73 @@
"""
+class MarkdownStream:
+ """An object to manager streaming markdown.
+
+ This will accumulate markdown fragments if they can't be rendered fast enough.
+
+ This object is typically created by the [Markdown.get_stream][textual.widgets.Markdown.get_stream] method.
+
+ """
+
+ def __init__(self, markdown_widget: Markdown) -> None:
+ """
+ Args:
+ markdown_widget: Markdown widget to update.
+ """
+ self.markdown_widget = markdown_widget
+ self._task: asyncio.Task | None = None
+ self._new_markup = asyncio.Event()
+ self._pending: list[str] = []
+ self._stopped = False
+
+ def start(self) -> None:
+ """Start the updater running in the background.
+
+ No need to call this, if the object was created by [Markdown.get_stream][textual.widgets.Markdown.get_stream].
+
+ """
+ if self._task is None:
+ self._task = asyncio.create_task(self._run())
+
+ async def stop(self) -> None:
+ """Stop the stream and await its finish."""
+ if self._task is not None:
+ self._task.cancel()
+ await self._task
+ self._task = None
+ self._stopped = True
+
+ async def write(self, markdown_fragment: str) -> None:
+ """Append or enqueue a markdown fragment.
+
+ Args:
+ markdown_fragment: A string to append at the end of the document.
+ """
+ if self._stopped:
+ raise RuntimeError("Can't write to the stream after it has stopped.")
+ if not markdown_fragment:
+ # Nothing to do for empty strings.
+ return
+ # Append the new fragment, and set an event to tell the _run loop to wake up
+ self._pending.append(markdown_fragment)
+ self._new_markup.set()
+
+ async def _run(self) -> None:
+ """Run a task to append markdown fragments when available."""
+ try:
+ while await self._new_markup.wait():
+ new_markdown = "".join(self._pending)
+ self._pending.clear()
+ self._new_markup.clear()
+ await asyncio.shield(self.markdown_widget.append(new_markdown))
+ except asyncio.CancelledError:
+ # Task has been cancelled, add any outstanding markdown
+ new_markdown = "".join(self._pending)
+ if new_markdown:
+ await self.markdown_widget.append(new_markdown)
+
+
class Navigator:
"""Manages a stack of paths like a browser."""
@@ -342,7 +409,7 @@ class MarkdownHorizontalRule(MarkdownBlock):
DEFAULT_CSS = """
MarkdownHorizontalRule {
- border-bottom: heavy $secondary;
+ border-bottom: solid $secondary;
height: 1;
padding-top: 1;
margin-bottom: 1;
@@ -618,19 +685,19 @@ async def _update_from_block(self, block: MarkdownBlock) -> None:
Args:
block: Existing markdown block.
"""
- assert isinstance(block, MarkdownTable)
- try:
- table_content = self.query_one(MarkdownTableContent)
- except NoMatches:
- pass
- else:
- if table_content.rows:
- current_rows = self._rows
- _new_headers, new_rows = block._get_headers_and_rows()
- updated_rows = new_rows[len(current_rows) - 1 :]
- self._rows = new_rows
- await table_content._update_rows(updated_rows)
- return
+ if isinstance(block, MarkdownTable):
+ try:
+ table_content = self.query_one(MarkdownTableContent)
+ except NoMatches:
+ pass
+ else:
+ if table_content.rows:
+ current_rows = self._rows
+ _new_headers, new_rows = block._get_headers_and_rows()
+ updated_rows = new_rows[len(current_rows) - 1 :]
+ self._rows = new_rows
+ await table_content._update_rows(updated_rows)
+ return
await super()._update_from_block(block)
@@ -712,14 +779,15 @@ class MarkdownFence(MarkdownBlock):
DEFAULT_CSS = """
MarkdownFence {
+ padding: 1 2;
margin: 1 0;
- overflow: auto;
- width: 100%;
- height: auto;
- max-height: 20;
+ overflow: hidden;
+ width: 1fr;
+ height: auto;
color: rgb(210,210,210);
background: black 10%;
-
+ text-wrap: nowrap;
+ text-overflow: clip;
&:light {
background: white 30%;
}
@@ -739,44 +807,8 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None:
if self.app.current_theme.dark
else self._markdown.code_light_theme
)
-
- def notify_style_update(self) -> None:
- self.call_later(self._retheme)
-
- def _block(self) -> Syntax:
- _, background_color = self.background_colors
- return Syntax(
- self.code,
- lexer=self.lexer,
- word_wrap=False,
- indent_guides=self._markdown.code_indent_guides,
- padding=(1, 2),
- theme=self.theme,
- background_color=background_color.css,
- )
-
- def _on_mount(self, _: Mount) -> None:
- """Watch app theme switching."""
- self.watch(self.app, "theme", self._retheme)
-
- def _retheme(self) -> None:
- """Rerender when the theme changes."""
- self.theme = (
- self._markdown.code_dark_theme
- if self.app.current_theme.dark
- else self._markdown.code_light_theme
- )
- try:
- self.get_child_by_type(Static).update(self._block())
- except NoMatches:
- pass
-
- def compose(self) -> ComposeResult:
- yield Static(
- self._block(),
- expand=True,
- shrink=False,
- )
+ code_content = highlight(self.code, language=lexer)
+ self.set_content(code_content)
HEADINGS = {
@@ -799,7 +831,7 @@ class Markdown(Widget):
layout: vertical;
color: $foreground;
# background: $surface;
- overflow-y: auto;
+ overflow-y: hidden;
&:focus {
background-tint: $foreground 5%;
@@ -875,6 +907,7 @@ def __init__(
self._parser_factory = parser_factory
self._table_of_contents: TableOfContentsType = []
self._open_links = open_links
+ self._last_parsed_line = 0
class TableOfContentsUpdated(Message):
"""The table of contents was updated."""
@@ -956,6 +989,46 @@ async def _on_mount(self, _: Mount) -> None:
).set_sender(self)
)
+ @classmethod
+ def get_stream(cls, markdown: Markdown) -> MarkdownStream:
+ """Get a [MarkdownStream][textual.widgets._markdown.MarkdownStream] instance stream Markdown in the background.
+
+ If you append to the Markdown document many times a second, it is possible the widget won't
+ be able to update as fast as you write (occurs around 20 appends per second). It will still
+ work, but the user will have to wait for the UI to catch up after the document has be retrieved.
+
+ Using a [MarkdownStream][textual.widgets._markdown.MarkdownStream] will combine several updates in to one
+ as necessary to keep up with the incoming data.
+
+ example:
+ ```python
+ # self.get_chunk is a hypothetical method that retrieves a
+ # markdown fragment from the network
+ @work
+ async def stream_markdown(self) -> None:
+ markdown_widget = self.query_one(Markdown)
+ container = self.query_one(VerticalScroll)
+ container.anchor()
+
+ stream = Markdown.get_stream(markdown_widget)
+ try:
+ while (chunk:= await self.get_chunk()) is not None:
+ await stream.write(chunk)
+ finally:
+ await stream.stop()
+ ```
+
+
+ Args:
+ markdown: A [Markdown][textual.widgets.Markdown] widget instance.
+
+ Returns:
+ The Markdown stream object.
+ """
+ updater = MarkdownStream(markdown)
+ updater.start()
+ return updater
+
def on_markdown_link_clicked(self, event: LinkClicked) -> None:
if self._open_links:
self.app.open_url(event.href)
@@ -1045,7 +1118,9 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None:
return None
def _parse_markdown(
- self, tokens: Iterable[Token], table_of_contents: TableOfContentsType
+ self,
+ tokens: Iterable[Token],
+ table_of_contents: TableOfContentsType,
) -> Iterable[MarkdownBlock]:
"""Create a stream of MarkdownBlock widgets from markdown.
@@ -1059,13 +1134,11 @@ def _parse_markdown(
stack: list[MarkdownBlock] = []
stack_append = stack.append
- block_id: int = 0
for token in tokens:
token_type = token.type
if token_type == "heading_open":
- block_id += 1
- stack_append(HEADINGS[token.tag](self, id=f"block{block_id}"))
+ stack_append(HEADINGS[token.tag](self))
elif token_type == "hr":
yield MarkdownHorizontalRule(self)
elif token_type == "paragraph_open":
@@ -1108,6 +1181,7 @@ def _parse_markdown(
if token.type == "heading_close":
heading = block._content.plain
level = int(token.tag[1:])
+ block.id = f"{slug(heading)}-{len(table_of_contents) + 1}"
table_of_contents.append((level, heading, block.id))
if stack:
stack[-1]._blocks.append(block)
@@ -1152,12 +1226,13 @@ async def await_update() -> None:
"""Update in batches."""
BATCH_SIZE = 200
batch: list[MarkdownBlock] = []
- tokens = await asyncio.get_running_loop().run_in_executor(
- None, parser.parse, markdown
- )
# Lock so that you can't update with more than one document simultaneously
async with self.lock:
+ tokens = await asyncio.get_running_loop().run_in_executor(
+ None, parser.parse, markdown
+ )
+
# Remove existing blocks for the first batch only
removed: bool = False
@@ -1212,38 +1287,35 @@ def append(self, markdown: str) -> AwaitComplete:
)
table_of_contents: TableOfContentsType = []
-
- self._markdown = updated_markdown = self.source + markdown
+ self._markdown = self.source + markdown
+ updated_source = "\n".join(
+ self._markdown.splitlines()[self._last_parsed_line :]
+ )
async def await_append() -> None:
"""Append new markdown widgets."""
- tokens = await asyncio.get_running_loop().run_in_executor(
- None, parser.parse, updated_markdown
- )
- existing_blocks = [
- child for child in self.children if isinstance(child, MarkdownBlock)
- ]
- new_blocks = list(self._parse_markdown(tokens, table_of_contents))
- last_index = len(existing_blocks) - 1
-
async with self.lock:
+ tokens = parser.parse(updated_source)
+ existing_blocks = [
+ child for child in self.children if isinstance(child, MarkdownBlock)
+ ]
+ for token in reversed(tokens):
+ if token.map is not None and token.level == 0:
+ self._last_parsed_line += token.map[0]
+ break
+ new_blocks = list(self._parse_markdown(tokens, table_of_contents))
with self.app.batch_update():
if existing_blocks and new_blocks:
- last_block = existing_blocks[last_index]
+ last_block = existing_blocks[-1]
try:
- await last_block._update_from_block(new_blocks[last_index])
+ await last_block._update_from_block(new_blocks[0])
except IndexError:
pass
else:
- last_index += 1
+ new_blocks = new_blocks[1:]
- append_blocks = new_blocks[last_index:]
- if append_blocks:
- self.log(append=append_blocks, children=self.children)
- try:
- await self.mount_all(append_blocks)
- except Exception as error:
- self.log(error)
+ if new_blocks:
+ await self.mount_all(new_blocks)
self._table_of_contents = table_of_contents
self.post_message(
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg
index 665565b457..3239f025e4 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg
@@ -19,144 +19,144 @@
font-weight: 700;
}
- .terminal-2909242669-matrix {
+ .terminal-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2909242669-title {
+ .terminal-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2909242669-r1 { fill: #8a4346 }
-.terminal-2909242669-r2 { fill: #868887 }
-.terminal-2909242669-r3 { fill: #6b546f }
-.terminal-2909242669-r4 { fill: #e0e0e0 }
-.terminal-2909242669-r5 { fill: #292929 }
-.terminal-2909242669-r6 { fill: #c5c8c6 }
-.terminal-2909242669-r7 { fill: #0178d4 }
-.terminal-2909242669-r8 { fill: #00ff00 }
-.terminal-2909242669-r9 { fill: #000000 }
-.terminal-2909242669-r10 { fill: #8d8d8d }
-.terminal-2909242669-r11 { fill: #7e8486 }
-.terminal-2909242669-r12 { fill: #e0e0e0;font-weight: bold }
-.terminal-2909242669-r13 { fill: #9eafbd }
-.terminal-2909242669-r14 { fill: #a1a5a8 }
+ .terminal-r1 { fill: #8a4346 }
+.terminal-r2 { fill: #868887 }
+.terminal-r3 { fill: #6b546f }
+.terminal-r4 { fill: #e0e0e0 }
+.terminal-r5 { fill: #292929 }
+.terminal-r6 { fill: #c5c8c6 }
+.terminal-r7 { fill: #0a0f13 }
+.terminal-r8 { fill: #00ff00 }
+.terminal-r9 { fill: #000000 }
+.terminal-r10 { fill: #8d8d8d }
+.terminal-r11 { fill: #7e8486 }
+.terminal-r12 { fill: #e0e0e0;font-weight: bold }
+.terminal-r13 { fill: #9eafbd }
+.terminal-r14 { fill: #a1a5a8 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- CommandPaletteApp
+ CommandPaletteApp
-
+
-
- RedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
-
-🔎Search for commands…
-
-
-Quit the application
-Quit the application as soon as possible
-Save screenshot
-Save an SVG 'screenshot' of the current screen
-Show keys and help panel
-Show help for the focused widget and a summary of available keys
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
-MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+
+ RedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
+
+🔎Search for commands…
+
+
+Quit the application
+Quit the application as soon as possible
+Save screenshot
+Save an SVG 'screenshot' of the current screen
+Show keys and help panel
+Show help for the focused widget and a summary of available keys
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
+MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg
index 376c21d409..c02c89a047 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg
@@ -35,7 +35,7 @@
.terminal-r1 { fill: #646464 }
.terminal-r2 { fill: #e0e0e0 }
.terminal-r3 { fill: #c5c8c6 }
-.terminal-r4 { fill: #0178d4 }
+.terminal-r4 { fill: #0a0f13 }
.terminal-r5 { fill: #00ff00 }
.terminal-r6 { fill: #000000 }
.terminal-r7 { fill: #121212 }
@@ -138,7 +138,7 @@
bar
baz
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg
index 57a3f2e72b..be1b1b894b 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg
@@ -34,7 +34,7 @@
.terminal-r1 { fill: #e0e0e0 }
.terminal-r2 { fill: #c5c8c6 }
-.terminal-r3 { fill: #0178d4 }
+.terminal-r3 { fill: #0a0f13 }
.terminal-r4 { fill: #00ff00 }
.terminal-r5 { fill: #000000 }
.terminal-r6 { fill: #121212 }
@@ -145,7 +145,7 @@
This is a test of this code 2
This is a test of this code 1
This is a test of this code 0
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg
index 2aa0ab9dc9..1157c6ea19 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg
@@ -34,7 +34,7 @@
.terminal-r1 { fill: #e0e0e0 }
.terminal-r2 { fill: #c5c8c6 }
-.terminal-r3 { fill: #0178d4 }
+.terminal-r3 { fill: #0a0f13 }
.terminal-r4 { fill: #00ff00 }
.terminal-r5 { fill: #000000 }
.terminal-r6 { fill: #121212 }
@@ -145,7 +145,7 @@
This is a test of this code 7
This is a test of this code 8
This is a test of this code 9
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg
index 6e7213f121..50841dacce 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg
@@ -41,10 +41,8 @@
.terminal-r7 { fill: #e0e0e0;font-weight: bold }
.terminal-r8 { fill: #e0e0e0;font-style: italic; }
.terminal-r9 { fill: #e0e0e0;text-decoration: line-through; }
-.terminal-r10 { fill: #d2d2d2 }
-.terminal-r11 { fill: #82aaff }
-.terminal-r12 { fill: #89ddff }
-.terminal-r13 { fill: #c3e88d }
+.terminal-r10 { fill: #ffffff }
+.terminal-r11 { fill: #47ad67 }
@@ -130,7 +128,7 @@
-
+
@@ -146,7 +144,7 @@
strikethrough
-print("Hello, world!")
+print("Hello, world!")
That was some code.
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg
index a27c354ba5..a8084dcdb8 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg
@@ -34,12 +34,10 @@
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #0178d4;font-weight: bold }
-.terminal-r3 { fill: #d2d2d2 }
-.terminal-r4 { fill: #859900 }
-.terminal-r5 { fill: #839496 }
-.terminal-r6 { fill: #268bd2 }
-.terminal-r7 { fill: #3f4e52;font-style: italic; }
-.terminal-r8 { fill: #2aa198 }
+.terminal-r3 { fill: #fea62b }
+.terminal-r4 { fill: #ffffff }
+.terminal-r5 { fill: #fea62b;text-decoration: underline; }
+.terminal-r6 { fill: #47ad67 }
@@ -125,15 +123,15 @@
-
+
This is a H1
-defmain():
-│ print("Hello world!")
+defmain():
+print("Hello world!")
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg
index 1137c5c82e..318133af19 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg
@@ -34,12 +34,10 @@
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #004578;font-weight: bold }
-.terminal-r3 { fill: #d2d2d2 }
-.terminal-r4 { fill: #859900 }
-.terminal-r5 { fill: #657b83 }
-.terminal-r6 { fill: #268bd2 }
-.terminal-r7 { fill: #b0b9b9;font-style: italic; }
-.terminal-r8 { fill: #2aa198 }
+.terminal-r3 { fill: #fea62b }
+.terminal-r4 { fill: #000000 }
+.terminal-r5 { fill: #fea62b;text-decoration: underline; }
+.terminal-r6 { fill: #5dc37d }
@@ -125,15 +123,15 @@
-
+
This is a H1
-defmain():
-│ print("Hello world!")
+defmain():
+print("Hello world!")
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg
index 0c07aa4f8f..6c8414fc1a 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg
@@ -35,20 +35,16 @@
.terminal-r1 { fill: #ff0000 }
.terminal-r2 { fill: #c5c8c6 }
.terminal-r3 { fill: #e0e0e0 }
-.terminal-r4 { fill: #121212 }
-.terminal-r5 { fill: #e0e0e0;text-decoration: underline; }
-.terminal-r6 { fill: #e0e0e0;font-style: italic; }
-.terminal-r7 { fill: #e0e0e0;font-weight: bold }
-.terminal-r8 { fill: #000000 }
-.terminal-r9 { fill: #e0e0e0;text-decoration: line-through; }
-.terminal-r10 { fill: #d2d2d2 }
-.terminal-r11 { fill: #546e7a;font-style: italic; }
-.terminal-r12 { fill: #bb80b3 }
-.terminal-r13 { fill: #eeffff }
-.terminal-r14 { fill: #ffcb6b }
-.terminal-r15 { fill: #89ddff }
-.terminal-r16 { fill: #3c4e55;font-style: italic; }
-.terminal-r17 { fill: #f78c6c }
+.terminal-r4 { fill: #e0e0e0;text-decoration: underline; }
+.terminal-r5 { fill: #e0e0e0;font-style: italic; }
+.terminal-r6 { fill: #e0e0e0;font-weight: bold }
+.terminal-r7 { fill: #e0e0e0;text-decoration: line-through; }
+.terminal-r8 { fill: #9f9f9f }
+.terminal-r9 { fill: #fea62b }
+.terminal-r10 { fill: #ffffff }
+.terminal-r11 { fill: #fea62b;font-weight: bold }
+.terminal-r12 { fill: #419c5d;font-style: italic; }
+.terminal-r13 { fill: #0178d4 }
@@ -134,23 +130,23 @@
-
+
- │X X│X X│X X X X X X
+ │X X│X X│X X X X X X
│││
-│X X│X X│X X X X X X
+│X X│X X│X X X X X X
│││
-│X X││X X X X X X
+│X X││X X X X X X
│││
-│X X▇▇││X X X X X X▇▇
+│X X││X X X X X X
│││
┌───────────────────────────────────────────────────────────────────────────────
│
│
-│# Two spaces: see?
-│classFoo:
-││ '''This is a doc string.'''
-││ some_code(1,2,3,4)
+│# Two spaces: see?
+│classFoo:
+│'''This is a doc string.'''
+│some_code(1, 2, 3, 4)
│
│
│
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
index 109f4822a9..318133af19 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg
@@ -34,14 +34,10 @@
.terminal-r1 { fill: #c5c8c6 }
.terminal-r2 { fill: #004578;font-weight: bold }
-.terminal-r3 { fill: #d2d2d2 }
-.terminal-r4 { fill: #008000;font-weight: bold }
-.terminal-r5 { fill: #bbbbbb }
-.terminal-r6 { fill: #0000ff }
-.terminal-r7 { fill: #000000 }
-.terminal-r8 { fill: #77a0a0;font-style: italic; }
-.terminal-r9 { fill: #008000 }
-.terminal-r10 { fill: #ba2121 }
+.terminal-r3 { fill: #fea62b }
+.terminal-r4 { fill: #000000 }
+.terminal-r5 { fill: #fea62b;text-decoration: underline; }
+.terminal-r6 { fill: #5dc37d }
@@ -127,15 +123,15 @@
-
+
This is a H1
-defmain():
-│ print("Hello world!")
+defmain():
+print("Hello world!")
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg
index 0e1989ccd1..f95b0fd901 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg
@@ -34,7 +34,7 @@
.terminal-r1 { fill: #e0e0e0 }
.terminal-r2 { fill: #c5c8c6 }
-.terminal-r3 { fill: #0178d4 }
+.terminal-r3 { fill: #0a0f13 }
.terminal-r4 { fill: #00ff00 }
.terminal-r5 { fill: #000000 }
.terminal-r6 { fill: #121212 }
@@ -140,7 +140,7 @@
Hello World
Help text
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg
index f6c2fc6436..92cda48848 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg
@@ -36,7 +36,7 @@
.terminal-r2 { fill: #0b3a5f }
.terminal-r3 { fill: #c5c8c6 }
.terminal-r4 { fill: #e0e0e0 }
-.terminal-r5 { fill: #0178d4 }
+.terminal-r5 { fill: #0a0f13 }
.terminal-r6 { fill: #00ff00 }
.terminal-r7 { fill: #000000 }
.terminal-r8 { fill: #6d7479 }
@@ -166,7 +166,7 @@
Save an SVG 'screenshot' of the current screen
Show keys and help panel
Show help for the focused widget and a summary of available keys
-▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
+▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
diff --git a/tests/test_content.py b/tests/test_content.py
index a5c459f3f6..fa54671654 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -274,3 +274,12 @@ def test_first_line():
first_line = content.first_line
assert first_line.plain == "foo"
assert first_line.spans == [Span(0, 3, "red")]
+
+
+def test_split_and_tabs():
+ spans = [
+ Span(0, 49, style="$text"),
+ ]
+
+ content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans)
+ content.render_strips({}, 80, None, Style())
diff --git a/tests/test_highlight.py b/tests/test_highlight.py
new file mode 100644
index 0000000000..9596620f14
--- /dev/null
+++ b/tests/test_highlight.py
@@ -0,0 +1,29 @@
+import pytest
+
+from textual.content import Span
+from textual.highlight import guess_language, highlight
+
+
+def test_highlight() -> None:
+ """Test simple application of highlight."""
+ import_this = highlight("import this", language="python")
+ assert import_this.plain == "import this"
+ print(import_this.spans)
+ assert import_this.spans == [
+ Span(0, 11, style="$text"),
+ Span(0, 6, style="$error"),
+ Span(7, 11, style="$primary"),
+ ]
+
+
+@pytest.mark.parametrize(
+ "code,path,language",
+ [
+ ("import this", "foo.py", "python"),
+ ("", "foo.xml", "xml"),
+ ("{}", "data.json", "json"),
+ ],
+)
+def test_guess_language(code: str, path: str, language: str) -> None:
+ """Test guess_language is working."""
+ assert guess_language(code, path) == language