1//! Hyperlinks as OneNote 2010 edits them (`corpus/link-edit/native-typed`): the Link dialog
2//! stores the address as typed in a hidden `HYPERLINK` field code before the label, a URL
3//! typed and ended by a space or Enter becomes a link of its own text, typing after a label
4//! is plain text, and Remove Link leaves the label as plain text.
5
6use super::format::restyle;
7use super::*;
8
9/// A link in a paragraph, in UTF-16 offsets of its stored text.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Link {
12 pub(crate) paragraph: usize,
13 /// The hidden field code naming the address, before the label; none where the label is
14 /// its own address.
15 pub(crate) code: Option<Range<u32>>,
16 pub label: Range<u32>,
17 pub(crate) target: String,
18}
19
20impl Link {
21 fn range(&self) -> Range<u32> {
22 self.code
23 .as_ref()
24 .map_or(self.label.start, |code| code.start)..self.label.end
25 }
26}
27
28/// The links in `text`, the text of paragraph `paragraph`.
29pub(crate) fn links(text: &Paragraph, paragraph: usize) -> Vec<Link> {
30 let mut found: Vec<Link> = Vec::new();
31 let mut code: Option<(Range<u32>, String)> = None;
32 let (mut byte, mut unit) = (0, 0);
33 for span in text.spans() {
34 let fragment = &text.text()[byte..span.end];
35 let range = unit..unit + fragment.encode_utf16().count() as u32;
36 (byte, unit) = (span.end, range.end);
37 let format = &span.format;
38 if format.hyperlink != Some(true) {
39 code = None;
40 continue;
41 }
42 if format.hidden == Some(true) {
43 code = fragment
44 .strip_prefix("\u{fddf}HYPERLINK \"")
45 .and_then(|target| target.strip_suffix('"'))
46 .map(|target| (range, target.to_owned()));
47 continue;
48 }
49 let labelled = format.hyperlink_label == Some(true);
50 match (code.take(), found.last_mut()) {
51 (Some((code, target)), _) if code.end == range.start => found.push(Link {
52 paragraph,
53 code: Some(code),
54 label: range,
55 target,
56 }),
57 // A label runs on across format changes; a bare link is its own text.
58 (_, Some(last)) if last.label.end == range.start && last.code.is_some() == labelled => {
59 last.label.end = range.end;
60 }
61 _ => found.push(Link {
62 paragraph,
63 code: None,
64 label: range,
65 target: String::new(),
66 }),
67 }
68 }
69 for link in &mut found {
70 if link.code.is_none() {
71 let start = text.byte_offset(link.label.start).unwrap_or(0);
72 let end = text.byte_offset(link.label.end).unwrap_or(start);
73 link.target = text.text()[start..end].to_owned();
74 }
75 }
76 found
77}
78
79/// Where a typed URL lies in `word`, a run of text ended by a space or Enter, as OneNote
80/// links it: from a scheme it knows (or `www.`, or a share's `\\`) preceded by punctuation
81/// only, to before any trailing punctuation or unmatched closing bracket.
82pub(crate) fn typed_url(word: &str) -> Option<Range<usize>> {
83 const SCHEMES: [&str; 9] = [
84 "http://", "https://", "ftp://", "file://", "mailto:", "news:", "onenote:", "www.", "\\\\",
85 ];
86 let lower = word.to_ascii_lowercase();
87 let (start, scheme) = SCHEMES
88 .iter()
89 .filter_map(|scheme| Some((lower.find(scheme)?, scheme.len())))
90 .min()?;
91 if word[..start].chars().any(char::is_alphanumeric) {
92 return None;
93 }
94 let mut end = word.len();
95 while let Some(last) = word[start..end].chars().last() {
96 let count = |c: char| word[start..end].matches(c).count();
97 let trailing = match last {
98 '.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => true,
99 ')' => count('(') < count(')'),
100 ']' => count('[') < count(']'),
101 '}' => count('{') < count('}'),
102 _ => false,
103 };
104 if !trailing {
105 break;
106 }
107 end -= last.len_utf8();
108 }
109 (end > start + scheme).then_some(start..end)
110}
111
112/// The URLs written as text in `text`, as byte ranges: OneNote links them when it opens a
113/// page, so they show and open as links without being stored as ones.
114pub fn shown_urls(text: &Paragraph) -> Vec<Range<usize>> {
115 let plain =
116 |format: &Format| ![format.hyperlink, format.hidden, format.math].contains(&Some(true));
117 let mut urls = Vec::new();
118 let mut start = 0;
119 for span in text.spans() {
120 if !plain(&span.format) {
121 start = span.end;
122 continue;
123 }
124 let run = &text.text()[start..span.end];
125 let mut at = 0;
126 for word in run.split(char::is_whitespace) {
127 // Most words hold none of what a URL starts with.
128 let candidate = word.contains([':', '\\'])
129 || word
130 .as_bytes()
131 .windows(4)
132 .any(|w| w.eq_ignore_ascii_case(b"www."));
133 if candidate && let Some(url) = typed_url(word) {
134 urls.push(start + at + url.start..start + at + url.end);
135 }
136 at += word.len()
137 + run[at + word.len()..]
138 .chars()
139 .next()
140 .map_or(0, char::len_utf8);
141 }
142 start = span.end;
143 }
144 urls
145}
146
147/// The format of the character at UTF-16 `offset`.
148pub(crate) fn format_after(text: &Paragraph, offset: u32) -> Option<&Format> {
149 let byte = text.byte_offset(offset).ok()?;
150 let length = text.text()[byte..].chars().next()?.len_utf16() as u32;
151 text.format_at(offset + length).ok()
152}
153
154/// Text that follows a link rather than extending it.
155pub(crate) fn unlinked(format: &mut Format) {
156 format.hidden = format.hidden.map(|_| false);
157 format.hyperlink = format.hyperlink.map(|_| false);
158 format.hyperlink_label = None;
159}
160
161/// A field code naming `address`, which cannot hold its quotes or line breaks.
162pub(super) fn field_code(address: &str) -> String {
163 let address: String = address
164 .trim()
165 .chars()
166 .filter(|c| !c.is_control() && *c != '\u{fddf}')
167 .map(|c| {
168 if c == '"' {
169 "%22".to_owned()
170 } else {
171 c.to_string()
172 }
173 })
174 .collect();
175 format!("\u{fddf}HYPERLINK \"{address}\"")
176}
177
178/// A range of a paragraph the Link dialog links, with the text it shows and the address it
179/// holds.
180type Linked = (usize, Range<u32>, String, String);
181
182impl CanvasEditor {
183 /// The link in the active outline at `position`, its ends included.
184 pub fn link_at(&self, position: TextPosition) -> Option<Link> {
185 let text = self
186 .active_outline()
187 .document
188 .paragraph(position.paragraph)?;
189 links(text, position.paragraph).into_iter().find(|link| {
190 link.range().contains(&position.offset) || link.label.end == position.offset
191 })
192 }
193
194 /// The address of the link drawn under document point `point` of outline `id`, which a
195 /// click opens as OneNote's does.
196 pub(crate) fn link_under(&self, id: ExGuid, [x, y]: [f32; 2]) -> Option<String> {
197 let outline = self.visible_outlines().find(|outline| outline.id == id)?;
198 let index = outline.paragraph_at(x, y).ok()?;
199 let paragraph = &outline.shaped.paragraphs[index];
200 let point = [x - paragraph.origin[0], y - paragraph.origin[1]];
201 let cursor = paragraph.text.hit_test(point[0], point[1]);
202 let edge = paragraph.text.caret(cursor, 0.0).x0 as f32;
203 let visible = paragraph.projection.text();
204 let byte = cursor.index();
205 // The character under the point lies on the side of the nearest boundary it is on.
206 let at = if point[0] < edge {
207 visible.text()[..byte].char_indices().next_back()?.0
208 } else {
209 visible.text()[byte..].chars().next()?;
210 byte
211 };
212 let (line, _) = paragraph
213 .text
214 .lines()
215 .find(|(_, bounds)| bounds.top <= point[1] && point[1] < bounds.top + bounds.height)?;
216 if point[0] > line.metrics().advance {
217 return None;
218 }
219 let source = paragraph
220 .projection
221 .source_offset(
222 visible.utf16_offset(at).ok()?,
223 onestore::page::text::Affinity::Downstream,
224 )
225 .ok()?;
226 let paragraph = outline.source_index(index);
227 links(outline.document.paragraph(paragraph)?, paragraph)
228 .into_iter()
229 .find(|link| link.label.contains(&source))
230 .map(|link| link.target)
231 .or_else(|| {
232 shown_urls(visible)
233 .into_iter()
234 .find(|url| url.contains(&at))
235 .map(|url| visible.text()[url].to_owned())
236 })
237 }
238
239 /// What the Link dialog links, as OneNote 2010 picks it: the selection within one
240 /// paragraph, else the link or word at the caret, else nothing at the caret. With the
241 /// text it shows and the address it holds; none for a selection across paragraphs, which
242 /// OneNote's Link leaves alone.
243 fn link_range(&self) -> Result<Option<Linked>, EditError> {
244 let [anchor, focus] = self.active_outline().selection.positions;
245 let (start, end) = (anchor.min(focus), anchor.max(focus));
246 if start.paragraph != end.paragraph {
247 return Ok(None);
248 }
249 let text = self
250 .active_outline()
251 .document
252 .paragraph(start.paragraph)
253 .ok_or(EditError::InvalidRange)?;
254 let shown = |range: Range<u32>| -> Result<String, EditError> {
255 Ok(text.slice(range)?.project()?.text().text().to_owned())
256 };
257 if start != end {
258 let mut range = start.offset..end.offset;
259 // Links the selection touches are relinked whole.
260 for link in links(text, start.paragraph) {
261 let whole = link.range();
262 if whole.start < range.end && range.start < whole.end {
263 range = range.start.min(whole.start)..range.end.max(whole.end);
264 }
265 }
266 let address = self
267 .link_at(start)
268 .filter(|link| link.range().start <= range.start && range.end <= link.label.end)
269 .map(|link| link.target)
270 .unwrap_or_default();
271 return Ok(Some((
272 start.paragraph,
273 range.clone(),
274 shown(range)?,
275 address,
276 )));
277 }
278 if let Some(link) = self.link_at(start) {
279 let label = shown(link.label.clone())?;
280 return Ok(Some((start.paragraph, link.range(), label, link.target)));
281 }
282 // The word at the caret, within plain visible text.
283 let byte = text.byte_offset(start.offset)?;
284 let plain = |at: usize, c: char| {
285 !c.is_whitespace()
286 && text
287 .utf16_offset(at)
288 .ok()
289 .and_then(|at| format_after(text, at))
290 .is_some_and(|format| {
291 ![format.hidden, format.hyperlink, format.math].contains(&Some(true))
292 })
293 };
294 let from = text.text()[..byte]
295 .char_indices()
296 .rev()
297 .take_while(|&(at, c)| plain(at, c))
298 .last()
299 .map_or(byte, |(at, _)| at);
300 let to = text.text()[byte..]
301 .char_indices()
302 .take_while(|&(at, c)| plain(byte + at, c))
303 .last()
304 .map_or(byte, |(at, c)| byte + at + c.len_utf8());
305 let range = text.utf16_offset(from)?..text.utf16_offset(to)?;
306 Ok(Some((
307 start.paragraph,
308 range.clone(),
309 shown(range)?,
310 String::new(),
311 )))
312 }
313
314 /// The text and address the Link dialog opens with; none where it does not open, over a
315 /// selection across paragraphs.
316 pub fn link_prefill(&self) -> Option<(String, String)> {
317 match self.link_range() {
318 Ok(Some((_, _, text, address))) => Some((text, address)),
319 Ok(None) => None,
320 Err(_) => Some(Default::default()),
321 }
322 }
323
324 /// OK in the Link dialog: what [`Self::link_prefill`] picked becomes `text`, or the
325 /// address where `text` is empty, linked to `address`, and the caret follows it.
326 pub fn set_link(
327 &mut self,
328 engine: &mut TextEngine,
329 text: &str,
330 address: &str,
331 ) -> Result<(), EditorError> {
332 if address.trim().is_empty() {
333 return Ok(());
334 }
335 let Some((paragraph, range, _, _)) = self.link_range()? else {
336 return Ok(());
337 };
338 let current = self
339 .active_outline()
340 .document
341 .paragraph(paragraph)
342 .ok_or(EditError::InvalidRange)?;
343 let label: String = if text.is_empty() {
344 address.trim()
345 } else {
346 text
347 }
348 .chars()
349 .map(|c| if c.is_control() { ' ' } else { c })
350 .collect();
351 let mut base = match format_after(current, range.start).filter(|_| !range.is_empty()) {
352 Some(format) => format.clone(),
353 None => self.typing_format(TextPosition {
354 paragraph,
355 offset: range.start,
356 })?,
357 };
358 unlinked(&mut base);
359 base.hidden = Some(false);
360 let mut code = base.clone();
361 code.hidden = Some(true);
362 code.hyperlink = Some(true);
363 code.hyperlink_label = Some(true);
364 let mut shown = base;
365 shown.hyperlink = Some(true);
366 shown.hyperlink_label = Some(true);
367 let replacement = Paragraph::from_runs([(field_code(address), code), (label, shown)]);
368 let length = replacement.utf16_offset(replacement.text().len())?;
369 let mut updated = current.clone();
370 updated.apply(onestore::page::text::Edit {
371 range: range.clone(),
372 replacement,
373 })?;
374 let caret = TextPosition {
375 paragraph,
376 offset: range.start + length,
377 };
378 self.rewrite(engine, paragraph, updated, [caret; 2].into())
379 }
380
381 /// Remove Link: the link at the caret keeps its label as plain text.
382 pub fn remove_link(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
383 let [anchor, focus] = self.active_outline().selection.positions;
384 let Some(link) = self.link_at(anchor.min(focus)) else {
385 return Ok(false);
386 };
387 let text = self
388 .active_outline()
389 .document
390 .paragraph(link.paragraph)
391 .ok_or(EditError::InvalidRange)?;
392 let start = text.byte_offset(link.label.start)?;
393 let end = text.byte_offset(link.label.end)?;
394 let mut updated = restyle(text, start..end, unlinked);
395 let mut label = link.label.clone();
396 if let Some(code) = &link.code {
397 updated.apply(onestore::page::text::Edit {
398 range: code.clone(),
399 replacement: Paragraph::new(String::new(), Format::default()),
400 })?;
401 label = label.start - code.len() as u32..label.end - code.len() as u32;
402 }
403 let at = |offset| TextPosition {
404 paragraph: link.paragraph,
405 offset,
406 };
407 self.rewrite(
408 engine,
409 link.paragraph,
410 updated,
411 [at(label.start), at(label.end)].into(),
412 )?;
413 Ok(true)
414 }
415
416 /// Select Link: the label of the link at the caret.
417 pub(crate) fn select_link(&mut self) -> Result<bool, EditError> {
418 let [anchor, focus] = self.active_outline().selection.positions;
419 let Some(link) = self.link_at(anchor.min(focus)) else {
420 return Ok(false);
421 };
422 let at = |offset| TextPosition {
423 paragraph: link.paragraph,
424 offset,
425 };
426 self.select([at(link.label.start), at(link.label.end)].into())?;
427 Ok(true)
428 }
429
430 /// Links the URL typed before `end` once a space or Enter ends it, as its own undo step.
431 pub(super) fn link_typed_url(
432 &mut self,
433 engine: &mut TextEngine,
434 end: TextPosition,
435 ) -> Result<(), EditorError> {
436 let Some(text) = self.active_outline().document.paragraph(end.paragraph) else {
437 return Ok(());
438 };
439 let byte = text.byte_offset(end.offset)?;
440 let from = text.text()[..byte]
441 .char_indices()
442 .rev()
443 .take_while(|&(_, c)| !c.is_whitespace())
444 .last()
445 .map_or(byte, |(at, _)| at);
446 let Some(url) = typed_url(&text.text()[from..byte]) else {
447 return Ok(());
448 };
449 let url = from + url.start..from + url.end;
450 // Only plain text links; a link, field code or equation there stays as it is.
451 let mut offset = text.utf16_offset(url.start)?;
452 for c in text.text()[url.clone()].chars() {
453 let format = format_after(text, offset).ok_or(EditError::InvalidRange)?;
454 if [format.hyperlink, format.hidden, format.math].contains(&Some(true)) {
455 return Ok(());
456 }
457 offset += c.len_utf16() as u32;
458 }
459 let updated = restyle(text, url, |format| format.hyperlink = Some(true));
460 let selection = self.active_outline().selection;
461 self.rewrite(engine, end.paragraph, updated, selection)
462 }
463
464 /// Moves a caret between a field code and its label to before the code, so what is
465 /// typed there stays outside the link.
466 pub(super) fn leave_link_code(&mut self) -> Result<(), EditError> {
467 let selection = self.active_outline().selection;
468 let [anchor, focus] = selection.positions;
469 if anchor != focus {
470 return Ok(());
471 }
472 let Some(link) = self.link_at(focus) else {
473 return Ok(());
474 };
475 if let Some(code) = link.code.filter(|code| code.end == focus.offset) {
476 let at = TextPosition {
477 paragraph: focus.paragraph,
478 offset: code.start,
479 };
480 self.active_outline_mut().selection = [at; 2].into();
481 }
482 Ok(())
483 }
484
485 /// Replaces the text of paragraph `paragraph` of the active outline as one undo step
486 /// ending at `selection`.
487 pub(super) fn rewrite(
488 &mut self,
489 engine: &mut TextEngine,
490 paragraph: usize,
491 text: Paragraph,
492 selection: Selection,
493 ) -> Result<(), EditorError> {
494 let (container, index, node) = self
495 .active_outline()
496 .document
497 .leaf(paragraph)
498 .ok_or(EditError::InvalidRange)?;
499 let mut node = node.clone();
500 node.text_mut().unwrap().text = text;
501 self.commit(
502 engine,
503 DocumentEdit {
504 columns: BTreeMap::new(),
505 container,
506 range: index..index + 1,
507 replacement: vec![node],
508 },
509 selection,
510 )
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use onestore::page::PageObject;
518
519 /// A paragraph's text as runs of (text, hyperlink, label, hidden), runs with the same
520 /// flags merged.
521 fn flags(text: &Paragraph) -> Vec<(String, bool, bool, bool)> {
522 let mut runs: Vec<(String, bool, bool, bool)> = Vec::new();
523 let mut start = 0;
524 for span in text.spans() {
525 let format = &span.format;
526 let run = (
527 text.text()[start..span.end].to_owned(),
528 format.hyperlink == Some(true),
529 format.hyperlink_label == Some(true),
530 format.hidden == Some(true),
531 );
532 start = span.end;
533 match runs.last_mut() {
534 Some(last) if (last.1, last.2, last.3) == (run.1, run.2, run.3) => {
535 last.0.push_str(&run.0);
536 }
537 _ => runs.push(run),
538 }
539 }
540 runs
541 }
542
543 fn native_paragraphs() -> Vec<Paragraph> {
544 let bytes = include_bytes!("../../../../corpus/link-edit/native-typed/notebook/links.one");
545 let store = onestore::Store::parse(bytes).unwrap();
546 let index = onestore::RevisionIndex::parse(&store).unwrap();
547 let document = onestore::document::Document::parse(&index).unwrap();
548 let (space, _) = document.pages().unwrap()[0];
549 let page = Page::from_space(&document, space).unwrap();
550 page.objects
551 .iter()
552 .filter_map(|object| match object {
553 PageObject::Outline(outline) if !outline.title => Some(outline),
554 _ => None,
555 })
556 .flat_map(|outline| &outline.paragraphs)
557 .filter_map(|paragraph| paragraph.text().map(|text| text.text.clone()))
558 .skip(1)
559 .collect()
560 }
561
562 /// Types `text`, each space on its own as a key does.
563 fn typed(editor: &mut CanvasEditor, engine: &mut TextEngine, text: &str) {
564 for (index, word) in text.split(' ').enumerate() {
565 if index > 0 {
566 editor.insert(engine, " ").unwrap();
567 }
568 if !word.is_empty() {
569 editor.insert(engine, word).unwrap();
570 }
571 }
572 }
573
574 fn at(paragraph: usize, offset: u32) -> Selection {
575 [TextPosition { paragraph, offset }; 2].into()
576 }
577
578 /// The editor replays what `tools/native_links.py` typed into OneNote 2010 and stores
579 /// the same links: typed URLs, the Link dialog on a word, on a selection and on nothing,
580 /// typing after a link, and Remove Link.
581 #[test]
582 fn link_editing_stores_what_onenote_stored() {
583 let native = native_paragraphs();
584 let mut engine = TextEngine::default();
585 let mut editor = CanvasEditor::new(
586 &mut engine,
587 TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(),
588 400.0,
589 )
590 .unwrap();
591 let engine = &mut engine;
592 for url in [
593 "www.example.com",
594 "http://example.org/a?b=1",
595 "https://a.example/p.",
596 "(http://b.example/q)",
597 "ftp://c.example/f",
598 "mailto:me@example.com",
599 "me@example.com",
600 "example.com",
601 "onenote:#Page&section-id={x}",
602 "file://c:/windows",
603 "\\\\server\\share\\f",
604 "news:comp.lang",
605 "www.d.example,",
606 "HTTP://E.EXAMPLE/UP",
607 "https://f.example/a_b-c~d%20e#frag",
608 ] {
609 // OneNote capitalized the first letter of each sentence as it was typed.
610 let after = if url.ends_with('.') { "Z" } else { "z" };
611 typed(&mut editor, engine, &format!("T {url} {after}"));
612 editor.enter(engine, false).unwrap();
613 }
614 typed(&mut editor, engine, "See http://g.example/end");
615 editor.enter(engine, false).unwrap();
616 typed(&mut editor, engine, "Zplain words here");
617 let paragraph = editor.selection().positions[0].paragraph;
618 editor.select(at(paragraph, 13)).unwrap();
619 assert_eq!(editor.link_prefill(), Some(("here".into(), String::new())));
620 editor.set_link(engine, "here", "example.net/x").unwrap();
621 typed(&mut editor, engine, "Q");
622 editor.enter(engine, false).unwrap();
623 typed(&mut editor, engine, "Select me please");
624 let paragraph = editor.selection().positions[0].paragraph;
625 editor
626 .select(
627 [
628 TextPosition {
629 paragraph,
630 offset: 7,
631 },
632 TextPosition {
633 paragraph,
634 offset: 16,
635 },
636 ]
637 .into(),
638 )
639 .unwrap();
640 assert_eq!(
641 editor.link_prefill(),
642 Some(("me please".into(), String::new()))
643 );
644 editor
645 .set_link(engine, "me please", "https://example.com/selected")
646 .unwrap();
647 editor.enter(engine, false).unwrap();
648 assert_eq!(editor.link_prefill(), Some((String::new(), String::new())));
649 editor
650 .set_link(engine, "", "https://example.com/only")
651 .unwrap();
652 typed(&mut editor, engine, " tail");
653 editor.enter(engine, false).unwrap();
654 typed(&mut editor, engine, "Gone ftp://h.example/f z");
655 let paragraph = editor.selection().positions[0].paragraph;
656 editor.select(at(paragraph, 8)).unwrap();
657 assert!(editor.remove_link(engine).unwrap());
658 let written: Vec<_> = editor
659 .active_outline()
660 .document
661 .text_nodes()
662 .map(|node| flags(&node.text().unwrap().text))
663 .collect();
664 let expected: Vec<_> = native.iter().map(flags).collect();
665 assert_eq!(written, expected);
666 }
667
668 /// Every URL OneNote 2010 linked or left as typed in `corpus/link-edit/native-typed`.
669 #[test]
670 fn typed_urls_link_as_onenote_links_them() {
671 for (word, linked) in [
672 ("www.example.com", Some("www.example.com")),
673 ("http://example.org/a?b=1", Some("http://example.org/a?b=1")),
674 ("https://a.example/p.", Some("https://a.example/p")),
675 ("(http://b.example/q)", Some("http://b.example/q")),
676 ("ftp://c.example/f", Some("ftp://c.example/f")),
677 ("mailto:me@example.com", Some("mailto:me@example.com")),
678 ("me@example.com", None),
679 ("example.com", None),
680 (
681 "onenote:#Page&section-id={x}",
682 Some("onenote:#Page&section-id={x}"),
683 ),
684 ("file://c:/windows", Some("file://c:/windows")),
685 ("\\\\server\\share\\f", Some("\\\\server\\share\\f")),
686 ("news:comp.lang", Some("news:comp.lang")),
687 ("www.d.example,", Some("www.d.example")),
688 ("HTTP://E.EXAMPLE/UP", Some("HTTP://E.EXAMPLE/UP")),
689 (
690 "https://f.example/a_b-c~d%20e#frag",
691 Some("https://f.example/a_b-c~d%20e#frag"),
692 ),
693 ("wwWw.example.com", None),
694 ("http://", None),
695 ] {
696 assert_eq!(typed_url(word).map(|range| &word[range]), linked, "{word}");
697 }
698 }
699}