mirror of
https://github.com/ralsina/tartrazine.git
synced 2025-08-02 13:59:51 +00:00
Compare commits
24 Commits
c95658320c
...
v0.11.1
Author | SHA1 | Date | |
---|---|---|---|
fff6cad5ac | |||
44e6af8546 | |||
9e2585a875 | |||
c16b139fa3 | |||
e11775040c | |||
30bc8cccba | |||
1638c253cb | |||
c374f52aee | |||
96fd9bdfe9 | |||
0423811c5d | |||
3d9d3ab5cf | |||
92a97490f1 | |||
22decedf3a | |||
8b34a1659d | |||
3bf8172b89 | |||
4432da2893 | |||
6a6827f26a | |||
766f9b4708 | |||
9d49ff78d6 | |||
fb924543a0 | |||
09d4b7b02e | |||
08e81683ca | |||
9c70fbf389 | |||
d26393d8c9 |
20
.ameba.yml
20
.ameba.yml
@@ -1,9 +1,9 @@
|
|||||||
# This configuration file was generated by `ameba --gen-config`
|
# This configuration file was generated by `ameba --gen-config`
|
||||||
# on 2024-09-11 00:56:14 UTC using Ameba version 1.6.1.
|
# on 2024-09-21 14:59:30 UTC using Ameba version 1.6.1.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the reported problems are removed from the code base.
|
# one by one as the reported problems are removed from the code base.
|
||||||
|
|
||||||
# Problems found: 4
|
# Problems found: 3
|
||||||
# Run `ameba --only Documentation/DocumentationAdmonition` for details
|
# Run `ameba --only Documentation/DocumentationAdmonition` for details
|
||||||
Documentation/DocumentationAdmonition:
|
Documentation/DocumentationAdmonition:
|
||||||
Description: Reports documentation admonitions
|
Description: Reports documentation admonitions
|
||||||
@@ -11,10 +11,24 @@ Documentation/DocumentationAdmonition:
|
|||||||
Excluded:
|
Excluded:
|
||||||
- src/lexer.cr
|
- src/lexer.cr
|
||||||
- src/actions.cr
|
- src/actions.cr
|
||||||
- spec/examples/crystal/lexer_spec.cr
|
|
||||||
Admonitions:
|
Admonitions:
|
||||||
- TODO
|
- TODO
|
||||||
- FIXME
|
- FIXME
|
||||||
- BUG
|
- BUG
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Severity: Warning
|
Severity: Warning
|
||||||
|
|
||||||
|
# Problems found: 1
|
||||||
|
# Run `ameba --only Lint/SpecFilename` for details
|
||||||
|
Lint/SpecFilename:
|
||||||
|
Description: Enforces spec filenames to have `_spec` suffix
|
||||||
|
Excluded:
|
||||||
|
- spec/examples/crystal/hello.cr
|
||||||
|
IgnoredDirs:
|
||||||
|
- spec/support
|
||||||
|
- spec/fixtures
|
||||||
|
- spec/data
|
||||||
|
IgnoredFilenames:
|
||||||
|
- spec_helper
|
||||||
|
Enabled: true
|
||||||
|
Severity: Warning
|
||||||
|
69
CHANGELOG.md
69
CHANGELOG.md
@@ -2,6 +2,75 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.11.1] - 2024-10-14
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Support choosing lexers when used as a library
|
||||||
|
|
||||||
|
## [0.11.0] - 2024-10-14
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Support selecting only some themes
|
||||||
|
|
||||||
|
## [0.10.0] - 2024-09-26
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- Optional conditional baking of lexers
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Strip binaries for release artifacts
|
||||||
|
- Fix metadata to show crystal
|
||||||
|
|
||||||
|
## [0.9.1] - 2024-09-22
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- Terminal formatter was skipping things that it could highlight
|
||||||
|
- Bug in high-level API for png formatter
|
||||||
|
|
||||||
|
### 🧪 Testing
|
||||||
|
|
||||||
|
- Added minimal tests for svg and png formatters
|
||||||
|
|
||||||
|
## [0.9.0] - 2024-09-21
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- PNG writer based on Stumpy libs
|
||||||
|
|
||||||
|
### ⚙️ Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Clean
|
||||||
|
- Detect version bump in release script
|
||||||
|
- Improve changelog handling
|
||||||
|
|
||||||
|
## [0.8.0] - 2024-09-21
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- SVG formatter
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- HTML formatter was setting bold wrong
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Added instructions to add as a dependency
|
||||||
|
|
||||||
|
### 🧪 Testing
|
||||||
|
|
||||||
|
- Add basic tests for crystal and delegating lexers
|
||||||
|
- Added tests for CSS generation
|
||||||
|
|
||||||
|
### ⚙ Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Fix example code in README
|
||||||
|
|
||||||
## [0.7.0] - 2024-09-10
|
## [0.7.0] - 2024-09-10
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
@@ -113,3 +113,11 @@ tasks:
|
|||||||
kcov --clean --include-path=./src ${PWD}/coverage ./bin/run_tests
|
kcov --clean --include-path=./src ${PWD}/coverage ./bin/run_tests
|
||||||
outputs:
|
outputs:
|
||||||
- coverage/index.html
|
- coverage/index.html
|
||||||
|
|
||||||
|
loc:
|
||||||
|
phony: true
|
||||||
|
always_run: true
|
||||||
|
dependencies:
|
||||||
|
- src
|
||||||
|
commands: |
|
||||||
|
tokei src -e src/constants/
|
||||||
|
47
README.md
47
README.md
@@ -71,7 +71,7 @@ This does more or less the same thing, but more manually:
|
|||||||
|
|
||||||
```crystal
|
```crystal
|
||||||
lexer = Tartrazine.lexer("crystal")
|
lexer = Tartrazine.lexer("crystal")
|
||||||
formatter = Tartrazine::Html.new (
|
formatter = Tartrazine::Html.new(
|
||||||
theme: Tartrazine.theme("catppuccin-macchiato"),
|
theme: Tartrazine.theme("catppuccin-macchiato"),
|
||||||
line_numbers: true,
|
line_numbers: true,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -82,6 +82,51 @@ puts formatter.format("puts \"Hello, world!\"", lexer)
|
|||||||
The reason you may want to use the manual version is to reuse
|
The reason you may want to use the manual version is to reuse
|
||||||
the lexer and formatter objects for performance reasons.
|
the lexer and formatter objects for performance reasons.
|
||||||
|
|
||||||
|
## Choosing what Lexers you want
|
||||||
|
|
||||||
|
By default Tartrazine will support all its lexers by embedding
|
||||||
|
them in the binary. This makes the binary large. If you are
|
||||||
|
using it as a library, you may want to just include a selection of lexers. To do that:
|
||||||
|
|
||||||
|
* Pass the `-Dnolexers` flag to the compiler
|
||||||
|
* Set the `TT_LEXERS` environment variable to a
|
||||||
|
comma-separated list of lexers you want to include.
|
||||||
|
|
||||||
|
|
||||||
|
This builds a binary with only the python, markdown, bash and yaml lexers (enough to highlight this `README.md`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> TT_LEXERS=python,markdown,bash,yaml shards build -Dnolexers -d --error-trace
|
||||||
|
Dependencies are satisfied
|
||||||
|
Building: tartrazine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Choosing what themes you want
|
||||||
|
|
||||||
|
Themes come from two places, tartrazine itself and [Sixteen](https://github.com/ralsina/sixteen).
|
||||||
|
|
||||||
|
To only embed selected themes, build your project with the `-Dnothemes` option, and
|
||||||
|
you can set two environment variables to control which themes are included:
|
||||||
|
|
||||||
|
* `TT_THEMES` is a comma-separated list of themes to include from tartrazine (see the styles directory in the source)
|
||||||
|
* `SIXTEEN_THEMES` is a comma-separated list of themes to include from Sixteen (see the base16 directory in the sixteen source)
|
||||||
|
|
||||||
|
For example (using the tartrazine CLI as the project):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ TT_THEMES=colorful,autumn SIXTEEN_THEMES=pasque,pico shards build -Dnothemes
|
||||||
|
Dependencies are satisfied
|
||||||
|
Building: tartrazine
|
||||||
|
|
||||||
|
$ ./bin/tartrazine --list-themes
|
||||||
|
autumn
|
||||||
|
colorful
|
||||||
|
pasque
|
||||||
|
pico
|
||||||
|
```
|
||||||
|
|
||||||
|
Be careful not to build without any themes at all, nothing will work.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork it (<https://github.com/ralsina/tartrazine/fork>)
|
1. Fork it (<https://github.com/ralsina/tartrazine/fork>)
|
||||||
|
@@ -7,10 +7,10 @@ docker run --rm --privileged \
|
|||||||
|
|
||||||
# Build for AMD64
|
# Build for AMD64
|
||||||
docker build . -f Dockerfile.static -t tartrazine-builder
|
docker build . -f Dockerfile.static -t tartrazine-builder
|
||||||
docker run -ti --rm -v "$PWD":/app --user="$UID" tartrazine-builder /bin/sh -c "cd /app && rm -rf lib shard.lock && shards build --static --release"
|
docker run -ti --rm -v "$PWD":/app --user="$UID" tartrazine-builder /bin/sh -c "cd /app && rm -rf lib shard.lock && shards build --static --release && strip bin/tartrazine"
|
||||||
mv bin/tartrazine bin/tartrazine-static-linux-amd64
|
mv bin/tartrazine bin/tartrazine-static-linux-amd64
|
||||||
|
|
||||||
# Build for ARM64
|
# Build for ARM64
|
||||||
docker build . -f Dockerfile.static --platform linux/arm64 -t tartrazine-builder
|
docker build . -f Dockerfile.static --platform linux/arm64 -t tartrazine-builder
|
||||||
docker run -ti --rm -v "$PWD":/app --platform linux/arm64 --user="$UID" tartrazine-builder /bin/sh -c "cd /app && rm -rf lib shard.lock && shards build --static --release"
|
docker run -ti --rm -v "$PWD":/app --platform linux/arm64 --user="$UID" tartrazine-builder /bin/sh -c "cd /app && rm -rf lib shard.lock && shards build --static --release && strip bin/tartrazine"
|
||||||
mv bin/tartrazine bin/tartrazine-static-linux-arm64
|
mv bin/tartrazine bin/tartrazine-static-linux-arm64
|
||||||
|
@@ -67,7 +67,6 @@ commit_parsers = [
|
|||||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||||
{ message = "^chore\\(pr\\)", skip = true },
|
{ message = "^chore\\(pr\\)", skip = true },
|
||||||
{ message = "^chore\\(pull\\)", skip = true },
|
{ message = "^chore\\(pull\\)", skip = true },
|
||||||
{ message = "^chore\\(ignore\\)", skip = true },
|
|
||||||
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||||
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||||
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||||
|
@@ -2,14 +2,14 @@
|
|||||||
set e
|
set e
|
||||||
|
|
||||||
PKGNAME=$(basename "$PWD")
|
PKGNAME=$(basename "$PWD")
|
||||||
VERSION=$(git cliff --bumped-version |cut -dv -f2)
|
VERSION=$(git cliff --bumped-version --unreleased |cut -dv -f2)
|
||||||
|
|
||||||
sed "s/^version:.*$/version: $VERSION/g" -i shard.yml
|
sed "s/^version:.*$/version: $VERSION/g" -i shard.yml
|
||||||
git add shard.yml
|
git add shard.yml
|
||||||
hace lint test
|
hace lint test
|
||||||
git cliff --bump -o
|
git cliff --bump -u -p CHANGELOG.md
|
||||||
git commit -a -m "bump: Release v$VERSION"
|
git commit -a -m "bump: Release v$VERSION"
|
||||||
|
hace static
|
||||||
git tag "v$VERSION"
|
git tag "v$VERSION"
|
||||||
git push --tags
|
git push --tags
|
||||||
hace static
|
|
||||||
gh release create "v$VERSION" "bin/$PKGNAME-static-linux-amd64" "bin/$PKGNAME-static-linux-arm64" --title "Release v$VERSION" --notes "$(git cliff -l -s all)"
|
gh release create "v$VERSION" "bin/$PKGNAME-static-linux-amd64" "bin/$PKGNAME-static-linux-arm64" --title "Release v$VERSION" --notes "$(git cliff -l -s all)"
|
||||||
|
BIN
fonts/courier-bold-oblique.pcf.gz
Normal file
BIN
fonts/courier-bold-oblique.pcf.gz
Normal file
Binary file not shown.
BIN
fonts/courier-bold.pcf.gz
Normal file
BIN
fonts/courier-bold.pcf.gz
Normal file
Binary file not shown.
BIN
fonts/courier-oblique.pcf.gz
Normal file
BIN
fonts/courier-oblique.pcf.gz
Normal file
Binary file not shown.
BIN
fonts/courier-regular.pcf.gz
Normal file
BIN
fonts/courier-regular.pcf.gz
Normal file
Binary file not shown.
@@ -38,6 +38,12 @@ for fname in glob.glob("lexers/*.xml"):
|
|||||||
lexer_by_filename[filename].add(lexer_name)
|
lexer_by_filename[filename].add(lexer_name)
|
||||||
|
|
||||||
with open("src/constants/lexers.cr", "w") as f:
|
with open("src/constants/lexers.cr", "w") as f:
|
||||||
|
# Crystal doesn't come from a xml file
|
||||||
|
lexer_by_name["crystal"] = "crystal"
|
||||||
|
lexer_by_name["cr"] = "crystal"
|
||||||
|
lexer_by_filename["*.cr"] = ["crystal"]
|
||||||
|
lexer_by_mimetype["text/x-crystal"] = "crystal"
|
||||||
|
|
||||||
f.write("module Tartrazine\n")
|
f.write("module Tartrazine\n")
|
||||||
f.write(" LEXERS_BY_NAME = {\n")
|
f.write(" LEXERS_BY_NAME = {\n")
|
||||||
for k in sorted(lexer_by_name.keys()):
|
for k in sorted(lexer_by_name.keys()):
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
name: tartrazine
|
name: tartrazine
|
||||||
version: 0.7.0
|
version: 0.11.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Roberto Alsina <roberto.alsina@gmail.com>
|
- Roberto Alsina <roberto.alsina@gmail.com>
|
||||||
@@ -18,6 +18,10 @@ dependencies:
|
|||||||
github: ralsina/sixteen
|
github: ralsina/sixteen
|
||||||
docopt:
|
docopt:
|
||||||
github: chenkovsky/docopt.cr
|
github: chenkovsky/docopt.cr
|
||||||
|
stumpy_utils:
|
||||||
|
github: stumpycr/stumpy_utils
|
||||||
|
stumpy_png:
|
||||||
|
github: stumpycr/stumpy_png
|
||||||
|
|
||||||
crystal: ">= 1.13.0"
|
crystal: ">= 1.13.0"
|
||||||
|
|
||||||
|
@@ -1 +1 @@
|
|||||||
.e {color: #aa0000;background-color: #ffaaaa;}.b {background-color: #f0f3f3;tab-size: 8;}.k {color: #006699;font-weight: bold;}.kp {font-weight: 600;}.kt {color: #007788;}.na {color: #330099;}.nb {color: #336666;}.nc {color: #00aa88;font-weight: bold;}.nc {color: #336600;}.nd {color: #9999ff;}.ne {color: #999999;font-weight: bold;}.ne {color: #cc0000;font-weight: bold;}.nf {color: #cc00ff;}.nl {color: #9999ff;}.nn {color: #00ccff;font-weight: bold;}.nt {color: #330099;font-weight: bold;}.nv {color: #003333;}.ls {color: #cc3300;}.lsd {font-style: italic;}.lse {color: #cc3300;font-weight: bold;}.lsi {color: #aa0000;}.lso {color: #cc3300;}.lsr {color: #33aaaa;}.lss {color: #ffcc33;}.ln {color: #ff6600;}.o {color: #555555;}.ow {color: #000000;font-weight: bold;}.c {color: #0099ff;font-style: italic;}.cs {font-weight: bold;}.cp {color: #009999;font-style: normal;}.gd {background-color: #ffcccc;border: 1px solid #cc0000;}.ge {font-style: italic;}.ge {color: #ff0000;}.gh {color: #003300;font-weight: bold;}.gi {background-color: #ccffcc;border: 1px solid #00cc00;}.go {color: #aaaaaa;}.gp {color: #000099;font-weight: bold;}.gs {font-weight: bold;}.gs {color: #003300;font-weight: bold;}.gt {color: #99cc66;}.gu {text-decoration: underline;}.tw {color: #bbbbbb;}.lh {}
|
.e {color: #aa0000;background-color: #ffaaaa;}.b {background-color: #f0f3f3;tab-size: 8;}.k {color: #006699;font-weight: 600;}.kp {}.kt {color: #007788;}.na {color: #330099;}.nb {color: #336666;}.nc {color: #00aa88;font-weight: 600;}.nc {color: #336600;}.nd {color: #9999ff;}.ne {color: #999999;font-weight: 600;}.ne {color: #cc0000;font-weight: 600;}.nf {color: #cc00ff;}.nl {color: #9999ff;}.nn {color: #00ccff;font-weight: 600;}.nt {color: #330099;font-weight: 600;}.nv {color: #003333;}.ls {color: #cc3300;}.lsd {font-style: italic;}.lse {color: #cc3300;font-weight: 600;}.lsi {color: #aa0000;}.lso {color: #cc3300;}.lsr {color: #33aaaa;}.lss {color: #ffcc33;}.ln {color: #ff6600;}.o {color: #555555;}.ow {color: #000000;font-weight: 600;}.c {color: #0099ff;font-style: italic;}.cs {font-weight: 600;}.cp {color: #009999;font-style: normal;}.gd {background-color: #ffcccc;border: 1px solid #cc0000;}.ge {font-style: italic;}.ge {color: #ff0000;}.gh {color: #003300;font-weight: 600;}.gi {background-color: #ccffcc;border: 1px solid #00cc00;}.go {color: #aaaaaa;}.gp {color: #000099;font-weight: 600;}.gs {font-weight: 600;}.gs {color: #003300;font-weight: 600;}.gt {color: #99cc66;}.gu {text-decoration: underline;}.tw {color: #bbbbbb;}.lh {}
|
||||||
|
@@ -1 +1 @@
|
|||||||
.b {color: #b7b7b7;background-color: #101010;font-weight: bold;tab-size: 8;}.lh {color: #8eaaaa;background-color: #232323;}.t {color: #b7b7b7;}.e {color: #de6e6e;}.c {color: #333333;}.cp {color: #876c4f;}.cpf {color: #5f8787;}.k {color: #d69094;}.kt {color: #de6e6e;}.na {color: #8eaaaa;}.nb {color: #de6e6e;}.nbp {color: #de6e6e;}.nc {color: #8eaaaa;}.nc {color: #dab083;}.nd {color: #dab083;}.nf {color: #8eaaaa;}.nn {color: #8eaaaa;}.nt {color: #d69094;}.nv {color: #8eaaaa;}.nvi {color: #de6e6e;}.ln {color: #dab083;}.o {color: #60a592;}.ow {color: #d69094;}.l {color: #5f8787;}.ls {color: #5f8787;}.lsi {color: #876c4f;}.lsr {color: #60a592;}.lss {color: #dab083;}
|
.b {color: #b7b7b7;background-color: #101010;font-weight: 600;tab-size: 8;}.lh {color: #8eaaaa;background-color: #232323;}.t {color: #b7b7b7;}.e {color: #de6e6e;}.c {color: #333333;}.cp {color: #876c4f;}.cpf {color: #5f8787;}.k {color: #d69094;}.kt {color: #de6e6e;}.na {color: #8eaaaa;}.nb {color: #de6e6e;}.nbp {color: #de6e6e;}.nc {color: #8eaaaa;}.nc {color: #dab083;}.nd {color: #dab083;}.nf {color: #8eaaaa;}.nn {color: #8eaaaa;}.nt {color: #d69094;}.nv {color: #8eaaaa;}.nvi {color: #de6e6e;}.ln {color: #dab083;}.o {color: #60a592;}.ow {color: #d69094;}.l {color: #5f8787;}.ls {color: #5f8787;}.lsi {color: #876c4f;}.lsr {color: #60a592;}.lss {color: #dab083;}
|
||||||
|
1
spec/examples/crystal/hello.cr
Normal file
1
spec/examples/crystal/hello.cr
Normal file
@@ -0,0 +1 @@
|
|||||||
|
puts "Hello Crystal!"
|
1
spec/examples/crystal/hello.cr.json
Normal file
1
spec/examples/crystal/hello.cr.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"type":"Text","value":"puts "},{"type":"LiteralString","value":"\"Hello Crystal!\""},{"type":"Text","value":"\n"}]
|
@@ -1,413 +0,0 @@
|
|||||||
require "./constants/lexers"
|
|
||||||
require "./heuristics"
|
|
||||||
require "baked_file_system"
|
|
||||||
require "crystal/syntax_highlighter"
|
|
||||||
|
|
||||||
module Tartrazine
|
|
||||||
class LexerFiles
|
|
||||||
extend BakedFileSystem
|
|
||||||
bake_folder "../lexers", __DIR__
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get the lexer object for a language name
|
|
||||||
# FIXME: support mimetypes
|
|
||||||
def self.lexer(name : String? = nil, filename : String? = nil, mimetype : String? = nil) : BaseLexer
|
|
||||||
return lexer_by_name(name) if name && name != "autodetect"
|
|
||||||
return lexer_by_filename(filename) if filename
|
|
||||||
return lexer_by_mimetype(mimetype) if mimetype
|
|
||||||
|
|
||||||
RegexLexer.from_xml(LexerFiles.get("/#{LEXERS_BY_NAME["plaintext"]}.xml").gets_to_end)
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.lexer_by_mimetype(mimetype : String) : BaseLexer
|
|
||||||
lexer_file_name = LEXERS_BY_MIMETYPE.fetch(mimetype, nil)
|
|
||||||
raise Exception.new("Unknown mimetype: #{mimetype}") if lexer_file_name.nil?
|
|
||||||
|
|
||||||
RegexLexer.from_xml(LexerFiles.get("/#{lexer_file_name}.xml").gets_to_end)
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.lexer_by_name(name : String) : BaseLexer
|
|
||||||
return CrystalLexer.new if name == "crystal"
|
|
||||||
lexer_file_name = LEXERS_BY_NAME.fetch(name.downcase, nil)
|
|
||||||
return create_delegating_lexer(name) if lexer_file_name.nil? && name.includes? "+"
|
|
||||||
raise Exception.new("Unknown lexer: #{name}") if lexer_file_name.nil?
|
|
||||||
|
|
||||||
RegexLexer.from_xml(LexerFiles.get("/#{lexer_file_name}.xml").gets_to_end)
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.lexer_by_filename(filename : String) : BaseLexer
|
|
||||||
if filename.ends_with?(".cr")
|
|
||||||
return CrystalLexer.new
|
|
||||||
end
|
|
||||||
|
|
||||||
candidates = Set(String).new
|
|
||||||
LEXERS_BY_FILENAME.each do |k, v|
|
|
||||||
candidates += v.to_set if File.match?(k, File.basename(filename))
|
|
||||||
end
|
|
||||||
|
|
||||||
case candidates.size
|
|
||||||
when 0
|
|
||||||
lexer_file_name = LEXERS_BY_NAME["plaintext"]
|
|
||||||
when 1
|
|
||||||
lexer_file_name = candidates.first
|
|
||||||
else
|
|
||||||
lexer_file_name = self.lexer_by_content(filename)
|
|
||||||
begin
|
|
||||||
return self.lexer(lexer_file_name)
|
|
||||||
rescue ex : Exception
|
|
||||||
raise Exception.new("Multiple lexers match the filename: #{candidates.to_a.join(", ")}, heuristics suggest #{lexer_file_name} but there is no matching lexer.")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
RegexLexer.from_xml(LexerFiles.get("/#{lexer_file_name}.xml").gets_to_end)
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.lexer_by_content(fname : String) : String?
|
|
||||||
h = Linguist::Heuristic.from_yaml(LexerFiles.get("/heuristics.yml").gets_to_end)
|
|
||||||
result = h.run(fname, File.read(fname))
|
|
||||||
case result
|
|
||||||
when Nil
|
|
||||||
raise Exception.new "No lexer found for #{fname}"
|
|
||||||
when String
|
|
||||||
result.as(String)
|
|
||||||
when Array(String)
|
|
||||||
result.first
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private def self.create_delegating_lexer(name : String) : BaseLexer
|
|
||||||
language, root = name.split("+", 2)
|
|
||||||
language_lexer = lexer(language)
|
|
||||||
root_lexer = lexer(root)
|
|
||||||
DelegatingLexer.new(language_lexer, root_lexer)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Return a list of all lexers
|
|
||||||
def self.lexers : Array(String)
|
|
||||||
LEXERS_BY_NAME.keys.sort!
|
|
||||||
end
|
|
||||||
|
|
||||||
# A token, the output of the tokenizer
|
|
||||||
alias Token = NamedTuple(type: String, value: String)
|
|
||||||
|
|
||||||
abstract class BaseTokenizer
|
|
||||||
end
|
|
||||||
|
|
||||||
class Tokenizer < BaseTokenizer
|
|
||||||
include Iterator(Token)
|
|
||||||
property lexer : BaseLexer
|
|
||||||
property text : Bytes
|
|
||||||
property pos : Int32 = 0
|
|
||||||
@dq = Deque(Token).new
|
|
||||||
property state_stack = ["root"]
|
|
||||||
|
|
||||||
def initialize(@lexer : BaseLexer, text : String, secondary = false)
|
|
||||||
# Respect the `ensure_nl` config option
|
|
||||||
if text.size > 0 && text[-1] != '\n' && @lexer.config[:ensure_nl] && !secondary
|
|
||||||
text += "\n"
|
|
||||||
end
|
|
||||||
@text = text.to_slice
|
|
||||||
end
|
|
||||||
|
|
||||||
def next : Iterator::Stop | Token
|
|
||||||
if @dq.size > 0
|
|
||||||
return @dq.shift
|
|
||||||
end
|
|
||||||
if pos == @text.size
|
|
||||||
return stop
|
|
||||||
end
|
|
||||||
|
|
||||||
matched = false
|
|
||||||
while @pos < @text.size
|
|
||||||
@lexer.states[@state_stack.last].rules.each do |rule|
|
|
||||||
matched, new_pos, new_tokens = rule.match(@text, @pos, self)
|
|
||||||
if matched
|
|
||||||
@pos = new_pos
|
|
||||||
split_tokens(new_tokens).each { |token| @dq << token }
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if !matched
|
|
||||||
if @text[@pos] == 10u8
|
|
||||||
@dq << {type: "Text", value: "\n"}
|
|
||||||
@state_stack = ["root"]
|
|
||||||
else
|
|
||||||
@dq << {type: "Error", value: String.new(@text[@pos..@pos])}
|
|
||||||
end
|
|
||||||
@pos += 1
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
self.next
|
|
||||||
end
|
|
||||||
|
|
||||||
# If a token contains a newline, split it into two tokens
|
|
||||||
def split_tokens(tokens : Array(Token)) : Array(Token)
|
|
||||||
split_tokens = [] of Token
|
|
||||||
tokens.each do |token|
|
|
||||||
if token[:value].includes?("\n")
|
|
||||||
values = token[:value].split("\n")
|
|
||||||
values.each_with_index do |value, index|
|
|
||||||
value += "\n" if index < values.size - 1
|
|
||||||
split_tokens << {type: token[:type], value: value}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
split_tokens << token
|
|
||||||
end
|
|
||||||
end
|
|
||||||
split_tokens
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
alias BaseLexer = Lexer
|
|
||||||
|
|
||||||
abstract class Lexer
|
|
||||||
property config = {
|
|
||||||
name: "",
|
|
||||||
priority: 0.0,
|
|
||||||
case_insensitive: false,
|
|
||||||
dot_all: false,
|
|
||||||
not_multiline: false,
|
|
||||||
ensure_nl: false,
|
|
||||||
}
|
|
||||||
property states = {} of String => State
|
|
||||||
|
|
||||||
def tokenizer(text : String, secondary = false) : BaseTokenizer
|
|
||||||
Tokenizer.new(self, text, secondary)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# This implements a lexer for Pygments RegexLexers as expressed
|
|
||||||
# in Chroma's XML serialization.
|
|
||||||
#
|
|
||||||
# For explanations on what actions and states do
|
|
||||||
# the Pygments documentation is a good place to start.
|
|
||||||
# https://pygments.org/docs/lexerdevelopment/
|
|
||||||
class RegexLexer < BaseLexer
|
|
||||||
# Collapse consecutive tokens of the same type for easier comparison
|
|
||||||
# and smaller output
|
|
||||||
def self.collapse_tokens(tokens : Array(Tartrazine::Token)) : Array(Tartrazine::Token)
|
|
||||||
result = [] of Tartrazine::Token
|
|
||||||
tokens = tokens.reject { |token| token[:value] == "" }
|
|
||||||
tokens.each do |token|
|
|
||||||
if result.empty?
|
|
||||||
result << token
|
|
||||||
next
|
|
||||||
end
|
|
||||||
last = result.last
|
|
||||||
if last[:type] == token[:type]
|
|
||||||
new_token = {type: last[:type], value: last[:value] + token[:value]}
|
|
||||||
result.pop
|
|
||||||
result << new_token
|
|
||||||
else
|
|
||||||
result << token
|
|
||||||
end
|
|
||||||
end
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.from_xml(xml : String) : Lexer
|
|
||||||
l = RegexLexer.new
|
|
||||||
lexer = XML.parse(xml).first_element_child
|
|
||||||
if lexer
|
|
||||||
config = lexer.children.find { |node|
|
|
||||||
node.name == "config"
|
|
||||||
}
|
|
||||||
if config
|
|
||||||
l.config = {
|
|
||||||
name: xml_to_s(config, name) || "",
|
|
||||||
priority: xml_to_f(config, priority) || 0.0,
|
|
||||||
not_multiline: xml_to_s(config, not_multiline) == "true",
|
|
||||||
dot_all: xml_to_s(config, dot_all) == "true",
|
|
||||||
case_insensitive: xml_to_s(config, case_insensitive) == "true",
|
|
||||||
ensure_nl: xml_to_s(config, ensure_nl) == "true",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
rules = lexer.children.find { |node|
|
|
||||||
node.name == "rules"
|
|
||||||
}
|
|
||||||
if rules
|
|
||||||
# Rules contains states 🤷
|
|
||||||
rules.children.select { |node|
|
|
||||||
node.name == "state"
|
|
||||||
}.each do |state_node|
|
|
||||||
state = State.new
|
|
||||||
state.name = state_node["name"]
|
|
||||||
if l.states.has_key?(state.name)
|
|
||||||
raise Exception.new("Duplicate state: #{state.name}")
|
|
||||||
else
|
|
||||||
l.states[state.name] = state
|
|
||||||
end
|
|
||||||
# And states contain rules 🤷
|
|
||||||
state_node.children.select { |node|
|
|
||||||
node.name == "rule"
|
|
||||||
}.each do |rule_node|
|
|
||||||
case rule_node["pattern"]?
|
|
||||||
when nil
|
|
||||||
if rule_node.first_element_child.try &.name == "include"
|
|
||||||
rule = IncludeStateRule.new(rule_node)
|
|
||||||
else
|
|
||||||
rule = UnconditionalRule.new(rule_node)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
rule = Rule.new(rule_node,
|
|
||||||
multiline: !l.config[:not_multiline],
|
|
||||||
dotall: l.config[:dot_all],
|
|
||||||
ignorecase: l.config[:case_insensitive])
|
|
||||||
end
|
|
||||||
state.rules << rule
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
l
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# A lexer that takes two lexers as arguments. A root lexer
|
|
||||||
# and a language lexer. Everything is scalled using the
|
|
||||||
# language lexer, afterwards all `Other` tokens are lexed
|
|
||||||
# using the root lexer.
|
|
||||||
#
|
|
||||||
# This is useful for things like template languages, where
|
|
||||||
# you have Jinja + HTML or Jinja + CSS and so on.
|
|
||||||
class DelegatingLexer < Lexer
|
|
||||||
property language_lexer : BaseLexer
|
|
||||||
property root_lexer : BaseLexer
|
|
||||||
|
|
||||||
def initialize(@language_lexer : BaseLexer, @root_lexer : BaseLexer)
|
|
||||||
end
|
|
||||||
|
|
||||||
def tokenizer(text : String, secondary = false) : DelegatingTokenizer
|
|
||||||
DelegatingTokenizer.new(self, text, secondary)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# This Tokenizer works with a DelegatingLexer. It first tokenizes
|
|
||||||
# using the language lexer, and "Other" tokens are tokenized using
|
|
||||||
# the root lexer.
|
|
||||||
class DelegatingTokenizer < BaseTokenizer
|
|
||||||
include Iterator(Token)
|
|
||||||
@dq = Deque(Token).new
|
|
||||||
@language_tokenizer : BaseTokenizer
|
|
||||||
|
|
||||||
def initialize(@lexer : DelegatingLexer, text : String, secondary = false)
|
|
||||||
# Respect the `ensure_nl` config option
|
|
||||||
if text.size > 0 && text[-1] != '\n' && @lexer.config[:ensure_nl] && !secondary
|
|
||||||
text += "\n"
|
|
||||||
end
|
|
||||||
@language_tokenizer = @lexer.language_lexer.tokenizer(text, true)
|
|
||||||
end
|
|
||||||
|
|
||||||
def next : Iterator::Stop | Token
|
|
||||||
if @dq.size > 0
|
|
||||||
return @dq.shift
|
|
||||||
end
|
|
||||||
token = @language_tokenizer.next
|
|
||||||
if token.is_a? Iterator::Stop
|
|
||||||
return stop
|
|
||||||
elsif token.as(Token).[:type] == "Other"
|
|
||||||
root_tokenizer = @lexer.root_lexer.tokenizer(token.as(Token).[:value], true)
|
|
||||||
root_tokenizer.each do |root_token|
|
|
||||||
@dq << root_token
|
|
||||||
end
|
|
||||||
else
|
|
||||||
@dq << token.as(Token)
|
|
||||||
end
|
|
||||||
self.next
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# A Lexer state. A state has a name and a list of rules.
|
|
||||||
# The state machine has a state stack containing references
|
|
||||||
# to states to decide which rules to apply.
|
|
||||||
struct State
|
|
||||||
property name : String = ""
|
|
||||||
property rules = [] of BaseRule
|
|
||||||
|
|
||||||
def +(other : State)
|
|
||||||
new_state = State.new
|
|
||||||
new_state.name = Random.base58(8)
|
|
||||||
new_state.rules = rules + other.rules
|
|
||||||
new_state
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class CustomCrystalHighlighter < Crystal::SyntaxHighlighter
|
|
||||||
@tokens = [] of Token
|
|
||||||
|
|
||||||
def render_delimiter(&block)
|
|
||||||
@tokens << {type: "LiteralString", value: block.call.to_s}
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_interpolation(&block)
|
|
||||||
@tokens << {type: "LiteralStringInterpol", value: "\#{"}
|
|
||||||
@tokens << {type: "Text", value: block.call.to_s}
|
|
||||||
@tokens << {type: "LiteralStringInterpol", value: "}"}
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_string_array(&block)
|
|
||||||
@tokens << {type: "LiteralString", value: block.call.to_s}
|
|
||||||
end
|
|
||||||
|
|
||||||
# ameba:disable Metrics/CyclomaticComplexity
|
|
||||||
def render(type : TokenType, value : String)
|
|
||||||
case type
|
|
||||||
when .comment?
|
|
||||||
@tokens << {type: "Comment", value: value}
|
|
||||||
when .number?
|
|
||||||
@tokens << {type: "LiteralNumber", value: value}
|
|
||||||
when .char?
|
|
||||||
@tokens << {type: "LiteralStringChar", value: value}
|
|
||||||
when .symbol?
|
|
||||||
@tokens << {type: "LiteralStringSymbol", value: value}
|
|
||||||
when .const?
|
|
||||||
@tokens << {type: "NameConstant", value: value}
|
|
||||||
when .string?
|
|
||||||
@tokens << {type: "LiteralString", value: value}
|
|
||||||
when .ident?
|
|
||||||
@tokens << {type: "NameVariable", value: value}
|
|
||||||
when .keyword?, .self?
|
|
||||||
@tokens << {type: "NameKeyword", value: value}
|
|
||||||
when .primitive_literal?
|
|
||||||
@tokens << {type: "Literal", value: value}
|
|
||||||
when .operator?
|
|
||||||
@tokens << {type: "Operator", value: value}
|
|
||||||
when Crystal::SyntaxHighlighter::TokenType::DELIMITED_TOKEN, Crystal::SyntaxHighlighter::TokenType::DELIMITER_START, Crystal::SyntaxHighlighter::TokenType::DELIMITER_END
|
|
||||||
@tokens << {type: "LiteralString", value: value}
|
|
||||||
else
|
|
||||||
@tokens << {type: "Text", value: value}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class CrystalTokenizer < Tartrazine::BaseTokenizer
|
|
||||||
include Iterator(Token)
|
|
||||||
@hl = CustomCrystalHighlighter.new
|
|
||||||
@lexer : BaseLexer
|
|
||||||
@iter : Iterator(Token)
|
|
||||||
|
|
||||||
# delegate next, to: @iter
|
|
||||||
|
|
||||||
def initialize(@lexer : BaseLexer, text : String, secondary = false)
|
|
||||||
# Respect the `ensure_nl` config option
|
|
||||||
if text.size > 0 && text[-1] != '\n' && @lexer.config[:ensure_nl] && !secondary
|
|
||||||
text += "\n"
|
|
||||||
end
|
|
||||||
# Just do the tokenizing
|
|
||||||
@hl.highlight(text)
|
|
||||||
@iter = @hl.@tokens.each
|
|
||||||
end
|
|
||||||
|
|
||||||
def next : Iterator::Stop | Token
|
|
||||||
@iter.next
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class CrystalLexer < BaseLexer
|
|
||||||
def tokenizer(text : String, secondary = false) : BaseTokenizer
|
|
||||||
CrystalTokenizer.new(self, text, secondary)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
require "digest/sha1"
|
||||||
|
|
||||||
# These are the testcases from Pygments
|
# These are the testcases from Pygments
|
||||||
testcases = Dir.glob("#{__DIR__}/tests/**/*txt").sort
|
testcases = Dir.glob("#{__DIR__}/tests/**/*txt").sort
|
||||||
@@ -103,6 +104,7 @@ describe Tartrazine do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "to_ansi" do
|
describe "to_ansi" do
|
||||||
it "should do basic highlighting" do
|
it "should do basic highlighting" do
|
||||||
ansi = Tartrazine.to_ansi("puts 'Hello, World!'", "ruby")
|
ansi = Tartrazine.to_ansi("puts 'Hello, World!'", "ruby")
|
||||||
@@ -114,11 +116,29 @@ describe Tartrazine do
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
ansi.should eq(
|
ansi.should eq(
|
||||||
"\e[38;2;171;70;66mputs\e[0m\e[38;2;216;216;216m \e[0m'Hello, World!'"
|
"\e[38;2;171;70;66mputs\e[0m\e[38;2;216;216;216m \e[0m\e[38;2;161;181;108m'Hello, World!'\e[0m"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "to_svg" do
|
||||||
|
it "should do basic highlighting" do
|
||||||
|
svg = Tartrazine.to_svg("puts 'Hello, World!'", "ruby", standalone: false)
|
||||||
|
svg.should eq(
|
||||||
|
"<text x=\"0\" y=\"19\" xml:space=\"preserve\"><tspan fill=\"#ab4642\">puts</tspan><tspan fill=\"#d8d8d8\"> </tspan><tspan fill=\"#a1b56c\">'Hello, World!'</tspan></text>"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "to_png" do
|
||||||
|
it "should do basic highlighting" do
|
||||||
|
png = Digest::SHA1.hexdigest(Tartrazine.to_png("puts 'Hello, World!'", "ruby"))
|
||||||
|
png.should eq(
|
||||||
|
"62d419dcd263fffffc265a0f04c156dc2530c362"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper that creates lexer and tokenizes
|
# Helper that creates lexer and tokenizes
|
||||||
|
@@ -471,7 +471,7 @@ module Tartrazine
|
|||||||
"application/x-fennel" => "fennel",
|
"application/x-fennel" => "fennel",
|
||||||
"application/x-fish" => "fish",
|
"application/x-fish" => "fish",
|
||||||
"application/x-forth" => "forth",
|
"application/x-forth" => "forth",
|
||||||
"application/x-gdscript" => "gdscript",
|
"application/x-gdscript" => "gdscript3",
|
||||||
"application/x-hcl" => "hcl",
|
"application/x-hcl" => "hcl",
|
||||||
"application/x-hy" => "hy",
|
"application/x-hy" => "hy",
|
||||||
"application/x-javascript" => "javascript",
|
"application/x-javascript" => "javascript",
|
||||||
@@ -594,7 +594,7 @@ module Tartrazine
|
|||||||
"text/x-fortran" => "fortran",
|
"text/x-fortran" => "fortran",
|
||||||
"text/x-fsharp" => "fsharp",
|
"text/x-fsharp" => "fsharp",
|
||||||
"text/x-gas" => "gas",
|
"text/x-gas" => "gas",
|
||||||
"text/x-gdscript" => "gdscript",
|
"text/x-gdscript" => "gdscript3",
|
||||||
"text/x-gherkin" => "gherkin",
|
"text/x-gherkin" => "gherkin",
|
||||||
"text/x-gleam" => "gleam",
|
"text/x-gleam" => "gleam",
|
||||||
"text/x-glslsrc" => "glsl",
|
"text/x-glslsrc" => "glsl",
|
||||||
|
@@ -17,12 +17,19 @@ module Tartrazine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format(text : String, lexer : Lexer) : String
|
def format(text : String, lexer : Lexer) : String
|
||||||
raise Exception.new("Not implemented")
|
outp = String::Builder.new("")
|
||||||
|
format(text, lexer, outp)
|
||||||
|
outp.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
# Return the styles, if the formatter supports it.
|
# Return the styles, if the formatter supports it.
|
||||||
def style_defs : String
|
def style_defs : String
|
||||||
raise Exception.new("Not implemented")
|
raise Exception.new("Not implemented")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Is this line in the highlighted ranges?
|
||||||
|
def highlighted?(line : Int) : Bool
|
||||||
|
highlight_lines.any?(&.includes?(line))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@@ -20,12 +20,6 @@ module Tartrazine
|
|||||||
"#{i + 1}".rjust(4).ljust(5)
|
"#{i + 1}".rjust(4).ljust(5)
|
||||||
end
|
end
|
||||||
|
|
||||||
def format(text : String, lexer : BaseLexer) : String
|
|
||||||
outp = String::Builder.new("")
|
|
||||||
format(text, lexer, outp)
|
|
||||||
outp.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def format(text : String, lexer : BaseLexer, outp : IO) : Nil
|
def format(text : String, lexer : BaseLexer, outp : IO) : Nil
|
||||||
tokenizer = lexer.tokenizer(text)
|
tokenizer = lexer.tokenizer(text)
|
||||||
i = 0
|
i = 0
|
||||||
@@ -40,8 +34,6 @@ module Tartrazine
|
|||||||
end
|
end
|
||||||
|
|
||||||
def colorize(text : String, token : String) : String
|
def colorize(text : String, token : String) : String
|
||||||
style = theme.styles.fetch(token, nil)
|
|
||||||
return text if style.nil?
|
|
||||||
if theme.styles.has_key?(token)
|
if theme.styles.has_key?(token)
|
||||||
s = theme.styles[token]
|
s = theme.styles[token]
|
||||||
else
|
else
|
||||||
|
@@ -106,8 +106,7 @@ module Tartrazine
|
|||||||
|
|
||||||
# These are true/false/nil
|
# These are true/false/nil
|
||||||
outp << "border: none;" if style.border == false
|
outp << "border: none;" if style.border == false
|
||||||
outp << "font-weight: bold;" if style.bold
|
outp << "font-weight: #{@weight_of_bold};" if style.bold
|
||||||
outp << "font-weight: #{@weight_of_bold};" if style.bold == false
|
|
||||||
outp << "font-style: italic;" if style.italic
|
outp << "font-style: italic;" if style.italic
|
||||||
outp << "font-style: normal;" if style.italic == false
|
outp << "font-style: normal;" if style.italic == false
|
||||||
outp << "text-decoration: underline;" if style.underline
|
outp << "text-decoration: underline;" if style.underline
|
||||||
@@ -134,10 +133,5 @@ module Tartrazine
|
|||||||
end
|
end
|
||||||
class_prefix + Abbreviations[token]
|
class_prefix + Abbreviations[token]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Is this line in the highlighted ranges?
|
|
||||||
def highlighted?(line : Int) : Bool
|
|
||||||
highlight_lines.any?(&.includes?(line))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
117
src/formatters/png.cr
Normal file
117
src/formatters/png.cr
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
require "../formatter"
|
||||||
|
require "compress/gzip"
|
||||||
|
require "digest/sha1"
|
||||||
|
require "stumpy_png"
|
||||||
|
require "stumpy_utils"
|
||||||
|
|
||||||
|
module Tartrazine
|
||||||
|
def self.to_png(text : String, language : String,
|
||||||
|
theme : String = "default-dark",
|
||||||
|
line_numbers : Bool = false) : String
|
||||||
|
buf = IO::Memory.new
|
||||||
|
|
||||||
|
Tartrazine::Png.new(
|
||||||
|
theme: Tartrazine.theme(theme),
|
||||||
|
line_numbers: line_numbers
|
||||||
|
).format(text, Tartrazine.lexer(name: language), buf)
|
||||||
|
buf.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
class FontFiles
|
||||||
|
extend BakedFileSystem
|
||||||
|
bake_folder "../../fonts", __DIR__
|
||||||
|
end
|
||||||
|
|
||||||
|
class Png < Formatter
|
||||||
|
include StumpyPNG
|
||||||
|
property? line_numbers : Bool = false
|
||||||
|
@font_regular : PCFParser::Font
|
||||||
|
@font_bold : PCFParser::Font
|
||||||
|
@font_oblique : PCFParser::Font
|
||||||
|
@font_bold_oblique : PCFParser::Font
|
||||||
|
@font_width = 15
|
||||||
|
@font_height = 24
|
||||||
|
|
||||||
|
def initialize(@theme : Theme = Tartrazine.theme("default-dark"), @line_numbers : Bool = false)
|
||||||
|
@font_regular = load_font("/courier-regular.pcf.gz")
|
||||||
|
@font_bold = load_font("/courier-bold.pcf.gz")
|
||||||
|
@font_oblique = load_font("/courier-oblique.pcf.gz")
|
||||||
|
@font_bold_oblique = load_font("/courier-bold-oblique.pcf.gz")
|
||||||
|
end
|
||||||
|
|
||||||
|
private def load_font(name : String) : PCFParser::Font
|
||||||
|
compressed = FontFiles.get(name)
|
||||||
|
uncompressed = Compress::Gzip::Reader.open(compressed) do |gzip|
|
||||||
|
gzip.gets_to_end
|
||||||
|
end
|
||||||
|
PCFParser::Font.new(IO::Memory.new uncompressed)
|
||||||
|
end
|
||||||
|
|
||||||
|
private def line_label(i : Int32) : String
|
||||||
|
"#{i + 1}".rjust(4).ljust(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format(text : String, lexer : BaseLexer, outp : IO) : Nil
|
||||||
|
# Create canvas of correct size
|
||||||
|
lines = text.split("\n")
|
||||||
|
canvas_height = lines.size * @font_height
|
||||||
|
canvas_width = lines.max_of(&.size)
|
||||||
|
canvas_width += 5 if line_numbers?
|
||||||
|
canvas_width *= @font_width
|
||||||
|
|
||||||
|
bg_color = RGBA.from_hex("##{theme.styles["Background"].background.try &.hex}")
|
||||||
|
canvas = Canvas.new(canvas_width, canvas_height, bg_color)
|
||||||
|
|
||||||
|
tokenizer = lexer.tokenizer(text)
|
||||||
|
x = 0
|
||||||
|
y = @font_height
|
||||||
|
i = 0
|
||||||
|
if line_numbers?
|
||||||
|
canvas.text(x, y, line_label(i), @font_regular, RGBA.from_hex("##{theme.styles["Background"].color.try &.hex}"))
|
||||||
|
x += 5 * @font_width
|
||||||
|
end
|
||||||
|
|
||||||
|
tokenizer.each do |token|
|
||||||
|
font, color = token_style(token[:type])
|
||||||
|
# These fonts are very limited
|
||||||
|
t = token[:value].gsub(/[^[:ascii:]]/, "?")
|
||||||
|
canvas.text(x, y, t.rstrip("\n"), font, color)
|
||||||
|
if token[:value].includes?("\n")
|
||||||
|
x = 0
|
||||||
|
y += @font_height
|
||||||
|
i += 1
|
||||||
|
if line_numbers?
|
||||||
|
canvas.text(x, y, line_label(i), @font_regular, RGBA.from_hex("##{theme.styles["Background"].color.try &.hex}"))
|
||||||
|
x += 4 * @font_width
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
x += token[:value].size * @font_width
|
||||||
|
end
|
||||||
|
|
||||||
|
StumpyPNG.write(canvas, outp)
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_style(token : String) : {PCFParser::Font, RGBA}
|
||||||
|
if theme.styles.has_key?(token)
|
||||||
|
s = theme.styles[token]
|
||||||
|
else
|
||||||
|
# Themes don't contain information for each specific
|
||||||
|
# token type. However, they may contain information
|
||||||
|
# for a parent style. Worst case, we go to the root
|
||||||
|
# (Background) style.
|
||||||
|
s = theme.styles[theme.style_parents(token).reverse.find { |parent|
|
||||||
|
theme.styles.has_key?(parent)
|
||||||
|
}]
|
||||||
|
end
|
||||||
|
|
||||||
|
color = RGBA.from_hex("##{theme.styles["Background"].color.try &.hex}")
|
||||||
|
color = RGBA.from_hex("##{s.color.try &.hex}") if s.color
|
||||||
|
|
||||||
|
return {@font_bold_oblique, color} if s.bold && s.italic
|
||||||
|
return {@font_bold, color} if s.bold
|
||||||
|
return {@font_oblique, color} if s.italic
|
||||||
|
return {@font_regular, color}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
129
src/formatters/svg.cr
Normal file
129
src/formatters/svg.cr
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
require "../constants/token_abbrevs.cr"
|
||||||
|
require "../formatter"
|
||||||
|
require "html"
|
||||||
|
|
||||||
|
module Tartrazine
|
||||||
|
def self.to_svg(text : String, language : String,
|
||||||
|
theme : String = "default-dark",
|
||||||
|
standalone : Bool = true,
|
||||||
|
line_numbers : Bool = false) : String
|
||||||
|
Tartrazine::Svg.new(
|
||||||
|
theme: Tartrazine.theme(theme),
|
||||||
|
standalone: standalone,
|
||||||
|
line_numbers: line_numbers
|
||||||
|
).format(text, Tartrazine.lexer(name: language))
|
||||||
|
end
|
||||||
|
|
||||||
|
class Svg < Formatter
|
||||||
|
property highlight_lines : Array(Range(Int32, Int32)) = [] of Range(Int32, Int32)
|
||||||
|
property line_number_id_prefix : String = "line-"
|
||||||
|
property line_number_start : Int32 = 1
|
||||||
|
property tab_width = 8
|
||||||
|
property? line_numbers : Bool = false
|
||||||
|
property? linkable_line_numbers : Bool = true
|
||||||
|
property? standalone : Bool = false
|
||||||
|
property weight_of_bold : Int32 = 600
|
||||||
|
property fs : Int32
|
||||||
|
property ystep : Int32
|
||||||
|
|
||||||
|
property theme : Theme
|
||||||
|
|
||||||
|
def initialize(@theme : Theme = Tartrazine.theme("default-dark"), *,
|
||||||
|
@highlight_lines = [] of Range(Int32, Int32),
|
||||||
|
@class_prefix : String = "",
|
||||||
|
@line_number_id_prefix = "line-",
|
||||||
|
@line_number_start = 1,
|
||||||
|
@tab_width = 8,
|
||||||
|
@line_numbers : Bool = false,
|
||||||
|
@linkable_line_numbers : Bool = true,
|
||||||
|
@standalone : Bool = false,
|
||||||
|
@weight_of_bold : Int32 = 600,
|
||||||
|
@font_family : String = "monospace",
|
||||||
|
@font_size : String = "14px")
|
||||||
|
if font_size.ends_with? "px"
|
||||||
|
@fs = font_size[0...-2].to_i
|
||||||
|
else
|
||||||
|
@fs = font_size.to_i
|
||||||
|
end
|
||||||
|
@ystep = @fs + 5
|
||||||
|
end
|
||||||
|
|
||||||
|
def format(text : String, lexer : BaseLexer, io : IO) : Nil
|
||||||
|
pre, post = wrap_standalone
|
||||||
|
io << pre if standalone?
|
||||||
|
format_text(text, lexer, io)
|
||||||
|
io << post if standalone?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wrap text into a full HTML document, including the CSS for the theme
|
||||||
|
def wrap_standalone
|
||||||
|
output = String.build do |outp|
|
||||||
|
outp << %(<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g font-family="#{self.@font_family}" font-size="#{self.@font_size}">)
|
||||||
|
end
|
||||||
|
{output.to_s, "</g></svg>"}
|
||||||
|
end
|
||||||
|
|
||||||
|
private def line_label(i : Int32, x : Int32, y : Int32) : String
|
||||||
|
line_label = "#{i + 1}".rjust(4).ljust(5)
|
||||||
|
line_style = highlighted?(i + 1) ? "font-weight=\"#{@weight_of_bold}\"" : ""
|
||||||
|
line_id = linkable_line_numbers? ? "id=\"#{line_number_id_prefix}#{i + 1}\"" : ""
|
||||||
|
%(<text #{line_style} #{line_id} x="#{4*ystep}" y="#{y}" text-anchor="end">#{line_label}</text>)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_text(text : String, lexer : BaseLexer, outp : IO)
|
||||||
|
x = 0
|
||||||
|
y = ystep
|
||||||
|
i = 0
|
||||||
|
line_x = x
|
||||||
|
line_x += 5 * ystep if line_numbers?
|
||||||
|
tokenizer = lexer.tokenizer(text)
|
||||||
|
outp << line_label(i, x, y) if line_numbers?
|
||||||
|
outp << %(<text x="#{line_x}" y="#{y}" xml:space="preserve">)
|
||||||
|
tokenizer.each do |token|
|
||||||
|
if token[:value].ends_with? "\n"
|
||||||
|
outp << "<tspan #{get_style(token[:type])}>#{HTML.escape(token[:value][0...-1])}</tspan>"
|
||||||
|
outp << "</text>"
|
||||||
|
x = 0
|
||||||
|
y += ystep
|
||||||
|
i += 1
|
||||||
|
outp << line_label(i, x, y) if line_numbers?
|
||||||
|
outp << %(<text x="#{line_x}" y="#{y}" xml:space="preserve">)
|
||||||
|
else
|
||||||
|
outp << "<tspan#{get_style(token[:type])}>#{HTML.escape(token[:value])}</tspan>"
|
||||||
|
x += token[:value].size * ystep
|
||||||
|
end
|
||||||
|
end
|
||||||
|
outp << "</text>"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Given a token type, return the style.
|
||||||
|
def get_style(token : String) : String
|
||||||
|
if !theme.styles.has_key? token
|
||||||
|
# Themes don't contain information for each specific
|
||||||
|
# token type. However, they may contain information
|
||||||
|
# for a parent style. Worst case, we go to the root
|
||||||
|
# (Background) style.
|
||||||
|
parent = theme.style_parents(token).reverse.find { |dad|
|
||||||
|
theme.styles.has_key?(dad)
|
||||||
|
}
|
||||||
|
theme.styles[token] = theme.styles[parent]
|
||||||
|
end
|
||||||
|
output = String.build do |outp|
|
||||||
|
style = theme.styles[token]
|
||||||
|
outp << " fill=\"##{style.color.try &.hex}\"" if style.color
|
||||||
|
# No support for background color or border in SVG
|
||||||
|
|
||||||
|
outp << " font-weight=\"#{@weight_of_bold}\"" if style.bold
|
||||||
|
outp << " font-weight=\"normal\"" if style.bold == false
|
||||||
|
outp << " font-style=\"italic\"" if style.italic
|
||||||
|
outp << " font-style=\"normal\"" if style.italic == false
|
||||||
|
outp << " text-decoration=\"underline\"" if style.underline
|
||||||
|
outp << " text-decoration=\"none" if style.underline == false
|
||||||
|
end
|
||||||
|
output
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
19
src/lexer.cr
19
src/lexer.cr
@@ -6,11 +6,21 @@ require "crystal/syntax_highlighter"
|
|||||||
module Tartrazine
|
module Tartrazine
|
||||||
class LexerFiles
|
class LexerFiles
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
bake_folder "../lexers", __DIR__
|
|
||||||
|
macro bake_selected_lexers
|
||||||
|
{% for lexer in env("TT_LEXERS").split "," %}
|
||||||
|
bake_file {{ lexer }}+".xml", {{ read_file "#{__DIR__}/../lexers/" + lexer + ".xml" }}
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
{% if flag?(:nolexers) %}
|
||||||
|
bake_selected_lexers
|
||||||
|
{% else %}
|
||||||
|
bake_folder "../lexers", __DIR__
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the lexer object for a language name
|
# Get the lexer object for a language name
|
||||||
# FIXME: support mimetypes
|
|
||||||
def self.lexer(name : String? = nil, filename : String? = nil, mimetype : String? = nil) : BaseLexer
|
def self.lexer(name : String? = nil, filename : String? = nil, mimetype : String? = nil) : BaseLexer
|
||||||
return lexer_by_name(name) if name && name != "autodetect"
|
return lexer_by_name(name) if name && name != "autodetect"
|
||||||
return lexer_by_filename(filename) if filename
|
return lexer_by_filename(filename) if filename
|
||||||
@@ -33,6 +43,8 @@ module Tartrazine
|
|||||||
raise Exception.new("Unknown lexer: #{name}") if lexer_file_name.nil?
|
raise Exception.new("Unknown lexer: #{name}") if lexer_file_name.nil?
|
||||||
|
|
||||||
RegexLexer.from_xml(LexerFiles.get("/#{lexer_file_name}.xml").gets_to_end)
|
RegexLexer.from_xml(LexerFiles.get("/#{lexer_file_name}.xml").gets_to_end)
|
||||||
|
rescue ex : BakedFileSystem::NoSuchFileError
|
||||||
|
raise Exception.new("Unknown lexer: #{name}")
|
||||||
end
|
end
|
||||||
|
|
||||||
private def self.lexer_by_filename(filename : String) : BaseLexer
|
private def self.lexer_by_filename(filename : String) : BaseLexer
|
||||||
@@ -84,7 +96,8 @@ module Tartrazine
|
|||||||
|
|
||||||
# Return a list of all lexers
|
# Return a list of all lexers
|
||||||
def self.lexers : Array(String)
|
def self.lexers : Array(String)
|
||||||
LEXERS_BY_NAME.keys.sort!
|
file_map = LexerFiles.files.map(&.path)
|
||||||
|
LEXERS_BY_NAME.keys.select { |k| file_map.includes?("/#{k}.xml") }.sort!
|
||||||
end
|
end
|
||||||
|
|
||||||
# A token, the output of the tokenizer
|
# A token, the output of the tokenizer
|
||||||
|
19
src/main.cr
19
src/main.cr
@@ -4,6 +4,10 @@ require "./tartrazine"
|
|||||||
HELP = <<-HELP
|
HELP = <<-HELP
|
||||||
tartrazine: a syntax highlighting tool
|
tartrazine: a syntax highlighting tool
|
||||||
|
|
||||||
|
You can use the CLI to generate HTML, terminal, JSON or SVG output
|
||||||
|
from a source file using different themes.
|
||||||
|
Keep in mind that not all formatters support all features.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
tartrazine (-h, --help)
|
tartrazine (-h, --help)
|
||||||
tartrazine FILE -f html [-t theme][--standalone][--line-numbers]
|
tartrazine FILE -f html [-t theme][--standalone][--line-numbers]
|
||||||
@@ -11,6 +15,10 @@ Usage:
|
|||||||
tartrazine -f html -t theme --css
|
tartrazine -f html -t theme --css
|
||||||
tartrazine FILE -f terminal [-t theme][-l lexer][--line-numbers]
|
tartrazine FILE -f terminal [-t theme][-l lexer][--line-numbers]
|
||||||
[-o output]
|
[-o output]
|
||||||
|
tartrazine FILE -f svg [-t theme][--standalone][--line-numbers]
|
||||||
|
[-l lexer][-o output]
|
||||||
|
tartrazine FILE -f png [-t theme][--line-numbers]
|
||||||
|
[-l lexer][-o output]
|
||||||
tartrazine FILE -f json [-o output]
|
tartrazine FILE -f json [-o output]
|
||||||
tartrazine --list-themes
|
tartrazine --list-themes
|
||||||
tartrazine --list-lexers
|
tartrazine --list-lexers
|
||||||
@@ -18,7 +26,7 @@ Usage:
|
|||||||
tartrazine --version
|
tartrazine --version
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-f <formatter> Format to use (html, terminal, json)
|
-f <formatter> Format to use (html, terminal, json, svg)
|
||||||
-t <theme> Theme to use, see --list-themes [default: default-dark]
|
-t <theme> Theme to use, see --list-themes [default: default-dark]
|
||||||
-l <lexer> Lexer (language) to use, see --list-lexers. Use more than
|
-l <lexer> Lexer (language) to use, see --list-lexers. Use more than
|
||||||
one lexer with "+" (e.g. jinja+yaml) [default: autodetect]
|
one lexer with "+" (e.g. jinja+yaml) [default: autodetect]
|
||||||
@@ -71,6 +79,15 @@ if options["-f"]
|
|||||||
formatter.theme = theme
|
formatter.theme = theme
|
||||||
when "json"
|
when "json"
|
||||||
formatter = Tartrazine::Json.new
|
formatter = Tartrazine::Json.new
|
||||||
|
when "svg"
|
||||||
|
formatter = Tartrazine::Svg.new
|
||||||
|
formatter.standalone = options["--standalone"] != nil
|
||||||
|
formatter.line_numbers = options["--line-numbers"] != nil
|
||||||
|
formatter.theme = theme
|
||||||
|
when "png"
|
||||||
|
formatter = Tartrazine::Png.new
|
||||||
|
formatter.line_numbers = options["--line-numbers"] != nil
|
||||||
|
formatter.theme = theme
|
||||||
else
|
else
|
||||||
puts "Invalid formatter: #{formatter}"
|
puts "Invalid formatter: #{formatter}"
|
||||||
exit 1
|
exit 1
|
||||||
|
@@ -11,7 +11,20 @@ module Tartrazine
|
|||||||
|
|
||||||
struct ThemeFiles
|
struct ThemeFiles
|
||||||
extend BakedFileSystem
|
extend BakedFileSystem
|
||||||
bake_folder "../styles", __DIR__
|
|
||||||
|
macro bake_selected_themes
|
||||||
|
{% if env("TT_THEMES") %}
|
||||||
|
{% for theme in env("TT_THEMES").split "," %}
|
||||||
|
bake_file {{ theme }}+".xml", {{ read_file "#{__DIR__}/../styles/" + theme + ".xml" }}
|
||||||
|
{% end %}
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|
||||||
|
{% if flag?(:nothemes) %}
|
||||||
|
bake_selected_themes
|
||||||
|
{% else %}
|
||||||
|
bake_folder "../styles", __DIR__
|
||||||
|
{% end %}
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.theme(name : String) : Theme
|
def self.theme(name : String) : Theme
|
||||||
|
Reference in New Issue
Block a user