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:
irisandClaude Sonnet committed 2026-09-04 22:47:41 -04:00
1 parent 62dd6b7912
commit 762c1290a1
7 files changed
+3495

No files matched your search

+581
View File
@@ -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),
];
+681
View File
@@ -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, &[]);
}
}
+702
View File
@@ -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",
"###",
"#",
"#![",
"[",
"r#\"",
"\\",
"'''",
"\"\"\"",
"0x",
"1.2.3",
"a#b//c/*d*/'e\"f",
"```",
"*",
"**",
"~~",
"> ",
"- ",
"1.",
"[x](",
"#######",
"|",
"|---|",
"<",
"<>",
"http://",
"a://",
"\n\n \n",
];
for language in Language::ALL {
for code in nasty {
let chars: Vec<char> = code.chars().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:?}"
);
}
}
}
}