Files
ghostty-rs/crates/ghostty-terminal/src/string_map.rs
T
ddidderr 42f05c926d feat(wasm): port Ghostty's wasm32 build
Ports main_wasm.zig as crates/ghostty-wasm (cdylib exporting the Zig
names: atlas_*, face_*, deferred_face_*, shaper_*, config_*), the
web_canvas font face and shaper (backend selected on wasm32), os/wasm
(logging, target, JS interop) and config/Wasm.zig.

C-library dependencies (freetype, harfbuzz, fontconfig, onig, gettext,
D-Bus, rustix) are target-gated. Like Zig's wasm build, link regex
search (StringMap::search_iterator) is unavailable without Oniguruma;
Collection/SharedGrid/harfbuzz are compiled out on wasm32. ghostty-os
switches from forbid to deny unsafe_code for the JS import blocks.

The JS import ABI is currently a documented handle-based interface with
a reference host (js/ghostty_host.js); aligning it with zig-js's exact
import names follows separately.

Test Plan:
- cargo build --target wasm32-unknown-unknown -p ghostty-font \
  -p ghostty-config -p ghostty-terminal -p ghostty-vt -p ghostty-wasm
- cargo test -p ghostty-wasm (node runs the module against a fake canvas)
2026-10-12 05:21:14 +02:00

255 lines
7.9 KiB
Rust

