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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  #include<stdio.h> + 2  #include<stdlib.h> + 3   + 4  #define MAX_USERS 10                                                     + 5   + 6  typedefenum {                                                           + 7      STATUS_ACTIVE,                                                       + 8      STATUS_INACTIVE                                                      + 9  Status;                                                                +10   +11  typedefstruct {                                                         +12  constchar *name;                                                    +13  int age;                                                             +14  Status status;                                                       +15  User;                                                                  +16   +17  voidgreet(constUser *user);                                            +18   +19  intmain(void) {                                                         +20  User user = {                                                        +21          .name = "Alice",                                                 +22          .age = 30,                                                       +23          .status = STATUS_ACTIVE                                          +24      };                                                                   +25   +26  greet(&user);                                                        +27   +28  for (int i = 0; i < 3; i++) {                                        +29  printf("Counter: %d  +30  ", i);                                                                   +31      }                                                                    +32   +33  switch (user.status) {                                               +34  case STATUS_ACTIVE:                                              +35  puts("Active");                                              +36  break;                                                       +37  case STATUS_INACTIVE:                                            +38  puts("Inactive");                                            +39  break;                                                       +40      }                                                                    +41   +42  return EXIT_SUCCESS;                                                 +43  }                                                                        +44   +45  voidgreet(constUser *user) {                                           +46  printf("Hello %s (%d)  +47  ", user->name, user->age);                                               +48  }                                                                        +49   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  ; Application configuration  + 2   + 3  [app]                                                                    + 4  name = "samplefile-api"                                                  + 5  version = 1.2.3                                                          + 6  debug = false                                                            + 7   + 8  [server]                                                                 + 9  host = localhost                                                         +10  port = 8080                                                              +11  base_url = https://api.example.com                                       +12   +13  [database]                                                               +14  driver = postgres                                                        +15  host = db.internal                                                       +16  port = 5432                                                              +17  username = admin                                                         +18  password = secret                                                        +19   +20  [logging]                                                                +21  level = info                                                             +22  file = /var/log/samplefile.log                                           +23   +24  [features]                                                               +25  cache = true                                                             +26  metrics = true                                                           +27  experimental = false                                                     +28   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  package com.example.demo                                                 + 2   + 3  import kotlin.math.PI                                                    + 4   + 5  // Data class + 6  dataclassUser(                                                         + 7  val name: String,                                                    + 8  val age: Int?,                                                       + 9  )                                                                        +10   +11  // Enum +12  enumclassStatus {                                                      +13      ACTIVE,                                                              +14      INACTIVE,                                                            +15  }                                                                        +16   +17  funUser.displayName(): String =                                         +18  "${name.uppercase()} (${age ?: 0})" +19   +20  funmain() {                                                             +21  val user = User("Alice"30)                                         +22   +23  val status = Status.ACTIVE                                           +24   +25  val area = { radius: Double ->                                       +26          PI * radius * radius                                             +27      }                                                                    +28   +29  when (status) {                                                      +30          Status.ACTIVE -> println(user.displayName())                     +31          Status.INACTIVE -> println("Inactive")                           +32      }                                                                    +33   +34  println("Area: ${area(5.0)}")                                        +35  }                                                                        +36   + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + 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") == ""