client-core: port the ANSI parser, syntax highlighter and markdown scanner
Ports app/.../Ansi.kt, Highlighter.kt, Languages.kt and MarkdownSyntax.kt to client-core, module for module, with every HighlighterTest and AnsiTest case ported alongside (49 tests total). ansi.rs replaces Compose's AnnotatedString/SpanStyle with a plain StyledText/Style pair so the crate stays free of any UI framework, per RUST.md. cargo test (49 passed), clippy --all-targets and fmt clean. Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
1 parent
62dd6b7912
commit
762c1290a1
7 files changed
+3495
No files matched your search
Generated
+951
@@ -0,0 +1,951 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"event-model",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cookie_store",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# The app's pure logic, held once instead of twice: the event model (shared
|
||||
# with `server/` via `event-model`), the REST + SSE clients for its HTTP
|
||||
# surface (see `server/src/routes.rs`'s module doc for the table), the
|
||||
# transcript fold and cache, the markdown block model, the syntax
|
||||
# highlighter and the ANSI parser. See CLIENT_CORE.md at the repo root for
|
||||
# what this holds today, what it does not yet, and how it corresponds to
|
||||
# the Kotlin it replaces.
|
||||
#
|
||||
# No UI framework dependency of any kind -- this crate is meant to outlive
|
||||
# whichever one the app ends up drawing with (see RUST.md).
|
||||
|
||||
[dependencies]
|
||||
event-model = { path = "../event-model" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
# The blocking HTTP client for the REST calls and the long-lived SSE GETs.
|
||||
# `server/` already depends on ureq for its own outbound HTTPS (the usage
|
||||
# poll in usage.rs) and it is rustls-backed like the rest of this project's
|
||||
# TLS, so this reuses that choice rather than pulling in reqwest's async
|
||||
# stack -- a client that runs one blocking request at a time, the way
|
||||
# Api.kt's `HttpURLConnection` calls and Sse.kt's blocking read loop do, has
|
||||
# no need of an async runtime, and RUST.md's brief for this port is
|
||||
# "lightweight" throughout.
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
# Verifying the server's pinned self-signed leaf against the CA `wg-app-link`
|
||||
# mints, the same way `ServerConfig.kt`'s `applyPinnedTls` does. rustls
|
||||
# rather than native-tls because ureq is already rustls-backed here and the
|
||||
# rest of this project's TLS goes through rustls too.
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-pemfile = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,534 @@
|
||||
//! What a tool printed, with its terminal styling applied and everything
|
||||
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
|
||||
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
|
||||
//! here, so a [`StyledText`] of plain text plus non-overlapping
|
||||
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
|
||||
//! [`Style`] onto whatever it draws with.
|
||||
//!
|
||||
//! Bash output arrives exactly as the program wrote it, escape sequences
|
||||
//! included, and drawn verbatim those are line noise in the middle of the
|
||||
//! thing being read. Stripping them all would be the other half-answer --
|
||||
//! colour is often the whole of what a diff or a test run is saying.
|
||||
//!
|
||||
//! So the sequences that decide how text *looks* become spans, and every
|
||||
//! other one is dropped rather than shown: the rest move a cursor around a
|
||||
//! grid this is not, and "go to column 40" has no meaning in a scrolling
|
||||
//! document.
|
||||
//!
|
||||
//! A carriage return is honoured the way a terminal honours it: what was
|
||||
//! written since the last line break is thrown away and the line starts
|
||||
//! again. That is what makes a progress bar show its final state rather
|
||||
//! than every state it passed through.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
|
||||
/// because the one place that needs partial transparency (dimming) says so
|
||||
/// with a separate flag rather than baking it into the colour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rgb {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Rgb {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Self { r, g, b }
|
||||
}
|
||||
}
|
||||
|
||||
/// The sixteen colours a terminal program names, and the two it assumes.
|
||||
///
|
||||
/// Its own palette rather than the syntax one: a program that prints in red
|
||||
/// has chosen red, where a highlighter's colours are this app's reading of
|
||||
/// somebody else's code.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnsiPalette {
|
||||
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
|
||||
pub colours: [Rgb; 16],
|
||||
/// What uncoloured text is, needed only where a style has to state a colour.
|
||||
pub foreground: Rgb,
|
||||
/// What the text sits on, needed for reverse video.
|
||||
pub background: Rgb,
|
||||
}
|
||||
|
||||
/// One span's worth of styling. `None` fields mean "unspecified", the same
|
||||
/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct Style {
|
||||
pub color: Option<Rgb>,
|
||||
/// How much of `color`'s alpha survives, 0.0-1.0; `None` is opaque.
|
||||
pub alpha: Option<f32>,
|
||||
pub background: Option<Rgb>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub strikethrough: bool,
|
||||
}
|
||||
|
||||
/// Plain text plus the non-overlapping, ordered spans that style parts of it
|
||||
/// -- this crate's stand-in for Compose's `AnnotatedString`.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct StyledText {
|
||||
pub text: String,
|
||||
pub spans: Vec<(Range<usize>, Style)>,
|
||||
}
|
||||
|
||||
impl StyledText {
|
||||
fn plain(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
spans: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ESC: char = '\u{1B}';
|
||||
const BELL: char = '\u{7}';
|
||||
|
||||
/// [text] with its terminal styling applied and everything else taken out;
|
||||
/// see the module doc.
|
||||
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
|
||||
// The common case by a long way -- nothing to do, and nothing allocated
|
||||
// to find that out.
|
||||
if !text.contains(ESC) && !text.contains('\r') {
|
||||
return StyledText::plain(text.to_string());
|
||||
}
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut runs: Vec<(String, Option<Style>)> = Vec::new();
|
||||
let mut sgr = Sgr::PLAIN;
|
||||
let mut at = 0usize;
|
||||
let mut plain = String::new();
|
||||
|
||||
let flush = |plain: &mut String, sgr: Sgr, runs: &mut Vec<(String, Option<Style>)>| {
|
||||
if !plain.is_empty() {
|
||||
runs.push((std::mem::take(plain), sgr.span(palette)));
|
||||
}
|
||||
};
|
||||
|
||||
while at < chars.len() {
|
||||
let c = chars[at];
|
||||
if c == ESC {
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
at = skip_escape(&chars, at, |params, final_byte| {
|
||||
if final_byte == 'm' {
|
||||
sgr = sgr.apply(params, palette);
|
||||
}
|
||||
});
|
||||
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
|
||||
// A bare carriage return rewrites the line. One before a newline
|
||||
// is the other half of a Windows line ending: it rewrites
|
||||
// nothing, and it is dropped rather than kept, since that pair
|
||||
// is one line break.
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
drop_line(&mut runs);
|
||||
at += 1;
|
||||
} else if c == '\r' {
|
||||
at += 1;
|
||||
} else if c >= ' ' || c == '\n' || c == '\t' {
|
||||
// Everything printable, plus the two control characters that are
|
||||
// layout rather than terminal commands. A stray bell or
|
||||
// backspace goes for the same reason a cursor move does.
|
||||
plain.push(c);
|
||||
at += 1;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
flush(&mut plain, sgr, &mut runs);
|
||||
|
||||
let mut out = String::new();
|
||||
let mut spans = Vec::new();
|
||||
for (run_text, style) in runs {
|
||||
let start = out.len();
|
||||
out.push_str(&run_text);
|
||||
if let Some(style) = style {
|
||||
spans.push((start..out.len(), style));
|
||||
}
|
||||
}
|
||||
StyledText { text: out, spans }
|
||||
}
|
||||
|
||||
/// Throws away everything written since the last line break, as a carriage
|
||||
/// return does.
|
||||
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
|
||||
while let Some((text, style)) = runs.pop() {
|
||||
if let Some(break_at) = text.rfind('\n') {
|
||||
runs.push((text[..=break_at].to_string(), style));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes that end a CSI sequence.
|
||||
fn is_csi_final(c: char) -> bool {
|
||||
('@'..='~').contains(&c)
|
||||
}
|
||||
|
||||
/// Steps over the escape sequence starting at `at`, reporting a CSI's
|
||||
/// parameters and final byte. One reader for every kind, because the point
|
||||
/// is to *leave* them all behind: a sequence this did not recognise would
|
||||
/// otherwise have its body printed as ordinary text. Three shapes -- the CSI
|
||||
/// (`ESC [ ... letter`), the string escapes which run to a terminator, and
|
||||
/// the two-character ones.
|
||||
fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) -> usize {
|
||||
let Some(&next) = chars.get(at + 1) else {
|
||||
return at + 1;
|
||||
};
|
||||
match next {
|
||||
'[' => {
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() && !is_csi_final(chars[end]) {
|
||||
end += 1;
|
||||
}
|
||||
if end >= chars.len() {
|
||||
// Cut off mid-sequence, which is what a stream that has not
|
||||
// finished arriving looks like: drop the fragment rather
|
||||
// than printing it, and the whole sequence arrives with the
|
||||
// next delta.
|
||||
chars.len()
|
||||
} else {
|
||||
let params: String = chars[at + 2..end].iter().collect();
|
||||
on_csi(¶ms, chars[end]);
|
||||
end + 1
|
||||
}
|
||||
}
|
||||
']' | 'P' | 'X' | '^' | '_' => {
|
||||
// Runs to a string terminator: `ESC \`, or the bell that xterm
|
||||
// allows after an OSC.
|
||||
let mut end = at + 2;
|
||||
while end < chars.len() {
|
||||
if chars[end] == BELL {
|
||||
return end + 1;
|
||||
}
|
||||
if chars[end] == ESC && chars.get(end + 1) == Some(&'\\') {
|
||||
return end + 2;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
chars.len()
|
||||
}
|
||||
_ => at + 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything an SGR sequence can turn on, as the terminal tracks it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Sgr {
|
||||
fg: Option<Rgb>,
|
||||
bg: Option<Rgb>,
|
||||
bold: bool,
|
||||
dim: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
reverse: bool,
|
||||
}
|
||||
|
||||
/// How much of its colour dim text keeps: enough to read, little enough to recede.
|
||||
const DIM_ALPHA: f32 = 0.65;
|
||||
|
||||
impl Sgr {
|
||||
const PLAIN: Sgr = Sgr {
|
||||
fg: None,
|
||||
bg: None,
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
/// `None` while nothing is set, so unstyled output costs no spans at all.
|
||||
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
|
||||
if *self == Sgr::PLAIN {
|
||||
return None;
|
||||
}
|
||||
let front = if self.reverse {
|
||||
Some(self.bg.unwrap_or(palette.background))
|
||||
} else {
|
||||
self.fg
|
||||
};
|
||||
let back = if self.reverse {
|
||||
Some(self.fg.unwrap_or(palette.foreground))
|
||||
} else {
|
||||
self.bg
|
||||
};
|
||||
// Dim has to have a colour to dim, so where none was named it dims
|
||||
// the ordinary one.
|
||||
let stated = front.or(if self.dim {
|
||||
Some(palette.foreground)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
Some(Style {
|
||||
color: stated,
|
||||
alpha: if self.dim { Some(DIM_ALPHA) } else { None },
|
||||
background: back,
|
||||
bold: self.bold,
|
||||
italic: self.italic,
|
||||
underline: self.underline,
|
||||
strikethrough: self.strike,
|
||||
})
|
||||
}
|
||||
|
||||
/// This state with `params` applied -- one `ESC[...m`, which carries any
|
||||
/// number of them.
|
||||
///
|
||||
/// A code this does not model is ignored rather than reset from: the
|
||||
/// program meant something by it, and starting again would also drop
|
||||
/// the codes beside it that are understood.
|
||||
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
|
||||
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
|
||||
// zero too.
|
||||
let codes: Vec<i64> = params
|
||||
.split(';')
|
||||
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
|
||||
.collect();
|
||||
let mut state = *self;
|
||||
let mut at = 0usize;
|
||||
while at < codes.len() {
|
||||
let code = codes[at];
|
||||
state = match code {
|
||||
0 => Sgr::PLAIN,
|
||||
1 => Sgr {
|
||||
bold: true,
|
||||
..state
|
||||
},
|
||||
2 => Sgr { dim: true, ..state },
|
||||
3 => Sgr {
|
||||
italic: true,
|
||||
..state
|
||||
},
|
||||
4 => Sgr {
|
||||
underline: true,
|
||||
..state
|
||||
},
|
||||
7 => Sgr {
|
||||
reverse: true,
|
||||
..state
|
||||
},
|
||||
9 => Sgr {
|
||||
strike: true,
|
||||
..state
|
||||
},
|
||||
21 | 22 => Sgr {
|
||||
bold: false,
|
||||
dim: false,
|
||||
..state
|
||||
},
|
||||
23 => Sgr {
|
||||
italic: false,
|
||||
..state
|
||||
},
|
||||
24 => Sgr {
|
||||
underline: false,
|
||||
..state
|
||||
},
|
||||
27 => Sgr {
|
||||
reverse: false,
|
||||
..state
|
||||
},
|
||||
29 => Sgr {
|
||||
strike: false,
|
||||
..state
|
||||
},
|
||||
30..=37 => Sgr {
|
||||
fg: Some(palette.colours[(code - 30) as usize]),
|
||||
..state
|
||||
},
|
||||
90..=97 => Sgr {
|
||||
fg: Some(palette.colours[(code - 90 + 8) as usize]),
|
||||
..state
|
||||
},
|
||||
40..=47 => Sgr {
|
||||
bg: Some(palette.colours[(code - 40) as usize]),
|
||||
..state
|
||||
},
|
||||
100..=107 => Sgr {
|
||||
bg: Some(palette.colours[(code - 100 + 8) as usize]),
|
||||
..state
|
||||
},
|
||||
39 => Sgr { fg: None, ..state },
|
||||
49 => Sgr { bg: None, ..state },
|
||||
38 | 48 => {
|
||||
let (colour, last) = extended_colour(&codes, at, palette);
|
||||
at = last;
|
||||
if code == 38 {
|
||||
Sgr {
|
||||
fg: colour,
|
||||
..state
|
||||
}
|
||||
} else {
|
||||
Sgr {
|
||||
bg: colour,
|
||||
..state
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => state,
|
||||
};
|
||||
at += 1;
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
|
||||
/// last parameter.
|
||||
///
|
||||
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
|
||||
/// one. The first sixteen of that table are the palette's own, so a program
|
||||
/// asking for "colour 1" through either spelling gets the same red.
|
||||
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
|
||||
match codes.get(at + 1) {
|
||||
Some(&5) => match codes.get(at + 2) {
|
||||
None => (None, at + 1),
|
||||
Some(&n) => (Some(indexed_colour(n, palette)), at + 2),
|
||||
},
|
||||
Some(&2) => {
|
||||
let r = codes.get(at + 2);
|
||||
let g = codes.get(at + 3);
|
||||
let b = codes.get(at + 4);
|
||||
match (r, g, b) {
|
||||
(Some(&r), Some(&g), Some(&b)) => (
|
||||
Some(Rgb::new(
|
||||
r.clamp(0, 255) as u8,
|
||||
g.clamp(0, 255) as u8,
|
||||
b.clamp(0, 255) as u8,
|
||||
)),
|
||||
at + 4,
|
||||
),
|
||||
_ => (None, at + 1),
|
||||
}
|
||||
}
|
||||
_ => (None, at + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
|
||||
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
|
||||
/// grey ramp.
|
||||
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
|
||||
if n < 0 {
|
||||
palette.foreground
|
||||
} else if n < 16 {
|
||||
palette.colours[n as usize]
|
||||
} else if n < 232 {
|
||||
let i = (n - 16) as usize;
|
||||
Rgb::new(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
|
||||
} else if n < 256 {
|
||||
let grey = (8 + (n - 232) * 10) as u8;
|
||||
Rgb::new(grey, grey, grey)
|
||||
} else {
|
||||
palette.foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
|
||||
/// white foreground, black background.
|
||||
fn palette() -> AnsiPalette {
|
||||
let mut colours = [Rgb::new(0, 0, 0); 16];
|
||||
for (i, c) in colours.iter_mut().enumerate() {
|
||||
*c = Rgb::new(i as u8, 0, 0);
|
||||
}
|
||||
AnsiPalette {
|
||||
colours,
|
||||
foreground: Rgb::new(255, 255, 255),
|
||||
background: Rgb::new(0, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled(text: &str) -> StyledText {
|
||||
ansi_styled(text, &palette())
|
||||
}
|
||||
|
||||
/// The style covering the first character of `word`, or `None` where
|
||||
/// nothing styles it.
|
||||
fn style_over(text: &str, word: &str) -> Option<Style> {
|
||||
let out = styled(text);
|
||||
let at = out
|
||||
.text
|
||||
.find(word)
|
||||
.unwrap_or_else(|| panic!("no {word:?} in {}", out.text));
|
||||
out.spans
|
||||
.iter()
|
||||
.find(|(range, _)| range.contains(&at))
|
||||
.map(|(_, style)| *style)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colour_becomes_a_span_and_the_sequence_itself_disappears() {
|
||||
let text = format!("plain {ESC}[31mred{ESC}[0m plain");
|
||||
assert_eq!(styled(&text).text, "plain red plain");
|
||||
assert_eq!(
|
||||
style_over(&text, "red").unwrap().color,
|
||||
Some(Rgb::new(1, 0, 0))
|
||||
);
|
||||
assert!(style_over(&text, "plain").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bright_background_and_256_colour_forms_all_reach_the_same_table() {
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[91mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(9, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[44mx"), "x").unwrap().background,
|
||||
Some(Rgb::new(4, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;1mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(1, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;16mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(0, 0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;5;231mx"), "x").unwrap().color,
|
||||
Some(Rgb::new(255, 255, 255))
|
||||
);
|
||||
assert_eq!(
|
||||
style_over(&format!("{ESC}[38;2;10;20;30mx"), "x")
|
||||
.unwrap()
|
||||
.color,
|
||||
Some(Rgb::new(10, 20, 30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
|
||||
// A cursor move, an erase, an OSC window title with its bell, and a
|
||||
// bare two-character escape.
|
||||
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
|
||||
assert_eq!(styled(&text).text, "abcde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carriage_return_rewrites_its_line_as_it_does_on_a_terminal() {
|
||||
assert_eq!(styled("10%\r50%\rdone\n").text, "done\n");
|
||||
assert_eq!(styled("kept\r\nfirst\rlast").text, "kept\nlast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_cut_off_mid_stream_takes_no_text_with_it() {
|
||||
assert_eq!(styled(&format!("text {ESC}[3")).text, "text ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstyled_text_costs_no_spans_at_all() {
|
||||
assert_eq!(styled("nothing to do here").spans.len(), 0);
|
||||
assert_eq!(styled(&format!("a{ESC}[2Jb")).spans.len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
//! A language the highlighter can colour, and the data-driven [`Rules`] each
|
||||
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
|
||||
//! why nearly every language is a row of data read by one shared scanner,
|
||||
//! with Markdown the one exception (`super::markdown`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Language {
|
||||
C,
|
||||
Coffeescript,
|
||||
Cpp,
|
||||
Csharp,
|
||||
Dart,
|
||||
Fish,
|
||||
Go,
|
||||
Java,
|
||||
Javascript,
|
||||
Json,
|
||||
Kotlin,
|
||||
Markdown,
|
||||
Perl,
|
||||
Php,
|
||||
Python,
|
||||
Ron,
|
||||
Ruby,
|
||||
Rust,
|
||||
Shell,
|
||||
Swift,
|
||||
Toml,
|
||||
Typescript,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// Every value, for the same exhaustiveness check the Kotlin test runs
|
||||
/// (`Language.entries`).
|
||||
pub const ALL: [Language; 22] = [
|
||||
Language::C,
|
||||
Language::Coffeescript,
|
||||
Language::Cpp,
|
||||
Language::Csharp,
|
||||
Language::Dart,
|
||||
Language::Fish,
|
||||
Language::Go,
|
||||
Language::Java,
|
||||
Language::Javascript,
|
||||
Language::Json,
|
||||
Language::Kotlin,
|
||||
Language::Markdown,
|
||||
Language::Perl,
|
||||
Language::Php,
|
||||
Language::Python,
|
||||
Language::Ron,
|
||||
Language::Ruby,
|
||||
Language::Rust,
|
||||
Language::Shell,
|
||||
Language::Swift,
|
||||
Language::Toml,
|
||||
Language::Typescript,
|
||||
];
|
||||
}
|
||||
|
||||
/// What [`super::scan`] needs to know about one language -- data, not code,
|
||||
/// so that adding a language is a row here rather than a branch anywhere.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Rules {
|
||||
/// Words drawn as keywords. Only plain words; the scanner cannot reach
|
||||
/// anything else.
|
||||
pub keywords: HashSet<&'static str>,
|
||||
/// Tokens that open a comment running to the end of the line.
|
||||
pub line_comments: Vec<&'static str>,
|
||||
/// Whether `line_comments` count only at the start of a word. The shells
|
||||
/// need it: `$#`, `${#x}` and `a#b` are not comments.
|
||||
pub line_comments_at_word_start: bool,
|
||||
pub block_comment: Option<BlockComment>,
|
||||
/// The string forms. The longest opener that matches wins, so `"""` is
|
||||
/// tried before `"`.
|
||||
pub quotes: Vec<Quote>,
|
||||
pub attributes: Attributes,
|
||||
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
|
||||
pub raw_strings: bool,
|
||||
/// Rust: `'` opens a character literal only when a backslash or one
|
||||
/// character and a `'` follow. Otherwise it is a lifetime or a label.
|
||||
pub lifetimes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BlockComment {
|
||||
pub open: &'static str,
|
||||
pub close: &'static str,
|
||||
pub nests: bool,
|
||||
}
|
||||
|
||||
/// One string form. `escapes` is whether a backslash escapes the closer
|
||||
/// (and itself).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Quote {
|
||||
pub open: &'static str,
|
||||
pub close: &'static str,
|
||||
pub escapes: bool,
|
||||
}
|
||||
|
||||
/// What opens a metadata span, of the shapes that exist across these languages.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum Attributes {
|
||||
#[default]
|
||||
None,
|
||||
/// `@` and a word: Kotlin and Java annotations, Python decorators.
|
||||
AtWord,
|
||||
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
|
||||
HashBracket,
|
||||
/// `#` at the start of a line, to the end of it: the C preprocessor.
|
||||
HashLine,
|
||||
/// `[` at the start of a line through the matching `]`: a TOML table header.
|
||||
LineBracket,
|
||||
}
|
||||
|
||||
const C_STYLE: BlockComment = BlockComment {
|
||||
open: "/*",
|
||||
close: "*/",
|
||||
nests: false,
|
||||
};
|
||||
const NESTING: BlockComment = BlockComment {
|
||||
open: "/*",
|
||||
close: "*/",
|
||||
nests: true,
|
||||
};
|
||||
|
||||
const DOUBLE: Quote = Quote {
|
||||
open: "\"",
|
||||
close: "\"",
|
||||
escapes: true,
|
||||
};
|
||||
const SINGLE: Quote = Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: true,
|
||||
};
|
||||
const TRIPLE_DOUBLE: Quote = Quote {
|
||||
open: "\"\"\"",
|
||||
close: "\"\"\"",
|
||||
escapes: true,
|
||||
};
|
||||
const TRIPLE_SINGLE: Quote = Quote {
|
||||
open: "'''",
|
||||
close: "'''",
|
||||
escapes: true,
|
||||
};
|
||||
|
||||
fn words(list: &'static str) -> HashSet<&'static str> {
|
||||
list.split_whitespace().collect()
|
||||
}
|
||||
|
||||
/// The rules for one language. A `match` rather than a lazily-built map --
|
||||
/// there is no once-per-process cost worth paying for in a language table
|
||||
/// this small, and it sidesteps the Kotlin version's own workaround for
|
||||
/// property initialization order.
|
||||
pub fn rules_for(language: Language) -> Rules {
|
||||
match language {
|
||||
Language::C => Rules {
|
||||
keywords: words(KEYWORDS_C),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashLine,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Cpp => Rules {
|
||||
keywords: words(KEYWORDS_CPP),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashLine,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Csharp => Rules {
|
||||
keywords: words(KEYWORDS_CSHARP),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
// `###` opens and closes a block comment and `#` opens a line one,
|
||||
// which is why the scanner tries the block opener first.
|
||||
Language::Coffeescript => Rules {
|
||||
keywords: words(KEYWORDS_COFFEESCRIPT),
|
||||
line_comments: vec!["#"],
|
||||
block_comment: Some(BlockComment {
|
||||
open: "###",
|
||||
close: "###",
|
||||
nests: false,
|
||||
}),
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Dart => Rules {
|
||||
keywords: words(KEYWORDS_DART),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Fish => Rules {
|
||||
keywords: words(KEYWORDS_FISH),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Go => Rules {
|
||||
keywords: words(KEYWORDS_GO),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Java => Rules {
|
||||
keywords: words(KEYWORDS_JAVA),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Javascript => Rules {
|
||||
keywords: words(KEYWORDS_JAVASCRIPT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: true,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Json => Rules {
|
||||
keywords: words(KEYWORDS_JSON),
|
||||
quotes: vec![DOUBLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Kotlin => Rules {
|
||||
keywords: words(KEYWORDS_KOTLIN),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![
|
||||
Quote {
|
||||
open: "\"\"\"",
|
||||
close: "\"\"\"",
|
||||
escapes: false,
|
||||
},
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Perl => Rules {
|
||||
keywords: words(KEYWORDS_PERL),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Php => Rules {
|
||||
keywords: words(KEYWORDS_PHP),
|
||||
line_comments: vec!["//", "#"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Python => Rules {
|
||||
keywords: words(KEYWORDS_PYTHON),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Ron => Rules {
|
||||
keywords: words(KEYWORDS_RON),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
attributes: Attributes::HashBracket,
|
||||
raw_strings: true,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Ruby => Rules {
|
||||
keywords: words(KEYWORDS_RUBY),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![DOUBLE, SINGLE],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Rust => Rules {
|
||||
keywords: words(KEYWORDS_RUST),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
// No `'` here: `lifetimes` decides when one opens a character literal.
|
||||
quotes: vec![DOUBLE],
|
||||
attributes: Attributes::HashBracket,
|
||||
raw_strings: true,
|
||||
lifetimes: true,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Shell => Rules {
|
||||
keywords: words(KEYWORDS_SHELL),
|
||||
line_comments: vec!["#"],
|
||||
line_comments_at_word_start: true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
Language::Swift => Rules {
|
||||
keywords: words(KEYWORDS_SWIFT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(NESTING),
|
||||
quotes: vec![TRIPLE_DOUBLE, DOUBLE],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Toml => Rules {
|
||||
keywords: words(KEYWORDS_TOML),
|
||||
line_comments: vec!["#"],
|
||||
quotes: vec![
|
||||
TRIPLE_DOUBLE,
|
||||
Quote {
|
||||
open: "'''",
|
||||
close: "'''",
|
||||
escapes: false,
|
||||
},
|
||||
DOUBLE,
|
||||
Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: false,
|
||||
},
|
||||
],
|
||||
attributes: Attributes::LineBracket,
|
||||
..Default::default()
|
||||
},
|
||||
Language::Typescript => Rules {
|
||||
keywords: words(KEYWORDS_TYPESCRIPT),
|
||||
line_comments: vec!["//"],
|
||||
block_comment: Some(C_STYLE),
|
||||
quotes: vec![
|
||||
DOUBLE,
|
||||
SINGLE,
|
||||
Quote {
|
||||
open: "`",
|
||||
close: "`",
|
||||
escapes: true,
|
||||
},
|
||||
],
|
||||
attributes: Attributes::AtWord,
|
||||
..Default::default()
|
||||
},
|
||||
// Markdown has no token rules; see `super::markdown::scan_markdown`.
|
||||
Language::Markdown => Rules::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// The keyword sets. Every list below other than RON, TOML, fish and JSON
|
||||
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
|
||||
// Kotlin scanner replaced, so that no fence which was coloured there turns
|
||||
// plain here either.
|
||||
|
||||
const KEYWORDS_C: &str =
|
||||
"auto break case char const continue default do double else enum extern float for goto if
|
||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||
void volatile while";
|
||||
|
||||
const KEYWORDS_CPP: &str =
|
||||
"asm auto bool break case catch char class const const_cast continue default delete do
|
||||
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
||||
int long mutable namespace new operator private protected public register reinterpret_cast
|
||||
return short signed sizeof static static_cast struct switch template this throw true try
|
||||
typedef typeid typename union unsigned using virtual void volatile wchar_t while";
|
||||
|
||||
const KEYWORDS_CSHARP: &str =
|
||||
"abstract as base bool break byte case catch char checked class const continue decimal
|
||||
default delegate do double else enum event explicit extern false finally fixed float for
|
||||
foreach goto if implicit in int interface internal is lock long namespace new null object
|
||||
operator out override params private protected public readonly ref return sbyte sealed short
|
||||
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
||||
unsafe ushort using virtual void volatile while";
|
||||
|
||||
const KEYWORDS_COFFEESCRIPT: &str =
|
||||
"Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
||||
default do else export extends false finally for function if import in instanceof is isnt
|
||||
let loop new no not null of on or package return super switch this throw true try typeof
|
||||
unless undefined var wait when with yield";
|
||||
|
||||
const KEYWORDS_DART: &str =
|
||||
"abstract as assert async await base break case catch class const continue covariant
|
||||
default deferred do dynamic else enum export extends external factory false final finally
|
||||
for get if implements import in interface is late library mixin new null on operator part
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
when with while yield";
|
||||
|
||||
/// fish is not in the library at all, so its fences are drawn plain today.
|
||||
/// The list is the shell's own words, which is what a fish fence is mostly
|
||||
/// made of.
|
||||
const KEYWORDS_FISH: &str =
|
||||
"and begin break builtin case command continue else end exec for function if in not or
|
||||
return switch while set echo test string math read source";
|
||||
|
||||
const KEYWORDS_GO: &str =
|
||||
"break case chan const continue default defer else fallthrough false for func go goto if
|
||||
import interface map package range return select struct switch true type var";
|
||||
|
||||
const KEYWORDS_JAVA: &str =
|
||||
"abstract assert boolean break byte case catch char class const continue default do double
|
||||
else enum extends final finally float for goto if implements import instanceof int interface
|
||||
long native new null package private protected public return short static strictfp super
|
||||
switch synchronized this throw throws transient try void volatile while";
|
||||
|
||||
const KEYWORDS_JAVASCRIPT: &str =
|
||||
"async await boolean break case catch class const continue debugger default delete do else
|
||||
enum export extends false finally for function if implements import in instanceof interface
|
||||
let new null package private protected public return super switch this throw true try typeof
|
||||
var void while with yield";
|
||||
|
||||
const KEYWORDS_JSON: &str = "true false null";
|
||||
|
||||
const KEYWORDS_KOTLIN: &str =
|
||||
"actual abstract annotation as break by catch class companion const constructor continue
|
||||
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
||||
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
||||
open operator out override package private protected public reified return sealed set super
|
||||
suspend tailrec this throw true try typealias typeof val var vararg when while yield";
|
||||
|
||||
const KEYWORDS_PERL: &str =
|
||||
"__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
||||
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
||||
use while xor";
|
||||
|
||||
const KEYWORDS_PHP: &str =
|
||||
"__halt_compiler abstract and array as break callable case catch class clone const continue
|
||||
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
||||
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
||||
include include_once instanceof insteadof interface isset list match new or print private
|
||||
protected public require require_once return static switch throw trait try unset use var
|
||||
while xor yield";
|
||||
|
||||
const KEYWORDS_PYTHON: &str =
|
||||
"False True and as assert async await break class continue def del elif else except finally
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||
yield";
|
||||
|
||||
/// RON is not in the library either; these are the words a RON file can hold.
|
||||
const KEYWORDS_RON: &str = "true false Some None inf NaN";
|
||||
|
||||
const KEYWORDS_RUBY: &str =
|
||||
"__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
||||
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
||||
super then true undef unless until when while yield";
|
||||
|
||||
const KEYWORDS_RUST: &str =
|
||||
"as async await break const continue crate dyn else enum extern false fn for if impl in
|
||||
let loop match mod move mut pub ref return Self self static struct super trait true type
|
||||
union unsafe use where while abstract become box do final macro override priv try typeof
|
||||
unsized virtual yield";
|
||||
|
||||
const KEYWORDS_SHELL: &str =
|
||||
"alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
||||
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
||||
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
||||
test";
|
||||
|
||||
const KEYWORDS_SWIFT: &str =
|
||||
"_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
||||
let open operator private precedencegroup protocol public rethrows static struct subscript
|
||||
typealias var break case catch continue default defer do else fallthrough for guard if in
|
||||
repeat return throw switch where while Any as await false is nil self Self super throws true
|
||||
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
unowned weak willSet";
|
||||
|
||||
/// TOML is not in the library; `inf` and `nan` are values rather than
|
||||
/// names, like the booleans.
|
||||
const KEYWORDS_TOML: &str = "true false inf nan";
|
||||
|
||||
const KEYWORDS_TYPESCRIPT: &str =
|
||||
"abstract as asserts await break case catch class const constructor continue debugger
|
||||
default delete do else enum export extends false finally for from function get if implements
|
||||
import in infer instanceof interface is keyof let module namespace new null number object
|
||||
package private protected public readonly require global return set static string super
|
||||
switch this throw true try type typeof undefined unique unknown var void while with yield";
|
||||
|
||||
/// The highlighter's language for a fence's info word, or `None` for one it
|
||||
/// has no rules for. Also what `super::file_language` reads for a file's
|
||||
/// extension -- one table, so a language added for fences is a language
|
||||
/// added for files.
|
||||
pub fn fence_language(name: Option<&str>) -> Option<Language> {
|
||||
let name = name?.trim().to_lowercase();
|
||||
FENCE_LANGUAGES
|
||||
.iter()
|
||||
.find(|(alias, _)| *alias == name)
|
||||
.map(|(_, language)| *language)
|
||||
}
|
||||
|
||||
/// The highlighter's language for a *file*, from its name.
|
||||
///
|
||||
/// The extension is the part after the *last* dot, which is what makes
|
||||
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
|
||||
/// extension, it has a name that starts with a dot. A name with no dot at
|
||||
/// all -- `Makefile` -- is likewise `None`.
|
||||
pub fn file_language(name: &str) -> Option<Language> {
|
||||
let dot = name.rfind('.')?;
|
||||
if dot < 1 {
|
||||
return None;
|
||||
}
|
||||
fence_language(Some(&name[dot + 1..]))
|
||||
}
|
||||
|
||||
const FENCE_LANGUAGES: &[(&str, Language)] = &[
|
||||
("kotlin", Language::Kotlin),
|
||||
("kt", Language::Kotlin),
|
||||
("kts", Language::Kotlin),
|
||||
("rust", Language::Rust),
|
||||
("rs", Language::Rust),
|
||||
("sh", Language::Shell),
|
||||
("bash", Language::Shell),
|
||||
("shell", Language::Shell),
|
||||
("zsh", Language::Shell),
|
||||
("console", Language::Shell),
|
||||
("python", Language::Python),
|
||||
("py", Language::Python),
|
||||
("javascript", Language::Javascript),
|
||||
("js", Language::Javascript),
|
||||
("jsx", Language::Javascript),
|
||||
("typescript", Language::Typescript),
|
||||
("ts", Language::Typescript),
|
||||
("tsx", Language::Typescript),
|
||||
("java", Language::Java),
|
||||
("c", Language::C),
|
||||
("h", Language::C),
|
||||
("cpp", Language::Cpp),
|
||||
("c++", Language::Cpp),
|
||||
("cc", Language::Cpp),
|
||||
("hpp", Language::Cpp),
|
||||
("csharp", Language::Csharp),
|
||||
("cs", Language::Csharp),
|
||||
("c#", Language::Csharp),
|
||||
("go", Language::Go),
|
||||
("golang", Language::Go),
|
||||
("swift", Language::Swift),
|
||||
("dart", Language::Dart),
|
||||
("ruby", Language::Ruby),
|
||||
("rb", Language::Ruby),
|
||||
("php", Language::Php),
|
||||
("perl", Language::Perl),
|
||||
("pl", Language::Perl),
|
||||
("coffeescript", Language::Coffeescript),
|
||||
("coffee", Language::Coffeescript),
|
||||
("ron", Language::Ron),
|
||||
("toml", Language::Toml),
|
||||
("fish", Language::Fish),
|
||||
("json", Language::Json),
|
||||
("markdown", Language::Markdown),
|
||||
("md", Language::Markdown),
|
||||
];
|
||||
@@ -0,0 +1,681 @@
|
||||
//! Markdown read into the spans that carry a colour -- a ```markdown fence
|
||||
//! in a reply, and a `.md` file in the viewer. Ported from
|
||||
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
|
||||
//! scanner rather than a row of [`super::Rules`] (what a character means
|
||||
//! depends on where it sits, not on what it is) and why an indented code
|
||||
//! block is deliberately not recognised.
|
||||
//!
|
||||
//! Structure is read a line at a time and each line's prose left to right,
|
||||
//! except the two decisions that are not: a fenced block is state carried
|
||||
//! forward, and a table is found by its delimiter row, which comes after
|
||||
//! the header it belongs to (the one place here that looks ahead).
|
||||
|
||||
use super::{Kind, Span};
|
||||
|
||||
/// The characters an unordered list may be bulleted with.
|
||||
const BULLETS: &str = "-*+";
|
||||
/// The characters a thematic break, or a setext heading's underline, can be
|
||||
/// drawn with.
|
||||
const RULE_MARKERS: &str = "-*_=";
|
||||
/// The characters that can open emphasis, strong emphasis or a strikethrough.
|
||||
const EMPHASIS: &str = "*_~";
|
||||
/// Characters that end a bare URL wherever they appear, and ones only
|
||||
/// trimmed off the end.
|
||||
const URL_STOPS: &str = "<>\"'`|";
|
||||
const URL_TRAILING: &str = ".,:;!?";
|
||||
|
||||
pub fn scan_markdown(code: &str) -> Vec<Span> {
|
||||
MarkdownScanner::new(code).run()
|
||||
}
|
||||
|
||||
struct MarkdownScanner {
|
||||
code: Vec<char>,
|
||||
spans: Vec<Span>,
|
||||
}
|
||||
|
||||
impl MarkdownScanner {
|
||||
fn new(code: &str) -> Self {
|
||||
Self {
|
||||
code: code.chars().collect(),
|
||||
spans: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
let mut at = 0usize;
|
||||
// The delimiter run that opened the fenced block we are inside, or
|
||||
// None between them.
|
||||
let mut fence: Option<Vec<char>> = None;
|
||||
// Whether the row above was part of a table, which is what makes
|
||||
// this one a body row.
|
||||
let mut table = false;
|
||||
loop {
|
||||
let end = self.line_end(at);
|
||||
if let Some(open) = fence.clone() {
|
||||
// The content and the closing line alike: a fence is one
|
||||
// block of code, and its own delimiters belong to it the
|
||||
// way a string's quotes belong to the string.
|
||||
self.emit(at, end, Kind::String);
|
||||
if self.closes_fence(at, end, &open) {
|
||||
fence = None;
|
||||
}
|
||||
} else {
|
||||
let opened = self.opens_fence(at, end);
|
||||
if opened.is_some() {
|
||||
table = false;
|
||||
fence = opened;
|
||||
} else {
|
||||
table = self.row(at, end, table);
|
||||
}
|
||||
}
|
||||
if end == self.code.len() {
|
||||
break;
|
||||
}
|
||||
at = end + 1;
|
||||
}
|
||||
self.spans
|
||||
}
|
||||
|
||||
/// The end of the line beginning at `at`: the newline, or the end of the text.
|
||||
fn line_end(&self, at: usize) -> usize {
|
||||
self.code[at..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map(|p| at + p)
|
||||
.unwrap_or(self.code.len())
|
||||
}
|
||||
|
||||
/// One line that is not inside a fence, and whether the table it may be
|
||||
/// part of is still open.
|
||||
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
|
||||
if self.table_delimiter(start, end) {
|
||||
let indented = self.indented(start, end);
|
||||
self.emit(indented, end, Kind::Mark);
|
||||
return true;
|
||||
}
|
||||
let header = end < self.code.len() && self.table_delimiter(end + 1, self.line_end(end + 1));
|
||||
if (table || header) && self.has_pipe(start, end) {
|
||||
self.table_row(start, end);
|
||||
return true;
|
||||
}
|
||||
self.structure(start, end);
|
||||
false
|
||||
}
|
||||
|
||||
/// A line of nothing but pipes, dashes, alignment colons and space, with
|
||||
/// one of each needed.
|
||||
fn table_delimiter(&self, start: usize, end: usize) -> bool {
|
||||
let mut dashes = false;
|
||||
let mut pipes = false;
|
||||
for at in self.indented(start, end)..end {
|
||||
match self.code[at] {
|
||||
'-' => dashes = true,
|
||||
'|' => pipes = true,
|
||||
':' | ' ' | '\t' => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
dashes && pipes
|
||||
}
|
||||
|
||||
fn has_pipe(&self, start: usize, end: usize) -> bool {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
if self.code[at] == '\\' {
|
||||
at += 2;
|
||||
} else if self.code[at] == '|' {
|
||||
return true;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A table row: the pipes are the structure, and what is between them is prose.
|
||||
fn table_row(&mut self, start: usize, end: usize) {
|
||||
let mut at = self.indented(start, end);
|
||||
let mut cell = at;
|
||||
while at < end {
|
||||
match self.code[at] {
|
||||
'\\' => at += 2,
|
||||
'|' => {
|
||||
self.inline(cell, at);
|
||||
self.emit(at, at + 1, Kind::Mark);
|
||||
at += 1;
|
||||
cell = at;
|
||||
}
|
||||
_ => at += 1,
|
||||
}
|
||||
}
|
||||
self.inline(cell, end);
|
||||
}
|
||||
|
||||
/// Spans, coalesced with the one before when they touch and agree.
|
||||
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
|
||||
if end <= start {
|
||||
return;
|
||||
}
|
||||
if let Some(last) = self.spans.last_mut()
|
||||
&& last.kind == kind
|
||||
&& last.end == start
|
||||
{
|
||||
last.end = end;
|
||||
return;
|
||||
}
|
||||
self.spans.push(Span { start, end, kind });
|
||||
}
|
||||
|
||||
/// The first character of the line at or after `start` that is not indentation.
|
||||
fn indented(&self, start: usize, end: usize) -> usize {
|
||||
let mut at = start;
|
||||
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The run of backticks or tildes that could open or close a fence on
|
||||
/// this line, or `None`.
|
||||
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
|
||||
let at = self.indented(start, end);
|
||||
if at == end {
|
||||
return None;
|
||||
}
|
||||
let marker = self.code[at];
|
||||
if marker != '`' && marker != '~' {
|
||||
return None;
|
||||
}
|
||||
let mut run = at;
|
||||
while run < end && self.code[run] == marker {
|
||||
run += 1;
|
||||
}
|
||||
if run - at >= 3 { Some((at, run)) } else { None }
|
||||
}
|
||||
|
||||
/// Draws an opening fence line and answers its delimiter, or `None` if
|
||||
/// this is not one.
|
||||
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
|
||||
let (run_start, run_end) = self.fence_run(start, end)?;
|
||||
self.emit(run_start, run_end, Kind::String);
|
||||
// The info word is what the fence is a fence *of*, which is
|
||||
// metadata about the block rather than part of it.
|
||||
let indented = self.indented(run_end, end);
|
||||
self.emit(indented, end, Kind::Metadata);
|
||||
Some(self.code[run_start..run_end].to_vec())
|
||||
}
|
||||
|
||||
/// Whether this line closes a fence opened by `open`: the same
|
||||
/// character, at least as many of them, and nothing else on the line.
|
||||
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
|
||||
let Some((run_start, run_end)) = self.fence_run(start, end) else {
|
||||
return false;
|
||||
};
|
||||
if self.code[run_start] != open[0] || run_end - run_start < open.len() {
|
||||
return false;
|
||||
}
|
||||
self.indented(run_end, end) == end
|
||||
}
|
||||
|
||||
/// One ordinary line: what its opening characters make it, and then its prose.
|
||||
fn structure(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
// Quote markers come before everything else and can be several
|
||||
// deep, and what follows one is an ordinary line again -- a heading
|
||||
// inside a quote is still a heading.
|
||||
while at < end && self.code[at] == '>' {
|
||||
at += 1;
|
||||
self.emit(at - 1, at, Kind::Mark);
|
||||
at = self.indented(at, end);
|
||||
}
|
||||
if at == end {
|
||||
return;
|
||||
}
|
||||
if self.heading(at, end) || self.thematic_break(at, end) {
|
||||
return;
|
||||
}
|
||||
let text_start = self.bullet(at, end);
|
||||
self.inline(text_start, end);
|
||||
}
|
||||
|
||||
/// `#` to `######` and a space. Without the space it is a word
|
||||
/// beginning with a hash.
|
||||
fn heading(&mut self, start: usize, end: usize) -> bool {
|
||||
let mut at = start;
|
||||
while at < end && self.code[at] == '#' {
|
||||
at += 1;
|
||||
}
|
||||
let depth = at - start;
|
||||
if !(1..=6).contains(&depth) {
|
||||
return false;
|
||||
}
|
||||
if at < end && self.code[at] != ' ' && self.code[at] != '\t' {
|
||||
return false;
|
||||
}
|
||||
self.emit(start, end, Kind::Keyword);
|
||||
true
|
||||
}
|
||||
|
||||
/// A line made of one repeated rule character and nothing else.
|
||||
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
|
||||
let marker = self.code[start];
|
||||
if !RULE_MARKERS.contains(marker) {
|
||||
return false;
|
||||
}
|
||||
let mut seen = 0usize;
|
||||
for at in start..end {
|
||||
let c = self.code[at];
|
||||
if c == marker {
|
||||
seen += 1;
|
||||
} else if !c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if seen < if marker == '=' { 1 } else { 3 } {
|
||||
return false;
|
||||
}
|
||||
self.emit(start, end, Kind::Mark);
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws a list marker if the line opens with one, and answers where
|
||||
/// the item's text starts.
|
||||
fn bullet(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
|
||||
self.emit(start, start + 1, Kind::Mark);
|
||||
return self.indented(start + 1, end);
|
||||
}
|
||||
let mut digits = start;
|
||||
while digits < end && self.code[digits].is_ascii_digit() {
|
||||
digits += 1;
|
||||
}
|
||||
let delimiter = self.code.get(digits).copied();
|
||||
if digits > start
|
||||
&& (delimiter == Some('.') || delimiter == Some(')'))
|
||||
&& self.space_or_end(digits + 1, end)
|
||||
{
|
||||
self.emit(start, digits + 1, Kind::Mark);
|
||||
return self.indented(digits + 1, end);
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
fn space_or_end(&self, at: usize, end: usize) -> bool {
|
||||
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
|
||||
}
|
||||
|
||||
/// The inline forms, left to right. Every branch answers a position
|
||||
/// strictly after `start` of its call, so this terminates.
|
||||
fn inline(&mut self, start: usize, end: usize) {
|
||||
let mut at = start;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
at = if c == '\\' {
|
||||
// A backslash takes the character after it out of the
|
||||
// running entirely, which is how `\*` stays an asterisk
|
||||
// rather than opening emphasis.
|
||||
at + 2
|
||||
} else if c == '`' {
|
||||
self.code_span(at, end)
|
||||
} else if c == '[' {
|
||||
self.link(at, at, end)
|
||||
} else if c == '!' && self.code.get(at + 1) == Some(&'[') {
|
||||
self.link(at, at + 1, end)
|
||||
} else if c == '<' {
|
||||
self.autolink(at, end)
|
||||
} else if EMPHASIS.contains(c) {
|
||||
self.emphasis(at, end)
|
||||
} else {
|
||||
self.url(at, end).unwrap_or(at + 1)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
|
||||
fn code_span(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == '`' {
|
||||
open += 1;
|
||||
}
|
||||
let ticks = open - start;
|
||||
let mut at = open;
|
||||
while at < end {
|
||||
if self.code[at] != '`' {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let mut close = at;
|
||||
while close < end && self.code[close] == '`' {
|
||||
close += 1;
|
||||
}
|
||||
if close - at == ticks {
|
||||
self.emit(start, close, Kind::String);
|
||||
return close;
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
// Nothing closes it on this line, so those were ordinary backticks.
|
||||
open
|
||||
}
|
||||
|
||||
/// `[text](destination)`, and the same with a leading `!` for an image.
|
||||
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
|
||||
let mut depth = 0i32;
|
||||
let mut close = bracket;
|
||||
while close < end {
|
||||
match self.code[close] {
|
||||
'\\' => close += 1,
|
||||
'[' => depth += 1,
|
||||
']' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close >= end {
|
||||
return start + 1;
|
||||
}
|
||||
let destination = close + 1;
|
||||
if self.code.get(destination) != Some(&'(') {
|
||||
return start + 1;
|
||||
}
|
||||
let Some(paren_rel) = self.code[destination..].iter().position(|&c| c == ')') else {
|
||||
return start + 1;
|
||||
};
|
||||
let paren = destination + paren_rel;
|
||||
if paren >= end {
|
||||
return start + 1;
|
||||
}
|
||||
self.emit(start, bracket + 1, Kind::Mark);
|
||||
self.inline(bracket + 1, close);
|
||||
self.emit(close, destination, Kind::Mark);
|
||||
self.emit(destination, paren + 1, Kind::Metadata);
|
||||
paren + 1
|
||||
}
|
||||
|
||||
/// `<https://example.com>` and `<name@example.com>`, drawn as the
|
||||
/// destination they are.
|
||||
fn autolink(&mut self, start: usize, end: usize) -> usize {
|
||||
let mut at = start + 1;
|
||||
let mut addressed = false;
|
||||
while at < end {
|
||||
let c = self.code[at];
|
||||
if c.is_whitespace() || c == '<' {
|
||||
return start + 1;
|
||||
}
|
||||
if c == '>' {
|
||||
if !addressed {
|
||||
return start + 1;
|
||||
}
|
||||
self.emit(start, at + 1, Kind::Metadata);
|
||||
return at + 1;
|
||||
}
|
||||
if c == ':' || c == '@' {
|
||||
addressed = true;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
start + 1
|
||||
}
|
||||
|
||||
/// A bare `scheme://...` written in prose, or `None` if one does not
|
||||
/// start here.
|
||||
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
|
||||
if start > 0 && is_word(self.code[start - 1]) {
|
||||
return None;
|
||||
}
|
||||
let mut scheme = start;
|
||||
while scheme < end && self.code[scheme].is_alphabetic() {
|
||||
scheme += 1;
|
||||
}
|
||||
if scheme == start || !starts_with(&self.code, scheme, "://") {
|
||||
return None;
|
||||
}
|
||||
let body = scheme + 3;
|
||||
let mut at = body;
|
||||
let mut openers = 0i32;
|
||||
let mut closers = 0i32;
|
||||
while at < end && !self.code[at].is_whitespace() && !URL_STOPS.contains(self.code[at]) {
|
||||
if self.code[at] == '(' {
|
||||
openers += 1;
|
||||
} else if self.code[at] == ')' {
|
||||
closers += 1;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
while at > body {
|
||||
let last = self.code[at - 1];
|
||||
if URL_TRAILING.contains(last) {
|
||||
at -= 1;
|
||||
} else if last == ')' && closers > openers {
|
||||
closers -= 1;
|
||||
at -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if at == body {
|
||||
return None;
|
||||
}
|
||||
self.emit(start, at, Kind::Metadata);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
|
||||
/// all.
|
||||
fn emphasis(&mut self, start: usize, end: usize) -> usize {
|
||||
let marker = self.code[start];
|
||||
let mut open = start;
|
||||
while open < end && self.code[open] == marker {
|
||||
open += 1;
|
||||
}
|
||||
let length = open - start;
|
||||
if marker == '~' && length != 2 {
|
||||
return open;
|
||||
}
|
||||
if length > 3 {
|
||||
return open;
|
||||
}
|
||||
if open == end || self.code[open].is_whitespace() {
|
||||
return open;
|
||||
}
|
||||
if marker == '_' && start > 0 && is_word(self.code[start - 1]) {
|
||||
return open;
|
||||
}
|
||||
let mut at = open;
|
||||
while at < end {
|
||||
if self.code[at] == '\\' {
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
if self.code[at] != marker {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let mut close = at;
|
||||
while close < end && self.code[close] == marker {
|
||||
close += 1;
|
||||
}
|
||||
let finish = at + length;
|
||||
if close - at >= length
|
||||
&& !self.code[at - 1].is_whitespace()
|
||||
&& !(marker == '_' && finish < end && is_word(self.code[finish]))
|
||||
{
|
||||
self.emit(start, finish, Kind::Literal);
|
||||
return finish;
|
||||
}
|
||||
at = close;
|
||||
}
|
||||
open
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn starts_with(code: &[char], at: usize, token: &str) -> bool {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
if at + token.len() > code.len() {
|
||||
return false;
|
||||
}
|
||||
code[at..at + token.len()] == token[..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Kind, Language, span_text, spans_of};
|
||||
|
||||
fn spans(code: &str, kind: Kind) -> Vec<String> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
spans_of(code, Language::Markdown)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == kind)
|
||||
.map(|s| span_text(&chars, &s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_spans(code: &str, kind: Kind, expected: &[&str]) {
|
||||
assert_eq!(spans(code, kind), expected.to_vec(), "{kind:?} in: {code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_is_coloured_whole_and_a_hash_inside_a_word_is_not_one() {
|
||||
let code = "## Layout\nissue #12 is fixed\n#hashtag";
|
||||
assert_spans(code, Kind::Keyword, &["## Layout"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seven_hashes_are_not_a_heading() {
|
||||
assert_spans("####### deep", Kind::Keyword, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fence_carries_its_language_as_metadata_and_its_body_as_one_string() {
|
||||
let code = "text\n```kotlin\nval x = 1\n```\nmore";
|
||||
assert_spans(code, Kind::Metadata, &["kotlin"]);
|
||||
assert_spans(code, Kind::String, &["```", "val x = 1", "```"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_longer_fence_is_not_closed_by_a_shorter_one_and_a_heading_inside_it_is_not_a_heading() {
|
||||
let code = "````\n```\n# not a heading\n````\nafter";
|
||||
assert_spans(code, Kind::Keyword, &[]);
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::String,
|
||||
&["````", "```", "# not a heading", "````"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_fence_runs_to_the_end_rather_than_panicking() {
|
||||
assert_spans("```\nstill going", Kind::String, &["```", "still going"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_markers_and_quote_markers_colour_without_their_text() {
|
||||
let code = "- one\n2. two\n> quoted";
|
||||
assert_spans(code, Kind::Mark, &["-", "2.", ">"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_and_a_setext_underline_are_the_same_mark() {
|
||||
assert_spans("Title\n=====\n\n---", Kind::Mark, &["=====", "---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emphasis_needs_something_on_both_sides_of_it() {
|
||||
assert_spans(
|
||||
"**bold** and *thin*",
|
||||
Kind::Literal,
|
||||
&["**bold**", "*thin*"],
|
||||
);
|
||||
assert_spans("a * b * c and *p = *q", Kind::Literal, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_underscore_inside_a_word_emphasises_nothing() {
|
||||
assert_spans("snake_case_name and _real_", Kind::Literal, &["_real_"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_span_holds_a_backtick_when_opened_with_two() {
|
||||
assert_spans("``a ` b`` and `c`", Kind::String, &["``a ` b``", "`c`"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_code_span_is_ordinary_text() {
|
||||
assert_spans("a ` b", Kind::String, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_marks_its_brackets_and_colours_its_destination() {
|
||||
let code = "see [the plan](PLAN.md) now";
|
||||
assert_spans(code, Kind::Mark, &["[", "]"]);
|
||||
assert_spans(code, Kind::Metadata, &["(PLAN.md)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_is_found_by_its_delimiter_row_and_pipes_elsewhere_are_plain() {
|
||||
let code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph";
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::Mark,
|
||||
&["|", "|", "|", "|---|---|", "|", "|", "|"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_without_outer_pipes_still_colours_and_the_table_ends_with_the_rows() {
|
||||
let code = "a | b\n--- | ---\nnot a row";
|
||||
assert_spans(code, Kind::Mark, &["|", "--- | ---"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autolink_colours_and_an_html_tag_does_not() {
|
||||
let code = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">";
|
||||
assert_spans(
|
||||
code,
|
||||
Kind::Metadata,
|
||||
&["<https://example.com>", "<a@b.com>", "http://x"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_url_gives_back_the_sentences_punctuation() {
|
||||
assert_spans(
|
||||
"see https://example.com/a., and ssh://host/x)",
|
||||
Kind::Metadata,
|
||||
&["https://example.com/a", "ssh://host/x"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bracket_a_url_opened_itself_stays_in_it() {
|
||||
assert_spans(
|
||||
"https://en.wikipedia.org/wiki/A_(b) here",
|
||||
Kind::Metadata,
|
||||
&["https://en.wikipedia.org/wiki/A_(b)"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_inside_a_link_destination_is_not_coloured_twice() {
|
||||
assert_spans(
|
||||
"[x](https://example.com)",
|
||||
Kind::Metadata,
|
||||
&["(https://example.com)"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bracket_with_no_destination_after_it_is_left_plain() {
|
||||
assert_spans("an [aside] here", Kind::Mark, &[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
//! `code` read once, left to right, into the spans that carry a colour.
|
||||
//! Ported from `app/.../Highlighter.kt`.
|
||||
//!
|
||||
//! One pass with a small state -- in a comment, in a string, or in ordinary
|
||||
//! code -- rather than a locator per token kind over the whole text, which
|
||||
//! is what the library this replaced did and is why it found comments
|
||||
//! before it knew the language: a `#` inside a shell string, a `//` inside
|
||||
//! a URL and a block-comment opener inside a shell glob each commented out
|
||||
//! the rest of a line that was nothing of the sort.
|
||||
//!
|
||||
//! Every span is produced by advancing an index forward, so the result is
|
||||
//! ordered, non-overlapping and inside the code by construction. Nothing
|
||||
//! here panics: an unterminated string or comment runs to the end of the
|
||||
//! code, which is also what it looks like while a fence is still being
|
||||
//! written.
|
||||
//!
|
||||
//! **Indices are char offsets, not byte offsets** -- the scanner works over
|
||||
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
|
||||
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
|
||||
//! back into the text it covers.
|
||||
|
||||
pub mod languages;
|
||||
pub mod markdown;
|
||||
|
||||
pub use languages::{
|
||||
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
|
||||
};
|
||||
|
||||
/// What a span of code is, in the terms a palette has a colour for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Kind {
|
||||
Keyword,
|
||||
String,
|
||||
Literal,
|
||||
Comment,
|
||||
Metadata,
|
||||
Punctuation,
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
/// The text a [`Span`] covers, for a caller working in char indices (every
|
||||
/// test in this module, and any UI that also holds `code` as `Vec<char>`).
|
||||
pub fn span_text(code: &[char], span: &Span) -> String {
|
||||
code[span.start..span.end].iter().collect()
|
||||
}
|
||||
|
||||
/// The spans `language` colours in `code` -- the one way to ask, whatever
|
||||
/// the language turns out to be made of. `None` draws plain.
|
||||
pub fn spans_of(code: &str, language: Language) -> Vec<Span> {
|
||||
if language == Language::Markdown {
|
||||
markdown::scan_markdown(code)
|
||||
} else {
|
||||
scan(code, &rules_for(language))
|
||||
}
|
||||
}
|
||||
|
||||
/// `code` read into the spans [`Rules`] describes. Also reachable directly
|
||||
/// for a caller that already has a [`Rules`] (there is currently only one:
|
||||
/// [`spans_of`]), kept public because the Kotlin original exposed it the
|
||||
/// same way.
|
||||
pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
|
||||
Scanner::new(code, rules).run()
|
||||
}
|
||||
|
||||
/// Characters coloured as punctuation, and as marks. Both sets are the ones
|
||||
/// the library this replaced used.
|
||||
const PUNCTUATION: &str = ",.:;";
|
||||
const MARKS: &str = "()={}<>-+[]|&";
|
||||
|
||||
struct Scanner<'a> {
|
||||
code: Vec<char>,
|
||||
rules: &'a Rules,
|
||||
spans: Vec<Span>,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Scanner<'a> {
|
||||
fn new(code: &str, rules: &'a Rules) -> Self {
|
||||
Self {
|
||||
code: code.chars().collect(),
|
||||
rules,
|
||||
spans: Vec::new(),
|
||||
at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(mut self) -> Vec<Span> {
|
||||
while self.at < self.code.len() {
|
||||
// Every branch that answers true has advanced `self.at`, so
|
||||
// this terminates.
|
||||
let consumed = self.block_comment()
|
||||
|| self.line_comment()
|
||||
|| self.raw_string()
|
||||
|| self.character_or_lifetime()
|
||||
|| self.string()
|
||||
|| self.attribute()
|
||||
|| self.number()
|
||||
|| self.word()
|
||||
|| self.single_character();
|
||||
if !consumed {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
self.spans
|
||||
}
|
||||
|
||||
fn emit(&mut self, start: usize, kind: Kind) {
|
||||
if self.at > start {
|
||||
self.spans.push(Span {
|
||||
start,
|
||||
end: self.at,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn starts(&self, token: &str) -> bool {
|
||||
starts_with_at(&self.code, self.at, token)
|
||||
}
|
||||
|
||||
/// Whether a line comment token here opens one; see
|
||||
/// [`Rules::line_comments_at_word_start`].
|
||||
fn at_word_start(&self) -> bool {
|
||||
self.at == 0
|
||||
|| self.code[self.at - 1].is_whitespace()
|
||||
|| ";|&(".contains(self.code[self.at - 1])
|
||||
}
|
||||
|
||||
/// Whether only whitespace stands between the start of this line and here.
|
||||
fn at_line_start(&self) -> bool {
|
||||
let mut back = self.at as isize - 1;
|
||||
while back >= 0 && self.code[back as usize] != '\n' {
|
||||
if !self.code[back as usize].is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
back -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn advance_to_end_of_line(&mut self) {
|
||||
while self.at < self.code.len() && self.code[self.at] != '\n' {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// From an open bracket through the one that matches it, or to the end
|
||||
/// if none does.
|
||||
fn advance_to_matching_bracket(&mut self) {
|
||||
let mut depth = 0i32;
|
||||
while self.at < self.code.len() {
|
||||
match self.code[self.at] {
|
||||
'[' => depth += 1,
|
||||
']' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
self.at += 1;
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_comment(&mut self) -> bool {
|
||||
let Some(comment) = self.rules.block_comment else {
|
||||
return false;
|
||||
};
|
||||
if !self.starts(comment.open) {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
self.at += comment.open.chars().count();
|
||||
let mut depth = 1i32;
|
||||
while self.at < self.code.len() && depth > 0 {
|
||||
// The closer is tried first so that a language whose two
|
||||
// delimiters are the same string -- CoffeeScript's `###` --
|
||||
// closes rather than nesting forever.
|
||||
if self.starts(comment.close) {
|
||||
depth -= 1;
|
||||
self.at += comment.close.chars().count();
|
||||
} else if comment.nests && self.starts(comment.open) {
|
||||
depth += 1;
|
||||
self.at += comment.open.chars().count();
|
||||
} else {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Comment);
|
||||
true
|
||||
}
|
||||
|
||||
fn line_comment(&mut self) -> bool {
|
||||
if !self.rules.line_comments.iter().any(|c| self.starts(c)) {
|
||||
return false;
|
||||
}
|
||||
if self.rules.line_comments_at_word_start && !self.at_word_start() {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
self.advance_to_end_of_line();
|
||||
self.emit(start, Kind::Comment);
|
||||
true
|
||||
}
|
||||
|
||||
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
|
||||
fn raw_string(&mut self) -> bool {
|
||||
if !self.rules.raw_strings {
|
||||
return false;
|
||||
}
|
||||
let mut ahead = self.at;
|
||||
if self.code.get(ahead) == Some(&'b') {
|
||||
ahead += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'r') {
|
||||
return false;
|
||||
}
|
||||
ahead += 1;
|
||||
let mut hashes = 0usize;
|
||||
while self.code.get(ahead) == Some(&'#') {
|
||||
ahead += 1;
|
||||
hashes += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'"') {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
let closer: String = std::iter::once('"')
|
||||
.chain(std::iter::repeat_n('#', hashes))
|
||||
.collect();
|
||||
let closer_chars: Vec<char> = closer.chars().collect();
|
||||
let closed = find_from(&self.code, ahead + 1, &closer_chars);
|
||||
self.at = match closed {
|
||||
Some(index) => index + closer_chars.len(),
|
||||
None => self.code.len(),
|
||||
};
|
||||
self.emit(start, Kind::String);
|
||||
true
|
||||
}
|
||||
|
||||
/// See [`Rules::lifetimes`]: an apostrophe that is not a character
|
||||
/// literal opens nothing.
|
||||
fn character_or_lifetime(&mut self) -> bool {
|
||||
if !self.rules.lifetimes || self.code[self.at] != '\'' {
|
||||
return false;
|
||||
}
|
||||
let Some(&next) = self.code.get(self.at + 1) else {
|
||||
return false;
|
||||
};
|
||||
if next == '\\' || self.code.get(self.at + 2) == Some(&'\'') {
|
||||
self.quoted(Quote {
|
||||
open: "'",
|
||||
close: "'",
|
||||
escapes: true,
|
||||
});
|
||||
} else {
|
||||
self.at += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn string(&mut self) -> bool {
|
||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
|
||||
// than an empty string followed by a quote.
|
||||
let mut quote: Option<Quote> = None;
|
||||
for candidate in &self.rules.quotes {
|
||||
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
|
||||
if self.starts(candidate.open) && candidate.open.chars().count() > current_len {
|
||||
quote = Some(*candidate);
|
||||
}
|
||||
}
|
||||
let Some(quote) = quote else {
|
||||
return false;
|
||||
};
|
||||
self.quoted(quote);
|
||||
true
|
||||
}
|
||||
|
||||
fn quoted(&mut self, quote: Quote) {
|
||||
let start = self.at;
|
||||
self.at += quote.open.chars().count();
|
||||
while self.at < self.code.len() {
|
||||
if quote.escapes && self.code[self.at] == '\\' && self.at + 1 < self.code.len() {
|
||||
self.at += 2;
|
||||
continue;
|
||||
}
|
||||
if self.starts(quote.close) {
|
||||
self.at += quote.close.chars().count();
|
||||
break;
|
||||
}
|
||||
self.at += 1;
|
||||
}
|
||||
self.at = self.at.min(self.code.len());
|
||||
self.emit(start, Kind::String);
|
||||
}
|
||||
|
||||
fn attribute(&mut self) -> bool {
|
||||
let start = self.at;
|
||||
match self.rules.attributes {
|
||||
Attributes::None => return false,
|
||||
Attributes::AtWord => {
|
||||
if self.code[self.at] != '@' || !is_word_start(self.code.get(self.at + 1).copied())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.at += 1;
|
||||
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
Attributes::HashBracket => {
|
||||
if self.code[self.at] != '#' {
|
||||
return false;
|
||||
}
|
||||
let mut ahead = self.at + 1;
|
||||
if self.code.get(ahead) == Some(&'!') {
|
||||
ahead += 1;
|
||||
}
|
||||
if self.code.get(ahead) != Some(&'[') {
|
||||
return false;
|
||||
}
|
||||
self.at = ahead;
|
||||
self.advance_to_matching_bracket();
|
||||
}
|
||||
Attributes::HashLine => {
|
||||
if self.code[self.at] != '#' || !self.at_line_start() {
|
||||
return false;
|
||||
}
|
||||
self.advance_to_end_of_line();
|
||||
}
|
||||
Attributes::LineBracket => {
|
||||
if self.code[self.at] != '[' || !self.at_line_start() {
|
||||
return false;
|
||||
}
|
||||
self.advance_to_matching_bracket();
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Metadata);
|
||||
true
|
||||
}
|
||||
|
||||
/// A number is a run starting with a digit and carrying on through
|
||||
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
|
||||
/// and `3.14` without a grammar for any of them.
|
||||
fn number(&mut self) -> bool {
|
||||
if !self.code[self.at].is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
while self.at < self.code.len() {
|
||||
let c = self.code[self.at];
|
||||
if c.is_alphanumeric() || c == '_' || c == '.' {
|
||||
self.at += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.emit(start, Kind::Literal);
|
||||
true
|
||||
}
|
||||
|
||||
fn word(&mut self) -> bool {
|
||||
if !is_word_start(Some(self.code[self.at])) {
|
||||
return false;
|
||||
}
|
||||
let start = self.at;
|
||||
while self.at < self.code.len() && is_word_part(self.code[self.at]) {
|
||||
self.at += 1;
|
||||
}
|
||||
let word: String = self.code[start..self.at].iter().collect();
|
||||
if self.rules.keywords.contains(word.as_str()) {
|
||||
self.emit(start, Kind::Keyword);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn single_character(&mut self) -> bool {
|
||||
let kind = if PUNCTUATION.contains(self.code[self.at]) {
|
||||
Kind::Punctuation
|
||||
} else if MARKS.contains(self.code[self.at]) {
|
||||
Kind::Mark
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
self.at += 1;
|
||||
self.emit(self.at - 1, kind);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_start(c: Option<char>) -> bool {
|
||||
matches!(c, Some(c) if c.is_alphabetic() || c == '_')
|
||||
}
|
||||
|
||||
fn is_word_part(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// Whether `code[at..]` starts with `token`, both read as chars.
|
||||
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
|
||||
let token: Vec<char> = token.chars().collect();
|
||||
if at + token.len() > code.len() {
|
||||
return false;
|
||||
}
|
||||
code[at..at + token.len()] == token[..]
|
||||
}
|
||||
|
||||
/// The first index at or after `from` where `code` contains `needle`, or
|
||||
/// `None`.
|
||||
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
|
||||
if needle.is_empty() || from > code.len() {
|
||||
return None;
|
||||
}
|
||||
(from..=code.len().saturating_sub(needle.len())).find(|&i| code[i..i + needle.len()] == *needle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spans(code: &str, language: Language, kind: Kind) -> Vec<String> {
|
||||
let chars: Vec<char> = code.chars().collect();
|
||||
spans_of(code, language)
|
||||
.into_iter()
|
||||
.filter(|s| s.kind == kind)
|
||||
.map(|s| span_text(&chars, &s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_spans(code: &str, language: Language, kind: Kind, expected: &[&str]) {
|
||||
assert_eq!(
|
||||
spans(code, language, kind),
|
||||
expected.to_vec(),
|
||||
"{kind:?} in: {code}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_glob_is_one_string_not_a_comment() {
|
||||
assert_spans("x '*/a/*'", Language::Shell, Kind::String, &["'*/a/*'"]);
|
||||
assert_spans("x '*/a/*'", Language::Shell, Kind::Comment, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_find_with_globs_has_no_comment_in_it() {
|
||||
let code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Shell,
|
||||
Kind::String,
|
||||
&["'*/.git/*'", "'*.kt'"],
|
||||
);
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_does_not_comment_out_the_rest_of_a_shell_line() {
|
||||
let code = "curl https://example.com/x && echo done";
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Shell, Kind::Keyword, &["echo"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_inside_a_kotlin_string_stays_a_string() {
|
||||
let code = "val url = \"https://example.com\"\nfun f() = 1";
|
||||
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"https://example.com\""],
|
||||
);
|
||||
assert_spans(code, Language::Kotlin, Kind::Keyword, &["val", "fun"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_attribute_is_metadata_and_the_struct_after_it_still_colours() {
|
||||
let code = "#[derive(Debug)]\nstruct A { b: u8 }";
|
||||
assert_spans(code, Language::Rust, Kind::Metadata, &["#[derive(Debug)]"]);
|
||||
assert_spans(code, Language::Rust, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Rust, Kind::Keyword, &["struct"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inner_rust_attribute_closes_at_its_own_bracket() {
|
||||
let code = "#![allow(dead_code)]\nfn f() {}";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Rust,
|
||||
Kind::Metadata,
|
||||
&["#![allow(dead_code)]"],
|
||||
);
|
||||
assert_spans(code, Language::Rust, Kind::Keyword, &["fn"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_c_preprocessor_line_is_metadata_rather_than_a_comment() {
|
||||
let code = "#include <stdio.h>\nint main() { return 0; }";
|
||||
assert_spans(code, Language::C, Kind::Metadata, &["#include <stdio.h>"]);
|
||||
assert_spans(code, Language::C, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::C, Kind::Keyword, &["int", "return"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kotlin_annotation_is_metadata() {
|
||||
assert_spans(
|
||||
"@Composable fun f() {}",
|
||||
Language::Kotlin,
|
||||
Kind::Metadata,
|
||||
&["@Composable"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_inside_a_kotlin_string_is_not_a_comment() {
|
||||
let code = "val c = \"#FF0000\"\nval d = 1";
|
||||
assert_spans(code, Language::Kotlin, Kind::Comment, &[]);
|
||||
assert_spans(code, Language::Kotlin, Kind::String, &["\"#FF0000\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_apostrophe_inside_a_kotlin_string_does_not_open_one() {
|
||||
let code = "val a = \"don't\"\nval b = \"x\"";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"don't\"", "\"x\""],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_lifetime_does_not_open_a_string_but_a_character_literal_does() {
|
||||
let code = "fn f<'a>(x: &'a str) { let c = 'x'; }";
|
||||
assert_spans(code, Language::Rust, Kind::String, &["'x'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_quote_is_inside_the_rust_character_literal() {
|
||||
assert_spans("let c = '\\'';", Language::Rust, Kind::String, &["'\\''"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_raw_string_keeps_its_inner_quotes() {
|
||||
let code = "let s = r#\"a \"quoted\" b\"#;";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Rust,
|
||||
Kind::String,
|
||||
&["r#\"a \"quoted\" b\"#"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kotlin_triple_quoted_string_is_one_string() {
|
||||
assert_spans(
|
||||
"val s = \"\"\"a \"b\" c\"\"\"",
|
||||
Language::Kotlin,
|
||||
Kind::String,
|
||||
&["\"\"\"a \"b\" c\"\"\""],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_single_quoted_string_takes_no_escapes() {
|
||||
assert_spans("echo 'a\\' b", Language::Shell, Kind::String, &["'a\\'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_and_kotlin_nest_block_comments() {
|
||||
let code = "/* a /* b */ c */ x";
|
||||
assert_spans(code, Language::Rust, Kind::Comment, &["/* a /* b */ c */"]);
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Kotlin,
|
||||
Kind::Comment,
|
||||
&["/* a /* b */ c */"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_ends_a_block_comment_at_the_first_close() {
|
||||
assert_spans(
|
||||
"/* a /* b */ c */ x",
|
||||
Language::C,
|
||||
Kind::Comment,
|
||||
&["/* a /* b */"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_comment_starts_only_at_a_word_boundary() {
|
||||
let code = "${#x} $# a#b # real";
|
||||
assert_spans(code, Language::Shell, Kind::Comment, &["# real"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_anywhere_is_a_python_comment() {
|
||||
assert_spans("x = 1 # note", Language::Python, Kind::Comment, &["# note"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toml_table_header_is_metadata_and_a_hash_in_a_value_is_not_a_comment() {
|
||||
let code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one";
|
||||
assert_spans(code, Language::Toml, Kind::Metadata, &["[server]"]);
|
||||
assert_spans(code, Language::Toml, Kind::String, &["\"#FF0000\""]);
|
||||
assert_spans(code, Language::Toml, Kind::Comment, &["# the real one"]);
|
||||
assert_spans(code, Language::Toml, Kind::Literal, &["8080"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ron_attribute_and_its_values_colour() {
|
||||
let code = "#![enable(implicit_some)]\n(count: 3, on: true)";
|
||||
assert_spans(
|
||||
code,
|
||||
Language::Ron,
|
||||
Kind::Metadata,
|
||||
&["#![enable(implicit_some)]"],
|
||||
);
|
||||
assert_spans(code, Language::Ron, Kind::Keyword, &["true"]);
|
||||
assert_spans(code, Language::Ron, Kind::Literal, &["3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_fence_language_is_none() {
|
||||
assert_eq!(fence_language(Some("brainfuck")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_language_the_fence_table_knows_has_a_scanner() {
|
||||
for language in Language::ALL {
|
||||
spans_of("x", language);
|
||||
}
|
||||
}
|
||||
|
||||
/// The scanner must never panic and must never answer a span the code
|
||||
/// does not contain: the library this replaced answered a reversed
|
||||
/// range here, which crashed a card, and a fence still being written is
|
||||
/// an unterminated string or comment on every keystroke.
|
||||
#[test]
|
||||
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
|
||||
let nasty = [
|
||||
"",
|
||||
"'",
|
||||
"\"",
|
||||
"\"unterminated",
|
||||
"/* unterminated",
|
||||
"###",
|
||||
"#",
|
||||
"#.collect();
|
||||
let spans = spans_of(code, language);
|
||||
for s in &spans {
|
||||
assert!(
|
||||
s.start <= s.end && s.end <= chars.len(),
|
||||
"{language:?} answered {s:?} for {code:?}"
|
||||
);
|
||||
}
|
||||
let mut sorted = spans.clone();
|
||||
sorted.sort_by_key(|s| s.start);
|
||||
assert_eq!(
|
||||
spans, sorted,
|
||||
"{language:?} answered spans out of order for {code:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! The app's pure logic, shared between the server and any Rust client --
|
||||
//! see `CLIENT_CORE.md` at the repo root for what lives here and what does
|
||||
//! not yet.
|
||||
|
||||
pub mod ansi;
|
||||
pub mod highlight;
|
||||
|
||||
pub use event_model::*;
|
||||
Reference in new issue
Block a user