diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0440949e46..b6a886733a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
+### Added
+
+- Added suport for tree-sitter-language-pack as an optional dependency for syntax highlighting
+
### Fixed
- Fixed parsing Kitty extended keys with multiple codepoints https://github.com/Textualize/textual/pull/6592
diff --git a/Makefile b/Makefile
index fd35774d27..ef43aac7af 100644
--- a/Makefile
+++ b/Makefile
@@ -90,6 +90,7 @@ clean: clean-screenshot-cache clean-offline-docs
setup:
poetry install
poetry install --extras syntax
+ poetry install --extras more-syntax
.PHONY: update
update:
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 386118dfe9..6cf5c813ac 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -46,6 +46,30 @@ If you would like to enable syntax highlighting in the [TextArea](./widgets/text
pip install "textual[syntax]"
```
+By default this support:
+
+- python
+- markdown
+- json
+- toml
+- yaml
+- html
+- css
+- javascript
+- rust
+- go
+- regex
+- sql
+- java
+- bash
+- xml
+
+If the built-in languages aren't enough for you, you can install the "more-syntax" extra:
+
+```
+pip install "textual[more-syntax]"
+```
+
### From conda-forge
Textual is also available on [conda-forge](https://conda-forge.org/). The preferred package manager for conda-forge is currently [micromamba](https://mamba.readthedocs.io/en/latest/installation/micromamba-installation.html):
diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md
index 928fd14786..39bdfc57b4 100644
--- a/docs/widgets/text_area.md
+++ b/docs/widgets/text_area.md
@@ -36,10 +36,42 @@ To enable syntax highlighting, you'll need to install the `syntax` extra depende
poetry add "textual[syntax]"
```
-This will install `tree-sitter` and `tree-sitter-languages`.
+This will install `tree-sitter` and `tree-sitter-languages` which support:
+
+- python
+- markdown
+- json
+- toml
+- yaml
+- html
+- css
+- javascript
+- rust
+- go
+- regex
+- sql
+- java
+- bash
+- xml
+
These packages are distributed as binary wheels, so it may limit your applications ability to run in environments where these wheels are not available.
After installing, you can set the [`language`][textual.widgets._text_area.TextArea.language] reactive attribute on the `TextArea` to enable highlighting.
+Alternatively if you need more languages can use the `more-syntax` extra:
+=== "pip"
+
+ ```
+ pip install "textual[more-syntax]"
+ ```
+
+=== "poetry"
+
+ ```
+ poetry add "textual[more-syntax]"
+ ```
+This use `tree-sitter-language-pack` instead of`tree-sitter-languages`
+You also can add manual support yourself, see below.
+
### Loading text
In this example we load some initial text into the `TextArea`, and set the language to `"python"` to enable syntax highlighting.
diff --git a/pyproject.toml b/pyproject.toml
index 7f2cceb0a3..f4e47ea7f1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -73,6 +73,7 @@ tree-sitter-sql = { version = ">=0.3.11", optional = true, python = ">=3.10" }
tree-sitter-java = { version = ">=0.23.0", optional = true, python = ">=3.10" }
tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.10" }
# end of [syntax] extras
+tree-sitter-language-pack = { version = ">=1.12.0", optional = true, python = ">=3.10" }
pygments = "^2.19.2"
[tool.poetry.extras]
@@ -94,6 +95,10 @@ syntax = [
"tree-sitter-java",
"tree-sitter-bash",
]
+more-syntax = [
+ "tree-sitter",
+ "tree-sitter-language-pack",
+]
[tool.poetry.group.dev.dependencies]
black = "24.4.2"
diff --git a/src/textual/_tree_sitter.py b/src/textual/_tree_sitter.py
index 5f92e2c146..3b75f06304 100644
--- a/src/textual/_tree_sitter.py
+++ b/src/textual/_tree_sitter.py
@@ -7,38 +7,54 @@
try:
from tree_sitter import Language
- _LANGUAGE_CACHE: dict[str, Language] = {}
+except ImportError:
+ _tree_sitter = False
+
+ def get_language(language_name: str) -> Language | None:
+ return None
+else:
_tree_sitter = True
+ try:
+ import tree_sitter_language_pack
+
+ except ImportError:
+ _LANGUAGE_CACHE: dict[str, Language] = {}
+
+ def get_language(language_name: str) -> Language | None:
+ if language_name in _LANGUAGE_CACHE:
+ return _LANGUAGE_CACHE[language_name]
- def get_language(language_name: str) -> Language | None:
- if language_name in _LANGUAGE_CACHE:
- return _LANGUAGE_CACHE[language_name]
-
- try:
- module = import_module(f"tree_sitter_{language_name}")
- except ImportError:
- return None
- else:
try:
- if language_name == "xml":
- # xml uses language_xml() instead of language()
- # it's the only outlier amongst the languages in the `textual[syntax]` extra
- language = Language(module.language_xml())
- else:
- language = Language(module.language())
- except (OSError, AttributeError):
- log.warning(f"Could not load language {language_name!r}.")
+ module = import_module(f"tree_sitter_{language_name}")
+ except ImportError:
return None
else:
- _LANGUAGE_CACHE[language_name] = language
- return language
+ try:
+ if language_name == "xml":
+ # xml uses language_xml() instead of language()
+ # it's the only outlier amongst the languages in the `textual[syntax]` extra
+ language = Language(module.language_xml())
+ else:
+ language = Language(module.language())
+ except (OSError, AttributeError):
+ log.warning(f"Could not load language {language_name!r}.")
+ return None
+ else:
+ _LANGUAGE_CACHE[language_name] = language
+ return language
-except ImportError:
- _tree_sitter = False
+ else:
- def get_language(language_name: str) -> Language | None:
- return None
+ def get_language(language_name: str) -> Language | None:
+ try:
+ return tree_sitter_language_pack.get_language(language_name)
+ except (
+ tree_sitter_language_pack.LanguageNotFoundError,
+ tree_sitter_language_pack.DownloadError,
+ ):
+ log.warning(f"Could not load language {language_name!r}.")
+ return None
TREE_SITTER = _tree_sitter
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
index 1ca74675e5..0158b4e8ea 100644
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -773,11 +773,11 @@ def code_editor(
)
@staticmethod
- def _get_builtin_highlight_query(language_name: str) -> str:
- """Get the highlight query for a builtin language.
+ def _get_highlight_query(language_name: str) -> str:
+ """Get the highlight query for a given language.
Args:
- language_name: The name of the builtin language.
+ language_name: The name of the language.
Returns:
The highlight query.
@@ -788,9 +788,19 @@ def _get_builtin_highlight_query(language_name: str) -> str:
)
highlight_query = highlight_query_path.read_text()
except OSError as error:
- log.warning(f"Unable to load highlight query. {error}")
- highlight_query = ""
-
+ # Only use tree_sitter_language_pack as a fallback
+ try:
+ import tree_sitter_language_pack
+ except ImportError:
+ log.warning(f"Unable to load highlight query. {error}")
+ highlight_query = ""
+ else:
+ result = tree_sitter_language_pack.get_highlights_query(language_name)
+ if result:
+ highlight_query = result
+ else:
+ log.warning(f"Unable to load highlight query. {error}")
+ highlight_query = ""
return highlight_query
def notify_style_update(self) -> None:
@@ -1139,7 +1149,7 @@ def _set_document(self, text: str, language: str | None) -> None:
document_language = get_language(language)
else:
# No user-registered language, so attempt to use a built-in language.
- highlight_query = self._get_builtin_highlight_query(language)
+ highlight_query = self._get_highlight_query(language)
document_language = get_language(language)
# No built-in language, and no user-registered language: use plain text and warn.
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[c].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[c].svg
new file mode 100644
index 0000000000..776b044958
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[c].svg
@@ -0,0 +1,271 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[ini].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[ini].svg
new file mode 100644
index 0000000000..254d6d67e3
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[ini].svg
@@ -0,0 +1,185 @@
+
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[kotlin].svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[kotlin].svg
new file mode 100644
index 0000000000..3927d45039
--- /dev/null
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_text_area_non_builtin_language_rendering[kotlin].svg
@@ -0,0 +1,220 @@
+
diff --git a/tests/snapshot_tests/language_snippets.py b/tests/snapshot_tests/language_snippets.py
index 1c34d46648..ec4e0b3436 100644
--- a/tests/snapshot_tests/language_snippets.py
+++ b/tests/snapshot_tests/language_snippets.py
@@ -938,6 +938,123 @@ class Rectangle implements Shape {
"""
+KOTLIN = """\
+package com.example.demo
+
+import kotlin.math.PI
+
+// Data class
+data class User(
+ val name: String,
+ val age: Int?,
+)
+
+// Enum
+enum class Status {
+ ACTIVE,
+ INACTIVE,
+}
+
+fun User.displayName(): String =
+ "${name.uppercase()} (${age ?: 0})"
+
+fun main() {
+ val user = User("Alice", 30)
+
+ val status = Status.ACTIVE
+
+ val area = { radius: Double ->
+ PI * radius * radius
+ }
+
+ when (status) {
+ Status.ACTIVE -> println(user.displayName())
+ Status.INACTIVE -> println("Inactive")
+ }
+
+ println("Area: ${area(5.0)}")
+}
+"""
+
+C = """\
+#include
+#include
+
+#define MAX_USERS 10
+
+typedef enum {
+ STATUS_ACTIVE,
+ STATUS_INACTIVE
+} Status;
+
+typedef struct {
+ const char *name;
+ int age;
+ Status status;
+} User;
+
+void greet(const User *user);
+
+int main(void) {
+ User user = {
+ .name = "Alice",
+ .age = 30,
+ .status = STATUS_ACTIVE
+ };
+
+ greet(&user);
+
+ for (int i = 0; i < 3; i++) {
+ printf("Counter: %d\n", i);
+ }
+
+ switch (user.status) {
+ case STATUS_ACTIVE:
+ puts("Active");
+ break;
+ case STATUS_INACTIVE:
+ puts("Inactive");
+ break;
+ }
+
+ return EXIT_SUCCESS;
+}
+
+void greet(const User *user) {
+ printf("Hello %s (%d)\n", user->name, user->age);
+}
+"""
+
+INI = """\
+; Application configuration
+
+[app]
+name = "samplefile-api"
+version = 1.2.3
+debug = false
+
+[server]
+host = localhost
+port = 8080
+base_url = https://api.example.com
+
+[database]
+driver = postgres
+host = db.internal
+port = 5432
+username = admin
+password = secret
+
+[logging]
+level = info
+file = /var/log/samplefile.log
+
+[features]
+cache = true
+metrics = true
+experimental = false
+"""
+
SNIPPETS = {
"python": PYTHON,
"markdown": MARKDOWN,
@@ -954,4 +1071,7 @@ class Rectangle implements Shape {
"rust": RUST,
"java": JAVA,
"xml": XML,
+ "kotlin": KOTLIN,
+ "c": C,
+ "ini": INI,
}
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 1632da45ed..efea4716a1 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -1305,6 +1305,24 @@ def setup_language(pilot) -> None:
terminal_size=(80, snippet.count("\n") + 4),
)
+@pytest.mark.syntax
+@pytest.mark.parametrize("language", ["kotlin", "c", "ini"])
+def test_text_area_non_builtin_language_rendering(language, snap_compare):
+ # This test will fail if we're missing a snapshot test for a tested language
+ # We should have a snapshot test for each language we test.
+
+ snippet = SNIPPETS.get(language)
+
+ def setup_language(pilot) -> None:
+ text_area = pilot.app.query_one(TextArea)
+ text_area.load_text(snippet)
+ text_area.language = language
+
+ assert snap_compare(
+ SNAPSHOT_APPS_DIR / "text_area.py",
+ run_before=setup_language,
+ terminal_size=(80, snippet.count("\n") + 4),
+ )
@pytest.mark.parametrize(
"selection",
diff --git a/tests/text_area/test_languages.py b/tests/text_area/test_languages.py
index f5f381354f..0f181fc6be 100644
--- a/tests/text_area/test_languages.py
+++ b/tests/text_area/test_languages.py
@@ -2,7 +2,7 @@
from textual.app import App, ComposeResult
from textual.widgets import TextArea
-from textual.widgets.text_area import LanguageDoesNotExist
+from textual.widgets.text_area import BUILTIN_LANGUAGES, LanguageDoesNotExist
class TextAreaApp(App):
@@ -60,6 +60,19 @@ async def test_setting_unknown_language():
text_area.language = "this-language-doesnt-exist"
+async def test_setting_language_to_non_builtin_supported_by_tree_sitter_language_pack():
+ class MyTextAreaApp(App):
+ def compose(self) -> ComposeResult:
+ text_area = TextArea("println('hello')")
+ text_area.language = "kotlin"
+ yield text_area
+
+ app = MyTextAreaApp()
+ async with app.run_test():
+ text_area = app.query_one(TextArea)
+ assert text_area.language == "kotlin"
+
+
@pytest.mark.syntax
async def test_register_language():
app = TextAreaApp()
@@ -93,3 +106,20 @@ async def test_update_highlight_query():
# We've overridden the highlight query with a blank one, so there are no highlights.
assert text_area._highlights == {}
+
+
+@pytest.mark.syntax
+async def test_getting_highlight_query():
+ app = TextAreaApp()
+ async with app.run_test():
+ text_area = app.query_one(TextArea)
+ # Test builtin languages
+ for language in BUILTIN_LANGUAGES:
+ assert text_area._get_highlight_query(language) != ""
+
+ # Test non-builtin supported by tree-sitter-language-pack
+ # Note: not all of them have highlight query
+ assert text_area._get_highlight_query("kotlin") != ""
+
+ # Test unknown language
+ assert text_area._get_highlight_query("this-language-doesnt-exist") == ""