//! A string along with the mapping of each individual byte in the string
//! to the point in the screen.
//!
//! Port of `ZIG/src/terminal/StringMap.zig`. Regex matching uses Oniguruma
//! (the `onig` crate), like Ghostty.
#[cfg(not(target_arch = "wasm32"))]
use onig::{MatchParam, Regex, Region, SearchOptions};
use crate::{page_list::Pin, selection::Selection};
/// Retry budget for StringMap regex searches.
///
/// Units are Oniguruma retry steps (internal backtracking/retry counter),
/// not bytes/characters/time.
///
/// This is Zig's `setRetryLimitInSearch`: a whole-search budget (set via
/// the `ghostty-onig` wrapper since the `onig` crate does not expose it).
/// Exceeding it aborts matching with an error which is treated as "no
/// further matches".
#[cfg(not(target_arch = "wasm32"))]
const ONI_SEARCH_RETRY_LIMIT: u32 = 100_000;
/// A string and a map from each byte of the string to the pin it came from.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StringMap {
pub string: String,
pub map: Vec<Pin>,
}
// Like Zig (`build_options.oniguruma == false`), regex search is not
// available on wasm32 (Oniguruma is a C library).
#[cfg(not(target_arch = "wasm32"))]
impl StringMap {
/// Returns an iterator that yields the next match of the given regex.
pub fn search_iterator<'a>(&'a self, regex: &'a Regex) -> SearchIterator<'a> {
SearchIterator {
map: self,
regex,
offset: 0,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Iterates over the regular expression matches of the string.
pub struct SearchIterator<'a> {
map: &'a StringMap,
regex: &'a Regex,
offset: usize,
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> SearchIterator<'a> {
/// Returns the next regular expression match or None if there are
/// no more matches.
pub fn next_match(&mut self) -> Option<Match<'a>> {
if self.offset >= self.map.string.len() {
return None;
}
// Use per-search match params so we can bound regex retry steps
// (Oniguruma's internal backtracking work counter).
let mut match_param = MatchParam::default();
ghostty_onig::set_retry_limit_in_search(&mut match_param, ONI_SEARCH_RETRY_LIMIT);
let haystack = &self.map.string[self.offset..];
let mut region = Region::new();
let result = self.regex.search_with_param(
haystack,
0,
haystack.len(),
SearchOptions::SEARCH_OPTION_NONE,
Some(&mut region),
match_param,
);
// Mismatch and retry/stack-limit errors mean no further matches.
let Ok(Some(_)) = result else {
self.offset = self.map.string.len();
return None;
};
let (start, end) = region.pos(0)?;
// Increment our offset by the number of bytes in the match.
// We do this after building the match so that it is returned
// with the old offset.
let m = Match {
map: self.map,
offset: self.offset,
start,
end,
};
self.offset += end;
Some(m)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> Iterator for SearchIterator<'a> {
type Item = Match<'a>;
fn next(&mut self) -> Option<Match<'a>> {
self.next_match()
}
}
/// A single regular expression match.
#[derive(Debug, Clone, Copy)]
pub struct Match<'a> {
map: &'a StringMap,
offset: usize,
/// Byte range of the match relative to `offset`.
start: usize,
end: usize,
}
impl Match<'_> {
/// Returns the selection containing the full match.
pub fn selection(&self) -> Selection {
let start_pt = self.map.map[self.offset + self.start];
let end_pt = self.map.map[self.offset + self.end - 1];
Selection::new(start_pt, end_pt, false)
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::{
point::{Coordinate, Point, Tag},
screen::{Options, Screen, SelectLine, SelectionString},
};
fn active(x: u16, y: u32) -> Point {
Point::Active(Coordinate { x, y })
}
fn screen_pt(s: &Screen, pin: Pin) -> Point {
s.pages.point_from_pin(Tag::Screen, pin).unwrap()
}
fn line_map(s: &Screen, x: u16, y: u32) -> StringMap {
let pin = s.pages.pin(active(x, y)).unwrap();
let line = s.select_line(SelectLine::new(pin)).unwrap();
s.selection_string_map(SelectionString {
sel: line,
trim: false,
})
}
#[test]
fn string_map_search_iterator() {
// Initialize our regex
let re = Regex::new("[A-B]{2}").unwrap();
// Initialize our screen
let mut s = Screen::new(Options::new(5, 5, 0)).unwrap();
s.test_write_string("1ABCD2EFGH\n3IJKL").unwrap();
let map = line_map(&s, 2, 1);
// Get our iterator
let mut it = map.search_iterator(&re);
{
let m = it.next_match().unwrap();
let sel = m.selection();
assert_eq!(
Point::Screen(Coordinate { x: 1, y: 0 }),
screen_pt(&s, sel.start(&s.pages))
);
assert_eq!(
Point::Screen(Coordinate { x: 2, y: 0 }),
screen_pt(&s, sel.end(&s.pages))
);
}
assert!(it.next_match().is_none());
}
#[test]
fn string_map_search_iterator_url_detection() {
let re = Regex::new(ghostty_config::url::REGEX).unwrap();
let mut s = Screen::new(Options::new(40, 5, 0)).unwrap();
s.test_write_string("hello https://example.com/path world")
.unwrap();
let map = line_map(&s, 10, 0);
let mut it = map.search_iterator(&re);
{
let m = it.next_match().unwrap();
let sel = m.selection();
// URL should start at x=6 ("https://example.com/path" starts after "hello ")
assert_eq!(
Point::Screen(Coordinate { x: 6, y: 0 }),
screen_pt(&s, sel.start(&s.pages))
);
// URL should end at x=29 (end of "/path")
assert_eq!(
Point::Screen(Coordinate { x: 29, y: 0 }),
screen_pt(&s, sel.end(&s.pages))
);
}
assert!(it.next_match().is_none());
}
#[test]
fn string_map_search_iterator_retry_limit_bounds_catastrophic_regex() {
// Exponential backtracking: without the whole-search retry limit
// this would effectively never finish.
let re = Regex::new("(a+)+$").unwrap();
let mut s = Screen::new(Options::new(60, 5, 0)).unwrap();
s.test_write_string(&format!("{}b", "a".repeat(50)))
.unwrap();
let map = line_map(&s, 10, 0);
let mut it = map.search_iterator(&re);
assert!(it.next_match().is_none());
}
#[test]
fn string_map_search_iterator_url_with_click_position() {
let re = Regex::new(ghostty_config::url::REGEX).unwrap();
let mut s = Screen::new(Options::new(40, 5, 0)).unwrap();
s.test_write_string("hello https://example.com world")
.unwrap();
// Simulate clicking on "example" (x=14)
let click_pin = s.pages.pin(active(14, 0)).unwrap();
let map = line_map(&s, 14, 0);
let mut it = map.search_iterator(&re);
let mut found_url = false;
while let Some(m) = it.next_match() {
let sel = m.selection();
if sel.contains(&s.pages, click_pin) {
found_url = true;
// Verify URL bounds
assert_eq!(
Point::Screen(Coordinate { x: 6, y: 0 }),
screen_pt(&s, sel.start(&s.pages))
);
assert_eq!(
Point::Screen(Coordinate { x: 24, y: 0 }),
screen_pt(&s, sel.end(&s.pages))
);
break;
}
}
assert!(found_url);
}
}