From ba02fe4aa7b55470088f86622b3af819f65c2fb4 Mon Sep 17 00:00:00 2001 From: PathGao Date: Mon, 3 Aug 2026 17:14:29 +0800 Subject: [PATCH] refactor(rust): move lib.rs's test module below the code it tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lib.rs` opened with its helper functions, then 1852 lines of `#[cfg(test)] mod tests`, and only then the Tauri commands that are the file's reason for existing. Reading it meant scrolling past 39% of the file — assertions for code you had not reached yet — before the first `#[tauri::command]`. The module moves verbatim to the end. First command goes from line 2366 to 518. Rust does not care about item order inside a module, so this is a pure move: the 1852 lines are byte-identical, and a sorted line-by-line diff of the whole file before and after differs only by the three-line comment banner and one blank line added at the seam. setup.rs already does it this way — its own tests sit at the end, below 1117 lines of installer code — so this is the file falling in line with the convention its sibling already follows, not a new one. Nothing else changes. The source-reading tests that `include_str!` their own file (the preprocessing-step registry, the raw-buffer fail-safe check) search by string rather than by offset, so they are unaffected; `cargo test` is 149 before and after. Co-Authored-By: Claude Opus 5 --- src-tauri/src/lib.rs | 7586 +++++++++++++++++++++--------------------- 1 file changed, 3795 insertions(+), 3791 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ab848ea..53da47b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -510,4229 +510,4233 @@ fn validate_vsix_archive_limits( Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - fn temp_path(tag: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("markpad-{tag}-{nonce}")) - } +mod setup; +mod tab_transfer; +mod window_runtime; +use window_runtime::{AppState, WatcherState}; - /// A directory to work in, so the case/normalization probes below cannot - /// collide with anything else in the temp dir. - fn temp_dir(tag: &str) -> PathBuf { - let dir = temp_path(tag); - fs::create_dir_all(&dir).unwrap(); - dir - } +#[tauri::command] +async fn show_window(window: tauri::Window) { + window_runtime::show_window(window).await; +} - /// Does the volume `dir` lives on fold case? The tests below assert - /// different things depending on the answer, because the point of - /// `canonical_identity` is that it reports what the FILESYSTEM does rather - /// than what the platform usually does — a case-sensitive volume on macOS - /// and a case-insensitive volume on Linux both exist and both must work. - fn folds_case(dir: &Path) -> bool { - let upper = dir.join("CaseProbe.md"); - fs::write(&upper, b"probe").unwrap(); - let found = fs::metadata(dir.join("caseprobe.md")).is_ok(); - fs::remove_file(&upper).unwrap(); - found - } +#[tauri::command] +fn save_window_state(app: AppHandle, json: String) -> Result<(), String> { + window_runtime::save_window_state(app, json) +} - #[test] - fn canonical_identity_reports_what_the_filesystem_does_about_case() { - let dir = temp_dir("canon-case"); - let real = dir.join("Alpha.md"); - fs::write(&real, b"body").unwrap(); +#[tauri::command] +fn load_window_state(app: AppHandle) -> Option { + window_runtime::load_window_state(app) +} - let by_real = canonical_identity(&real).unwrap(); - let lowered = dir.join("alpha.md"); - // What the frontend consumes is whether these two come out EQUAL, so - // that is what gets asserted — not whether the lookup happened to - // succeed. Asking `is_err()` here tested the mechanism instead of the - // property, and got it wrong: on a case-sensitive volume the lowercase - // spelling names no file, but `canonical_identity` still answers for it - // through the parent-directory fallback that Save As depends on. - let by_lowered = canonical_identity(&lowered).ok(); +#[tauri::command] +fn clear_window_state(app: AppHandle) -> Result<(), String> { + window_runtime::clear_window_state(app) +} - // Either way, the identity is the name the DIRECTORY holds rather than - // the spelling that was asked for — otherwise the answer would depend - // on which spelling happened to be opened first. - assert_eq!(by_real.file_name().unwrap(), "Alpha.md"); +#[tauri::command] +fn set_window_meta( + window: tauri::Window, + state: State<'_, AppState>, + tag_name: Option, + tag_color: Option, + active_tab_title: String, + tab_count: usize, +) { + window_runtime::set_window_meta(window, state, tag_name, tag_color, active_tab_title, tab_count) +} - if folds_case(&dir) { - // The two spellings name ONE file: opening both must not give two - // tabs, and saving through one must not be treated as a save to a - // different file. Both reduce to the same identity string, which - // is what lets the frontend keep comparing with `===`. - assert_eq!( - by_lowered.as_ref(), - Some(&by_real), - "one file must have one identity whichever way it is spelled", - ); - } else { - // On a case-sensitive volume these are two different files and must - // keep being two. A `to_lowercase()` scheme would merge them here - // and quietly close a tab holding a genuinely different document — - // which is VS Code's open bug #123660, and the thing this whole - // approach exists to avoid. - assert_ne!( - by_lowered.as_ref(), - Some(&by_real), - "two files must keep two identities", - ); - } +#[tauri::command] +fn list_viewer_windows(state: State<'_, AppState>) -> Vec { + window_runtime::list_viewer_windows(state) +} - fs::remove_file(&real).unwrap(); - fs::remove_dir_all(&dir).unwrap(); - } +#[tauri::command] +fn offer_tab_to_window(app: AppHandle, target_label: String, token: String) -> Result<(), String> { + window_runtime::offer_tab_to_window(app, target_label, token) +} - #[test] - fn canonical_identity_folds_unicode_normalization_when_the_volume_does() { - // APFS is normalization-INSENSITIVE as well as case-insensitive: a - // file written as NFC opens under its NFD spelling and vice versa. - // Case folding cannot reach this — the two strings differ in code - // points, not in case — so it is the clearest evidence that the - // question has to go to the filesystem. - // - // The two axes are independent, and all four combinations are real: - // default APFS folds both, case-sensitive APFS folds only - // normalization, NTFS folds only case, ext4 folds neither. So this - // probes normalization on its own rather than inferring it from the - // case answer or from the platform. - let dir = temp_dir("canon-nfd"); - let nfc = dir.join("caf\u{e9}.md"); // café - let nfd = dir.join("cafe\u{301}.md"); // cafe + combining acute - fs::write(&nfc, b"body").unwrap(); +#[tauri::command] +fn focus_window(app: AppHandle, label: String) -> Result<(), String> { + window_runtime::focus_window(app, label) +} - let by_nfc = canonical_identity(&nfc).unwrap(); - let by_nfd = canonical_identity(&nfd).ok(); +#[tauri::command] +fn list_pinned_tags(app: AppHandle) -> Vec { + window_runtime::list_pinned_tags(app) +} - if fs::metadata(&nfd).is_ok() { - assert_eq!( - by_nfd.as_ref(), - Some(&by_nfc), - "one file must have one identity whichever way it is spelled", - ); - } else { - // A volume that keeps them apart really does have two files here, - // so the identities must stay apart too. As above, the assertion is - // on the identities and not on whether the lookup succeeded: the - // NFD spelling names nothing, but the parent-directory fallback - // still answers for it. - assert_ne!( - by_nfd.as_ref(), - Some(&by_nfc), - "two files must keep two identities", - ); - } +#[tauri::command] +fn save_pinned_tag(app: AppHandle, name: String, color: String, files: Vec) -> Result<(), String> { + window_runtime::save_pinned_tag(app, name, color, files) +} - fs::remove_file(&nfc).unwrap(); - fs::remove_dir_all(&dir).unwrap(); - } +#[tauri::command] +fn remove_pinned_tag(app: AppHandle, name: String) -> Result<(), String> { + window_runtime::remove_pinned_tag(app, name) +} - #[cfg(unix)] - #[test] - fn canonical_identity_resolves_a_symlink_to_its_target() { - // A link and its target are one file for every purpose Markpad has: - // `atomic_write` follows the link when it writes, so two tabs on the - // two names would be two auto-save timers on one document. - let dir = temp_dir("canon-link"); - let target = dir.join("archive.md"); - let link = dir.join("today.md"); - fs::write(&target, b"body").unwrap(); - std::os::unix::fs::symlink(&target, &link).unwrap(); +/// Byte ranges of code regions — fenced code blocks and inline code spans — +/// paired with CommonMark's rules. The regex alternation previously used for +/// protection (```` ```.*?```|`.*?` ````) cannot express them: a fence closes +/// only on a line-leading run of the same character at least as long as the +/// opener, and a span opened by N backticks closes only on a run of exactly +/// N. One mismatched pairing (e.g. a 4-backtick inline sample, or a ~~~ +/// fence, which the old pattern did not know at all) desynchronized the +/// protection for the entire rest of the document. +/// +/// The result is in ascending document order, which is not cosmetic: +/// `in_code_region` binary-searches it. That order is produced by +/// construction rather than by a sort at the end — the scan alternates +/// between the two kinds of region (the text before a fence, then the fence, +/// then the text after it), so each push is at a higher offset than the last. +/// The previous shape collected fences here and appended every inline span in +/// a second pass afterwards, which left the vector unsorted for any document +/// containing both, and made a single `sort_unstable()` call the only thing +/// standing between the search and a wrong answer. +fn code_region_ranges(content: &str) -> Vec<(usize, usize)> { + let len = content.len(); + let mut regions: Vec<(usize, usize)> = Vec::new(); + // (fence char, opener run length, region start) + let mut fence: Option<(u8, usize, usize)> = None; + let mut seg_start = 0usize; - assert_eq!( - canonical_identity(&link).unwrap(), - canonical_identity(&target).unwrap(), - ); + let mut line_start = 0usize; + while line_start < len { + let line_end = content[line_start..] + .find('\n') + .map(|i| line_start + i + 1) + .unwrap_or(len); + let line = &content[line_start..line_end]; + let trimmed = line.trim_start_matches(' '); + let indent = line.len() - trimmed.len(); + let marker = trimmed.as_bytes().first().copied(); + let run_len = trimmed + .as_bytes() + .iter() + .take_while(|&&b| Some(b) == marker) + .count(); + let is_fence_line = indent <= 3 + && matches!(marker, Some(b'`') | Some(b'~')) + && run_len >= 3; - fs::remove_file(&link).unwrap(); - fs::remove_file(&target).unwrap(); - fs::remove_dir_all(&dir).unwrap(); + match fence { + Some((ch, opener_len, start)) => { + if is_fence_line + && marker == Some(ch) + && run_len >= opener_len + && trimmed[run_len..].trim().is_empty() + { + regions.push((start, line_end)); + fence = None; + seg_start = line_end; + } + } + None => { + // The info string of a backtick fence may not contain backticks. + let info_ok = marker != Some(b'`') || !trimmed[run_len..].contains('`'); + if is_fence_line && info_ok { + // Before the fence region itself, so the two kinds of + // region stay interleaved in document order. + if seg_start < line_start { + push_inline_code_spans(content, seg_start, line_start, &mut regions); + } + fence = Some((marker.unwrap(), run_len, line_start)); + } + } + } + line_start = line_end; + } + match fence { + // An unclosed fence runs to the end of the input. + Some((_, _, start)) => regions.push((start, len)), + None => { + if seg_start < len { + push_inline_code_spans(content, seg_start, len, &mut regions); + } + } } - #[test] - fn canonical_identity_still_answers_for_a_file_that_does_not_exist_yet() { - // Save As names a file that is not there yet, so `realpath` fails on - // it. The directory is still resolvable, and that is the part that - // carries the symlinks and the `..` segments. - let dir = temp_dir("canon-new"); - let nested = dir.join("sub"); - fs::create_dir_all(&nested).unwrap(); - - let indirect = nested.join("..").join("sub").join("fresh.md"); - assert_eq!( - canonical_identity(&indirect).unwrap(), - canonical_identity(&nested).unwrap().join("fresh.md"), - ); - - // A missing DIRECTORY has no identity to report; the caller keeps the - // literal path, which is what it would have used anyway. - assert!(canonical_identity(&dir.join("nope").join("x.md")).is_err()); - - // The fallback must never manufacture the identity of a file that DOES - // exist — that would merge a Save As target into an unrelated open - // document. This is the property both branches above lean on whenever a - // volume distinguishes two spellings: the spelling that names nothing - // still gets an identity, and it has to be a different one. - // - // It runs on every platform and every volume, which matters because the - // two axes cannot all be reproduced on one machine: macOS folds Unicode - // normalization on every filesystem it mounts, so the "normalization - // distinguishes these" branch of the test above is only ever taken on - // Linux and Windows. This asserts the same underlying property here. - let existing = nested.join("taken.md"); - fs::write(&existing, b"body").unwrap(); - assert_ne!( - canonical_identity(&nested.join("not-taken.md")).unwrap(), - canonical_identity(&existing).unwrap(), - "a name that resolves to nothing must not borrow another file's identity", - ); + debug_assert!( + regions.windows(2).all(|pair| pair[0] <= pair[1]), + "code_region_ranges emitted regions out of document order, which \ + makes in_code_region's binary search miss them: {regions:?}", + ); + regions +} - fs::remove_dir_all(&dir).unwrap(); +/// Splits `content[start..end]` — a stretch of text between fences — into +/// blocks and records the inline code spans of each. +/// +/// CommonMark parses inline elements one block at a time and a blank line +/// ends a block, so pairing is confined to each blank-line-delimited chunk: a +/// stray backtick in prose must not open a span that runs on until the +/// opening backtick of a real code span paragraphs later, suppressing every +/// embed, wikilink and highlight in between. +fn push_inline_code_spans( + content: &str, + start: usize, + end: usize, + regions: &mut Vec<(usize, usize)>, +) { + let mut chunk_start = start; + let mut line_start = start; + while line_start < end { + let line_end = content[line_start..end] + .find('\n') + .map(|i| line_start + i + 1) + .unwrap_or(end); + if content[line_start..line_end].trim().is_empty() { + pair_inline_code_runs(content, chunk_start, line_start, regions); + chunk_start = line_end; + } + line_start = line_end; } + pair_inline_code_runs(content, chunk_start, end, regions); +} - #[test] - fn verbatim_prefix_is_stripped_so_paths_stay_displayable() { - // `canonicalize` returns `\\?\C:\...` on Windows. That string reaches - // the tab bar, the window title and the recent-files list, and several - // Win32 APIs reject it, so it never leaves this module. - assert_eq!( - strip_verbatim_prefix(PathBuf::from(r"\\?\C:\notes\a.md")), - PathBuf::from(r"C:\notes\a.md"), - ); - assert_eq!( - strip_verbatim_prefix(PathBuf::from(r"\\?\UNC\server\share\a.md")), - PathBuf::from(r"\\server\share\a.md"), - ); - // Untouched everywhere else. - assert_eq!( - strip_verbatim_prefix(PathBuf::from("/notes/a.md")), - PathBuf::from("/notes/a.md"), - ); +/// Records the inline code spans inside `content[start..end]`, which must be +/// a single block's worth of text. A run of N backticks pairs with the next +/// run of exactly N; runs that never pair are literal text. +fn pair_inline_code_runs(content: &str, start: usize, end: usize, regions: &mut Vec<(usize, usize)>) { + let chunk = &content.as_bytes()[start..end]; + let mut runs: Vec<(usize, usize)> = Vec::new(); // (offset in chunk, len) + let mut i = 0usize; + while i < chunk.len() { + if chunk[i] == b'`' { + let run_start = i; + while i < chunk.len() && chunk[i] == b'`' { + i += 1; + } + runs.push((run_start, i - run_start)); + } else { + i += 1; + } } - #[test] - fn atomic_write_refuses_a_read_only_target() { - // Replacing an inode by rename only needs write permission on the - // parent directory, so without an explicit check a `chmod 444` file - // would be rewritten on Unix and its read-only bit put back, while - // Windows' MoveFileExW refuses the same operation. - let path = temp_path("readonly"); - fs::write(&path, b"original").unwrap(); - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_readonly(true); - fs::set_permissions(&path, perms).unwrap(); - - let error = atomic_write(&path, b"replacement") - .expect_err("a read-only target must be refused, not silently replaced"); - assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); - assert_eq!(fs::read(&path).unwrap(), b"original"); - - // Deleting needs write permission on the directory rather than the - // file, so Unix needs no permission restore here; Windows refuses to - // delete a file that still carries the read-only attribute. - #[cfg(windows)] - { - let mut perms = fs::metadata(&path).unwrap().permissions(); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - fs::set_permissions(&path, perms).unwrap(); + let mut r = 0usize; + while r < runs.len() { + let (open_start, open_len) = runs[r]; + if let Some(close) = (r + 1..runs.len()).find(|&j| runs[j].1 == open_len) { + let (close_start, close_len) = runs[close]; + regions.push((start + open_start, start + close_start + close_len)); + r = close + 1; + } else { + r += 1; } - fs::remove_file(&path).unwrap(); } +} - #[test] - fn atomic_write_replaces_the_target_and_leaves_no_temp_file() { - let dir = temp_path("atomic-dir"); - fs::create_dir_all(&dir).unwrap(); - let path = dir.join("session.json"); - fs::write(&path, b"{\"old\":true}").unwrap(); - - atomic_write(&path, b"{\"new\":true}").unwrap(); - - assert_eq!(fs::read(&path).unwrap(), b"{\"new\":true}"); - let leftovers: Vec = fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.contains("markpad-tmp")) - .collect(); - assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); +fn in_code_region(regions: &[(usize, usize)], pos: usize) -> bool { + regions + .binary_search_by(|&(s, e)| { + if pos < s { + std::cmp::Ordering::Greater + } else if pos >= e { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} - fs::remove_dir_all(dir).unwrap(); - } +/// Picks the viewer window that should receive an externally opened file: +/// the focused viewer if any, else the viewer the user focused most +/// recently, else any viewer. The middle rung matters for Finder opens — +/// Finder is frontmost at that moment, so is_focused() is false for every +/// Markpad window and delivery would otherwise degrade to arbitrary map +/// order. Viewer windows are "main" and detached "window-*" windows; +/// "installer" never receives files. +fn pick_delivery_window(app: &AppHandle) -> Option { + window_runtime::pick_delivery_window(app) +} - #[test] - fn every_read_path_decodes_legacy_encodings_leniently() { - // "中文" in GBK. `read_to_string` rejects the whole document on the - // first invalid byte, so the same file used to open or fail purely by - // size: the truncated-preview branch has always decoded leniently. - let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4]; - assert!(String::from_utf8(gbk.to_vec()).is_err()); +/// Creates the destination window for a tab transfer. The window's label +/// embeds the transfer token ("window-"), so the new frontend can +/// derive which pending transfer to claim from its own label — no URL +/// query involved (the asset protocol 404s on "index.html?x=y" paths). +/// Deliberately async. `WebviewWindowBuilder::build()` deadlocks on Windows +/// when it runs inside a synchronous command: WebView2 needs the main thread +/// to pump messages while the webview is created, but a sync command IS the +/// main thread, blocked waiting for build() to return. The whole app then +/// freezes — no new window, no menus, an unresponsive close button +/// (tauri-apps/tauri#12521). An async command runs off the event loop, and +/// Tauri dispatches the actual window creation to the main thread itself, so +/// macOS's main-thread requirement is still satisfied. +#[tauri::command] +async fn create_transfer_window(app: AppHandle, token: String) -> Result<(), String> { + window_runtime::create_transfer_window(app, token) +} - let path = temp_path("gbk.txt"); - fs::write(&path, gbk).unwrap(); - assert!( - fs::read_to_string(&path).is_err(), - "strict decoding is expected to reject these bytes", - ); +fn process_internal_embeds(content: &str) -> Cow<'_, str> { + let regions = code_region_ranges(content); - let decoded = read_to_string_lossy(path.to_str().unwrap()) - .expect("lenient decoding must open the file instead of failing"); - assert!(decoded.content.contains('\u{FFFD}'), "got: {:?}", decoded.content); + INTERNAL_EMBED_RE.replace_all(content, |caps: &Captures| { + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); + } - fs::remove_file(path).unwrap(); - } + let inner = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + let mut parts = inner.split('|'); + let path = parts.next().unwrap_or(""); + let size = parts.next(); - // --- Decode fidelity ------------------------------------------------ - // - // Opening these files leniently is deliberate (above). What must never - // follow is writing the result back: U+FFFD is not reversible, so an - // auto-save 1.5s after the first keystroke would destroy a document that - // was merely in another encoding. Every read path therefore reports - // whether its decode was destructive, and the frontend refuses that write - // (documentSession.saveContent). These pin the reporting half. - const GBK_ZHONGWEN: &[u8] = &[0xD6, 0xD0, 0xCE, 0xC4]; + // Every interpolated value is HTML-escaped: the target comes straight + // from the document, so a quote in it would otherwise close the + // attribute and let the rest be read as markup. + let src = escape_html_attribute(&path.replace(" ", "%20")); + let alt = escape_html_attribute(path); - #[test] - fn gbk_bytes_are_reported_as_a_lossy_decode() { - let decoded = decode_utf8_lossy(GBK_ZHONGWEN.to_vec()); - assert!(decoded.lossy, "GBK bytes cannot decode faithfully as UTF-8"); - assert!( - decoded.content.contains('\u{FFFD}'), - "expected replacement characters, got {:?}", - decoded.content, - ); - } - - #[test] - fn valid_utf8_is_reported_as_faithful() { - let decoded = decode_utf8_lossy("中文".as_bytes().to_vec()); - assert!(!decoded.lossy); - assert_eq!(decoded.content, "中文"); - } - - #[test] - fn read_file_content_checked_reports_a_lossy_file() { - // The tripwire that used to assert `read_file_content` REFUSES these - // bytes. #371 made every read path lenient, so refusing is no longer - // the protection — reporting is. A caller that fills an editable - // buffer must use this command and carry the flag onto the tab. - let path = temp_path("gbk-checked.md"); - fs::write(&path, GBK_ZHONGWEN).unwrap(); + if let Some(size_str) = size { + if size_str.contains('x') { + let mut dims = size_str.split('x'); + let width = escape_html_attribute(dims.next().unwrap_or("")); + let height = escape_html_attribute(dims.next().unwrap_or("")); + format!( + "\"{}\"", + src, width, height, alt + ) + } else { + format!( + "\"{}\"", + src, + escape_html_attribute(size_str), + alt + ) + } + } else { + format!("\"{}\"", src, alt) + } + }) +} - let decoded = read_to_string_lossy(path.to_str().unwrap()).unwrap(); - fs::remove_file(&path).unwrap(); +fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { + let mut processed = Cow::Borrowed(content); - assert!( - decoded.lossy, - "read_file_content_checked returned mojibake without reporting it", - ); - assert!(decoded.content.contains('\u{FFFD}')); - } + // 1. Process [[#heading]], [[file#heading]] and the |alias form of each. + // Obsidian documents all of these (help.obsidian.md/links: + // "[[About Obsidian#Links are first-class citizens]]", + // "[[2023-01-01#^37066d]]", "[[Example#Details|Section name]]"), and + // the file form is what Markpad's own "Copy Reference" menu item puts + // on the clipboard — it used to paste back in as dead literal text. + // + // Obsidian's bare note link "[[Notes]]" is NOT handled: it is a + // separate feature rather than part of this defect, and claiming every + // "[[…]]" would capture bracketed citation numbering ("[[1]]") and + // pre-empt CommonMark reference links ("[[foo]]" with a "[foo]: url" + // definition). Every Copy Reference call site emits a "#", so requiring + // one fixes the defect completely without touching either. + if WIKILINK_RE.is_match(&processed) { + let regions = code_region_ranges(&processed); + let source: &str = &processed; + let replaced = WIKILINK_RE.replace_all(source, |caps: &Captures| { + let full = caps.get(0).unwrap(); + let literal = || full.as_str().to_string(); + if in_code_region(®ions, full.start()) { + return literal(); + } + // The pattern is line-agnostic, but neither a heading id nor a + // filename contains a newline, so a target spanning lines can + // never resolve. Leaving it literal also keeps the line count + // stable — rewriting it to a single line would shift the source + // positions of every task checkbox below it (see + // `multiline_wikilinks_do_not_shift_task_source_positions`). + if full.as_str().contains('\n') { + return literal(); + } + // `![[…]]` is an embed, already rewritten by + // process_internal_embeds; it is never a link. + if source.as_bytes()[..full.start()].last() == Some(&b'!') { + return literal(); + } + // "[[1#x]](https://example.com)" is a CommonMark link whose text + // is "[1#x]"; claiming the brackets would strand the "(url)". + // Requiring a "#" already protects the common citation spelling + // "[[1]](url)", but not the forms that do carry one. + if source[full.end()..].starts_with('(') { + return literal(); + } - #[test] - fn a_small_non_utf8_file_is_flagged_by_the_preview_too() { - // The ≤max_bytes branch. Before #371 it decoded strictly and could - // only fail, so it needed no flag; now it opens the same mojibake the - // truncated branch always did, and needs the same guard. - let path = temp_path("gbk-small.md"); - let mut bytes = b"# ".to_vec(); - bytes.extend_from_slice(GBK_ZHONGWEN); - fs::write(&path, &bytes).unwrap(); + let inner = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + let (target, alias) = match inner.split_once('|') { + Some((target, alias)) => (target, Some(alias)), + None => (inner, None), + }; + let alias = alias.filter(|a| !a.trim().is_empty()); + // The `#` the pattern matched may have been in the alias half + // ("[[Notes|see #1]]"), which is a bare note link, not a heading + // link. Only a `#` in the target counts. + let Some((path, heading)) = target.split_once('#') else { + return literal(); + }; + let (path, heading) = (path.trim(), heading.trim()); + if heading.is_empty() { + return literal(); + } + let anchor = heading_anchor_id(heading); - let preview = build_markdown_preview(&path, 50_000).unwrap(); - fs::remove_file(&path).unwrap(); + if path.is_empty() { + // Same document: [[#Setup]] / [[#Setup|jump]]. + return format!("[{}](#{anchor})", alias.unwrap_or(heading)); + } - assert!(preview.is_full, "this file fits in the preview budget"); - assert!(preview.lossy, "the save guard has nothing to go on without this"); - assert!(preview.content.contains('\u{FFFD}')); + let Some(destination) = wikilink_file_destination(path) else { + return literal(); + }; + // Obsidian renders an un-aliased heading link as "Note > Heading"; + // keeping that spelling means a pasted reference reads the same in + // both apps. + format!( + "[{}]({destination}#{anchor})", + alias + .map(str::to_string) + .unwrap_or_else(|| format!("{path} > {heading}")), + ) + }); + processed = Cow::Owned(replaced.into_owned()); } - #[test] - fn an_oversized_non_utf8_preview_is_flagged() { - let path = temp_path("gbk-preview.md"); - let mut bytes = b"# ".to_vec(); - bytes.extend_from_slice(GBK_ZHONGWEN); - bytes.extend_from_slice(b"\nplus enough text to exceed the preview budget\n"); - fs::write(&path, &bytes).unwrap(); - - let preview = build_markdown_preview(&path, 8).unwrap(); - fs::remove_file(&path).unwrap(); - - assert!(!preview.is_full); - assert!(preview.lossy); - assert!(preview.content.contains('\u{FFFD}')); + // 2. Process ^block-id at the end of lines + // For block IDs, they are trailing. We skip code blocks but also need to be careful with inline code at EOL. + if BLOCK_ID_RE.is_match(&processed) { + let regions = code_region_ranges(&processed); + let replaced = BLOCK_ID_RE.replace_all(&processed, |caps: &Captures| { + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); + } + // Re-emit the matched whitespace verbatim. For the common + // trailing form (" ^id") that is the same single space this used + // to hardcode; for an id on its own line it is the newline that + // keeps the following lines at their original numbers. + let leading = caps.get(1).map(|m| m.as_str()).unwrap_or(" "); + let id = caps.get(2).map(|m| m.as_str()).unwrap_or(""); + format!( + "{}", + leading, id, id + ) + }); + processed = Cow::Owned(replaced.into_owned()); } - #[test] - fn an_oversized_utf8_preview_is_not_flagged() { - // The preview budget lands inside a multi-byte character. Reporting - // that as lossy would lock every large CJK or emoji document out of - // saving — a guard worse than the bug it protects against. - let path = temp_path("utf8-preview.md"); - fs::write(&path, "中文标题很长".as_bytes()).unwrap(); - - let preview = build_markdown_preview(&path, 4).unwrap(); - fs::remove_file(&path).unwrap(); - - assert!(!preview.is_full); - assert!(!preview.lossy, "unexpected flag on {:?}", preview.content); - assert_eq!(preview.content, "中"); + // 3. Convert ==highlight== to highlight + if HIGHLIGHT_RE.is_match(&processed) { + let regions = code_region_ranges(&processed); + let replaced = HIGHLIGHT_RE.replace_all(&processed, |caps: &Captures| { + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); + } + format!("{}", caps.get(1).unwrap().as_str()) + }); + processed = Cow::Owned(replaced.into_owned()); } - #[test] - fn truncation_boundary_drops_only_a_split_trailing_character() { - assert_eq!(utf8_truncation_boundary(&[]), 0); - assert_eq!(utf8_truncation_boundary(b"ab"), 2); - - let three_byte = "中".as_bytes(); - assert_eq!(utf8_truncation_boundary(three_byte), 3); - assert_eq!(utf8_truncation_boundary(&three_byte[..2]), 0); - assert_eq!(utf8_truncation_boundary(&three_byte[..1]), 0); + // 4. Convert ^[inline footnote] to a footnote reference + if INLINE_FOOTNOTE_RE.is_match(&processed) { + let regions = code_region_ranges(&processed); + let mut footnote_defs = String::new(); + let mut fn_count = 0usize; + let replaced = INLINE_FOOTNOTE_RE.replace_all(&processed, |caps: &Captures| { + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); + } + fn_count += 1; + let label = format!("ifn-{}", fn_count); + footnote_defs.push_str(&format!( + "\n[^{}]: {}\n", + label, + caps.get(1).unwrap().as_str() + )); + format!("[^{}]", label) + }); + let mut out = replaced.into_owned(); + out.push_str(&footnote_defs); + processed = Cow::Owned(out); + } - let four_byte = "🙂".as_bytes(); - assert_eq!(utf8_truncation_boundary(four_byte), 4); - assert_eq!(utf8_truncation_boundary(&four_byte[..3]), 0); + processed +} - // Only the tail is affected; earlier bytes are kept. - let mixed = "ab中".as_bytes(); - assert_eq!(utf8_truncation_boundary(&mixed[..4]), 2); +fn process_parenthesized_autolinks(content: &str) -> Cow<'_, str> { + let regions = code_region_ranges(content); + let mut output = String::new(); + let mut copied_to = 0; + let mut scan_from = 0; - // A buffer that is not UTF-8 at all must still yield a full-length - // preview; at most the last three bytes can ever be dropped. - let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4]; - assert!(utf8_truncation_boundary(&gbk) >= gbk.len() - 3); - } - - #[test] - fn zip_entry_reads_stop_at_the_limit_even_when_the_header_understates_size() { - let payload = vec![b'a'; 64]; - assert_eq!( - read_zip_entry_to_string(payload.as_slice(), 64).unwrap().len(), - 64, - ); - assert!( - read_zip_entry_to_string(payload.as_slice(), 32).is_err(), - "an entry larger than the ceiling must be rejected, not buffered", - ); - } - - #[test] - fn export_data_url_uses_mime_from_extension_case_insensitively() { - assert_eq!(mime_type_for_export_path(Path::new("diagram.PNG")), "image/png"); - assert_eq!(mime_type_for_export_path(Path::new("photo.JpEg")), "image/jpeg"); - assert_eq!(mime_type_for_export_path(Path::new("vector.svg")), "image/svg+xml"); - assert_eq!(mime_type_for_export_path(Path::new("unknown.bin")), "application/octet-stream"); - } + while let Some(opening_offset) = content[scan_from..].find('(') { + let opening = scan_from + opening_offset; + let url_start = opening + 1; + let url_tail = &content[url_start..]; + if !(url_tail.starts_with("http://") + || url_tail.starts_with("https://") + || url_tail.starts_with("ftp://")) + { + scan_from = url_start; + continue; + } - #[test] - fn export_data_url_encodes_bytes_with_mime() { - assert_eq!( - file_bytes_to_data_url("image/png", b"Markpad"), - "data:image/png;base64,TWFya3BhZA==", - ); - } + let mut depth = 1usize; + let mut closing = None; + for (offset, ch) in url_tail.char_indices() { + if ch.is_whitespace() { + break; + } + match ch { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + closing = Some(url_start + offset); + break; + } + } + _ => {} + } + } - #[test] - fn task_list_checkbox_is_emitted_at_the_start_of_its_list_item() { - // The attribute order here is comrak's, captured, not a requirement: - // 0.18 wrote `disabled="" checked=""` and 0.54 writes them the other - // way round. What this pins is that `data-task-checkbox` is present on - // *both* items and that the marker sits at the start of the `
  • `. - // `TASK_ITEM_RE` deliberately no longer depends on the order, so a - // future reordering fails here — loudly — instead of quietly - // un-marking one of the two. - let html = convert_markdown("- [ ] open task\n- [x] completed task\n"); - assert!( - html.contains("
  • open task
  • "), - "unexpected task-list HTML: {html}", - ); - assert!( - html.contains("
  • completed task
  • "), - "unexpected task-list HTML: {html}", - ); - } + let Some(closing) = closing else { + scan_from = url_start; + continue; + }; + let after_closing = closing + ')'.len_utf8(); + let adjacent_text = content[after_closing..] + .chars() + .next() + .is_some_and(char::is_alphanumeric); + if !adjacent_text || in_code_region(®ions, opening) { + scan_from = after_closing; + continue; + } - #[test] - fn raw_html_checkboxes_are_not_marked_as_tasks() { - let html = convert_markdown("- raw control\n"); - assert!( - !html.contains("data-task-checkbox"), - "raw HTML control was incorrectly marked as a task: {html}", - ); + let url = &content[url_start..closing]; + output.push_str(&content[copied_to..url_start]); + output.push('['); + output.push_str(url); + output.push_str("]("); + output.push_str(url); + output.push_str(")"); + output.push(')'); + copied_to = after_closing; + scan_from = after_closing; } - #[test] - fn nested_and_quoted_task_checkboxes_are_marked() { - let html = convert_markdown("- [ ] parent\n - [x] nested\n\n> - [ ] quoted\n"); - assert_eq!( - html.matches("data-task-checkbox").count(), - 3, - "unexpected task-list HTML: {html}", - ); + if output.is_empty() { + Cow::Borrowed(content) + } else { + output.push_str(&content[copied_to..]); + Cow::Owned(output) } +} - #[test] - fn markdown_protocol_preserves_task_markers_for_many_source_lines() { - let markdown = (1..=64) - .map(|line| match line % 3 { - 0 => format!("> - [ ] quoted task {line}"), - 1 => format!("- [ ] task {line}"), - _ => format!(" - [x] nested task {line}"), - }) - .collect::>() - .join("\n"); +// --------------------------------------------------------------------------- +// Math spans +// +// comrak keeps applying CommonMark inline rules *inside* math delimiters, so +// the formula KaTeX finally sees has already been rewritten. Three reported +// bugs are the same bug: +// +// #174 `$\bar{b}_{1} + \bar{b}_{2}$` — the two `_` pair into ``, which +// is why only the first subscript ever worked and why putting a space +// in front of it "fixed" it (a space makes the `_` non-left-flanking). +// #197 `$$ … \\ … $$` — `\\` is a CommonMark escape for a literal `\`, so +// every row separator of an `aligned` block is eaten and the whole +// block collapses onto one over-wide line. +// #177 `\%` loses its backslash, and a bare `%` starts a TeX comment that +// swallows the rest of the formula ("Unexpected end of input"). +// +// Patching one character class at a time (the previous +// `protect_display_math_underscores` only rewrote `_`, and only between `$$`) +// cannot win: Markdown has no business parsing TeX at all. So the whole span +// is replaced by an opaque token before comrak runs and put back afterwards. +// +// Escaped dollars are hidden the same way, for the mirror-image reason. A +// reader who writes `\$\$x\$\$` is saying "not a formula", and CommonMark +// resolving that escape destroys the only evidence of it: the frontend, which +// is the side that actually decides, then sees the same bytes a real `$$x$$` +// produces and typesets the reader's dollar signs. See +// `find_escaped_dollar_spans`. +// +// Deliberately NOT handled here: `\(…\)` and `\[…\]`, which the frontend also +// renders. They have the same root cause and are not an oversight — CommonMark +// eats the backslash (`\(` → `(`) before the frontend ever sees a delimiter, so +// they have never worked in Markpad at all. Fixing them is a separate change +// with its own regression surface; masking them here would silently start +// claiming text that no released version ever treated as math. That the +// frontend *emits* those two spellings is a different matter: they are its +// private vocabulary for a decision it has already made. +// +// The token is deliberately plain ASCII rather than a private-use character: +// comrak percent-encodes anything non-ASCII that ends up in a link +// destination (`http://x/$a$` would come back as `http://x/%EE%80%80…`), +// while `[A-Z0-9]` survives text nodes, attribute values and hrefs verbatim +// and carries no CommonMark meaning. Uniqueness is established by +// construction instead of by luck: the prefix grows until it does not occur +// in the document. +// --------------------------------------------------------------------------- - let html = convert_markdown(&markdown); - assert_eq!(html.matches("data-task-checkbox").count(), 64, "{html}"); - assert!(html.contains("data-sourcepos=\"64:1-64:"), "{html}"); - } +const MATH_MASK_PREFIX: &str = "MPMATHMASK"; +const MATH_MASK_SUFFIX: char = 'E'; - #[test] - fn multiline_wikilinks_do_not_shift_task_source_positions() { - let html = convert_markdown("[[#first\nsecond|alias]]\n- [ ] task\n"); - assert!( - html.contains("data-task-checkbox"), - "task source position was shifted by a multiline wikilink: {html}", - ); - } +struct MaskedMath { + /// The source with every math span and every escaped dollar replaced by a + /// token. + text: String, + /// The token prefix actually used — see `mask_math_spans`. + prefix: String, + /// The masked source, one entry per line of each span, indexed by token. + spans: Vec, +} - #[test] - fn embed_protection_survives_longer_backtick_runs_earlier_in_the_doc() { - // A 4-backtick inline sample desynchronized the old regex pairing and - // exposed every later code span to rewriting. - let input = "```` ```mermaid ```` fence sample\n\ncode: `![[not-an-embed.md]]`\n"; - let out = process_internal_embeds(input); - assert!(out.contains("`![[not-an-embed.md]]`"), "got: {out}"); - assert!(!out.contains(" Vec<(usize, usize)> { + let len = content.len(); + let mut segments = Vec::new(); + let mut line_start = 0usize; + // `regions` is sorted and both loops only move forward, so each region is + // visited once across the whole document rather than once per line. + let mut first_region = 0usize; + loop { + let newline = content[line_start..] + .find('\n') + .map(|offset| line_start + offset) + .unwrap_or(len); + let mut line_end = newline; + if line_end > line_start && content.as_bytes()[line_end - 1] == b'\r' { + line_end -= 1; + } - #[test] - fn fence_closes_only_on_a_run_at_least_as_long() { - let input = "````\n```\n![[still-code.md]]\n````\n![[after.md]]\n"; - let out = process_internal_embeds(input); - assert!(out.contains("![[still-code.md]]"), "got: {out}"); - assert!(out.contains("= line_end { + break; + } + if region_start > cursor { + segments.push((cursor, region_start.min(line_end))); + } + cursor = cursor.max(region_end); + if cursor >= line_end { + break; + } + } + if cursor < line_end { + segments.push((cursor, line_end)); + } - #[test] - fn double_backtick_span_pairs_only_with_double_backticks() { - // `` a ` b `` is ONE span; the inner single backtick does not close it. - let input = "`` a ` ![[in-span.md]] `` then ![[outside.md]]\n"; - let out = process_internal_embeds(input); - assert!(out.contains("![[in-span.md]]"), "got: {out}"); - assert!(out.contains("= len { + break; + } + line_start = newline + 1; } + segments +} - #[test] - fn embeds_outside_code_are_still_rewritten_with_sizes() { - let out = process_internal_embeds("![[pic.png|300x200]]\n"); - assert!(out.contains("width=\"300\""), "got: {out}"); - assert!(out.contains("height=\"200\""), "got: {out}"); +fn char_before(content: &str, low: usize, at: usize) -> Option { + if at <= low { + return None; } + content[low..at].chars().next_back() +} - #[test] - fn highlight_protection_survives_quadruple_backtick_inline_code() { - let input = "```` ``` ```` intro\n\n`==not highlighted==` but ==this is==\n"; - let out = process_wikilinks(input); - assert!(out.contains("`==not highlighted==`"), "got: {out}"); - assert!(out.contains("this is"), "got: {out}"); +fn char_after(content: &str, high: usize, dollar: usize) -> Option { + let next = dollar + 1; + if next >= high { + return None; } + content[next..high].chars().next() +} - #[test] - fn wikilinks_and_inline_footnotes_in_code_spans_stay_literal() { - let input = "`[[#heading]]` and `^[not a footnote]` but [[#real|jump]]\n"; - let out = process_wikilinks(input); - assert!(out.contains("`[[#heading]]`"), "got: {out}"); - assert!(out.contains("`^[not a footnote]`"), "got: {out}"); - assert!(out.contains("[jump](#real)"), "got: {out}"); +/// The closing `$` of an inline span opened at `open`, or `None`. +/// +/// A faithful port of `findInlineMathEnd` in `src/lib/utils/markdown.ts`, and +/// the reason `$100 and $200` is not math: the first candidate closer decides, +/// and a candidate preceded by whitespace or followed by a digit does not +/// merely get skipped — it abandons the span. Anything looser turns ordinary +/// prices into formulas, which is a far worse failure than the bug being +/// fixed here. +fn find_inline_close(content: &str, low: usize, high: usize, from: usize) -> Option { + let bytes = content.as_bytes(); + let mut index = from; + while index < high { + if bytes[index] != b'$' { + index += 1; + continue; + } + let before = char_before(content, low, index); + if before == Some('\\') { + index += 1; + continue; + } + if before.is_some_and(char::is_whitespace) { + return None; + } + if char_after(content, high, index).is_some_and(|c| c.is_ascii_digit()) { + return None; + } + return Some(index); } + None +} - /// A document that has BOTH kinds of code region, with the inline span at - /// a lower offset than the fence. - /// - /// `in_code_region` is a binary search, so `code_region_ranges` has to - /// emit its regions in document order. The two kinds are found by - /// different parts of the scan — fences by the line walk, inline spans by - /// `push_inline_code_spans` over the text between fences — and a build - /// order that appends all of one kind after all of the other leaves the - /// vector unsorted for exactly this shape of document. The binary search - /// then walks straight past the fence and every marker inside it is - /// reported as ordinary prose (#375 / #389 all over again). - /// - /// Every marker below is checked, not just one. `process_wikilinks` runs - /// a separate pass per marker kind and each probes `in_code_region` at its - /// own offset, so a probe that happens to land inside the region does not - /// say anything about the probes beside it. - const FENCE_AFTER_INLINE_CODE: &str = concat!( - "Prose with `a code span` in it.\n", - "\n", - "```text\n", - "![[embed.md]]\n", - "[[wikilink]]\n", - "==highlight==\n", - "^[footnote]\n", - "```\n", - ); - - #[test] - fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_embeds() { - let out = process_internal_embeds(FENCE_AFTER_INLINE_CODE); - assert!( - out.contains("![[embed.md]]") && !out.contains(" Option { + let bytes = content.as_bytes(); + let len = content.len(); - #[test] - fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_wikilinks() { - let out = process_wikilinks(FENCE_AFTER_INLINE_CODE); - for marker in ["[[wikilink]]", "==highlight==", "^[footnote]"] { - assert!( - out.contains(marker), - "`{marker}` inside the fence was rewritten — code regions \ - reached `in_code_region` out of document order: {out}", - ); + let mut index = open + 2; + while index + 1 < segment_end { + if bytes[index] == b'$' && bytes[index + 1] == b'$' && bytes[index - 1] != b'\\' { + return Some(index + 2); } - // The inline span itself is still protected, and still a span. - assert!(out.contains("`a code span`"), "got: {out}"); + index += 1; } - #[test] - fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_autolinks() { - // The third consumer. A bare URL inside a fence must stay text; - // comrak's own autolinker never sees a code block. - let input = "Prose with `a code span` in it.\n\n```text\n(https://example.com/x)y\n```\n"; - let out = process_parenthesized_autolinks(input); - assert!( - !out.contains("]("), - "a URL inside the fence was linkified: {out}", - ); + let (opener_line_end, mut next_line_start) = line_bounds(content, open); + if !content[open + 2..opener_line_end].trim().is_empty() { + return None; } - - #[test] - fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_math() { - // The fourth consumer. `mask_math_spans` hides math from CommonMark's - // inline rules; dollars inside a fence are not math and masking them - // rewrites the code block the user typed. - let input = "Prose with `a code span` in it.\n\n```text\n$x_1$ and $y_2$\n```\n"; - let masked = mask_math_spans(input); - assert_eq!( - masked.text, input, - "dollars inside the fence were masked as math", - ); + while next_line_start < len { + let (line_end, following) = line_bounds(content, next_line_start); + let line = &content[next_line_start..line_end]; + if line.trim().is_empty() { + return None; + } + if regions + .iter() + .any(|&(start, end)| start < line_end && end > next_line_start) + { + return None; + } + if let Some(offset) = line.find("$$") { + let close = next_line_start + offset; + if content[next_line_start..close].trim().is_empty() + && content[close + 2..line_end].trim().is_empty() + { + return Some(close + 2); + } + } + next_line_start = following; } + None +} - #[test] - fn multibyte_content_inside_a_fence_does_not_panic() { - let input = "```text\n中文开头的一行\n```\n\n![[outside.png]]\n"; - let result = std::panic::catch_unwind(|| process_internal_embeds(input)); - - let out = result.expect("fenced multibyte content must not panic"); - assert!(out.contains("中文开头的一行"), "got: {out}"); - assert!(out.contains(" (usize, usize) { + let len = content.len(); + let newline = content[at..] + .find('\n') + .map(|offset| at + offset) + .unwrap_or(len); + let mut line_end = newline; + if line_end > 0 && content.as_bytes()[line_end - 1] == b'\r' { + line_end -= 1; } + (line_end, (newline + 1).min(len)) +} - #[test] - fn autolink_inside_parentheses_stops_before_adjacent_text() { - let input = "See (https://www.speedtest.net/awards/united_states/)for more information."; - let html = convert_markdown(input); +/// The math spans of `content`, as sorted, non-overlapping byte ranges. +/// +/// A port of `convertInlineMathDelimiters` in `src/lib/utils/markdown.ts`, +/// which is the only thing that decides what the user actually sees rendered. +/// Recognising a span here that the frontend will not render would strip the +/// Markdown out of ordinary prose; recognising less would leave the formula +/// mangled — so the rules have to be the same rules. +fn find_math_spans(content: &str, regions: &[(usize, usize)]) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut spans: Vec<(usize, usize)> = Vec::new(); + let mut barrier = 0usize; - assert!( - html.contains("href=\"https://www.speedtest.net/awards/united_states/\""), - "got: {html}" - ); - assert!(html.contains(")for more information."), "got: {html}"); - assert!( - !html.contains("href=\"https://www.speedtest.net/awards/united_states/)for\""), - "got: {html}" - ); - } - - #[test] - fn path_components_reject_traversal_separators_and_absolute_paths() { - for invalid in ["", ".", "..", "../theme", "folder/theme", "folder\\theme", "/tmp/theme"] { - assert!(safe_path_component(invalid, "test").is_err(), "{invalid}"); + for (segment_start, segment_end) in math_scan_segments(content, regions) { + if segment_end <= barrier { + continue; } - assert_eq!(safe_path_component("SynthWave '84", "test").unwrap(), "SynthWave '84"); - } + // A multi-line span already consumed the head of this line. + let low = segment_start.max(barrier); + let mut index = low; + // Lets `$a$$b$` open a second span while keeping `$$` itself out of it. + let mut previous_dollar_allows_open = false; - #[cfg(unix)] - #[test] - fn image_directory_rejects_symlink_escape() { - use std::os::unix::fs::symlink; + while index < segment_end { + if bytes[index] != b'$' { + previous_dollar_allows_open = false; + index += content[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + let before = char_before(content, low, index); + let after = char_after(content, segment_end, index); - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let root = std::env::temp_dir().join(format!("markpad-path-root-{nonce}")); - let outside = std::env::temp_dir().join(format!("markpad-path-outside-{nonce}")); - fs::create_dir_all(&root).unwrap(); - fs::create_dir_all(&outside).unwrap(); - symlink(&outside, root.join("images")).unwrap(); + if before != Some('\\') && after == Some('$') { + match find_display_close(content, regions, index, segment_end) { + Some(end) => { + spans.push((index, end)); + if end > segment_end { + barrier = end; + break; + } + previous_dollar_allows_open = true; + index = end; + } + None => { + previous_dollar_allows_open = false; + index += 2; + } + } + continue; + } - assert!(resolve_image_directory(root.to_str().unwrap(), "images").is_err()); + if before == Some('\\') + || (before == Some('$') && !previous_dollar_allows_open) + || after.is_some_and(char::is_whitespace) + { + previous_dollar_allows_open = false; + index += 1; + continue; + } - fs::remove_dir_all(root).unwrap(); - fs::remove_dir_all(outside).unwrap(); + match find_inline_close(content, low, segment_end, index + 1) { + Some(close) => { + spans.push((index, close + 1)); + previous_dollar_allows_open = true; + index = close + 1; + } + None => { + previous_dollar_allows_open = false; + index += 1; + } + } + } } + spans +} - #[test] - fn theme_slug_collapses_punctuation_runs() { - assert_eq!(theme_slug("SynthWave '84"), "synthwave-84"); +/// The escaped dollars of `content`: a `$` carrying a run of backslashes in +/// front of it, masked together with the whole run. +/// +/// Declining to treat `\$` as a delimiter — which `find_math_spans` already +/// does — protects the formula that is not there, and nothing else. comrak +/// still resolves the escape, so `\$\$x\$\$` reaches the frontend as the same +/// eight bytes an unescaped `$$x$$` does, and from that point on no rule can +/// tell the two apart: the frontend renders the reader's literal dollars as a +/// formula. `convertInlineMathDelimiters` has carried a "a `$` behind a +/// backslash is not a delimiter" branch since before #402, and it has never +/// once been able to fire, because the backslash is gone by the time the +/// frontend looks. +/// +/// So the escape is hidden from comrak exactly the way math is, and put back +/// verbatim. The frontend gets its backslash, its dead branch comes alive, and +/// resolving the escape becomes the job of the side that also decides what is +/// math — which is the only way the two decisions can agree. +/// +/// The whole backslash run is taken, not just the last one, so that `\\$` +/// (an escaped backslash, then a live dollar) is not mistaken for `\$` after +/// comrak has halved the run. Ranges inside a math span are skipped: there a +/// `\$` is TeX for a dollar sign, and the math span already shields it. +fn find_escaped_dollar_spans( + content: &str, + regions: &[(usize, usize)], + math: &[(usize, usize)], +) -> Vec<(usize, usize)> { + let bytes = content.as_bytes(); + let mut spans = Vec::new(); + for (segment_start, segment_end) in math_scan_segments(content, regions) { + let mut index = segment_start; + while index < segment_end { + if bytes[index] != b'\\' { + index += content[index..].chars().next().map_or(1, char::len_utf8); + continue; + } + let mut run_end = index; + while run_end < segment_end && bytes[run_end] == b'\\' { + run_end += 1; + } + if run_end < segment_end && bytes[run_end] == b'$' { + if !math.iter().any(|&(start, end)| start < run_end + 1 && end > index) { + spans.push((index, run_end + 1)); + } + index = run_end + 1; + } else { + index = run_end; + } + } } + spans +} - #[test] - fn display_math_keeps_multiple_braced_subscripts_out_of_markdown_emphasis() { - let html = convert_markdown("$$\\bar{b}_{1} + \\bar{b}_{2}$$\n"); - assert!( - html.contains("$$\\bar{b}_{1} + \\bar{b}_{2}$$"), - "unexpected parser output: {html}", - ); - assert!(!html.contains(" String { + let backslashes = span.len() - 1; + let mut out = "\\".repeat(backslashes / 2); + out.push('$'); + out +} + +/// Replaces every math span — and every escaped dollar — with a token comrak +/// cannot rewrite. +/// +/// One token per line of a span, so a six-line `$$…$$` block still occupies +/// six lines: the line-number contract in `mod tests` is not negotiable, and a +/// span collapsed into a single token would move every task checkbox below it. +/// Leading indentation stays outside the token so that a formula inside a list +/// item keeps belonging to that item. +fn mask_math_spans(content: &str) -> MaskedMath { + // Case-insensitively, because comrak lowercases the token again when it + // derives a heading id from it — see `restore_math_spans`. + let haystack = content.to_ascii_lowercase(); + let mut prefix = String::from(MATH_MASK_PREFIX); + while haystack.contains(&prefix.to_ascii_lowercase()) { + prefix.push('X'); } - // ----------------------------------------------------------------- - // Math delimiters - // - // comrak runs CommonMark inline rules inside math delimiters, so KaTeX - // never sees what the user typed. The three cases below are the - // reported symptoms of that one cause; the block after them is the - // other half of the bargain, because a rule that mistakes prose for - // math breaks far more documents than the bug it fixes. - // ----------------------------------------------------------------- + let regions = code_region_ranges(content); + let math = find_math_spans(content, ®ions); + // Both halves of one decision: what the frontend must render, and what it + // must refuse to render. Hiding only the first half is what let an + // explicitly escaped `\$\$x\$\$` come out typeset. + let mut found: Vec<(usize, usize, bool)> = math + .iter() + .map(|&(start, end)| (start, end, false)) + .chain( + find_escaped_dollar_spans(content, ®ions, &math) + .into_iter() + .map(|(start, end)| (start, end, true)), + ) + .collect(); + found.sort_by_key(|&(start, _, _)| start); + if found.is_empty() { + return MaskedMath { + text: content.to_owned(), + prefix, + spans: Vec::new(), + }; + } - /// The rendered text with the markup taken back out, i.e. roughly what - /// `textContent` hands to KaTeX in the frontend. - fn rendered_math_source(html: &str) -> String { - let mut out = String::new(); - let mut rest = html; - while let Some(at) = rest.find('<') { - out.push_str(&rest[..at]); - match rest[at..].find('>') { - Some(end) => rest = &rest[at + end + 1..], - None => { - rest = ""; - break; + let mut text = String::with_capacity(content.len()); + let mut spans: Vec = Vec::new(); + let mut copied_to = 0usize; + for (start, end, escaped) in found { + text.push_str(&content[copied_to..start]); + for piece in content[start..end].split_inclusive('\n') { + let mut body = piece; + let mut line_ending = ""; + if let Some(stripped) = body.strip_suffix('\n') { + body = stripped; + line_ending = "\n"; + if let Some(stripped) = body.strip_suffix('\r') { + body = stripped; + line_ending = "\r\n"; } } + let indent = body.len() - body.trim_start().len(); + text.push_str(&body[..indent]); + let core = &body[indent..]; + if !core.is_empty() { + text.push_str(&format!("{prefix}{}{MATH_MASK_SUFFIX}", spans.len())); + spans.push(MaskedSpan { + attribute: if escaped { + resolve_escaped_dollar(core) + } else { + core.to_owned() + }, + text: core.to_owned(), + }); + } + text.push_str(line_ending); } - out.push_str(rest); - out.replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("&", "&") + copied_to = end; } + text.push_str(&content[copied_to..]); - #[test] - fn issue_174_inline_math_keeps_both_braced_subscripts() { - // `$\bar{b}_{1} + \bar{b}_{2}$` — the two `_` are both left- and - // right-flanking, so CommonMark pairs them into an `` and KaTeX - // receives `$\bar{b}{1} + \bar{b}{2}$`. That is the whole of - // "only the first subscript can use braces", and why a space before - // the first `_` appeared to fix it. - let html = convert_markdown("Let $\\bar{b}_{1} + \\bar{b}_{2}$ be the estimates.\n"); - assert!(!html.contains("` per row. - let markdown = "$$\n\\begin{aligned}\nx &= 1\n\\end{aligned}\n$$\n\n- [ ] task\n"; - let html = convert_markdown(markdown); - assert_eq!(html.matches("\\$x\\$"), - "the mask reached into a code span: {html}", - ); - } - - #[test] - fn dollars_inside_code_never_open_a_math_span() { - // A single `$$` inside a fence used to flip the delimiter parity of - // the entire document, because the old protection just split on `$$`. - let markdown = "```sh\necho $$\n```\n\nA *word* and $$x_1$$ after.\n\n`$a$` stays code.\n"; - let html = convert_markdown(markdown); - assert!(html.contains("$a$"), - "an inline code span was treated as math: {html}", - ); - } - - #[test] - fn a_math_span_never_reaches_across_an_inline_code_span() { - // The frontend cannot pair delimiters across a `` element — - // `processInlineMath` rejects the whole subtree — so neither may the - // backend: masking here would silently eat the code span. - let html = convert_markdown("$a `x` b$ and *emphasis*.\n"); - assert!(html.contains(">x"), "got: {html}"); - assert!(html.contains(" d \"e\"$\n"); - assert!( - html.contains("$a < b & c > d "e"$"), - "the restored span was not escaped: {html}", - ); - } - - // ----------------------------------------------------------------- - // The cross-language math-delimiter contract - // - // Everything above proves the backend hides the right spans from - // comrak. It cannot prove the thing correctness actually rests on: - // that the set the backend hides equals the set the *frontend* - // renders. Those are two implementations of one rule in two - // languages, and until now only one of them was pinned — loosening - // `findInlineMathEnd` in markdown.ts would have left every test in - // this file green. - // - // backend ⊂ frontend → comrak mangles the formula before KaTeX - // sees it; that is #174, #177 and #197. - // backend ⊃ frontend → the text is held back from Markdown and - // then rendered by nobody: the reader gets - // dead text that is neither prose nor a - // formula. - // - // So both sides are asserted against one shared, hand-authored - // table: scripts/mathDelimiterCorpus.json. The other half lives in - // scripts/mathDelimiterContract.test.ts and runs the real frontend. - // Change one side's rule and exactly one of the two goes red. - // ----------------------------------------------------------------- - - #[derive(serde::Deserialize)] - struct MathContractSpan { - kind: String, - source: String, - } - - #[derive(serde::Deserialize)] - struct MathContractCase { - name: String, - markdown: String, - html: String, - math: Vec, - } - - #[derive(serde::Deserialize)] - struct MathContractCorpus { - cases: Vec, - } - - fn math_contract_corpus() -> MathContractCorpus { - serde_json::from_str(include_str!("../../scripts/mathDelimiterCorpus.json")) - .expect("scripts/mathDelimiterCorpus.json must stay valid JSON") - } - - /// What the backend decided, in the corpus's vocabulary. - fn recognised_math(markdown: &str) -> Vec<(String, String)> { - let regions = code_region_ranges(markdown); - find_math_spans(markdown, ®ions) - .into_iter() - .map(|(start, end)| { - let span = &markdown[start..end]; - match span - .strip_prefix("$$") - .and_then(|inner| inner.strip_suffix("$$")) - { - // `extractDisplayMathBlock` trims; mirror it exactly. - Some(inner) => ("display".to_owned(), inner.trim().to_owned()), - None => ("inline".to_owned(), span[1..span.len() - 1].to_owned()), - } - }) - .collect() - } - - #[test] - fn the_backend_recognises_exactly_the_math_the_contract_lists() { - for case in math_contract_corpus().cases { - let expected: Vec<(String, String)> = case - .math - .iter() - .map(|span| (span.kind.clone(), span.source.clone())) - .collect(); - assert_eq!( - recognised_math(&case.markdown), - expected, - "{}: the backend and the contract disagree about what is math\n input: {:?}", - case.name, - case.markdown, - ); - } - } - - #[test] - fn the_math_contract_corpus_is_a_live_capture() { - // Keeps the `html` the frontend test consumes honest: it is what - // this renderer produces today, not what it produced once. - for case in math_contract_corpus().cases { - assert_eq!( - convert_markdown(&case.markdown), - case.html, - "{}: scripts/mathDelimiterCorpus.json no longer matches this \ - renderer — replace its `html` with the value on the left, and \ - leave `markdown` and `math` alone", - case.name, - ); - } - } - - #[test] - fn math_in_a_heading_keeps_the_anchor_a_wikilink_can_reach() { - // comrak derives the heading id from the *rendered* text, so the mask - // would otherwise become the anchor and silently break every - // `[[#heading]]` pointing at a heading that contains a formula. - let heading = "A heading with $x_1$"; - let html = convert_markdown(&format!("# {heading}\n")); - assert!(html.contains("$x_1$"), "the math was lost: {html}"); - assert_eq!( - heading_anchor_id(heading), - "a-heading-with-x_1", - "the wikilink side changed", - ); - assert!( - html.contains("id=\"a-heading-with-x_1\""), - "the mask leaked into the anchor: {html}", - ); - } - - #[test] - fn math_in_a_link_destination_keeps_the_link_working() { - // The token has to survive `escape_href` too, which is why it is - // plain ASCII rather than a private-use character. - let html = convert_markdown("[t](http://example.com/$a$)\n"); - assert!( - html.contains("href=\"http://example.com/$a$\""), - "the link destination was mangled: {html}", - ); - } - - #[test] - fn a_stray_backtick_does_not_swallow_later_paragraphs() { - // CommonMark parses inline elements per block and a blank line ends a - // block, so the loose backtick in the first paragraph cannot pair with - // the opening backtick of `run()` two paragraphs down. - let input = - "Use the ` character to start code.\n\n![[photo.png]]\n\nThen `run()` finishes.\n"; - - let out = process_internal_embeds(input); - assert!(out.contains("important"), "got: {out}"); - assert!(out.contains("(#some-heading)"), "got: {out}"); - } - - #[test] - fn inline_code_spans_still_pair_across_lines_inside_one_paragraph() { - // A code span may legitimately span several lines of the same block; - // the blank-line reset must not break that. - let input = "start `code\n![[inside.png]]` end\n"; - let out = process_internal_embeds(input); - assert!(out.contains("![[inside.png]]"), "got: {out}"); - assert!(!out.contains(""` are the four characters that would otherwise change the +/// meaning of the markup in any of the three. The frontend reads the span back +/// out with `textContent`, which undoes the escaping before KaTeX sees it. +/// +/// A heading is the one place the token appears twice in two different +/// spellings: comrak anchorizes the heading's rendered text into `id=` and +/// `href="#…"`, which lowercases it. There the *anchorized* source goes back +/// instead, so that `[[#A heading with $x_1$]]` still resolves — the wikilink +/// side computes the same id from the raw buffer with `heading_anchor_id`. +/// +/// Whether the token landed in markup or in text is tracked as it goes, and it +/// is not a nicety: an escaped dollar goes back as `\$` only where a frontend +/// pass will resolve it. Nothing resolves anything inside an `href` or an +/// `alt`, so a token that landed there gets the resolved `$` instead — putting +/// the backslash into a link destination would break the link. comrak escapes +/// `<` and `>` everywhere else, so an unclosed `<` really does mean "inside a +/// tag". +fn restore_math_spans(html: &str, masked: &MaskedMath) -> String { + if masked.spans.is_empty() { + return html.to_owned(); } - - #[test] - fn wikilink_anchors_match_the_ids_comrak_actually_renders() { - // Asserted against comrak's real output rather than a copy of its - // rules: if a comrak upgrade changes anchorization, this fails instead - // of silently producing wikilinks that jump nowhere. - for heading in [ - "1. 概述", - "Ticks aren't in", - "Hello, World!", - "Setup & Teardown", - "under_score here", - ] { - let html = convert_markdown(&format!("## {heading}\n")); - let rendered_id = html - .split("id=\"") - .nth(1) - .and_then(|rest| rest.split('"').next()) - .unwrap_or_else(|| panic!("no heading id rendered: {html}")); - assert_eq!( - heading_anchor_id(heading), - rendered_id, - "anchor for {heading:?} drifted from comrak: {html}", - ); + let anchor_prefix = masked.prefix.to_ascii_lowercase(); + let mut out = String::with_capacity(html.len()); + let mut rest = html; + let mut in_tag = false; + while let Some((at, anchored)) = [ + (rest.find(masked.prefix.as_str()), false), + (rest.find(anchor_prefix.as_str()), true), + ] + .into_iter() + .filter_map(|(at, anchored)| at.map(|at| (at, anchored))) + .min() + { + out.push_str(&rest[..at]); + if let Some(bracket) = rest[..at].rfind(['<', '>']) { + in_tag = rest.as_bytes()[bracket] == b'<'; + } + let after = &rest[at + masked.prefix.len()..]; + let digits = after + .as_bytes() + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + let suffix = if anchored { + MATH_MASK_SUFFIX.to_ascii_lowercase() + } else { + MATH_MASK_SUFFIX + }; + let index = if digits > 0 && after[digits..].starts_with(suffix) { + after[..digits].parse::().ok() + } else { + None + }; + match index.and_then(|index| masked.spans.get(index)) { + Some(original) if anchored => { + out.push_str(&Anchorizer::new().anchorize(&original.text)); + rest = &after[digits + suffix.len_utf8()..]; + } + Some(original) => { + out.push_str(&escape_html_text(if in_tag { + &original.attribute + } else { + &original.text + })); + rest = &after[digits + suffix.len_utf8()..]; + } + None => { + out.push_str(&rest[at..at + masked.prefix.len()]); + rest = after; + } } } + out.push_str(rest); + out +} - #[test] - fn wikilink_targets_survive_punctuation_in_the_heading() { - // "1. 概述" used to become "1.-概述" while comrak rendered "1-概述", - // so the link resolved to nothing. - assert_eq!(heading_anchor_id("1. 概述"), "1-概述"); - - let out = process_wikilinks("[[#1. 概述|Overview]]\n"); - assert!(out.contains("[Overview](#1-概述)"), "got: {out}"); - } - - #[test] - fn multiline_wikilinks_are_left_literal() { - // A heading id can never contain a newline, so such a target cannot - // resolve; rewriting it would also collapse two source lines into one - // and shift every task checkbox below it. - let out = process_wikilinks("[[#first\nsecond|alias]]\n"); - assert!(out.contains("[[#first\nsecond|alias]]"), "got: {out}"); - } - - // ---- [[file#heading]] wikilinks ------------------------------------- - // - // What these tests do NOT cover, and why: - // * Bare note links, "[[Notes]]" with no heading. Deliberately out of - // scope — see `wikilinks_without_a_heading_are_deliberately_left_literal`. - // * Whether the target file exists. Resolution is the frontend's job - // (`resolveMarkdownTargetPath` in src/lib/utils/markdownLinks.ts); the - // Rust side never touches the filesystem here, so a link to a missing - // note is emitted like any other and simply fails to open. - // * Obsidian's nested-heading paths (`[[file#H1#H2]]`). Everything after - // the first `#` is taken as one heading name, so such a target - // anchorizes to the two names run together and will not resolve. That - // matches the existing behaviour of the same-document form. - // * Duplicate headings. comrak appends `-1`, `-2`, … to the second and - // later headings with the same text; a wikilink can only ever address - // the first one (see the doc comment on `heading_anchor_id`). - // * The actual click-through. The href *shape* the frontend accepts is - // pinned from the TypeScript side in scripts/wikilinkFileTargets.test.ts. - - #[test] - fn copy_reference_output_becomes_a_real_link() { - // `[[Notes#Setup]]` is exactly what the app's own "Copy Reference" - // menu item writes to the clipboard (MarkdownViewer.svelte); it used - // to render as literal text because the pattern required `#` to - // follow `[[` immediately. - let out = process_wikilinks("[[Notes#Setup]]\n"); - assert!(out.contains("[Notes > Setup](Notes.md#setup)"), "got: {out}"); - } - - #[test] - fn file_wikilink_href_carries_a_markdown_extension_the_frontend_recognizes() { - // getMarkdownLinkTarget() only claims a link whose path has a known - // markdown extension, so a note name written without one — the way - // Copy Reference writes it — has to gain one here or the click falls - // through to the external-URL opener. - let out = process_wikilinks("[[docs/Guide#Setup]]\n"); - assert!(out.contains("(docs/Guide.md#setup)"), "got: {out}"); - } - - #[test] - fn file_wikilink_keeps_an_extension_it_was_already_given() { - let out = process_wikilinks("[[Notes.md#Setup]]\n"); - assert!(out.contains("(Notes.md#setup)"), "got: {out}"); - assert!(!out.contains("Notes.md.md"), "got: {out}"); - - let txt = process_wikilinks("[[log.txt#Errors]]\n"); - assert!(txt.contains("(log.txt#errors)"), "got: {txt}"); - assert!(!txt.contains("log.txt.md"), "got: {txt}"); - } - - #[test] - fn wikilinks_without_a_heading_are_deliberately_left_literal() { - // Obsidian's bare note link "[[Notes]]" is out of scope: this change - // fixes Copy Reference, whose every call site emits a "#". Claiming - // every "[[…]]" would also swallow bracketed citation numbering and - // pre-empt CommonMark reference links, neither of which is a wikilink. - // See the PR description. - for input in [ - "[[Notes]]\n", - "[[1]] Author, Title.\n", - "[[TODO]] revisit this.\n", - "[[foo]] and [[foo|bar]]\n", - "[[docs/Guide|Guide]]\n", - ] { - assert_eq!(process_wikilinks(input), input, "should be literal"); +fn escape_html_text(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(ch), } + } + out +} - // A "#" in the alias half does not make it a heading link either. - let aliased = "[[Notes|see #1]]\n"; - assert_eq!(process_wikilinks(aliased), aliased); +/// ⚠️ Every preprocessing step below is bound by a line-number contract: +/// `sourcepos` describes the *preprocessed* text, but the frontend uses those +/// line numbers to edit the *raw* buffer, so input line N must stay output +/// line N. The contract, the rationale and the tests that enforce it live in +/// `mod tests` under "The line-number contract of `convert_markdown`" — +/// a new step here must also be registered in `line_preserving_transforms()`. +#[tauri::command] +fn convert_markdown(content: &str) -> String { + // The buffer this command was called with, captured before anything runs + // and never rebound. What `annotate_task_checkboxes` is handed at the end + // has to be this rather than the parameter name: a new step written as a + // `let content = ...` shadow would rebind that name and silently retarget + // the call without touching it. See that function's doc comment and + // `convert_markdown_hands_the_fail_safe_the_raw_buffer`. + let raw_buffer = content; - // A reference definition must keep resolving the CommonMark way. - let html = convert_markdown("[[foo]] here.\n\n[foo]: https://example.com\n"); - assert!(html.contains("href=\"https://example.com\""), "got: {html}"); - assert!(!html.contains("foo.md"), "got: {html}"); - } + let processed_autolinks = process_parenthesized_autolinks(content); + let processed_embeds = process_internal_embeds(&processed_autolinks); + let processed_links = process_wikilinks(&processed_embeds); + let masked_math = mask_math_spans(&processed_links); - #[test] - fn file_wikilink_alias_and_subfolder_and_punctuated_heading() { - let out = process_wikilinks("[[docs/Guide#1. 概述|Overview]]\n"); - assert!(out.contains("[Overview](docs/Guide.md#1-概述)"), "got: {out}"); - } + let mut options = Options::default(); + options.extension.strikethrough = true; + options.extension.table = true; + options.extension.autolink = true; + options.extension.tasklist = true; + options.extension.superscript = false; + options.extension.footnotes = true; + options.extension.description_lists = true; + // `header_ids` in 0.18; the option only ever set the *prefix* prepended to + // the anchorized heading text, and 0.52 renamed it to say so. `Some("")` + // means "ids on, no prefix" in both spellings. + options.extension.header_id_prefix = Some(String::new()); + options.render.r#unsafe = true; + options.render.hardbreaks = true; + options.render.sourcepos = true; - #[test] - fn file_wikilink_percent_encodes_what_would_break_the_destination() { - // A space would end the destination and the rest would be read as a - // title; parentheses would close it early. decodeLinkPath() on the - // frontend undoes all of this. - let out = process_wikilinks("[[My Notes (v2)#Setup]]\n"); - assert!(out.contains("(My%20Notes%20%28v2%29.md#setup)"), "got: {out}"); - assert!(out.contains("[My Notes (v2) > Setup]"), "got: {out}"); - } + let html = markdown_to_html(&masked_math.text, &options); + annotate_task_checkboxes(restore_math_spans(&html, &masked_math), raw_buffer) +} - #[test] - fn file_wikilink_block_reference_targets_the_block_id_anchor() { - // `^abc123` at the end of a line becomes , and comrak's - // anchorizer drops the caret, so both sides agree on "abc123". - let out = process_wikilinks("[[Notes#^abc123]]\n"); - assert!(out.contains("(Notes.md#abc123)"), "got: {out}"); - } +/// Marks the rendered task checkboxes the frontend is allowed to toggle. +/// +/// `markdown` is the **raw, unpreprocessed** buffer — deliberately, and not a +/// bug. This is the fail-safe for the line contract described in `mod tests`. +/// +/// The `data-sourcepos` line numbers in `html` describe the *preprocessed* +/// text, while a click on the checkbox makes the frontend rewrite that line +/// number of the *raw* buffer (`documentSession.toggleTaskCheckbox`). The two +/// only agree while every preprocessing step preserves line numbers. Checking +/// the raw buffer here is what turns a broken step into "the checkbox stays +/// disabled" instead of a write aimed at the wrong line of the user's +/// document — the P0 that issue #352 fixed. +/// +/// What a wrong line costs is narrower than it was, and worth stating +/// precisely, because an overstated reason invites the next reader to check +/// it, find it false, and delete the guard as theatre. Since #352 the +/// frontend rewrites only lines that already match +/// `/^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)\[( |x|X)\]/` +/// (`documentSession.toggleTaskCheckbox`), so a wrong line that is ordinary +/// prose is a no-op and the toggle reports failure — it does NOT write a +/// `- [x]` marker into whatever happens to sit there, as this comment used to +/// claim of the pre-#352 frontend. What still corrupts is a wrong line that +/// is itself task-shaped, and neither spelling of that is exotic: a task list +/// quoted inside a fenced code block is ordinary content in a notes app, and +/// a real task elsewhere in the same document means the user clicks one +/// checkbox and a different one silently flips. +/// +/// So do NOT "unify" this with the preprocessed text that produced the HTML. +/// Passing `&processed_links` here would make the two sides agree by +/// definition, delete the guard, and turn every future line-count regression +/// straight into a mis-aimed write. Nor is passing the *parameter name* +/// enough at the call site: `convert_markdown` captures its input as +/// `raw_buffer` first precisely so that a later `let content = …` cannot +/// retarget the call without touching it. Keep the contract honest in the +/// transforms instead; `every_preprocessing_step_preserves_source_line_numbers` +/// is what enforces it, +/// `task_checkboxes_stay_inert_when_the_html_and_the_buffer_disagree` pins +/// this guard, and `convert_markdown_hands_the_fail_safe_the_raw_buffer` pins +/// the argument it is given. +fn annotate_task_checkboxes(html: String, markdown: &str) -> String { + let markdown_lines = markdown.lines().collect::>(); - #[test] - fn wikilinks_to_files_the_viewer_cannot_open_stay_literal() { - // A non-markdown target would not be claimed by getMarkdownLinkTarget, - // so the click would reach openUrl() with a relative path resolved - // against the webview origin. Leaving it as text is the honest result. - for input in ["[[report.pdf#Intro]]\n", "[[diagram.svg#part]]\n"] { - let out = process_wikilinks(input); - assert_eq!(out, input, "got: {out}"); - } - } + TASK_ITEM_RE + .replace_all(&html, |captures: &Captures| { + let line = captures["line"].parse::().unwrap_or_default(); + let source_line = markdown_lines.get(line.saturating_sub(1)); + if !source_line.is_some_and(|line| TASK_SOURCE_RE.is_match(line)) { + return captures[0].to_string(); + } - #[test] - fn same_document_wikilinks_are_unchanged_by_the_file_form() { - let out = process_wikilinks("[[#Some Heading|jump]]\n"); - assert!(out.contains("[jump](#some-heading)"), "got: {out}"); + // Anchored on the tag name, not on one of the boolean attributes, + // for the same reason `TASK_ITEM_RE` no longer spells their order + // out: `disabled` is not guaranteed to be the first one. + let input = captures["input"].replacen( + "{}", + &captures["sourcepos"], + input, + ) + }) + .into_owned() +} - let bare = process_wikilinks("[[#Setup]]\n"); - assert!(bare.contains("[Setup](#setup)"), "got: {bare}"); - } +#[tauri::command] +async fn open_markdown(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let content = fs::read_to_string(path).map_err(|e| e.to_string())?; + Ok(convert_markdown(&content)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - #[test] - fn file_wikilinks_in_code_spans_and_fences_stay_literal() { - let span = process_wikilinks("`[[Notes#Setup]]` but [[Notes#Setup]]\n"); - assert!(span.contains("`[[Notes#Setup]]`"), "got: {span}"); - assert!(span.contains("(Notes.md#setup)"), "got: {span}"); +struct MarkdownPreview { + html: String, + content: String, + is_full: bool, + lossy: bool, +} - let fence = process_wikilinks("```\n[[Notes#Setup]]\n```\n"); - assert!(fence.contains("```\n[[Notes#Setup]]\n```"), "got: {fence}"); +/// The body of `open_markdown_preview`, kept synchronous and path-taking so +/// the decode-fidelity behaviour can be exercised against real files. +fn build_markdown_preview(path: &Path, max_bytes: usize) -> Result { + use std::io::Read; + let path_str = path.to_str().ok_or("Invalid path")?; + let mut f = fs::File::open(path).map_err(|e| e.to_string())?; + + let metadata = f.metadata().map_err(|e| e.to_string())?; + if metadata.len() <= max_bytes as u64 { + let decoded = read_to_string_lossy(path_str).map_err(|e| e.to_string())?; + let html = convert_markdown(&decoded.content); + return Ok(MarkdownPreview { + html, + content: decoded.content, + is_full: true, + lossy: decoded.lossy, + }); } - #[test] - fn embeds_are_not_also_treated_as_file_wikilinks() { - // process_internal_embeds runs first and consumes `![[…]]`; the guard - // matters for the standalone call and for an embed it declined. - let out = process_wikilinks("![[photo.png]]\n"); - assert!(out.contains("![[photo.png]]"), "got: {out}"); + // `Read::read` only guarantees *at most* `buf.len()` bytes and may + // return a short read for reasons that have nothing to do with EOF, + // truncating the preview well below the requested budget. + // `take(..).read_to_end(..)` keeps reading to the limit or EOF. + let mut vec_buf = Vec::new(); + Read::by_ref(&mut f) + .take(max_bytes as u64) + .read_to_end(&mut vec_buf) + .map_err(|e| e.to_string())?; + // The cut lands on a raw byte offset, which can slice a multi-byte + // character in half; drop the partial tail instead of rendering it as + // a replacement character. This also keeps a perfectly good UTF-8 file + // from being reported as a lossy decode just because the preview budget + // fell inside one of its characters. + vec_buf.truncate(utf8_truncation_boundary(&vec_buf)); - let html = convert_markdown("![[photo.png]]\n"); - assert!(html.contains(" Result<(String, String, bool, bool), String> { + tauri::async_runtime::spawn_blocking(move || { + let preview = build_markdown_preview(Path::new(&path), max_bytes)?; + Ok(( + preview.html, + preview.content, + preview.is_full, + preview.lossy, + )) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - #[test] - fn attribute_escaping_covers_the_html_metacharacters() { - assert_eq!( - escape_html_attribute("a\"b'c&df"), - "a"b'c&d<e>f", - ); - assert_eq!(escape_html_attribute("plain.png"), "plain.png"); - } +#[tauri::command] +async fn render_markdown(content: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + Ok(convert_markdown(&content)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - /// Creates `/src/` holding `body` and returns its path. - fn drop_source(root: &Path, index: usize, name: &str, body: &[u8]) -> PathBuf { - let dir = root.join(format!("src{index}")); - fs::create_dir_all(&dir).unwrap(); - let file = dir.join(name); - fs::write(&file, body).unwrap(); - file - } +/// Reads a file, with the fidelity of the decode: returns `(content, lossy)`. +/// Since every read path decodes leniently, a caller that puts the text into +/// an EDITABLE buffer must carry `lossy` onto the tab — otherwise the first +/// auto-save writes U+FFFD over a file that was merely in another encoding. +/// +/// This is now the only read-to-string command. Its sibling +/// `read_file_content` returned the text and dropped the verdict; it survived +/// #379 for callers that re-read a file whose tab was already flagged, then +/// lost its last call site and stayed registered — a command whose defining +/// property is that it hides the flag, one `invoke` away from any new caller. +/// Deleting it makes "which command should this use" a question with one +/// answer rather than a convention. +/// +/// Deliberately async, like every other file-touching command here. A +/// synchronous `#[tauri::command]` runs on the main thread, so a read from a +/// slow volume (SMB, iCloud, a failing USB stick) freezes the whole +/// application — every window, its menus and its scrolling — until the I/O +/// returns. `spawn_blocking` moves the wait onto the blocking pool, which is +/// what `tauri::async_runtime` provides it for. +#[tauri::command] +async fn read_file_content_checked(path: String) -> Result<(String, bool), String> { + tauri::async_runtime::spawn_blocking(move || { + read_to_string_lossy(&path) + .map(|decoded| (decoded.content, decoded.lossy)) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - fn drop_into_img(src: &Path, doc_dir: &Path) -> String { - copy_file_to_img_blocking(src.to_str().unwrap(), doc_dir.to_str().unwrap(), "img").unwrap() +fn mime_type_for_export_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", + Some("bmp") => "image/bmp", + Some("ico") => "image/x-icon", + Some("avif") => "image/avif", + _ => "application/octet-stream", } +} - #[test] - fn repeated_drops_of_the_same_name_never_overwrite_an_earlier_copy() { - // Three same-named images from different folders, dropped in the same - // second. The conflict name used to be a *second-resolution* timestamp - // that was never re-checked for existence, so drops #2 and #3 computed - // the identical name and #3 silently replaced the bytes behind a link - // the document had already been given. - let root = temp_path("imgcopy-repeat"); - let doc_dir = root.join("doc"); - fs::create_dir_all(&doc_dir).unwrap(); - let bodies: [&[u8]; 3] = [b"first", b"second", b"third"]; - let sources: Vec = bodies - .iter() - .enumerate() - .map(|(i, body)| drop_source(&root, i, "a.png", body)) - .collect(); - - let written: Vec = sources.iter().map(|src| drop_into_img(src, &doc_dir)).collect(); - - let distinct: std::collections::HashSet<&String> = written.iter().collect(); - assert_eq!(distinct.len(), written.len(), "two drops shared a name: {written:?}"); - for (rel, body) in written.iter().zip(bodies.iter()) { - assert_eq!( - fs::read(doc_dir.join(rel)).unwrap(), - *body, - "{rel} no longer holds the image that was dropped for it", - ); - } +fn file_bytes_to_data_url(mime_type: &str, bytes: &[u8]) -> String { + use base64::{engine::general_purpose, Engine as _}; + format!( + "data:{};base64,{}", + mime_type, + general_purpose::STANDARD.encode(bytes) + ) +} - fs::remove_dir_all(root).unwrap(); - } +#[tauri::command] +async fn read_file_as_data_url(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let bytes = fs::read(&path).map_err(|e| e.to_string())?; + let mime_type = mime_type_for_export_path(Path::new(&path)); + Ok(file_bytes_to_data_url(mime_type, &bytes)) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - #[test] - fn a_conflicting_dotfile_name_does_not_grow_a_trailing_dot() { - // ".png" is a dotfile, not an extension: the frontend's drop filter - // reads the name with `split('.').pop()` and sees "png", so it lets it - // through, while Rust's `Path::extension()` returns None. The old - // format string appended the separator unconditionally, so the conflict - // name ended in a dot. Windows strips a trailing dot when creating the - // file, so the link written into the document named a file that does - // not exist on disk; mac/Linux keep the dot and the link resolves. - let root = temp_path("imgcopy-dotfile"); - let doc_dir = root.join("doc"); - fs::create_dir_all(&doc_dir).unwrap(); - let first = drop_source(&root, 0, ".png", b"first"); - let second = drop_source(&root, 1, ".png", b"second"); +/// Async because `atomic_write` fsyncs twice (the file, then its directory). +/// On a network or removable volume that is seconds of blocking I/O, and on +/// the main thread it would stall every window until the save completes. +#[tauri::command] +async fn save_file_content(path: String, content: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + atomic_write(Path::new(&path), content.as_bytes()).map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - drop_into_img(&first, &doc_dir); - let rel = drop_into_img(&second, &doc_dir); +/// Resolve `path` to the identity the filesystem gives it — see +/// `canonical_identity` for what that folds and why the filesystem, rather +/// than a platform guess, is the thing being asked. +/// +/// The frontend calls this once per path, when the path ENTERS the app, and +/// keeps the answer on the tab (`Tab.pathKey`). Comparisons themselves stay +/// synchronous string equality: `TabManager.claimPath` runs on every navigate +/// and cannot become async without turning six sync call sites into promises. +/// +/// Async because `realpath` walks the path component by component and can hit +/// a network volume that is slow or unreachable. +#[tauri::command] +async fn canonicalize_path(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + canonical_identity(Path::new(&path)) + .map(|resolved| resolved.to_string_lossy().into_owned()) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - assert!(!rel.ends_with('.'), "conflict name ends in a dot: {rel}"); - assert_eq!(fs::read(doc_dir.join(&rel)).unwrap(), b"second"); +#[tauri::command] +fn print_pdf(window: tauri::WebviewWindow) -> Result<(), String> { + window.print().map_err(|error| error.to_string()) +} - fs::remove_dir_all(root).unwrap(); - } +#[tauri::command] +async fn export_pdf_windows(window: tauri::WebviewWindow, path: String) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::sync::mpsc::sync_channel; + use std::time::Duration; + use webview2_com::{ + PrintToPdfCompletedHandler, + Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment6, ICoreWebView2_7}, + }; + use windows::core::{Interface, HSTRING}; - #[test] - fn concurrent_drops_of_the_same_name_each_get_their_own_file() { - // Two windows dropping the same image at the same moment: an - // `exists()` test followed by a separate copy leaves a window in which - // both callers see the name as free and one copy lands on top of the - // other. - const DROPS: usize = 8; - let root = temp_path("imgcopy-concurrent"); - let doc_dir = root.join("doc"); - fs::create_dir_all(&doc_dir).unwrap(); - let sources: Vec<(PathBuf, Vec)> = (0..DROPS) - .map(|i| { - let body = format!("image-{i}").into_bytes(); - (drop_source(&root, i, "a.png", &body), body) - }) - .collect(); + let (sender, receiver) = sync_channel(1); + window + .with_webview(move |platform_webview| unsafe { + let result = (|| -> Result<(), String> { + let controller = platform_webview.controller(); + let webview = controller + .CoreWebView2() + .map_err(|error| format!("failed to access WebView2: {error}"))? + .cast::() + .map_err(|error| { + format!("WebView2 runtime does not support PDF export: {error}") + })?; + let settings = platform_webview + .environment() + .cast::() + .map_err(|error| { + format!("WebView2 runtime does not support print settings: {error}") + })? + .CreatePrintSettings() + .map_err(|error| format!("failed to create PDF print settings: {error}"))?; - let written: Vec<(String, Vec)> = std::thread::scope(|scope| { - let handles: Vec<_> = sources - .iter() - .map(|(src, body)| { - let doc_dir = doc_dir.clone(); - scope.spawn(move || (drop_into_img(src, &doc_dir), body.clone())) - }) - .collect(); - handles.into_iter().map(|h| h.join().unwrap()).collect() - }); + settings + .SetShouldPrintHeaderAndFooter(false) + .map_err(|error| { + format!("failed to disable PDF headers and footers: {error}") + })?; + settings + .SetShouldPrintBackgrounds(true) + .map_err(|error| format!("failed to enable PDF backgrounds: {error}"))?; - let distinct: std::collections::HashSet<&String> = written.iter().map(|(rel, _)| rel).collect(); - assert_eq!(distinct.len(), DROPS, "concurrent drops shared a name: {written:?}"); - for (rel, body) in &written { - assert_eq!( - &fs::read(doc_dir.join(rel)).unwrap(), - body, - "{rel} no longer holds the image that was dropped for it", - ); - } + let callback_sender = sender.clone(); + let completion = + PrintToPdfCompletedHandler::create(Box::new(move |status, succeeded| { + let result = status + .map_err(|error| format!("WebView2 PDF export failed: {error}")) + .and_then(|_| { + succeeded.then_some(()).ok_or_else(|| { + "WebView2 did not create the PDF file".to_string() + }) + }); + let _ = callback_sender.send(result); + Ok(()) + })); - fs::remove_dir_all(root).unwrap(); - } + webview + .PrintToPdf(&HSTRING::from(path), &settings, &completion) + .map_err(|error| format!("could not start PDF export: {error}")) + })(); - // --------------------------------------------------------------------- - // The line-number contract of `convert_markdown` - // - // `convert_markdown` preprocesses the raw buffer, renders the *result* - // with `sourcepos = true`, and hands those line numbers to the frontend. - // The frontend then writes task-checkbox toggles back into the *raw* - // buffer at that line number. So every preprocessing step has to map - // input line N to output line N; a step that quietly eats or inserts a - // line makes the reading-mode checkbox rewrite a different line of the - // user's document (issue #352). - // - // A step MAY append after the last input line — the inline-footnote step - // parks its `[^ifn-N]: …` definitions there, which cannot shift the - // number of any line that already existed. It must never insert or drop - // a line inside the document. - // - // ⚠️ EVERY preprocessing step of `convert_markdown` MUST be registered in - // `line_preserving_transforms()` below. It is not optional and it is not - // best-effort: `every_convert_markdown_preprocessing_step_is_registered` - // re-reads this source file, extracts the calls `convert_markdown` - // actually makes, and fails if one of them is missing from the list. - // Adding a fifth transform without registering it turns that test red. - // --------------------------------------------------------------------- + if let Err(error) = result { + let _ = sender.send(Err(error)); + } + }) + .map_err(|error| format!("failed to schedule PDF export: {error}"))?; - type LineTransform = fn(&str) -> String; + tauri::async_runtime::spawn_blocking(move || receiver.recv_timeout(Duration::from_secs(60))) + .await + .map_err(|error| format!("PDF export task failed: {error}"))? + .map_err(|error| format!("PDF export callback failed or timed out: {error}"))? + } - /// The registry the contract test walks. Add every new preprocessing step. - fn line_preserving_transforms() -> Vec<(&'static str, LineTransform)> { - vec![ - ( - "process_parenthesized_autolinks", - (|s| process_parenthesized_autolinks(s).into_owned()) as LineTransform, - ), - ( - "process_internal_embeds", - (|s| process_internal_embeds(s).into_owned()) as LineTransform, - ), - ( - "process_wikilinks", - (|s| process_wikilinks(s).into_owned()) as LineTransform, - ), - ( - "mask_math_spans", - (|s| mask_math_spans(s).text) as LineTransform, - ), - ] + #[cfg(not(target_os = "windows"))] + { + let _ = (window, path); + Err("controlled PDF export is only available on Windows".to_string()) } +} - /// Documents exercising every syntax the preprocessing steps claim, plus - /// the malformed spellings of each one — a lone `![[`, an unterminated - /// `^[`, a wikilink split over two lines — because those are exactly the - /// inputs where a lazy or newline-crossing pattern runs away. - const LINE_CONTRACT_CORPUS: &[&str] = &[ - // A stray embed opener with a real embed further down. - "Prose with ![[ a stray opener.\n\n- [ ] task one\n\nLater an image ![[real.png]] here.\n", - // An inline footnote whose text wraps onto a second line. - "Some claim^[See the long explanation\nthat wraps to a second line] and more.\n\n- [ ] task\n", - // A block id sitting on its own line, Obsidian's block-reference form. - "A quotable paragraph.\n^blockid\n\n- [ ] task\n", - // A block id at the end of its own line. - "A quotable paragraph. ^blockid\n\n- [ ] task\n", - // Every well-formed spelling at once. - "![[pic.png|300x200]] [[#Setup|jump]] [[Notes#Setup]] ==mark== text^[note]\n\n- [ ] task\n", - // A wikilink split over two lines (already guarded, kept as a pin). - "[[#first\nsecond|alias]]\n- [ ] task\n", - // Code fences and spans, which every step must leave alone. - "```\n![[inside.md]]\n^[inside]\n==inside==\n```\n\n`==x==` ![[out.png]]\n", - // Unclosed fence, longer fences, tilde fences. - "~~~\n![[a.md]]\n\n````\n```\n![[b.md]]\n````\n\n```\n![[never-closed.md]]\n", - // Parenthesized autolink with nested parentheses. - "See (https://example.com/a(b)c)text here\n\n- [ ] task\n", - // Display math with underscores. - "$$\na_b\nc_d\n$$\n\nx^[note] and $$y_1$$\n\n- [ ] task\n", - // Headings, quotes, tables, nested and quoted tasks. - "# Head\n\n> quote ^qid\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n- [ ] task\n - [x] nested\n\n> - [ ] quoted\n", - // Unbalanced brackets and carets in prose. - "A ^[ dangling footnote opener and a [[ dangling wikilink\n\n- [ ] task\n", - // Multibyte content — offsets are bytes, line numbers are not. - "中文段落 ![[图片.png]] ^[脚注]\n\n- [ ] 任务\n", - // Blank lines, CRLF, and no trailing newline. - "one\r\ntwo ![[x.png]]\r\n\r\n- [ ] task", - ]; +#[tauri::command] +async fn save_file_binary(path: String, data: Vec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + atomic_write(Path::new(&path), &data).map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} - const LINE_CONTRACT_SENTINEL: &str = "MPLINECONTRACTSENTINEL"; +#[tauri::command] +fn open_file_folder(path: String) -> Result<(), String> { + opener::reveal(path).map_err(|e| e.to_string()) +} - fn sentinel_line(text: &str) -> Option { - text.lines() - .position(|line| line.contains(LINE_CONTRACT_SENTINEL)) - } +#[tauri::command] +fn rename_file(old_path: String, new_path: String) -> Result<(), String> { + fs::rename(old_path, new_path).map_err(|e| e.to_string()) +} - /// Asserts that `transform` keeps a marker line at the same line number. - /// - /// The marker is appended to every line-prefix of `input`, not just to - /// the whole document: a step that drops one line and inserts another - /// would leave the total unchanged, but no prefix boundary between the - /// two survives. Checking the sentinel rather than the raw line count is - /// what lets the inline-footnote step append its definitions afterwards. - fn assert_transform_preserves_line_numbers( - name: &str, - transform: LineTransform, - input: &str, - ) { - let lines: Vec<&str> = input.split_inclusive('\n').collect(); - for take in 0..=lines.len() { - let mut probe = lines[..take].concat(); - if !probe.is_empty() && !probe.ends_with('\n') { - probe.push('\n'); - } - probe.push_str(LINE_CONTRACT_SENTINEL); - probe.push('\n'); +#[tauri::command] +fn watch_file( + window: tauri::Window, + handle: AppHandle, + state: State<'_, WatcherState>, + path: String, +) -> Result<(), String> { + window_runtime::watch_file(window, handle, state, path) +} - let expected = sentinel_line(&probe).expect("the probe carries the sentinel"); - let output = transform(&probe); - let actual = sentinel_line(&output).unwrap_or_else(|| { - panic!( - "{name} swallowed the sentinel line entirely\n input: {probe:?}\n output: {output:?}" - ) - }); - assert_eq!( - expected, actual, - "{name} moved line {expected} to line {actual}\n input: {probe:?}\n output: {output:?}", - ); - } +#[tauri::command] +fn unwatch_file(window: tauri::Window, state: State<'_, WatcherState>) -> Result<(), String> { + window_runtime::unwatch_file(window, state) +} + +#[tauri::command] +fn send_markdown_path(state: State<'_, AppState>) -> Vec { + window_runtime::send_markdown_path(state) +} + +#[tauri::command] +fn save_theme(app: AppHandle, theme: String) -> Result<(), String> { + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + let theme_path = config_dir.join("theme.txt"); + atomic_write(&theme_path, theme.as_bytes()).map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn get_app_mode() -> String { + let args: Vec = std::env::args().collect(); + if args.iter().any(|arg| arg == "--uninstall") { + return "uninstall".to_string(); } - #[test] - fn every_preprocessing_step_preserves_source_line_numbers() { - for (name, transform) in line_preserving_transforms() { - for input in LINE_CONTRACT_CORPUS { - assert_transform_preserves_line_numbers(name, transform, input); - } + let current_exe = std::env::current_exe().unwrap_or_default(); + let exe_name = current_exe + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + + let is_installer_mode = + args.iter().any(|arg| arg == "--install") || exe_name.contains("installer"); + + if setup::is_installed() { + "app".to_string() + } else { + if is_installer_mode { + "installer".to_string() + } else { + "app".to_string() } } +} - #[test] - fn the_whole_preprocessing_pipeline_preserves_source_line_numbers() { - // Individually line-preserving steps could still compose badly: one - // step's output is the next one's input, so a rewrite that creates a - // new `^[` or `![[` opener would only show up here. - let pipeline: LineTransform = |content| { - let autolinks = process_parenthesized_autolinks(content); - let embeds = process_internal_embeds(&autolinks); - let links = process_wikilinks(&embeds); - mask_math_spans(&links).text - }; - for input in LINE_CONTRACT_CORPUS { - assert_transform_preserves_line_numbers("the preprocessing pipeline", pipeline, input); - } +fn theme_slug(value: &str) -> String { + let lowercase = value.to_lowercase(); + lowercase + .split(|c: char| !c.is_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-") +} + +#[tauri::command] +async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result { + use std::io::Cursor; + // Parse URL: e.g. https://vscodethemes.com/e/teabyii.ayu/ayu-dark-bordered + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() < 5 || parts[3] != "e" { + return Err("Invalid vscodethemes.com URL".to_string()); + } + let pub_ext = parts[4]; + let theme_name = parts + .get(5) + .unwrap_or(&"") + .split('?') + .next() + .unwrap_or("") + .to_string(); + let pe_parts: Vec<&str> = pub_ext.split('.').collect(); + if pe_parts.len() != 2 { + return Err("Invalid extension format in URL".to_string()); } + let publisher = pe_parts[0]; + let extension = pe_parts[1]; - /// The body of `convert_markdown`, read back out of this source file. - /// - /// The needle is assembled at runtime so that it does not match this - /// file's own source text. Line endings are normalised first: git checks - /// this file out with CRLF wherever `core.autocrlf` is on — the default - /// on Windows, and what the Windows CI runner does — and the `\n`-anchored - /// needles are about the shape of the source, not about how the working - /// tree happens to store it. - fn convert_markdown_body() -> String { - let source = include_str!("lib.rs").replace("\r\n", "\n"); - let needle = format!("\nfn {}(content: &str) -> String {{", "convert_markdown"); - let start = source - .find(&needle) - .expect("convert_markdown must keep its `&str -> String` signature"); - let rest = &source[start + needle.len()..]; - rest[..rest - .find("\n}\n") - .expect("convert_markdown must be terminated")] - .to_string() + let vsix_url = format!("https://{publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/{publisher}/extension/{extension}/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"); + + // Bound the request explicitly. `reqwest::get` has no timeout at all, so + // a marketplace host that accepts the connection and then stalls leaves + // the theme import pending for the rest of the session. + let client = reqwest::Client::builder() + .connect_timeout(VSIX_CONNECT_TIMEOUT) + .timeout(VSIX_REQUEST_TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + let mut response = client.get(&vsix_url).send().await.map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("VSIX download failed with HTTP {}", response.status())); + } + if response.content_length().is_some_and(|length| length > MAX_VSIX_DOWNLOAD_BYTES as u64) { + return Err("VSIX download exceeds the allowed size".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + if bytes.len() + chunk.len() > MAX_VSIX_DOWNLOAD_BYTES { + return Err("VSIX download exceeds the allowed size".to_string()); + } + bytes.extend_from_slice(&chunk); } - #[test] - fn convert_markdown_hands_the_fail_safe_the_raw_buffer() { - // `annotate_task_checkboxes` is a fail-safe only while what reaches it - // is the buffer the command was called with. The hazard is not the - // call — it is the *name*: adding a step the obvious way, - // - // let content = process_new_thing(content); - // - // near the top rebinds the parameter, and the unchanged call at the - // bottom starts handing over preprocessed text. Nothing about that - // edit looks wrong and no behavioural test can see it, because the two - // sides it is supposed to cross-check now agree by definition. - // - // "This string is the one the caller passed in" is provenance, not a - // type, so the compiler cannot be made to check it. What can be made - // structural is the shadowing: `convert_markdown` copies its input to - // `raw_buffer` before anything else runs, which turns the shadowing - // edit above into a harmless one. This test pins the three properties - // that copy depends on. - let body = convert_markdown_body(); + let reader = Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader).map_err(|e| e.to_string())?; + validate_vsix_archive_limits(&mut archive)?; - let capture = "let raw_buffer = content;"; - let first_let = body - .find("\n let ") - .map(|i| i + "\n ".len()) - .expect("convert_markdown must bind something"); - assert!( - body[first_let..].starts_with(capture), - "the raw buffer must be captured before the first preprocessing \ - step, or the step can shadow `content` above it:\n{body}", - ); - assert_eq!( - body.matches(capture).count(), - 1, - "`raw_buffer` is bound more than once — a second binding is the \ - same hole under a new name:\n{body}", - ); - assert!( - Regex::new(r"annotate_task_checkboxes\([^;]*,\s*raw_buffer\s*\)") - .unwrap() - .is_match(&body), - "the fail-safe is no longer handed `raw_buffer`; whatever it now \ - receives can agree with the HTML by construction:\n{body}", - ); - } + let package_json_data = if let Ok(file) = archive.by_name("extension/package.json") { + if file.size() > MAX_THEME_JSON_BYTES { + return Err("VSIX package manifest exceeds the allowed size".to_string()); + } + read_zip_entry_to_string(file, MAX_THEME_JSON_BYTES)? + } else { + return Err("No package.json found in VSIX".to_string()); + }; - #[test] - fn every_convert_markdown_preprocessing_step_is_registered() { - // Re-reads this file so that a fifth preprocessing step cannot be - // added to `convert_markdown` without also being put under the line - // contract. The needle is assembled at runtime so that it does not - // match this test's own source text. - // - // Line endings are normalised first. Git checks this file out with - // CRLF wherever `core.autocrlf` is on — the default on Windows, and - // what the Windows CI runner does — and the `\n`-anchored needles - // below are about the shape of the source, not about how the working - // tree happens to store it. - let source = include_str!("lib.rs").replace("\r\n", "\n"); - let needle = format!("\nfn {}(content: &str) -> String {{", "convert_markdown"); - let start = source - .find(&needle) - .expect("convert_markdown must keep its `&str -> String` signature"); - let rest = &source[start + needle.len()..]; - let body = &rest[..rest.find("\n}\n").expect("convert_markdown must be terminated")]; + let package_json: serde_json::Value = + serde_json::from_str(&package_json_data).map_err(|e| e.to_string())?; + let themes = package_json + .get("contributes") + .and_then(|c| c.get("themes")) + .and_then(|t| t.as_array()) + .ok_or("No themes found in extension")?; - // Bare `name(` calls: `.method(` and `Type::assoc(` are excluded by - // the leading character class. - let call = Regex::new(r"(?:^|[^A-Za-z0-9_:.])([a-z_][a-z0-9_]*)\s*\(").unwrap(); - // Calls in `convert_markdown` that are not preprocessing steps. - // `annotate_task_checkboxes` runs on the rendered HTML, after - // sourcepos numbers exist; it is the fail-safe for this contract - // rather than a participant in it. `restore_math_spans` also runs on - // the rendered HTML — it is the second half of `mask_math_spans`, - // which *is* registered, and it never sees the source buffer. - let not_a_transform = [ - "markdown_to_html", - "annotate_task_checkboxes", - "restore_math_spans", - ]; + let mut theme_path = None; + let mut matched_name_str = theme_name.clone(); - let mut found: Vec = call - .captures_iter(body) - .map(|caps| caps[1].to_string()) - .filter(|name| !not_a_transform.contains(&name.as_str())) - .collect(); - found.sort(); - found.dedup(); + for t in themes { + let label = t + .get("label") + .or(t.get("id")) + .and_then(|l| l.as_str()) + .unwrap_or(""); + let path = t.get("path").and_then(|p| p.as_str()).unwrap_or(""); - let mut registered: Vec = line_preserving_transforms() - .into_iter() - .map(|(name, _)| name.to_string()) - .collect(); - registered.sort(); + let label_slug = theme_slug(label); - assert_eq!( - found, registered, - "convert_markdown's preprocessing steps and the line-contract \ - registry have drifted apart — register every new step in \ - line_preserving_transforms() (or, if the call is not a \ - preprocessing step, add it to not_a_transform and say why)", - ); + // If theme_name is empty, just take the first one + if theme_name.is_empty() + || label_slug == theme_name.to_lowercase() + || path.to_lowercase().contains(&theme_name.to_lowercase()) + { + theme_path = Some(path.to_string()); + if theme_name.is_empty() { + matched_name_str = label_slug; + } + break; + } } - #[test] - fn a_stray_embed_opener_leaves_the_document_and_its_tasks_intact() { - let markdown = - "Prose with ![[ a stray opener.\n\n- [ ] task one\n\nLater an image ![[real.png]] here.\n"; - let html = convert_markdown(markdown); - assert!( - html.contains("task one"), - "the stray opener swallowed the prose: {html}", - ); - assert!( - html.contains("data-task-checkbox"), - "the stray opener shifted the task source position: {html}", - ); - // A real embed further down still renders. - assert!(html.contains(" MAX_THEME_JSON_BYTES { + return Err("VSIX theme file exceeds the allowed size".to_string()); + } + let theme_json = read_zip_entry_to_string(theme_file, MAX_THEME_JSON_BYTES)?; - #[test] - fn a_multiline_inline_footnote_does_not_shift_task_source_positions() { - let html = convert_markdown( - "Some claim^[See the long explanation\nthat wraps to a second line] and more.\n\n- [ ] task\n", - ); - assert!( - html.contains("data-task-checkbox"), - "the multiline inline footnote shifted the task source position: {html}", - ); - } + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let themes_dir = config_dir.join("themes"); + fs::create_dir_all(&themes_dir).map_err(|e| e.to_string())?; - #[test] - fn a_block_id_on_its_own_line_does_not_shift_task_source_positions() { - let markdown = "A quotable paragraph.\n^blockid\n\n- [ ] task\n"; - let html = convert_markdown(markdown); - assert!( - html.contains("id=\"blockid\""), - "the block id anchor disappeared: {html}", - ); - assert!( - html.contains("data-task-checkbox"), - "the block id shifted the task source position: {html}", - ); - } + let dest_name = if matched_name_str.is_empty() { + "downloaded_theme".to_string() + } else { + matched_name_str.clone() + }; + let dest_name = safe_path_component(&dest_name, "theme name")?; + let theme_file_path = themes_dir.join(format!("{}.json", dest_name)); + atomic_write(&theme_file_path, theme_json.as_bytes()).map_err(|e| e.to_string())?; - #[test] - fn task_checkboxes_stay_inert_when_the_html_and_the_buffer_disagree() { - // The fail-safe in `annotate_task_checkboxes`. Feed it HTML whose - // sourcepos numbers came from one document and the raw buffer of a - // different one — the shape a broken line contract produces. Line 3 - // of the buffer is a fence, so annotating would let a click write a - // "- [x]" marker into a code block (issue #352). - let rendered = - convert_markdown("intro paragraph\n\n- [ ] task\n").replace(" data-task-checkbox=\"\"", ""); - assert!( - rendered.contains("data-sourcepos=\"3:1-3:10\"> Result, String> { + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let themes_dir = config_dir.join("themes"); + let mut themes = Vec::new(); + if let Ok(entries) = fs::read_dir(themes_dir) { + for entry in entries.flatten() { + if let Some(ext) = entry.path().extension() { + if ext == "json" { + if let Some(name) = entry.path().file_stem().and_then(|n| n.to_str()) { + themes.push(name.to_string()); + } + } + } + } } + Ok(themes) +} + +#[tauri::command] +fn read_vscode_theme(app: AppHandle, name: String) -> Result { + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let name = safe_path_component(&name, "theme name")?; + let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); + fs::read_to_string(theme_file_path).map_err(|e| e.to_string()) } -mod setup; -mod tab_transfer; -mod window_runtime; -use window_runtime::{AppState, WatcherState}; - #[tauri::command] -async fn show_window(window: tauri::Window) { - window_runtime::show_window(window).await; +fn delete_vscode_theme(app: AppHandle, name: String) -> Result<(), String> { + let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; + let name = safe_path_component(&name, "theme name")?; + let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); + fs::remove_file(theme_file_path).map_err(|e| e.to_string()) } #[tauri::command] -fn save_window_state(app: AppHandle, json: String) -> Result<(), String> { - window_runtime::save_window_state(app, json) +fn is_win11() -> bool { + #[cfg(target_os = "windows")] + { + use winreg::enums::*; + use winreg::RegKey; + + let hklim = RegKey::predef(HKEY_LOCAL_MACHINE); + if let Ok(current_version) = + hklim.open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion") + { + if let Ok(current_build) = current_version.get_value::("CurrentBuild") { + if let Ok(build_num) = current_build.parse::() { + return build_num >= 22000; + } + } + } + } + false } +/// Async because enumerating every installed font family is a slow, +/// filesystem-heavy call (fontconfig on Linux, DirectWrite on Windows, +/// CoreText on macOS) and the settings dialog invokes it on open. On the main +/// thread it stalls every window for the duration. #[tauri::command] -fn load_window_state(app: AppHandle) -> Option { - window_runtime::load_window_state(app) +async fn get_system_fonts() -> Vec { + tauri::async_runtime::spawn_blocking(|| { + use font_kit::source::SystemSource; + let source = SystemSource::new(); + let mut families = source.all_families().unwrap_or_default(); + families.sort(); + families.dedup(); + families + }) + .await + .unwrap_or_default() } #[tauri::command] -fn clear_window_state(app: AppHandle) -> Result<(), String> { - window_runtime::clear_window_state(app) +fn get_os_type() -> String { + #[cfg(target_os = "macos")] + { + "macos".to_string() + } + #[cfg(target_os = "windows")] + { + "windows".to_string() + } + #[cfg(target_os = "linux")] + { + "linux".to_string() + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + "unknown".to_string() + } } + #[tauri::command] -fn set_window_meta( - window: tauri::Window, - state: State<'_, AppState>, - tag_name: Option, - tag_color: Option, - active_tab_title: String, - tab_count: usize, -) { - window_runtime::set_window_meta(window, state, tag_name, tag_color, active_tab_title, tab_count) +fn clipboard_write_text(text: String) -> Result<(), String> { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + clipboard.set_text(text).map_err(|e| e.to_string()) } #[tauri::command] -fn list_viewer_windows(state: State<'_, AppState>) -> Vec { - window_runtime::list_viewer_windows(state) +fn clipboard_read_text() -> Result { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + clipboard.get_text().map_err(|e| e.to_string()) } #[tauri::command] -fn offer_tab_to_window(app: AppHandle, target_label: String, token: String) -> Result<(), String> { - window_runtime::offer_tab_to_window(app, target_label, token) +fn clipboard_read_image(macos_image_scaling: bool) -> Result { + #[cfg(not(target_os = "macos"))] + let _ = macos_image_scaling; + + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + let image = clipboard.get_image().map_err(|e| e.to_string())?; + + // encode as png + let mut png_data = Vec::new(); + { + let encoder = image::codecs::png::PngEncoder::new(&mut png_data); + use image::ImageEncoder; + + // Check if running on macOS and scale image if needed + #[cfg(target_os = "macos")] + { + if macos_image_scaling { + // Use image crate for high-quality scaling + use image::{DynamicImage, ImageBuffer, Rgba}; + + // Convert arboard Image to ImageBuffer + let mut img_buffer = ImageBuffer::new(image.width as u32, image.height as u32); + for (x, y, pixel) in img_buffer.enumerate_pixels_mut() { + let idx = (y * image.width as u32 + x) as usize * 4; + if idx + 3 < image.bytes.len() { + *pixel = Rgba([ + image.bytes[idx], + image.bytes[idx + 1], + image.bytes[idx + 2], + image.bytes[idx + 3] + ]); + } + } + + // Create DynamicImage + let dynamic_image = DynamicImage::ImageRgba8(img_buffer); + + // Resize with high-quality Lanczos3 filter + let resized = dynamic_image.resize( + (image.width / 2) as u32, + (image.height / 2) as u32, + image::imageops::FilterType::Lanczos3 + ); + + // Write the resized image + let resized_rgba = resized.to_rgba8(); + encoder + .write_image( + resized_rgba.as_raw(), + (image.width / 2) as u32, + (image.height / 2) as u32, + image::ExtendedColorType::Rgba8, + ) + .map_err(|e| e.to_string())?; + } else { + // Use original image if scaling is disabled + encoder + .write_image( + image.bytes.as_ref(), + image.width as u32, + image.height as u32, + image::ExtendedColorType::Rgba8, + ) + .map_err(|e| e.to_string())?; + } + } + + #[cfg(not(target_os = "macos"))] + { + // For other platforms, use the original image + encoder + .write_image( + image.bytes.as_ref(), + image.width as u32, + image.height as u32, + image::ExtendedColorType::Rgba8, + ) + .map_err(|e| e.to_string())?; + } + } + + use base64::{engine::general_purpose, Engine as _}; + Ok(general_purpose::STANDARD.encode(&png_data)) } #[tauri::command] -fn focus_window(app: AppHandle, label: String) -> Result<(), String> { - window_runtime::focus_window(app, label) +async fn save_image( + parent_dir: String, + filename: String, + base64_data: String, + image_directory: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + save_image_blocking(&parent_dir, &filename, &base64_data, &image_directory) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) } -#[tauri::command] -fn list_pinned_tags(app: AppHandle) -> Vec { - window_runtime::list_pinned_tags(app) +fn save_image_blocking( + parent_dir: &str, + filename: &str, + base64_data: &str, + image_directory: &str, +) -> Result { + let filename = safe_path_component(filename, "image filename")?; + let (root, img_dir) = resolve_image_directory(parent_dir, image_directory)?; + let file_path = img_dir.join(filename); + ensure_path_within_root(&root, &file_path)?; + + // remove potential data:image/png;base64, prefix + let b64 = if let Some(pos) = base64_data.find("base64,") { + &base64_data[pos + 7..] + } else { + base64_data + }; + + use base64::{engine::general_purpose, Engine as _}; + let bytes = general_purpose::STANDARD + .decode(b64) + .map_err(|e: base64::DecodeError| e.to_string())?; + + atomic_write(&file_path, &bytes).map_err(|e| e.to_string())?; + + let rel_path = if image_directory.is_empty() { + filename.to_string() + } else { + format!("{}/{}", image_directory, filename) + }; + + Ok(rel_path) } #[tauri::command] -fn save_pinned_tag(app: AppHandle, name: String, color: String, files: Vec) -> Result<(), String> { - window_runtime::save_pinned_tag(app, name, color, files) +async fn copy_file_to_img( + src_path: String, + parent_dir: String, + image_directory: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + copy_file_to_img_blocking(&src_path, &parent_dir, &image_directory) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())) +} + +/// How many conflict names to try before giving up. Chromium's download path +/// reservation gives up after 100 for the same reason: past that, the user is +/// better served by an error than by an unbounded directory scan. +const MAX_IMG_NAME_ATTEMPTS: u32 = 100; + +/// Builds the `attempt`-th conflict name, e.g. `photo_1.png`, `photo_2.png`. +/// +/// Every mainstream implementation resolves a name conflict with an +/// incrementing counter — Chrome/Firefox downloads (`photo (1).png`), Windows +/// Explorer (`photo (2).png`), macOS Finder (`photo 2.png`) — and none uses a +/// timestamp. They disagree only on the decoration, so this picks the one that +/// survives the destination: the name is about to be pasted into a Markdown +/// link, where parentheses are metacharacters and spaces need escaping, while +/// `_` needs neither. It is also the separator this function already used. +/// +/// An empty extension gets no separator: `Path::extension()` is `None` for a +/// dotfile such as `.png`, and appending the dot unconditionally produced +/// `photo_1.` — a name Windows silently creates *without* the trailing dot, +/// leaving the link written into the document pointing at nothing. +fn img_conflict_name(stem: &str, ext: &str, attempt: u32) -> String { + if ext.is_empty() { + format!("{stem}_{attempt}") + } else { + format!("{stem}_{attempt}.{ext}") + } } -#[tauri::command] -fn remove_pinned_tag(app: AppHandle, name: String) -> Result<(), String> { - window_runtime::remove_pinned_tag(app, name) -} +fn copy_file_to_img_blocking( + src_path: &str, + parent_dir: &str, + image_directory: &str, +) -> Result { + let (root, img_dir) = resolve_image_directory(parent_dir, image_directory)?; -/// Byte ranges of code regions — fenced code blocks and inline code spans — -/// paired with CommonMark's rules. The regex alternation previously used for -/// protection (```` ```.*?```|`.*?` ````) cannot express them: a fence closes -/// only on a line-leading run of the same character at least as long as the -/// opener, and a span opened by N backticks closes only on a run of exactly -/// N. One mismatched pairing (e.g. a 4-backtick inline sample, or a ~~~ -/// fence, which the old pattern did not know at all) desynchronized the -/// protection for the entire rest of the document. -/// -/// The result is in ascending document order, which is not cosmetic: -/// `in_code_region` binary-searches it. That order is produced by -/// construction rather than by a sort at the end — the scan alternates -/// between the two kinds of region (the text before a fence, then the fence, -/// then the text after it), so each push is at a higher offset than the last. -/// The previous shape collected fences here and appended every inline span in -/// a second pass afterwards, which left the vector unsorted for any document -/// containing both, and made a single `sort_unstable()` call the only thing -/// standing between the search and a wrong answer. -fn code_region_ranges(content: &str) -> Vec<(usize, usize)> { - let len = content.len(); - let mut regions: Vec<(usize, usize)> = Vec::new(); - // (fence char, opener run length, region start) - let mut fence: Option<(u8, usize, usize)> = None; - let mut seg_start = 0usize; + let src = Path::new(src_path); + if !src.exists() { + return Err("Source file does not exist".to_string()); + } - let mut line_start = 0usize; - while line_start < len { - let line_end = content[line_start..] - .find('\n') - .map(|i| line_start + i + 1) - .unwrap_or(len); - let line = &content[line_start..line_end]; - let trimmed = line.trim_start_matches(' '); - let indent = line.len() - trimmed.len(); - let marker = trimmed.as_bytes().first().copied(); - let run_len = trimmed - .as_bytes() - .iter() - .take_while(|&&b| Some(b) == marker) - .count(); - let is_fence_line = indent <= 3 - && matches!(marker, Some(b'`') | Some(b'~')) - && run_len >= 3; + let file_name = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid source filename".to_string())?; + let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("image"); + let ext = src.extension().and_then(|e| e.to_str()).unwrap_or(""); - match fence { - Some((ch, opener_len, start)) => { - if is_fence_line - && marker == Some(ch) - && run_len >= opener_len - && trimmed[run_len..].trim().is_empty() - { - regions.push((start, line_end)); - fence = None; - seg_start = line_end; + let mut source = fs::File::open(src).map_err(|e| e.to_string())?; + + // The destination name is claimed with `create_new`, which is a single + // atomic syscall (`O_EXCL` / `CREATE_NEW`): whoever creates the file wins + // and everyone else gets `AlreadyExists` and moves to the next name. The + // previous code tested `exists()` and then copied, so two drops that + // computed the same name — trivially, since the name carried a + // second-resolution timestamp that was never re-checked — both saw the + // name as free and the second overwrote an image the document already + // linked to. + // + // Residual races: `O_EXCL` is not reliable on old NFSv2 mounts, and + // nothing stops an outside process from deleting our file after we create + // it. Neither is a same-app data-loss path, which is what this guards. + // Streaming into the handle we just created, rather than `fs::copy`, also + // means the copy no longer inherits the source's permission bits — a + // read-only original used to produce a read-only file in `img/`. + let mut dest_name = file_name.to_string(); + let mut attempt: u32 = 0; + loop { + let candidate = img_dir.join(&dest_name); + ensure_path_within_root(&root, &candidate)?; + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(mut dest) => { + // The name is ours; only the bytes can still fail. Drop the + // placeholder if they do, so a failed drop does not leave a + // truncated image behind under a name the user may reuse. + if let Err(e) = std::io::copy(&mut source, &mut dest) { + drop(dest); + let _ = fs::remove_file(&candidate); + return Err(e.to_string()); } + break; } - None => { - // The info string of a backtick fence may not contain backticks. - let info_ok = marker != Some(b'`') || !trimmed[run_len..].contains('`'); - if is_fence_line && info_ok { - // Before the fence region itself, so the two kinds of - // region stay interleaved in document order. - if seg_start < line_start { - push_inline_code_spans(content, seg_start, line_start, &mut regions); - } - fence = Some((marker.unwrap(), run_len, line_start)); + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + attempt += 1; + if attempt > MAX_IMG_NAME_ATTEMPTS { + return Err(format!( + "Too many images named \"{}\" in this folder", + file_name + )); } + dest_name = img_conflict_name(stem, ext, attempt); } - } - line_start = line_end; - } - match fence { - // An unclosed fence runs to the end of the input. - Some((_, _, start)) => regions.push((start, len)), - None => { - if seg_start < len { - push_inline_code_spans(content, seg_start, len, &mut regions); - } + Err(e) => return Err(e.to_string()), } } - debug_assert!( - regions.windows(2).all(|pair| pair[0] <= pair[1]), - "code_region_ranges emitted regions out of document order, which \ - makes in_code_region's binary search miss them: {regions:?}", - ); - regions -} + let rel_path = if image_directory.is_empty() { + dest_name + } else { + format!("{}/{}", image_directory, dest_name) + }; -/// Splits `content[start..end]` — a stretch of text between fences — into -/// blocks and records the inline code spans of each. -/// -/// CommonMark parses inline elements one block at a time and a blank line -/// ends a block, so pairing is confined to each blank-line-delimited chunk: a -/// stray backtick in prose must not open a span that runs on until the -/// opening backtick of a real code span paragraphs later, suppressing every -/// embed, wikilink and highlight in between. -fn push_inline_code_spans( - content: &str, - start: usize, - end: usize, - regions: &mut Vec<(usize, usize)>, -) { - let mut chunk_start = start; - let mut line_start = start; - while line_start < end { - let line_end = content[line_start..end] - .find('\n') - .map(|i| line_start + i + 1) - .unwrap_or(end); - if content[line_start..line_end].trim().is_empty() { - pair_inline_code_runs(content, chunk_start, line_start, regions); - chunk_start = line_end; - } - line_start = line_end; - } - pair_inline_code_runs(content, chunk_start, end, regions); + Ok(rel_path) } -/// Records the inline code spans inside `content[start..end]`, which must be -/// a single block's worth of text. A run of N backticks pairs with the next -/// run of exactly N; runs that never pair are literal text. -fn pair_inline_code_runs(content: &str, start: usize, end: usize, regions: &mut Vec<(usize, usize)>) { - let chunk = &content.as_bytes()[start..end]; - let mut runs: Vec<(usize, usize)> = Vec::new(); // (offset in chunk, len) - let mut i = 0usize; - while i < chunk.len() { - if chunk[i] == b'`' { - let run_start = i; - while i < chunk.len() && chunk[i] == b'`' { - i += 1; - } - runs.push((run_start, i - run_start)); - } else { - i += 1; - } - } - - let mut r = 0usize; - while r < runs.len() { - let (open_start, open_len) = runs[r]; - if let Some(close) = (r + 1..runs.len()).find(|&j| runs[j].1 == open_len) { - let (close_start, close_len) = runs[close]; - regions.push((start + open_start, start + close_start + close_len)); - r = close + 1; - } else { - r += 1; - } +#[tauri::command] +fn delete_file(path: String) -> Result<(), String> { + let p = Path::new(&path); + if p.exists() { + fs::remove_file(p).map_err(|e| e.to_string())?; } + Ok(()) } -fn in_code_region(regions: &[(usize, usize)], pos: usize) -> bool { - regions - .binary_search_by(|&(s, e)| { - if pos < s { - std::cmp::Ordering::Greater - } else if pos >= e { - std::cmp::Ordering::Less - } else { - std::cmp::Ordering::Equal - } - }) - .is_ok() -} - -/// Picks the viewer window that should receive an externally opened file: -/// the focused viewer if any, else the viewer the user focused most -/// recently, else any viewer. The middle rung matters for Finder opens — -/// Finder is frontmost at that moment, so is_focused() is false for every -/// Markpad window and delivery would otherwise degrade to arbitrary map -/// order. Viewer windows are "main" and detached "window-*" windows; -/// "installer" never receives files. -fn pick_delivery_window(app: &AppHandle) -> Option { - window_runtime::pick_delivery_window(app) -} - -/// Creates the destination window for a tab transfer. The window's label -/// embeds the transfer token ("window-"), so the new frontend can -/// derive which pending transfer to claim from its own label — no URL -/// query involved (the asset protocol 404s on "index.html?x=y" paths). -/// Deliberately async. `WebviewWindowBuilder::build()` deadlocks on Windows -/// when it runs inside a synchronous command: WebView2 needs the main thread -/// to pump messages while the webview is created, but a sync command IS the -/// main thread, blocked waiting for build() to return. The whole app then -/// freezes — no new window, no menus, an unresponsive close button -/// (tauri-apps/tauri#12521). An async command runs off the event loop, and -/// Tauri dispatches the actual window creation to the main thread itself, so -/// macOS's main-thread requirement is still satisfied. #[tauri::command] -async fn create_transfer_window(app: AppHandle, token: String) -> Result<(), String> { - window_runtime::create_transfer_window(app, token) +fn copy_file(src: String, dest: String) -> Result<(), String> { + fs::copy(src, dest).map(|_| ()).map_err(|e| e.to_string()) } -fn process_internal_embeds(content: &str) -> Cow<'_, str> { - let regions = code_region_ranges(content); - - INTERNAL_EMBED_RE.replace_all(content, |caps: &Captures| { - let full = caps.get(0).unwrap(); - if in_code_region(®ions, full.start()) { - return full.as_str().to_string(); +#[tauri::command] +fn cleanup_empty_img_dir(parent_dir: String, image_directory: String) -> Result<(), String> { + let img_dir = Path::new(&parent_dir).join(&image_directory); + if img_dir.exists() && img_dir.is_dir() { + if fs::read_dir(&img_dir) + .map_err(|e| e.to_string())? + .next() + .is_none() + { + fs::remove_dir(img_dir).map_err(|e| e.to_string())?; } + } + Ok(()) +} - let inner = caps.get(1).map(|m| m.as_str()).unwrap_or(""); - let mut parts = inner.split('|'); - let path = parts.next().unwrap_or(""); - let size = parts.next(); - - // Every interpolated value is HTML-escaped: the target comes straight - // from the document, so a quote in it would otherwise close the - // attribute and let the rest be read as markup. - let src = escape_html_attribute(&path.replace(" ", "%20")); - let alt = escape_html_attribute(path); +#[tauri::command] +async fn list_directory_contents(path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = Path::new(&path); + if !dir.exists() || !dir.is_dir() { + return Err("Not a directory".to_string()); + } - if let Some(size_str) = size { - if size_str.contains('x') { - let mut dims = size_str.split('x'); - let width = escape_html_attribute(dims.next().unwrap_or("")); - let height = escape_html_attribute(dims.next().unwrap_or("")); - format!( - "\"{}\"", - src, width, height, alt - ) + let mut entries = Vec::new(); + for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + entries.push(format!("{}/", name)); } else { - format!( - "\"{}\"", - src, - escape_html_attribute(size_str), - alt - ) + entries.push(name); } - } else { - format!("\"{}\"", src, alt) } + Ok(entries) }) + .await + .unwrap_or_else(|e| Err(e.to_string())) } -fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { - let mut processed = Cow::Borrowed(content); +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + #[cfg(target_os = "linux")] + { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } - // 1. Process [[#heading]], [[file#heading]] and the |alias form of each. - // Obsidian documents all of these (help.obsidian.md/links: - // "[[About Obsidian#Links are first-class citizens]]", - // "[[2023-01-01#^37066d]]", "[[Example#Details|Section name]]"), and - // the file form is what Markpad's own "Copy Reference" menu item puts - // on the clipboard — it used to paste back in as dead literal text. - // - // Obsidian's bare note link "[[Notes]]" is NOT handled: it is a - // separate feature rather than part of this defect, and claiming every - // "[[…]]" would capture bracketed citation numbering ("[[1]]") and - // pre-empt CommonMark reference links ("[[foo]]" with a "[foo]: url" - // definition). Every Copy Reference call site emits a "#", so requiring - // one fixes the defect completely without touching either. - if WIKILINK_RE.is_match(&processed) { - let regions = code_region_ranges(&processed); - let source: &str = &processed; - let replaced = WIKILINK_RE.replace_all(source, |caps: &Captures| { - let full = caps.get(0).unwrap(); - let literal = || full.as_str().to_string(); - if in_code_region(®ions, full.start()) { - return literal(); - } - // The pattern is line-agnostic, but neither a heading id nor a - // filename contains a newline, so a target spanning lines can - // never resolve. Leaving it literal also keeps the line count - // stable — rewriting it to a single line would shift the source - // positions of every task checkbox below it (see - // `multiline_wikilinks_do_not_shift_task_source_positions`). - if full.as_str().contains('\n') { - return literal(); - } - // `![[…]]` is an embed, already rewritten by - // process_internal_embeds; it is never a link. - if source.as_bytes()[..full.start()].last() == Some(&b'!') { - return literal(); - } - // "[[1#x]](https://example.com)" is a CommonMark link whose text - // is "[1#x]"; claiming the brackets would strand the "(url)". - // Requiring a "#" already protects the common citation spelling - // "[[1]](url)", but not the forms that do carry one. - if source[full.end()..].starts_with('(') { - return literal(); - } + #[cfg(target_os = "windows")] + { + std::env::set_var( + "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", + "--enable-features=SmoothScrolling", + ); + } - let inner = caps.get(1).map(|m| m.as_str()).unwrap_or(""); - let (target, alias) = match inner.split_once('|') { - Some((target, alias)) => (target, Some(alias)), - None => (inner, None), - }; - let alias = alias.filter(|a| !a.trim().is_empty()); - // The `#` the pattern matched may have been in the alias half - // ("[[Notes|see #1]]"), which is a bare note link, not a heading - // link. Only a `#` in the target counts. - let Some((path, heading)) = target.split_once('#') else { - return literal(); - }; - let (path, heading) = (path.trim(), heading.trim()); - if heading.is_empty() { - return literal(); - } - let anchor = heading_anchor_id(heading); + tauri::Builder::default() + .manage(AppState::new()) + .manage(WatcherState::new()) + .manage(tab_transfer::TabTransferBroker::new()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + window_runtime::handle_single_instance(app, args, cwd); + })) + .plugin(tauri_plugin_prevent_default::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::SIZE + | tauri_plugin_window_state::StateFlags::POSITION + | tauri_plugin_window_state::StateFlags::MAXIMIZED + | tauri_plugin_window_state::StateFlags::VISIBLE + | tauri_plugin_window_state::StateFlags::FULLSCREEN, + ) + // Detached tab windows share one saved state instead of + // accumulating a state entry per generated label. + .map_label(|label| { + if label.starts_with("window-") { + "secondary" + } else { + label + } + }) + .build(), + ) + .setup(|app| { + let args: Vec = std::env::args().collect(); + println!("Setup Args: {:?}", args); - if path.is_empty() { - // Same document: [[#Setup]] / [[#Setup|jump]]. - return format!("[{}](#{anchor})", alias.unwrap_or(heading)); - } + let current_exe = std::env::current_exe().unwrap_or_default(); + let exe_name = current_exe + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + let is_installer_mode = + args.iter().any(|arg| arg == "--install") || exe_name.contains("installer"); - let Some(destination) = wikilink_file_destination(path) else { - return literal(); + let label = if is_installer_mode { + "installer" + } else { + "main" }; - // Obsidian renders an un-aliased heading link as "Note > Heading"; - // keeping that spelling means a pasted reference reads the same in - // both apps. - format!( - "[{}]({destination}#{anchor})", - alias - .map(str::to_string) - .unwrap_or_else(|| format!("{path} > {heading}")), - ) - }); - processed = Cow::Owned(replaced.into_owned()); - } - // 2. Process ^block-id at the end of lines - // For block IDs, they are trailing. We skip code blocks but also need to be careful with inline code at EOL. - if BLOCK_ID_RE.is_match(&processed) { - let regions = code_region_ranges(&processed); - let replaced = BLOCK_ID_RE.replace_all(&processed, |caps: &Captures| { - let full = caps.get(0).unwrap(); - if in_code_region(®ions, full.start()) { - return full.as_str().to_string(); - } - // Re-emit the matched whitespace verbatim. For the common - // trailing form (" ^id") that is the same single space this used - // to hardcode; for an id on its own line it is the newline that - // keeps the following lines at their original numbers. - let leading = caps.get(1).map(|m| m.as_str()).unwrap_or(" "); - let id = caps.get(2).map(|m| m.as_str()).unwrap_or(""); - format!( - "{}", - leading, id, id + let mut window_builder = tauri::WebviewWindowBuilder::new( + app, + label, + tauri::WebviewUrl::App("index.html".into()), ) - }); - processed = Cow::Owned(replaced.into_owned()); - } + .title("Markpad") + .inner_size(900.0, 650.0) + .min_inner_size(400.0, 300.0) + .visible(false) + .resizable(true) + .shadow(false) + .center(); - // 3. Convert ==highlight== to highlight - if HIGHLIGHT_RE.is_match(&processed) { - let regions = code_region_ranges(&processed); - let replaced = HIGHLIGHT_RE.replace_all(&processed, |caps: &Captures| { - let full = caps.get(0).unwrap(); - if in_code_region(®ions, full.start()) { - return full.as_str().to_string(); - } - format!("{}", caps.get(1).unwrap().as_str()) - }); - processed = Cow::Owned(replaced.into_owned()); - } + #[cfg(target_os = "macos")] + { + window_builder = window_builder + .decorations(true) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + } - // 4. Convert ^[inline footnote] to a footnote reference - if INLINE_FOOTNOTE_RE.is_match(&processed) { - let regions = code_region_ranges(&processed); - let mut footnote_defs = String::new(); - let mut fn_count = 0usize; - let replaced = INLINE_FOOTNOTE_RE.replace_all(&processed, |caps: &Captures| { - let full = caps.get(0).unwrap(); - if in_code_region(®ions, full.start()) { - return full.as_str().to_string(); + #[cfg(not(target_os = "macos"))] + { + window_builder = window_builder.decorations(false); } - fn_count += 1; - let label = format!("ifn-{}", fn_count); - footnote_defs.push_str(&format!( - "\n[^{}]: {}\n", - label, - caps.get(1).unwrap().as_str() - )); - format!("[^{}]", label) - }); - let mut out = replaced.into_owned(); - out.push_str(&footnote_defs); - processed = Cow::Owned(out); - } - processed -} + let window = window_builder.build()?; -fn process_parenthesized_autolinks(content: &str) -> Cow<'_, str> { - let regions = code_region_ranges(content); - let mut output = String::new(); - let mut copied_to = 0; - let mut scan_from = 0; + #[cfg(target_os = "macos")] + { + use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; - while let Some(opening_offset) = content[scan_from..].find('(') { - let opening = scan_from + opening_offset; - let url_start = opening + 1; - let url_tail = &content[url_start..]; - if !(url_tail.starts_with("http://") - || url_tail.starts_with("https://") - || url_tail.starts_with("ftp://")) - { - scan_from = url_start; - continue; - } + let app_name = app.package_info().name.clone(); - let mut depth = 1usize; - let mut closing = None; - for (offset, ch) in url_tail.char_indices() { - if ch.is_whitespace() { - break; + let check_item = + MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(app)?; + let settings_item = MenuItemBuilder::with_id("menu-app-settings", "Settings…") + .accelerator("CmdOrCtrl+,") + .build(app)?; + + let app_submenu = SubmenuBuilder::new(app, &app_name) + .item(&PredefinedMenuItem::about( + app, + Some(&format!("About {}", app_name)), + None, + )?) + .separator() + .item(&settings_item) + .item(&check_item) + .separator() + .item(&PredefinedMenuItem::services(app, None)?) + .separator() + .item(&PredefinedMenuItem::hide(app, None)?) + .separator() + .item( + &MenuItemBuilder::with_id( + "menu-app-quit", + format!("Quit {}", app_name), + ) + .accelerator("CmdOrCtrl+Q") + .build(app)?, + ) + .build()?; + + let menu = MenuBuilder::new(app) + .items(&[&app_submenu]) + .build()?; + + app.set_menu(menu)?; } - match ch { - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - closing = Some(url_start + offset); - break; + + let config_dir = app.path().app_config_dir()?; + let theme_path = config_dir.join("theme.txt"); + let theme_pref = + fs::read_to_string(theme_path).unwrap_or_else(|_| "system".to_string()); + + let bg_color = match theme_pref.as_str() { + "dark" => Some(tauri::window::Color(24, 24, 24, 255)), + "light" => Some(tauri::window::Color(253, 253, 253, 255)), + _ => { + if let Ok(t) = window.theme() { + match t { + tauri::Theme::Dark => Some(tauri::window::Color(24, 24, 24, 255)), + _ => Some(tauri::window::Color(253, 253, 253, 255)), + } + } else { + Some(tauri::window::Color(253, 253, 253, 255)) } } - _ => {} + }; + + let _ = window.set_background_color(bg_color); + + let _ = window.set_shadow(true); + + let file_path = args.iter().skip(1).find(|arg| !arg.starts_with("-")); + + if let Some(path) = file_path { + let _ = window.emit("file-path", path.as_str()); + window_runtime::bring_to_front(&window); } - } - let Some(closing) = closing else { - scan_from = url_start; - continue; - }; - let after_closing = closing + ')'.len_utf8(); - let adjacent_text = content[after_closing..] - .chars() - .next() - .is_some_and(char::is_alphanumeric); - if !adjacent_text || in_code_region(®ions, opening) { - scan_from = after_closing; - continue; - } + // If installer, force size (this will be saved to installer-state, not main-state) + if is_installer_mode { + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 450.0, + height: 650.0, + })); + let _ = window.center(); + } - let url = &content[url_start..closing]; - output.push_str(&content[copied_to..url_start]); - output.push('['); - output.push_str(url); - output.push_str("]("); - output.push_str(url); - output.push_str(")"); - output.push(')'); - copied_to = after_closing; - scan_from = after_closing; - } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + clipboard_write_text, + clipboard_read_text, + clipboard_read_image, + open_markdown, + open_markdown_preview, + render_markdown, + send_markdown_path, + read_file_content_checked, + canonicalize_path, + read_file_as_data_url, + save_file_content, + export_pdf_windows, + print_pdf, + save_file_binary, + get_app_mode, + setup::install_app, + setup::uninstall_app, + setup::check_install_status, + is_win11, + open_file_folder, + rename_file, + watch_file, + unwatch_file, + show_window, + save_theme, + get_system_fonts, + get_os_type, + fetch_vscode_theme, + get_saved_vscode_themes, + read_vscode_theme, + delete_vscode_theme, + save_image, + copy_file_to_img, + delete_file, + copy_file, + cleanup_empty_img_dir, + list_directory_contents, + tab_transfer::stage_detached_tab, + tab_transfer::claim_detached_tab, + tab_transfer::complete_detached_tab, + tab_transfer::cancel_detached_tab, + create_transfer_window, + set_window_meta, + list_viewer_windows, + offer_tab_to_window, + focus_window, + list_pinned_tags, + save_pinned_tag, + remove_pinned_tag, + save_window_state, + load_window_state, + clear_window_state + ]) + .on_window_event(window_runtime::handle_window_event) + .on_menu_event(|app, event| { + let id = event.id().as_ref(); + // Emit to the focused webview window's label rather than + // `window.emit(...)`, which broadcasts to every webview and + // would fire menu actions (New/Close/Save…) in all windows at + // once. Falls back to "main" if no window is focused (e.g. menu + // fired while the app is in the background). + let target = app + .webview_windows() + .into_values() + .find(|w| w.is_focused().unwrap_or(false)) + .or_else(|| app.get_webview_window("main")); + let Some(window) = target else { return }; - if output.is_empty() { - Cow::Borrowed(content) - } else { - output.push_str(&content[copied_to..]); - Cow::Owned(output) - } + if id == "menu-app-settings" { + let _ = app.emit_to(window.label(), "menu-app-settings", ()); + } else if id == "check-updates" { + let _ = app.emit_to(window.label(), "menu-check-updates", ()); + } else if id == "menu-app-quit" { + let _ = app.emit_to(window.label(), id, ()); + } + }) + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|_app_handle, _event| { + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = _event { + for url in urls { + if let Ok(path_buf) = url.to_file_path() { + let path_str = path_buf.to_string_lossy().to_string(); + + let state = _app_handle.state::(); + window_runtime::lock_recover(&state.startup_files) + .push(path_str.clone()); + + if let Some(window) = pick_delivery_window(_app_handle) { + let _ = _app_handle.emit_to(window.label(), "file-path", path_str); + window_runtime::bring_to_front(&window); + } + } + } + } + }); } // --------------------------------------------------------------------------- -// Math spans -// -// comrak keeps applying CommonMark inline rules *inside* math delimiters, so -// the formula KaTeX finally sees has already been rewritten. Three reported -// bugs are the same bug: -// -// #174 `$\bar{b}_{1} + \bar{b}_{2}$` — the two `_` pair into ``, which -// is why only the first subscript ever worked and why putting a space -// in front of it "fixed" it (a space makes the `_` non-left-flanking). -// #197 `$$ … \\ … $$` — `\\` is a CommonMark escape for a literal `\`, so -// every row separator of an `aligned` block is eaten and the whole -// block collapses onto one over-wide line. -// #177 `\%` loses its backslash, and a bare `%` starts a TeX comment that -// swallows the rest of the formula ("Unexpected end of input"). -// -// Patching one character class at a time (the previous -// `protect_display_math_underscores` only rewrote `_`, and only between `$$`) -// cannot win: Markdown has no business parsing TeX at all. So the whole span -// is replaced by an opaque token before comrak runs and put back afterwards. -// -// Escaped dollars are hidden the same way, for the mirror-image reason. A -// reader who writes `\$\$x\$\$` is saying "not a formula", and CommonMark -// resolving that escape destroys the only evidence of it: the frontend, which -// is the side that actually decides, then sees the same bytes a real `$$x$$` -// produces and typesets the reader's dollar signs. See -// `find_escaped_dollar_spans`. -// -// Deliberately NOT handled here: `\(…\)` and `\[…\]`, which the frontend also -// renders. They have the same root cause and are not an oversight — CommonMark -// eats the backslash (`\(` → `(`) before the frontend ever sees a delimiter, so -// they have never worked in Markpad at all. Fixing them is a separate change -// with its own regression surface; masking them here would silently start -// claiming text that no released version ever treated as math. That the -// frontend *emits* those two spellings is a different matter: they are its -// private vocabulary for a decision it has already made. -// -// The token is deliberately plain ASCII rather than a private-use character: -// comrak percent-encodes anything non-ASCII that ends up in a link -// destination (`http://x/$a$` would come back as `http://x/%EE%80%80…`), -// while `[A-Z0-9]` survives text nodes, attribute values and hrefs verbatim -// and carries no CommonMark meaning. Uniqueness is established by -// construction instead of by luck: the prefix grows until it does not occur -// in the document. +// Tests // --------------------------------------------------------------------------- -const MATH_MASK_PREFIX: &str = "MPMATHMASK"; -const MATH_MASK_SUFFIX: char = 'E'; +#[cfg(test)] +mod tests { + use super::*; -struct MaskedMath { - /// The source with every math span and every escaped dollar replaced by a - /// token. - text: String, - /// The token prefix actually used — see `mask_math_spans`. - prefix: String, - /// The masked source, one entry per line of each span, indexed by token. - spans: Vec, -} + fn temp_path(tag: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("markpad-{tag}-{nonce}")) + } -/// One masked piece of source and the two spellings it can come back as. -struct MaskedSpan { - /// The source, verbatim. This is what a text node gets, because a text - /// node is where the frontend's own passes run. - text: String, - /// What an attribute value gets instead. Nothing unescapes an `href` or an - /// `alt` on the way to the reader, so an escaped dollar has to arrive there - /// already resolved — the way comrak would have resolved it. For a math - /// span the two spellings are the same string. - attribute: String, -} + /// A directory to work in, so the case/normalization probes below cannot + /// collide with anything else in the temp dir. + fn temp_dir(tag: &str) -> PathBuf { + let dir = temp_path(tag); + fs::create_dir_all(&dir).unwrap(); + dir + } -/// Byte ranges of `content` that may hold math: one entry per line, with the -/// code regions cut out. -/// -/// The split mirrors what the frontend can see. `convert_markdown` renders -/// with `hardbreaks`, so every source line becomes its own DOM text node, and -/// `processInlineMath` skips `code`/`pre` subtrees entirely — a `$` on the far -/// side of a line break or of an inline code span is in a different text node -/// and can never pair. Scanning the raw buffer the same way is what keeps the -/// two ends agreeing, and it is also what stops a single `$$` inside a fenced -/// block from flipping the delimiter parity of the whole document. -fn math_scan_segments(content: &str, regions: &[(usize, usize)]) -> Vec<(usize, usize)> { - let len = content.len(); - let mut segments = Vec::new(); - let mut line_start = 0usize; - // `regions` is sorted and both loops only move forward, so each region is - // visited once across the whole document rather than once per line. - let mut first_region = 0usize; - loop { - let newline = content[line_start..] - .find('\n') - .map(|offset| line_start + offset) - .unwrap_or(len); - let mut line_end = newline; - if line_end > line_start && content.as_bytes()[line_end - 1] == b'\r' { - line_end -= 1; - } + /// Does the volume `dir` lives on fold case? The tests below assert + /// different things depending on the answer, because the point of + /// `canonical_identity` is that it reports what the FILESYSTEM does rather + /// than what the platform usually does — a case-sensitive volume on macOS + /// and a case-insensitive volume on Linux both exist and both must work. + fn folds_case(dir: &Path) -> bool { + let upper = dir.join("CaseProbe.md"); + fs::write(&upper, b"probe").unwrap(); + let found = fs::metadata(dir.join("caseprobe.md")).is_ok(); + fs::remove_file(&upper).unwrap(); + found + } - while first_region < regions.len() && regions[first_region].1 <= line_start { - first_region += 1; - } - let mut cursor = line_start; - for &(region_start, region_end) in ®ions[first_region..] { - if region_end <= cursor { - continue; - } - if region_start >= line_end { - break; - } - if region_start > cursor { - segments.push((cursor, region_start.min(line_end))); - } - cursor = cursor.max(region_end); - if cursor >= line_end { - break; - } - } - if cursor < line_end { - segments.push((cursor, line_end)); + #[test] + fn canonical_identity_reports_what_the_filesystem_does_about_case() { + let dir = temp_dir("canon-case"); + let real = dir.join("Alpha.md"); + fs::write(&real, b"body").unwrap(); + + let by_real = canonical_identity(&real).unwrap(); + let lowered = dir.join("alpha.md"); + // What the frontend consumes is whether these two come out EQUAL, so + // that is what gets asserted — not whether the lookup happened to + // succeed. Asking `is_err()` here tested the mechanism instead of the + // property, and got it wrong: on a case-sensitive volume the lowercase + // spelling names no file, but `canonical_identity` still answers for it + // through the parent-directory fallback that Save As depends on. + let by_lowered = canonical_identity(&lowered).ok(); + + // Either way, the identity is the name the DIRECTORY holds rather than + // the spelling that was asked for — otherwise the answer would depend + // on which spelling happened to be opened first. + assert_eq!(by_real.file_name().unwrap(), "Alpha.md"); + + if folds_case(&dir) { + // The two spellings name ONE file: opening both must not give two + // tabs, and saving through one must not be treated as a save to a + // different file. Both reduce to the same identity string, which + // is what lets the frontend keep comparing with `===`. + assert_eq!( + by_lowered.as_ref(), + Some(&by_real), + "one file must have one identity whichever way it is spelled", + ); + } else { + // On a case-sensitive volume these are two different files and must + // keep being two. A `to_lowercase()` scheme would merge them here + // and quietly close a tab holding a genuinely different document — + // which is VS Code's open bug #123660, and the thing this whole + // approach exists to avoid. + assert_ne!( + by_lowered.as_ref(), + Some(&by_real), + "two files must keep two identities", + ); } - if newline >= len { - break; + fs::remove_file(&real).unwrap(); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn canonical_identity_folds_unicode_normalization_when_the_volume_does() { + // APFS is normalization-INSENSITIVE as well as case-insensitive: a + // file written as NFC opens under its NFD spelling and vice versa. + // Case folding cannot reach this — the two strings differ in code + // points, not in case — so it is the clearest evidence that the + // question has to go to the filesystem. + // + // The two axes are independent, and all four combinations are real: + // default APFS folds both, case-sensitive APFS folds only + // normalization, NTFS folds only case, ext4 folds neither. So this + // probes normalization on its own rather than inferring it from the + // case answer or from the platform. + let dir = temp_dir("canon-nfd"); + let nfc = dir.join("caf\u{e9}.md"); // café + let nfd = dir.join("cafe\u{301}.md"); // cafe + combining acute + fs::write(&nfc, b"body").unwrap(); + + let by_nfc = canonical_identity(&nfc).unwrap(); + let by_nfd = canonical_identity(&nfd).ok(); + + if fs::metadata(&nfd).is_ok() { + assert_eq!( + by_nfd.as_ref(), + Some(&by_nfc), + "one file must have one identity whichever way it is spelled", + ); + } else { + // A volume that keeps them apart really does have two files here, + // so the identities must stay apart too. As above, the assertion is + // on the identities and not on whether the lookup succeeded: the + // NFD spelling names nothing, but the parent-directory fallback + // still answers for it. + assert_ne!( + by_nfd.as_ref(), + Some(&by_nfc), + "two files must keep two identities", + ); } - line_start = newline + 1; + + fs::remove_file(&nfc).unwrap(); + fs::remove_dir_all(&dir).unwrap(); } - segments -} -fn char_before(content: &str, low: usize, at: usize) -> Option { - if at <= low { - return None; + #[cfg(unix)] + #[test] + fn canonical_identity_resolves_a_symlink_to_its_target() { + // A link and its target are one file for every purpose Markpad has: + // `atomic_write` follows the link when it writes, so two tabs on the + // two names would be two auto-save timers on one document. + let dir = temp_dir("canon-link"); + let target = dir.join("archive.md"); + let link = dir.join("today.md"); + fs::write(&target, b"body").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert_eq!( + canonical_identity(&link).unwrap(), + canonical_identity(&target).unwrap(), + ); + + fs::remove_file(&link).unwrap(); + fs::remove_file(&target).unwrap(); + fs::remove_dir_all(&dir).unwrap(); } - content[low..at].chars().next_back() -} -fn char_after(content: &str, high: usize, dollar: usize) -> Option { - let next = dollar + 1; - if next >= high { - return None; - } - content[next..high].chars().next() -} + #[test] + fn canonical_identity_still_answers_for_a_file_that_does_not_exist_yet() { + // Save As names a file that is not there yet, so `realpath` fails on + // it. The directory is still resolvable, and that is the part that + // carries the symlinks and the `..` segments. + let dir = temp_dir("canon-new"); + let nested = dir.join("sub"); + fs::create_dir_all(&nested).unwrap(); -/// The closing `$` of an inline span opened at `open`, or `None`. -/// -/// A faithful port of `findInlineMathEnd` in `src/lib/utils/markdown.ts`, and -/// the reason `$100 and $200` is not math: the first candidate closer decides, -/// and a candidate preceded by whitespace or followed by a digit does not -/// merely get skipped — it abandons the span. Anything looser turns ordinary -/// prices into formulas, which is a far worse failure than the bug being -/// fixed here. -fn find_inline_close(content: &str, low: usize, high: usize, from: usize) -> Option { - let bytes = content.as_bytes(); - let mut index = from; - while index < high { - if bytes[index] != b'$' { - index += 1; - continue; - } - let before = char_before(content, low, index); - if before == Some('\\') { - index += 1; - continue; - } - if before.is_some_and(char::is_whitespace) { - return None; - } - if char_after(content, high, index).is_some_and(|c| c.is_ascii_digit()) { - return None; - } - return Some(index); - } - None -} + let indirect = nested.join("..").join("sub").join("fresh.md"); + assert_eq!( + canonical_identity(&indirect).unwrap(), + canonical_identity(&nested).unwrap().join("fresh.md"), + ); -/// The end offset (exclusive) of a `$$…$$` span opened at `open`. -/// -/// Within one line this is `findDisplayMathEnd`. Across lines it is the block -/// form `processDisplayMathBlocks` renders, so both delimiters must sit alone -/// on their line; that is what stops two unrelated `$$` in prose from pairing -/// across a hard break. The search stops at a blank line (which ends the -/// CommonMark block) and at any code region. -fn find_display_close( - content: &str, - regions: &[(usize, usize)], - open: usize, - segment_end: usize, -) -> Option { - let bytes = content.as_bytes(); - let len = content.len(); + // A missing DIRECTORY has no identity to report; the caller keeps the + // literal path, which is what it would have used anyway. + assert!(canonical_identity(&dir.join("nope").join("x.md")).is_err()); - let mut index = open + 2; - while index + 1 < segment_end { - if bytes[index] == b'$' && bytes[index + 1] == b'$' && bytes[index - 1] != b'\\' { - return Some(index + 2); - } - index += 1; + // The fallback must never manufacture the identity of a file that DOES + // exist — that would merge a Save As target into an unrelated open + // document. This is the property both branches above lean on whenever a + // volume distinguishes two spellings: the spelling that names nothing + // still gets an identity, and it has to be a different one. + // + // It runs on every platform and every volume, which matters because the + // two axes cannot all be reproduced on one machine: macOS folds Unicode + // normalization on every filesystem it mounts, so the "normalization + // distinguishes these" branch of the test above is only ever taken on + // Linux and Windows. This asserts the same underlying property here. + let existing = nested.join("taken.md"); + fs::write(&existing, b"body").unwrap(); + assert_ne!( + canonical_identity(&nested.join("not-taken.md")).unwrap(), + canonical_identity(&existing).unwrap(), + "a name that resolves to nothing must not borrow another file's identity", + ); + + fs::remove_dir_all(&dir).unwrap(); } - let (opener_line_end, mut next_line_start) = line_bounds(content, open); - if !content[open + 2..opener_line_end].trim().is_empty() { - return None; + #[test] + fn verbatim_prefix_is_stripped_so_paths_stay_displayable() { + // `canonicalize` returns `\\?\C:\...` on Windows. That string reaches + // the tab bar, the window title and the recent-files list, and several + // Win32 APIs reject it, so it never leaves this module. + assert_eq!( + strip_verbatim_prefix(PathBuf::from(r"\\?\C:\notes\a.md")), + PathBuf::from(r"C:\notes\a.md"), + ); + assert_eq!( + strip_verbatim_prefix(PathBuf::from(r"\\?\UNC\server\share\a.md")), + PathBuf::from(r"\\server\share\a.md"), + ); + // Untouched everywhere else. + assert_eq!( + strip_verbatim_prefix(PathBuf::from("/notes/a.md")), + PathBuf::from("/notes/a.md"), + ); } - while next_line_start < len { - let (line_end, following) = line_bounds(content, next_line_start); - let line = &content[next_line_start..line_end]; - if line.trim().is_empty() { - return None; - } - if regions - .iter() - .any(|&(start, end)| start < line_end && end > next_line_start) + + #[test] + fn atomic_write_refuses_a_read_only_target() { + // Replacing an inode by rename only needs write permission on the + // parent directory, so without an explicit check a `chmod 444` file + // would be rewritten on Unix and its read-only bit put back, while + // Windows' MoveFileExW refuses the same operation. + let path = temp_path("readonly"); + fs::write(&path, b"original").unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&path, perms).unwrap(); + + let error = atomic_write(&path, b"replacement") + .expect_err("a read-only target must be refused, not silently replaced"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(fs::read(&path).unwrap(), b"original"); + + // Deleting needs write permission on the directory rather than the + // file, so Unix needs no permission restore here; Windows refuses to + // delete a file that still carries the read-only attribute. + #[cfg(windows)] { - return None; - } - if let Some(offset) = line.find("$$") { - let close = next_line_start + offset; - if content[next_line_start..close].trim().is_empty() - && content[close + 2..line_end].trim().is_empty() - { - return Some(close + 2); - } + let mut perms = fs::metadata(&path).unwrap().permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + fs::set_permissions(&path, perms).unwrap(); } - next_line_start = following; + fs::remove_file(&path).unwrap(); } - None -} -/// `(end of the line holding `at`, start of the next line)`, with `\r` -/// excluded from the first and `len` used when there is no next line. -fn line_bounds(content: &str, at: usize) -> (usize, usize) { - let len = content.len(); - let newline = content[at..] - .find('\n') - .map(|offset| at + offset) - .unwrap_or(len); - let mut line_end = newline; - if line_end > 0 && content.as_bytes()[line_end - 1] == b'\r' { - line_end -= 1; + #[test] + fn atomic_write_replaces_the_target_and_leaves_no_temp_file() { + let dir = temp_path("atomic-dir"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session.json"); + fs::write(&path, b"{\"old\":true}").unwrap(); + + atomic_write(&path, b"{\"new\":true}").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"{\"new\":true}"); + let leftovers: Vec = fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains("markpad-tmp")) + .collect(); + assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}"); + + fs::remove_dir_all(dir).unwrap(); } - (line_end, (newline + 1).min(len)) -} -/// The math spans of `content`, as sorted, non-overlapping byte ranges. -/// -/// A port of `convertInlineMathDelimiters` in `src/lib/utils/markdown.ts`, -/// which is the only thing that decides what the user actually sees rendered. -/// Recognising a span here that the frontend will not render would strip the -/// Markdown out of ordinary prose; recognising less would leave the formula -/// mangled — so the rules have to be the same rules. -fn find_math_spans(content: &str, regions: &[(usize, usize)]) -> Vec<(usize, usize)> { - let bytes = content.as_bytes(); - let mut spans: Vec<(usize, usize)> = Vec::new(); - let mut barrier = 0usize; + #[test] + fn every_read_path_decodes_legacy_encodings_leniently() { + // "中文" in GBK. `read_to_string` rejects the whole document on the + // first invalid byte, so the same file used to open or fail purely by + // size: the truncated-preview branch has always decoded leniently. + let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4]; + assert!(String::from_utf8(gbk.to_vec()).is_err()); - for (segment_start, segment_end) in math_scan_segments(content, regions) { - if segment_end <= barrier { - continue; - } - // A multi-line span already consumed the head of this line. - let low = segment_start.max(barrier); - let mut index = low; - // Lets `$a$$b$` open a second span while keeping `$$` itself out of it. - let mut previous_dollar_allows_open = false; + let path = temp_path("gbk.txt"); + fs::write(&path, gbk).unwrap(); + assert!( + fs::read_to_string(&path).is_err(), + "strict decoding is expected to reject these bytes", + ); + + let decoded = read_to_string_lossy(path.to_str().unwrap()) + .expect("lenient decoding must open the file instead of failing"); + assert!(decoded.content.contains('\u{FFFD}'), "got: {:?}", decoded.content); + + fs::remove_file(path).unwrap(); + } + + // --- Decode fidelity ------------------------------------------------ + // + // Opening these files leniently is deliberate (above). What must never + // follow is writing the result back: U+FFFD is not reversible, so an + // auto-save 1.5s after the first keystroke would destroy a document that + // was merely in another encoding. Every read path therefore reports + // whether its decode was destructive, and the frontend refuses that write + // (documentSession.saveContent). These pin the reporting half. + const GBK_ZHONGWEN: &[u8] = &[0xD6, 0xD0, 0xCE, 0xC4]; + + #[test] + fn gbk_bytes_are_reported_as_a_lossy_decode() { + let decoded = decode_utf8_lossy(GBK_ZHONGWEN.to_vec()); + assert!(decoded.lossy, "GBK bytes cannot decode faithfully as UTF-8"); + assert!( + decoded.content.contains('\u{FFFD}'), + "expected replacement characters, got {:?}", + decoded.content, + ); + } - while index < segment_end { - if bytes[index] != b'$' { - previous_dollar_allows_open = false; - index += content[index..].chars().next().map_or(1, char::len_utf8); - continue; - } - let before = char_before(content, low, index); - let after = char_after(content, segment_end, index); + #[test] + fn valid_utf8_is_reported_as_faithful() { + let decoded = decode_utf8_lossy("中文".as_bytes().to_vec()); + assert!(!decoded.lossy); + assert_eq!(decoded.content, "中文"); + } - if before != Some('\\') && after == Some('$') { - match find_display_close(content, regions, index, segment_end) { - Some(end) => { - spans.push((index, end)); - if end > segment_end { - barrier = end; - break; - } - previous_dollar_allows_open = true; - index = end; - } - None => { - previous_dollar_allows_open = false; - index += 2; - } - } - continue; - } + #[test] + fn read_file_content_checked_reports_a_lossy_file() { + // The tripwire that used to assert `read_file_content` REFUSES these + // bytes. #371 made every read path lenient, so refusing is no longer + // the protection — reporting is. A caller that fills an editable + // buffer must use this command and carry the flag onto the tab. + let path = temp_path("gbk-checked.md"); + fs::write(&path, GBK_ZHONGWEN).unwrap(); - if before == Some('\\') - || (before == Some('$') && !previous_dollar_allows_open) - || after.is_some_and(char::is_whitespace) - { - previous_dollar_allows_open = false; - index += 1; - continue; - } + let decoded = read_to_string_lossy(path.to_str().unwrap()).unwrap(); + fs::remove_file(&path).unwrap(); - match find_inline_close(content, low, segment_end, index + 1) { - Some(close) => { - spans.push((index, close + 1)); - previous_dollar_allows_open = true; - index = close + 1; - } - None => { - previous_dollar_allows_open = false; - index += 1; - } - } - } + assert!( + decoded.lossy, + "read_file_content_checked returned mojibake without reporting it", + ); + assert!(decoded.content.contains('\u{FFFD}')); } - spans -} -/// The escaped dollars of `content`: a `$` carrying a run of backslashes in -/// front of it, masked together with the whole run. -/// -/// Declining to treat `\$` as a delimiter — which `find_math_spans` already -/// does — protects the formula that is not there, and nothing else. comrak -/// still resolves the escape, so `\$\$x\$\$` reaches the frontend as the same -/// eight bytes an unescaped `$$x$$` does, and from that point on no rule can -/// tell the two apart: the frontend renders the reader's literal dollars as a -/// formula. `convertInlineMathDelimiters` has carried a "a `$` behind a -/// backslash is not a delimiter" branch since before #402, and it has never -/// once been able to fire, because the backslash is gone by the time the -/// frontend looks. -/// -/// So the escape is hidden from comrak exactly the way math is, and put back -/// verbatim. The frontend gets its backslash, its dead branch comes alive, and -/// resolving the escape becomes the job of the side that also decides what is -/// math — which is the only way the two decisions can agree. -/// -/// The whole backslash run is taken, not just the last one, so that `\\$` -/// (an escaped backslash, then a live dollar) is not mistaken for `\$` after -/// comrak has halved the run. Ranges inside a math span are skipped: there a -/// `\$` is TeX for a dollar sign, and the math span already shields it. -fn find_escaped_dollar_spans( - content: &str, - regions: &[(usize, usize)], - math: &[(usize, usize)], -) -> Vec<(usize, usize)> { - let bytes = content.as_bytes(); - let mut spans = Vec::new(); - for (segment_start, segment_end) in math_scan_segments(content, regions) { - let mut index = segment_start; - while index < segment_end { - if bytes[index] != b'\\' { - index += content[index..].chars().next().map_or(1, char::len_utf8); - continue; - } - let mut run_end = index; - while run_end < segment_end && bytes[run_end] == b'\\' { - run_end += 1; - } - if run_end < segment_end && bytes[run_end] == b'$' { - if !math.iter().any(|&(start, end)| start < run_end + 1 && end > index) { - spans.push((index, run_end + 1)); - } - index = run_end + 1; - } else { - index = run_end; - } - } - } - spans -} + #[test] + fn a_small_non_utf8_file_is_flagged_by_the_preview_too() { + // The ≤max_bytes branch. Before #371 it decoded strictly and could + // only fail, so it needed no flag; now it opens the same mojibake the + // truncated branch always did, and needs the same guard. + let path = temp_path("gbk-small.md"); + let mut bytes = b"# ".to_vec(); + bytes.extend_from_slice(GBK_ZHONGWEN); + fs::write(&path, &bytes).unwrap(); -/// `\$` as comrak would have rendered it: every pair of backslashes collapses -/// to one, and the escaping backslash in front of the `$` disappears. -fn resolve_escaped_dollar(span: &str) -> String { - let backslashes = span.len() - 1; - let mut out = "\\".repeat(backslashes / 2); - out.push('$'); - out -} + let preview = build_markdown_preview(&path, 50_000).unwrap(); + fs::remove_file(&path).unwrap(); -/// Replaces every math span — and every escaped dollar — with a token comrak -/// cannot rewrite. -/// -/// One token per line of a span, so a six-line `$$…$$` block still occupies -/// six lines: the line-number contract in `mod tests` is not negotiable, and a -/// span collapsed into a single token would move every task checkbox below it. -/// Leading indentation stays outside the token so that a formula inside a list -/// item keeps belonging to that item. -fn mask_math_spans(content: &str) -> MaskedMath { - // Case-insensitively, because comrak lowercases the token again when it - // derives a heading id from it — see `restore_math_spans`. - let haystack = content.to_ascii_lowercase(); - let mut prefix = String::from(MATH_MASK_PREFIX); - while haystack.contains(&prefix.to_ascii_lowercase()) { - prefix.push('X'); + assert!(preview.is_full, "this file fits in the preview budget"); + assert!(preview.lossy, "the save guard has nothing to go on without this"); + assert!(preview.content.contains('\u{FFFD}')); } - let regions = code_region_ranges(content); - let math = find_math_spans(content, ®ions); - // Both halves of one decision: what the frontend must render, and what it - // must refuse to render. Hiding only the first half is what let an - // explicitly escaped `\$\$x\$\$` come out typeset. - let mut found: Vec<(usize, usize, bool)> = math - .iter() - .map(|&(start, end)| (start, end, false)) - .chain( - find_escaped_dollar_spans(content, ®ions, &math) - .into_iter() - .map(|(start, end)| (start, end, true)), - ) - .collect(); - found.sort_by_key(|&(start, _, _)| start); - if found.is_empty() { - return MaskedMath { - text: content.to_owned(), - prefix, - spans: Vec::new(), - }; + #[test] + fn an_oversized_non_utf8_preview_is_flagged() { + let path = temp_path("gbk-preview.md"); + let mut bytes = b"# ".to_vec(); + bytes.extend_from_slice(GBK_ZHONGWEN); + bytes.extend_from_slice(b"\nplus enough text to exceed the preview budget\n"); + fs::write(&path, &bytes).unwrap(); + + let preview = build_markdown_preview(&path, 8).unwrap(); + fs::remove_file(&path).unwrap(); + + assert!(!preview.is_full); + assert!(preview.lossy); + assert!(preview.content.contains('\u{FFFD}')); } - let mut text = String::with_capacity(content.len()); - let mut spans: Vec = Vec::new(); - let mut copied_to = 0usize; - for (start, end, escaped) in found { - text.push_str(&content[copied_to..start]); - for piece in content[start..end].split_inclusive('\n') { - let mut body = piece; - let mut line_ending = ""; - if let Some(stripped) = body.strip_suffix('\n') { - body = stripped; - line_ending = "\n"; - if let Some(stripped) = body.strip_suffix('\r') { - body = stripped; - line_ending = "\r\n"; - } - } - let indent = body.len() - body.trim_start().len(); - text.push_str(&body[..indent]); - let core = &body[indent..]; - if !core.is_empty() { - text.push_str(&format!("{prefix}{}{MATH_MASK_SUFFIX}", spans.len())); - spans.push(MaskedSpan { - attribute: if escaped { - resolve_escaped_dollar(core) - } else { - core.to_owned() - }, - text: core.to_owned(), - }); - } - text.push_str(line_ending); - } - copied_to = end; + #[test] + fn an_oversized_utf8_preview_is_not_flagged() { + // The preview budget lands inside a multi-byte character. Reporting + // that as lossy would lock every large CJK or emoji document out of + // saving — a guard worse than the bug it protects against. + let path = temp_path("utf8-preview.md"); + fs::write(&path, "中文标题很长".as_bytes()).unwrap(); + + let preview = build_markdown_preview(&path, 4).unwrap(); + fs::remove_file(&path).unwrap(); + + assert!(!preview.is_full); + assert!(!preview.lossy, "unexpected flag on {:?}", preview.content); + assert_eq!(preview.content, "中"); } - text.push_str(&content[copied_to..]); - MaskedMath { - text, - prefix, - spans, - } -} + #[test] + fn truncation_boundary_drops_only_a_split_trailing_character() { + assert_eq!(utf8_truncation_boundary(&[]), 0); + assert_eq!(utf8_truncation_boundary(b"ab"), 2); + + let three_byte = "中".as_bytes(); + assert_eq!(utf8_truncation_boundary(three_byte), 3); + assert_eq!(utf8_truncation_boundary(&three_byte[..2]), 0); + assert_eq!(utf8_truncation_boundary(&three_byte[..1]), 0); -/// Puts the masked source back into the rendered HTML. -/// -/// The span is re-escaped the way comrak escapes a text node, not inserted -/// raw: the token can legitimately land in a text node, an `alt` value or an -/// `href`, and `&<>"` are the four characters that would otherwise change the -/// meaning of the markup in any of the three. The frontend reads the span back -/// out with `textContent`, which undoes the escaping before KaTeX sees it. -/// -/// A heading is the one place the token appears twice in two different -/// spellings: comrak anchorizes the heading's rendered text into `id=` and -/// `href="#…"`, which lowercases it. There the *anchorized* source goes back -/// instead, so that `[[#A heading with $x_1$]]` still resolves — the wikilink -/// side computes the same id from the raw buffer with `heading_anchor_id`. -/// -/// Whether the token landed in markup or in text is tracked as it goes, and it -/// is not a nicety: an escaped dollar goes back as `\$` only where a frontend -/// pass will resolve it. Nothing resolves anything inside an `href` or an -/// `alt`, so a token that landed there gets the resolved `$` instead — putting -/// the backslash into a link destination would break the link. comrak escapes -/// `<` and `>` everywhere else, so an unclosed `<` really does mean "inside a -/// tag". -fn restore_math_spans(html: &str, masked: &MaskedMath) -> String { - if masked.spans.is_empty() { - return html.to_owned(); + let four_byte = "🙂".as_bytes(); + assert_eq!(utf8_truncation_boundary(four_byte), 4); + assert_eq!(utf8_truncation_boundary(&four_byte[..3]), 0); + + // Only the tail is affected; earlier bytes are kept. + let mixed = "ab中".as_bytes(); + assert_eq!(utf8_truncation_boundary(&mixed[..4]), 2); + + // A buffer that is not UTF-8 at all must still yield a full-length + // preview; at most the last three bytes can ever be dropped. + let gbk = [0xD6u8, 0xD0, 0xCE, 0xC4]; + assert!(utf8_truncation_boundary(&gbk) >= gbk.len() - 3); } - let anchor_prefix = masked.prefix.to_ascii_lowercase(); - let mut out = String::with_capacity(html.len()); - let mut rest = html; - let mut in_tag = false; - while let Some((at, anchored)) = [ - (rest.find(masked.prefix.as_str()), false), - (rest.find(anchor_prefix.as_str()), true), - ] - .into_iter() - .filter_map(|(at, anchored)| at.map(|at| (at, anchored))) - .min() - { - out.push_str(&rest[..at]); - if let Some(bracket) = rest[..at].rfind(['<', '>']) { - in_tag = rest.as_bytes()[bracket] == b'<'; - } - let after = &rest[at + masked.prefix.len()..]; - let digits = after - .as_bytes() - .iter() - .take_while(|byte| byte.is_ascii_digit()) - .count(); - let suffix = if anchored { - MATH_MASK_SUFFIX.to_ascii_lowercase() - } else { - MATH_MASK_SUFFIX - }; - let index = if digits > 0 && after[digits..].starts_with(suffix) { - after[..digits].parse::().ok() - } else { - None - }; - match index.and_then(|index| masked.spans.get(index)) { - Some(original) if anchored => { - out.push_str(&Anchorizer::new().anchorize(&original.text)); - rest = &after[digits + suffix.len_utf8()..]; - } - Some(original) => { - out.push_str(&escape_html_text(if in_tag { - &original.attribute - } else { - &original.text - })); - rest = &after[digits + suffix.len_utf8()..]; - } - None => { - out.push_str(&rest[at..at + masked.prefix.len()]); - rest = after; - } - } + + #[test] + fn zip_entry_reads_stop_at_the_limit_even_when_the_header_understates_size() { + let payload = vec![b'a'; 64]; + assert_eq!( + read_zip_entry_to_string(payload.as_slice(), 64).unwrap().len(), + 64, + ); + assert!( + read_zip_entry_to_string(payload.as_slice(), 32).is_err(), + "an entry larger than the ceiling must be rejected, not buffered", + ); } - out.push_str(rest); - out -} -fn escape_html_text(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - for ch in text.chars() { - match ch { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - _ => out.push(ch), - } + #[test] + fn export_data_url_uses_mime_from_extension_case_insensitively() { + assert_eq!(mime_type_for_export_path(Path::new("diagram.PNG")), "image/png"); + assert_eq!(mime_type_for_export_path(Path::new("photo.JpEg")), "image/jpeg"); + assert_eq!(mime_type_for_export_path(Path::new("vector.svg")), "image/svg+xml"); + assert_eq!(mime_type_for_export_path(Path::new("unknown.bin")), "application/octet-stream"); } - out -} -/// ⚠️ Every preprocessing step below is bound by a line-number contract: -/// `sourcepos` describes the *preprocessed* text, but the frontend uses those -/// line numbers to edit the *raw* buffer, so input line N must stay output -/// line N. The contract, the rationale and the tests that enforce it live in -/// `mod tests` under "The line-number contract of `convert_markdown`" — -/// a new step here must also be registered in `line_preserving_transforms()`. -#[tauri::command] -fn convert_markdown(content: &str) -> String { - // The buffer this command was called with, captured before anything runs - // and never rebound. What `annotate_task_checkboxes` is handed at the end - // has to be this rather than the parameter name: a new step written as a - // `let content = ...` shadow would rebind that name and silently retarget - // the call without touching it. See that function's doc comment and - // `convert_markdown_hands_the_fail_safe_the_raw_buffer`. - let raw_buffer = content; + #[test] + fn export_data_url_encodes_bytes_with_mime() { + assert_eq!( + file_bytes_to_data_url("image/png", b"Markpad"), + "data:image/png;base64,TWFya3BhZA==", + ); + } - let processed_autolinks = process_parenthesized_autolinks(content); - let processed_embeds = process_internal_embeds(&processed_autolinks); - let processed_links = process_wikilinks(&processed_embeds); - let masked_math = mask_math_spans(&processed_links); + #[test] + fn task_list_checkbox_is_emitted_at_the_start_of_its_list_item() { + // The attribute order here is comrak's, captured, not a requirement: + // 0.18 wrote `disabled="" checked=""` and 0.54 writes them the other + // way round. What this pins is that `data-task-checkbox` is present on + // *both* items and that the marker sits at the start of the `
  • `. + // `TASK_ITEM_RE` deliberately no longer depends on the order, so a + // future reordering fails here — loudly — instead of quietly + // un-marking one of the two. + let html = convert_markdown("- [ ] open task\n- [x] completed task\n"); + assert!( + html.contains("
  • open task
  • "), + "unexpected task-list HTML: {html}", + ); + assert!( + html.contains("
  • completed task
  • "), + "unexpected task-list HTML: {html}", + ); + } - let mut options = Options::default(); - options.extension.strikethrough = true; - options.extension.table = true; - options.extension.autolink = true; - options.extension.tasklist = true; - options.extension.superscript = false; - options.extension.footnotes = true; - options.extension.description_lists = true; - // `header_ids` in 0.18; the option only ever set the *prefix* prepended to - // the anchorized heading text, and 0.52 renamed it to say so. `Some("")` - // means "ids on, no prefix" in both spellings. - options.extension.header_id_prefix = Some(String::new()); - options.render.r#unsafe = true; - options.render.hardbreaks = true; - options.render.sourcepos = true; + #[test] + fn raw_html_checkboxes_are_not_marked_as_tasks() { + let html = convert_markdown("- raw control\n"); + assert!( + !html.contains("data-task-checkbox"), + "raw HTML control was incorrectly marked as a task: {html}", + ); + } - let html = markdown_to_html(&masked_math.text, &options); - annotate_task_checkboxes(restore_math_spans(&html, &masked_math), raw_buffer) -} + #[test] + fn nested_and_quoted_task_checkboxes_are_marked() { + let html = convert_markdown("- [ ] parent\n - [x] nested\n\n> - [ ] quoted\n"); + assert_eq!( + html.matches("data-task-checkbox").count(), + 3, + "unexpected task-list HTML: {html}", + ); + } -/// Marks the rendered task checkboxes the frontend is allowed to toggle. -/// -/// `markdown` is the **raw, unpreprocessed** buffer — deliberately, and not a -/// bug. This is the fail-safe for the line contract described in `mod tests`. -/// -/// The `data-sourcepos` line numbers in `html` describe the *preprocessed* -/// text, while a click on the checkbox makes the frontend rewrite that line -/// number of the *raw* buffer (`documentSession.toggleTaskCheckbox`). The two -/// only agree while every preprocessing step preserves line numbers. Checking -/// the raw buffer here is what turns a broken step into "the checkbox stays -/// disabled" instead of a write aimed at the wrong line of the user's -/// document — the P0 that issue #352 fixed. -/// -/// What a wrong line costs is narrower than it was, and worth stating -/// precisely, because an overstated reason invites the next reader to check -/// it, find it false, and delete the guard as theatre. Since #352 the -/// frontend rewrites only lines that already match -/// `/^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)\[( |x|X)\]/` -/// (`documentSession.toggleTaskCheckbox`), so a wrong line that is ordinary -/// prose is a no-op and the toggle reports failure — it does NOT write a -/// `- [x]` marker into whatever happens to sit there, as this comment used to -/// claim of the pre-#352 frontend. What still corrupts is a wrong line that -/// is itself task-shaped, and neither spelling of that is exotic: a task list -/// quoted inside a fenced code block is ordinary content in a notes app, and -/// a real task elsewhere in the same document means the user clicks one -/// checkbox and a different one silently flips. -/// -/// So do NOT "unify" this with the preprocessed text that produced the HTML. -/// Passing `&processed_links` here would make the two sides agree by -/// definition, delete the guard, and turn every future line-count regression -/// straight into a mis-aimed write. Nor is passing the *parameter name* -/// enough at the call site: `convert_markdown` captures its input as -/// `raw_buffer` first precisely so that a later `let content = …` cannot -/// retarget the call without touching it. Keep the contract honest in the -/// transforms instead; `every_preprocessing_step_preserves_source_line_numbers` -/// is what enforces it, -/// `task_checkboxes_stay_inert_when_the_html_and_the_buffer_disagree` pins -/// this guard, and `convert_markdown_hands_the_fail_safe_the_raw_buffer` pins -/// the argument it is given. -fn annotate_task_checkboxes(html: String, markdown: &str) -> String { - let markdown_lines = markdown.lines().collect::>(); + #[test] + fn markdown_protocol_preserves_task_markers_for_many_source_lines() { + let markdown = (1..=64) + .map(|line| match line % 3 { + 0 => format!("> - [ ] quoted task {line}"), + 1 => format!("- [ ] task {line}"), + _ => format!(" - [x] nested task {line}"), + }) + .collect::>() + .join("\n"); - TASK_ITEM_RE - .replace_all(&html, |captures: &Captures| { - let line = captures["line"].parse::().unwrap_or_default(); - let source_line = markdown_lines.get(line.saturating_sub(1)); - if !source_line.is_some_and(|line| TASK_SOURCE_RE.is_match(line)) { - return captures[0].to_string(); - } + let html = convert_markdown(&markdown); + assert_eq!(html.matches("data-task-checkbox").count(), 64, "{html}"); + assert!(html.contains("data-sourcepos=\"64:1-64:"), "{html}"); + } - // Anchored on the tag name, not on one of the boolean attributes, - // for the same reason `TASK_ITEM_RE` no longer spells their order - // out: `disabled` is not guaranteed to be the first one. - let input = captures["input"].replacen( - "{}", - &captures["sourcepos"], - input, - ) - }) - .into_owned() -} + #[test] + fn multiline_wikilinks_do_not_shift_task_source_positions() { + let html = convert_markdown("[[#first\nsecond|alias]]\n- [ ] task\n"); + assert!( + html.contains("data-task-checkbox"), + "task source position was shifted by a multiline wikilink: {html}", + ); + } -#[tauri::command] -async fn open_markdown(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let content = fs::read_to_string(path).map_err(|e| e.to_string())?; - Ok(convert_markdown(&content)) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn embed_protection_survives_longer_backtick_runs_earlier_in_the_doc() { + // A 4-backtick inline sample desynchronized the old regex pairing and + // exposed every later code span to rewriting. + let input = "```` ```mermaid ```` fence sample\n\ncode: `![[not-an-embed.md]]`\n"; + let out = process_internal_embeds(input); + assert!(out.contains("`![[not-an-embed.md]]`"), "got: {out}"); + assert!(!out.contains(" Result { - use std::io::Read; - let path_str = path.to_str().ok_or("Invalid path")?; - let mut f = fs::File::open(path).map_err(|e| e.to_string())?; + #[test] + fn fence_closes_only_on_a_run_at_least_as_long() { + let input = "````\n```\n![[still-code.md]]\n````\n![[after.md]]\n"; + let out = process_internal_embeds(input); + assert!(out.contains("![[still-code.md]]"), "got: {out}"); + assert!(out.contains("this is"), "got: {out}"); + } -/// Returns `(html, content, is_full, lossy)`. See `DecodedText::lossy`: the -/// frontend uses it to refuse writing the buffer back over a file it could -/// not decode faithfully. -#[tauri::command] -async fn open_markdown_preview( - path: String, - max_bytes: usize, -) -> Result<(String, String, bool, bool), String> { - tauri::async_runtime::spawn_blocking(move || { - let preview = build_markdown_preview(Path::new(&path), max_bytes)?; - Ok(( - preview.html, - preview.content, - preview.is_full, - preview.lossy, - )) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn wikilinks_and_inline_footnotes_in_code_spans_stay_literal() { + let input = "`[[#heading]]` and `^[not a footnote]` but [[#real|jump]]\n"; + let out = process_wikilinks(input); + assert!(out.contains("`[[#heading]]`"), "got: {out}"); + assert!(out.contains("`^[not a footnote]`"), "got: {out}"); + assert!(out.contains("[jump](#real)"), "got: {out}"); + } -#[tauri::command] -async fn render_markdown(content: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - Ok(convert_markdown(&content)) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + /// A document that has BOTH kinds of code region, with the inline span at + /// a lower offset than the fence. + /// + /// `in_code_region` is a binary search, so `code_region_ranges` has to + /// emit its regions in document order. The two kinds are found by + /// different parts of the scan — fences by the line walk, inline spans by + /// `push_inline_code_spans` over the text between fences — and a build + /// order that appends all of one kind after all of the other leaves the + /// vector unsorted for exactly this shape of document. The binary search + /// then walks straight past the fence and every marker inside it is + /// reported as ordinary prose (#375 / #389 all over again). + /// + /// Every marker below is checked, not just one. `process_wikilinks` runs + /// a separate pass per marker kind and each probes `in_code_region` at its + /// own offset, so a probe that happens to land inside the region does not + /// say anything about the probes beside it. + const FENCE_AFTER_INLINE_CODE: &str = concat!( + "Prose with `a code span` in it.\n", + "\n", + "```text\n", + "![[embed.md]]\n", + "[[wikilink]]\n", + "==highlight==\n", + "^[footnote]\n", + "```\n", + ); -/// Reads a file, with the fidelity of the decode: returns `(content, lossy)`. -/// Since every read path decodes leniently, a caller that puts the text into -/// an EDITABLE buffer must carry `lossy` onto the tab — otherwise the first -/// auto-save writes U+FFFD over a file that was merely in another encoding. -/// -/// This is now the only read-to-string command. Its sibling -/// `read_file_content` returned the text and dropped the verdict; it survived -/// #379 for callers that re-read a file whose tab was already flagged, then -/// lost its last call site and stayed registered — a command whose defining -/// property is that it hides the flag, one `invoke` away from any new caller. -/// Deleting it makes "which command should this use" a question with one -/// answer rather than a convention. -/// -/// Deliberately async, like every other file-touching command here. A -/// synchronous `#[tauri::command]` runs on the main thread, so a read from a -/// slow volume (SMB, iCloud, a failing USB stick) freezes the whole -/// application — every window, its menus and its scrolling — until the I/O -/// returns. `spawn_blocking` moves the wait onto the blocking pool, which is -/// what `tauri::async_runtime` provides it for. -#[tauri::command] -async fn read_file_content_checked(path: String) -> Result<(String, bool), String> { - tauri::async_runtime::spawn_blocking(move || { - read_to_string_lossy(&path) - .map(|decoded| (decoded.content, decoded.lossy)) - .map_err(|e| e.to_string()) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_embeds() { + let out = process_internal_embeds(FENCE_AFTER_INLINE_CODE); + assert!( + out.contains("![[embed.md]]") && !out.contains(" &'static str { - match path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref() - { - Some("png") => "image/png", - Some("jpg") | Some("jpeg") => "image/jpeg", - Some("gif") => "image/gif", - Some("webp") => "image/webp", - Some("svg") => "image/svg+xml", - Some("bmp") => "image/bmp", - Some("ico") => "image/x-icon", - Some("avif") => "image/avif", - _ => "application/octet-stream", + #[test] + fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_autolinks() { + // The third consumer. A bare URL inside a fence must stay text; + // comrak's own autolinker never sees a code block. + let input = "Prose with `a code span` in it.\n\n```text\n(https://example.com/x)y\n```\n"; + let out = process_parenthesized_autolinks(input); + assert!( + !out.contains("]("), + "a URL inside the fence was linkified: {out}", + ); } -} -fn file_bytes_to_data_url(mime_type: &str, bytes: &[u8]) -> String { - use base64::{engine::general_purpose, Engine as _}; - format!( - "data:{};base64,{}", - mime_type, - general_purpose::STANDARD.encode(bytes) - ) -} + #[test] + fn an_inline_span_before_a_fence_does_not_expose_the_fence_to_math() { + // The fourth consumer. `mask_math_spans` hides math from CommonMark's + // inline rules; dollars inside a fence are not math and masking them + // rewrites the code block the user typed. + let input = "Prose with `a code span` in it.\n\n```text\n$x_1$ and $y_2$\n```\n"; + let masked = mask_math_spans(input); + assert_eq!( + masked.text, input, + "dollars inside the fence were masked as math", + ); + } -#[tauri::command] -async fn read_file_as_data_url(path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let bytes = fs::read(&path).map_err(|e| e.to_string())?; - let mime_type = mime_type_for_export_path(Path::new(&path)); - Ok(file_bytes_to_data_url(mime_type, &bytes)) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn multibyte_content_inside_a_fence_does_not_panic() { + let input = "```text\n中文开头的一行\n```\n\n![[outside.png]]\n"; + let result = std::panic::catch_unwind(|| process_internal_embeds(input)); -/// Async because `atomic_write` fsyncs twice (the file, then its directory). -/// On a network or removable volume that is seconds of blocking I/O, and on -/// the main thread it would stall every window until the save completes. -#[tauri::command] -async fn save_file_content(path: String, content: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - atomic_write(Path::new(&path), content.as_bytes()).map_err(|e| e.to_string()) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + let out = result.expect("fenced multibyte content must not panic"); + assert!(out.contains("中文开头的一行"), "got: {out}"); + assert!(out.contains(" Result { - tauri::async_runtime::spawn_blocking(move || { - canonical_identity(Path::new(&path)) - .map(|resolved| resolved.to_string_lossy().into_owned()) - .map_err(|e| e.to_string()) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn autolink_inside_parentheses_stops_before_adjacent_text() { + let input = "See (https://www.speedtest.net/awards/united_states/)for more information."; + let html = convert_markdown(input); -#[tauri::command] -fn print_pdf(window: tauri::WebviewWindow) -> Result<(), String> { - window.print().map_err(|error| error.to_string()) -} + assert!( + html.contains("href=\"https://www.speedtest.net/awards/united_states/\""), + "got: {html}" + ); + assert!(html.contains(")for more information."), "got: {html}"); + assert!( + !html.contains("href=\"https://www.speedtest.net/awards/united_states/)for\""), + "got: {html}" + ); + } -#[tauri::command] -async fn export_pdf_windows(window: tauri::WebviewWindow, path: String) -> Result<(), String> { - #[cfg(target_os = "windows")] - { - use std::sync::mpsc::sync_channel; - use std::time::Duration; - use webview2_com::{ - PrintToPdfCompletedHandler, - Microsoft::Web::WebView2::Win32::{ICoreWebView2Environment6, ICoreWebView2_7}, - }; - use windows::core::{Interface, HSTRING}; + #[test] + fn path_components_reject_traversal_separators_and_absolute_paths() { + for invalid in ["", ".", "..", "../theme", "folder/theme", "folder\\theme", "/tmp/theme"] { + assert!(safe_path_component(invalid, "test").is_err(), "{invalid}"); + } + assert_eq!(safe_path_component("SynthWave '84", "test").unwrap(), "SynthWave '84"); + } - let (sender, receiver) = sync_channel(1); - window - .with_webview(move |platform_webview| unsafe { - let result = (|| -> Result<(), String> { - let controller = platform_webview.controller(); - let webview = controller - .CoreWebView2() - .map_err(|error| format!("failed to access WebView2: {error}"))? - .cast::() - .map_err(|error| { - format!("WebView2 runtime does not support PDF export: {error}") - })?; - let settings = platform_webview - .environment() - .cast::() - .map_err(|error| { - format!("WebView2 runtime does not support print settings: {error}") - })? - .CreatePrintSettings() - .map_err(|error| format!("failed to create PDF print settings: {error}"))?; + #[cfg(unix)] + #[test] + fn image_directory_rejects_symlink_escape() { + use std::os::unix::fs::symlink; - settings - .SetShouldPrintHeaderAndFooter(false) - .map_err(|error| { - format!("failed to disable PDF headers and footers: {error}") - })?; - settings - .SetShouldPrintBackgrounds(true) - .map_err(|error| format!("failed to enable PDF backgrounds: {error}"))?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("markpad-path-root-{nonce}")); + let outside = std::env::temp_dir().join(format!("markpad-path-outside-{nonce}")); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("images")).unwrap(); - let callback_sender = sender.clone(); - let completion = - PrintToPdfCompletedHandler::create(Box::new(move |status, succeeded| { - let result = status - .map_err(|error| format!("WebView2 PDF export failed: {error}")) - .and_then(|_| { - succeeded.then_some(()).ok_or_else(|| { - "WebView2 did not create the PDF file".to_string() - }) - }); - let _ = callback_sender.send(result); - Ok(()) - })); + assert!(resolve_image_directory(root.to_str().unwrap(), "images").is_err()); - webview - .PrintToPdf(&HSTRING::from(path), &settings, &completion) - .map_err(|error| format!("could not start PDF export: {error}")) - })(); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(outside).unwrap(); + } - if let Err(error) = result { - let _ = sender.send(Err(error)); + #[test] + fn theme_slug_collapses_punctuation_runs() { + assert_eq!(theme_slug("SynthWave '84"), "synthwave-84"); + } + + #[test] + fn display_math_keeps_multiple_braced_subscripts_out_of_markdown_emphasis() { + let html = convert_markdown("$$\\bar{b}_{1} + \\bar{b}_{2}$$\n"); + assert!( + html.contains("$$\\bar{b}_{1} + \\bar{b}_{2}$$"), + "unexpected parser output: {html}", + ); + assert!(!html.contains(" String { + let mut out = String::new(); + let mut rest = html; + while let Some(at) = rest.find('<') { + out.push_str(&rest[..at]); + match rest[at..].find('>') { + Some(end) => rest = &rest[at + end + 1..], + None => { + rest = ""; + break; } - }) - .map_err(|error| format!("failed to schedule PDF export: {error}"))?; + } + } + out.push_str(rest); + out.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&") + } + + #[test] + fn issue_174_inline_math_keeps_both_braced_subscripts() { + // `$\bar{b}_{1} + \bar{b}_{2}$` — the two `_` are both left- and + // right-flanking, so CommonMark pairs them into an `` and KaTeX + // receives `$\bar{b}{1} + \bar{b}{2}$`. That is the whole of + // "only the first subscript can use braces", and why a space before + // the first `_` appeared to fix it. + let html = convert_markdown("Let $\\bar{b}_{1} + \\bar{b}_{2}$ be the estimates.\n"); + assert!(!html.contains(") -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || { - atomic_write(Path::new(&path), &data).map_err(|e| e.to_string()) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + #[test] + fn math_delimiters_survive_every_other_commonmark_inline_rule() { + // The point of masking the span rather than one character class: + // emphasis, escapes and code marks are all TeX here. + let html = convert_markdown("$$a*b*c \\_ \\{ \\& ~y~ [z](w)$$\n"); + assert!(!html.contains(" Result<(), String> { - opener::reveal(path).map_err(|e| e.to_string()) -} + #[test] + fn a_multiline_display_math_block_keeps_one_line_per_source_line() { + // The mask is per line, so the block still occupies six lines and + // the frontend still sees one `
    ` per row. + let markdown = "$$\n\\begin{aligned}\nx &= 1\n\\end{aligned}\n$$\n\n- [ ] task\n"; + let html = convert_markdown(markdown); + assert_eq!(html.matches(" Result<(), String> { - fs::rename(old_path, new_path).map_err(|e| e.to_string()) -} + #[test] + fn a_crlf_display_math_block_keeps_its_line_endings() { + let html = convert_markdown("$$\r\na_1 \\\\\r\nb_2\r\n$$\r\n"); + assert!(!html.contains(", - path: String, -) -> Result<(), String> { - window_runtime::watch_file(window, handle, state, path) -} + // --- the other half: prose that must NOT become math ---------------- -#[tauri::command] -fn unwatch_file(window: tauri::Window, state: State<'_, WatcherState>) -> Result<(), String> { - window_runtime::unwatch_file(window, state) -} + #[test] + fn two_prices_in_one_sentence_are_not_a_math_span() { + // The failure mode that matters most. `$100 and $200` pairs under any + // naive "next `$` closes it" rule and would silently swallow the + // Markdown of every document that mentions two prices. + let html = convert_markdown("It cost $100 and $200 today.\n"); + assert!( + html.contains("It cost $100 and $200 today."), + "a price was treated as math: {html}", + ); + } -#[tauri::command] -fn send_markdown_path(state: State<'_, AppState>) -> Vec { - window_runtime::send_markdown_path(state) -} + #[test] + fn ordinary_dollar_amounts_are_not_math_spans() { + for markdown in [ + "The price is $5.\n", + "Between $5 and $10.\n", + "$100$200 back to back.\n", + "A lone $ sign.\n", + "Trailing dollar $\n", + "Costs $ 5 with a space.\n", + "$5\n$6\n", + ] { + let html = convert_markdown(markdown); + assert_eq!( + rendered_math_source(&html).trim(), + markdown.trim(), + "input {markdown:?} was rewritten: {html}", + ); + } + } -#[tauri::command] -fn save_theme(app: AppHandle, theme: String) -> Result<(), String> { - let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; - let theme_path = config_dir.join("theme.txt"); - atomic_write(&theme_path, theme.as_bytes()).map_err(|e| e.to_string()) -} + #[test] + fn an_escaped_dollar_never_opens_a_math_span() { + let html = convert_markdown("Pay \\$100 for $x$ items.\n"); + assert!( + html.contains("Pay \\$100 for $x$ items."), + "the escaped dollar opened a span: {html}", + ); + } -#[tauri::command] -async fn get_app_mode() -> String { - let args: Vec = std::env::args().collect(); - if args.iter().any(|arg| arg == "--uninstall") { - return "uninstall".to_string(); + #[test] + fn an_escaped_dollar_reaches_the_frontend_still_escaped() { + // comrak resolves `\$` to `$`, after which nothing downstream can tell + // "the reader wants a dollar sign" from "the reader wants a formula" — + // and the frontend, which is the side that decides, renders the second. + // So the escape is masked like math is and handed over intact; the + // frontend resolves it in `convertInlineMathDelimiters`. + let html = convert_markdown("Literal \\$\\$x\\$\\$ here.\n"); + assert!( + html.contains("Literal \\$\\$x\\$\\$ here."), + "the escape was resolved before the frontend could honour it: {html}", + ); } - let current_exe = std::env::current_exe().unwrap_or_default(); - let exe_name = current_exe - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); + #[test] + fn an_escaped_backslash_keeps_the_dollar_behind_it_live() { + // `\\$` is an escaped backslash and then an ordinary `$`. Masking only + // the last backslash would hand the frontend `\$` and lose the + // distinction the whole mask exists to preserve. + let html = convert_markdown("A backslash \\\\$ here.\n"); + assert!( + html.contains("A backslash \\\\$ here."), + "the backslash run was split: {html}", + ); + } - let is_installer_mode = - args.iter().any(|arg| arg == "--install") || exe_name.contains("installer"); + #[test] + fn an_escaped_dollar_inside_a_formula_stays_part_of_the_formula() { + // Inside math, `\$` is TeX for a dollar sign. The math span already + // shields it, and claiming it separately would cut the span in two. + let html = convert_markdown("$a \\$ b$\n"); + assert_eq!(rendered_math_source(&html).trim(), "$a \\$ b$"); + } - if setup::is_installed() { - "app".to_string() - } else { - if is_installer_mode { - "installer".to_string() - } else { - "app".to_string() - } + #[test] + fn an_escaped_dollar_in_a_link_destination_is_resolved_not_forwarded() { + // Nothing unescapes an `href` on the way to the reader, so the + // backslash would simply become part of the URL. + let html = convert_markdown("[t](http://example.com/\\$5)\n"); + assert!( + html.contains("href=\"http://example.com/$5\""), + "the escape leaked into the link destination: {html}", + ); } -} -fn theme_slug(value: &str) -> String { - let lowercase = value.to_lowercase(); - lowercase - .split(|c: char| !c.is_alphanumeric()) - .filter(|segment| !segment.is_empty()) - .collect::>() - .join("-") -} + #[test] + fn an_escaped_dollar_inside_code_is_left_to_commonmark() { + // A backslash is not an escape character inside code, so `\$` is two + // literal characters there and comrak already gets it right. + let html = convert_markdown("Use `\\$x\\$` here.\n"); + assert!( + html.contains(">\\$x\\$"), + "the mask reached into a code span: {html}", + ); + } -#[tauri::command] -async fn fetch_vscode_theme(app: AppHandle, url: String) -> Result { - use std::io::Cursor; - // Parse URL: e.g. https://vscodethemes.com/e/teabyii.ayu/ayu-dark-bordered - let parts: Vec<&str> = url.split('/').collect(); - if parts.len() < 5 || parts[3] != "e" { - return Err("Invalid vscodethemes.com URL".to_string()); + #[test] + fn dollars_inside_code_never_open_a_math_span() { + // A single `$$` inside a fence used to flip the delimiter parity of + // the entire document, because the old protection just split on `$$`. + let markdown = "```sh\necho $$\n```\n\nA *word* and $$x_1$$ after.\n\n`$a$` stays code.\n"; + let html = convert_markdown(markdown); + assert!(html.contains("$a$"), + "an inline code span was treated as math: {html}", + ); } - let pub_ext = parts[4]; - let theme_name = parts - .get(5) - .unwrap_or(&"") - .split('?') - .next() - .unwrap_or("") - .to_string(); - let pe_parts: Vec<&str> = pub_ext.split('.').collect(); - if pe_parts.len() != 2 { - return Err("Invalid extension format in URL".to_string()); + + #[test] + fn a_math_span_never_reaches_across_an_inline_code_span() { + // The frontend cannot pair delimiters across a `` element — + // `processInlineMath` rejects the whole subtree — so neither may the + // backend: masking here would silently eat the code span. + let html = convert_markdown("$a `x` b$ and *emphasis*.\n"); + assert!(html.contains(">x"), "got: {html}"); + assert!(html.contains(" MAX_VSIX_DOWNLOAD_BYTES as u64) { - return Err("VSIX download exceeds the allowed size".to_string()); + + #[test] + fn a_document_containing_the_mask_prefix_still_round_trips() { + // Uniqueness is by construction, not by luck: the prefix grows until + // the document does not contain it. + let markdown = format!("{MATH_MASK_PREFIX}0{MATH_MASK_SUFFIX} and $x_1$ here.\n"); + let html = convert_markdown(&markdown); + assert!( + html.contains(&format!("{MATH_MASK_PREFIX}0{MATH_MASK_SUFFIX}")), + "the document's own text was eaten: {html}", + ); + assert!(html.contains("$x_1$"), "the math was lost: {html}"); + assert!(!html.contains(" MAX_VSIX_DOWNLOAD_BYTES { - return Err("VSIX download exceeds the allowed size".to_string()); - } - bytes.extend_from_slice(&chunk); + + #[test] + fn a_masked_span_is_escaped_the_way_comrak_escapes_text() { + let html = convert_markdown("$a < b & c > d \"e\"$\n"); + assert!( + html.contains("$a < b & c > d "e"$"), + "the restored span was not escaped: {html}", + ); } - let reader = Cursor::new(bytes); - let mut archive = zip::ZipArchive::new(reader).map_err(|e| e.to_string())?; - validate_vsix_archive_limits(&mut archive)?; + // ----------------------------------------------------------------- + // The cross-language math-delimiter contract + // + // Everything above proves the backend hides the right spans from + // comrak. It cannot prove the thing correctness actually rests on: + // that the set the backend hides equals the set the *frontend* + // renders. Those are two implementations of one rule in two + // languages, and until now only one of them was pinned — loosening + // `findInlineMathEnd` in markdown.ts would have left every test in + // this file green. + // + // backend ⊂ frontend → comrak mangles the formula before KaTeX + // sees it; that is #174, #177 and #197. + // backend ⊃ frontend → the text is held back from Markdown and + // then rendered by nobody: the reader gets + // dead text that is neither prose nor a + // formula. + // + // So both sides are asserted against one shared, hand-authored + // table: scripts/mathDelimiterCorpus.json. The other half lives in + // scripts/mathDelimiterContract.test.ts and runs the real frontend. + // Change one side's rule and exactly one of the two goes red. + // ----------------------------------------------------------------- - let package_json_data = if let Ok(file) = archive.by_name("extension/package.json") { - if file.size() > MAX_THEME_JSON_BYTES { - return Err("VSIX package manifest exceeds the allowed size".to_string()); - } - read_zip_entry_to_string(file, MAX_THEME_JSON_BYTES)? - } else { - return Err("No package.json found in VSIX".to_string()); - }; + #[derive(serde::Deserialize)] + struct MathContractSpan { + kind: String, + source: String, + } - let package_json: serde_json::Value = - serde_json::from_str(&package_json_data).map_err(|e| e.to_string())?; - let themes = package_json - .get("contributes") - .and_then(|c| c.get("themes")) - .and_then(|t| t.as_array()) - .ok_or("No themes found in extension")?; + #[derive(serde::Deserialize)] + struct MathContractCase { + name: String, + markdown: String, + html: String, + math: Vec, + } - let mut theme_path = None; - let mut matched_name_str = theme_name.clone(); + #[derive(serde::Deserialize)] + struct MathContractCorpus { + cases: Vec, + } - for t in themes { - let label = t - .get("label") - .or(t.get("id")) - .and_then(|l| l.as_str()) - .unwrap_or(""); - let path = t.get("path").and_then(|p| p.as_str()).unwrap_or(""); + fn math_contract_corpus() -> MathContractCorpus { + serde_json::from_str(include_str!("../../scripts/mathDelimiterCorpus.json")) + .expect("scripts/mathDelimiterCorpus.json must stay valid JSON") + } - let label_slug = theme_slug(label); + /// What the backend decided, in the corpus's vocabulary. + fn recognised_math(markdown: &str) -> Vec<(String, String)> { + let regions = code_region_ranges(markdown); + find_math_spans(markdown, ®ions) + .into_iter() + .map(|(start, end)| { + let span = &markdown[start..end]; + match span + .strip_prefix("$$") + .and_then(|inner| inner.strip_suffix("$$")) + { + // `extractDisplayMathBlock` trims; mirror it exactly. + Some(inner) => ("display".to_owned(), inner.trim().to_owned()), + None => ("inline".to_owned(), span[1..span.len() - 1].to_owned()), + } + }) + .collect() + } - // If theme_name is empty, just take the first one - if theme_name.is_empty() - || label_slug == theme_name.to_lowercase() - || path.to_lowercase().contains(&theme_name.to_lowercase()) - { - theme_path = Some(path.to_string()); - if theme_name.is_empty() { - matched_name_str = label_slug; - } - break; + #[test] + fn the_backend_recognises_exactly_the_math_the_contract_lists() { + for case in math_contract_corpus().cases { + let expected: Vec<(String, String)> = case + .math + .iter() + .map(|span| (span.kind.clone(), span.source.clone())) + .collect(); + assert_eq!( + recognised_math(&case.markdown), + expected, + "{}: the backend and the contract disagree about what is math\n input: {:?}", + case.name, + case.markdown, + ); } } - if let Some(mut path) = theme_path { - if path.starts_with("./") { - path = path[2..].to_string(); - } - let full_path = format!("extension/{}", path).replace("\\", "/"); - let theme_file = archive.by_name(&full_path).map_err(|e| e.to_string())?; - if theme_file.size() > MAX_THEME_JSON_BYTES { - return Err("VSIX theme file exceeds the allowed size".to_string()); + #[test] + fn the_math_contract_corpus_is_a_live_capture() { + // Keeps the `html` the frontend test consumes honest: it is what + // this renderer produces today, not what it produced once. + for case in math_contract_corpus().cases { + assert_eq!( + convert_markdown(&case.markdown), + case.html, + "{}: scripts/mathDelimiterCorpus.json no longer matches this \ + renderer — replace its `html` with the value on the left, and \ + leave `markdown` and `math` alone", + case.name, + ); } - let theme_json = read_zip_entry_to_string(theme_file, MAX_THEME_JSON_BYTES)?; + } - let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - let themes_dir = config_dir.join("themes"); - fs::create_dir_all(&themes_dir).map_err(|e| e.to_string())?; + #[test] + fn math_in_a_heading_keeps_the_anchor_a_wikilink_can_reach() { + // comrak derives the heading id from the *rendered* text, so the mask + // would otherwise become the anchor and silently break every + // `[[#heading]]` pointing at a heading that contains a formula. + let heading = "A heading with $x_1$"; + let html = convert_markdown(&format!("# {heading}\n")); + assert!(html.contains("$x_1$"), "the math was lost: {html}"); + assert_eq!( + heading_anchor_id(heading), + "a-heading-with-x_1", + "the wikilink side changed", + ); + assert!( + html.contains("id=\"a-heading-with-x_1\""), + "the mask leaked into the anchor: {html}", + ); + } - let dest_name = if matched_name_str.is_empty() { - "downloaded_theme".to_string() - } else { - matched_name_str.clone() - }; - let dest_name = safe_path_component(&dest_name, "theme name")?; - let theme_file_path = themes_dir.join(format!("{}.json", dest_name)); - atomic_write(&theme_file_path, theme_json.as_bytes()).map_err(|e| e.to_string())?; + #[test] + fn math_in_a_link_destination_keeps_the_link_working() { + // The token has to survive `escape_href` too, which is why it is + // plain ASCII rather than a private-use character. + let html = convert_markdown("[t](http://example.com/$a$)\n"); + assert!( + html.contains("href=\"http://example.com/$a$\""), + "the link destination was mangled: {html}", + ); + } + + #[test] + fn a_stray_backtick_does_not_swallow_later_paragraphs() { + // CommonMark parses inline elements per block and a blank line ends a + // block, so the loose backtick in the first paragraph cannot pair with + // the opening backtick of `run()` two paragraphs down. + let input = + "Use the ` character to start code.\n\n![[photo.png]]\n\nThen `run()` finishes.\n"; - return Ok(dest_name.to_string()); + let out = process_internal_embeds(input); + assert!(out.contains(" Result, String> { - let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - let themes_dir = config_dir.join("themes"); - let mut themes = Vec::new(); - if let Ok(entries) = fs::read_dir(themes_dir) { - for entry in entries.flatten() { - if let Some(ext) = entry.path().extension() { - if ext == "json" { - if let Some(name) = entry.path().file_stem().and_then(|n| n.to_str()) { - themes.push(name.to_string()); - } - } - } - } + let out = process_wikilinks(input); + assert!(out.contains("important"), "got: {out}"); + assert!(out.contains("(#some-heading)"), "got: {out}"); } - Ok(themes) -} -#[tauri::command] -fn read_vscode_theme(app: AppHandle, name: String) -> Result { - let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - let name = safe_path_component(&name, "theme name")?; - let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); - fs::read_to_string(theme_file_path).map_err(|e| e.to_string()) -} + #[test] + fn inline_code_spans_still_pair_across_lines_inside_one_paragraph() { + // A code span may legitimately span several lines of the same block; + // the blank-line reset must not break that. + let input = "start `code\n![[inside.png]]` end\n"; + let out = process_internal_embeds(input); + assert!(out.contains("![[inside.png]]"), "got: {out}"); + assert!(!out.contains(" Result<(), String> { - let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?; - let name = safe_path_component(&name, "theme name")?; - let theme_file_path = config_dir.join("themes").join(format!("{}.json", name)); - fs::remove_file(theme_file_path).map_err(|e| e.to_string()) -} + #[test] + fn embed_attributes_are_html_escaped() { + // The viewer sanitizes with DOMPurify, but the HTML export path writes + // this markup straight to disk, so the quote has to die here. + let out = process_internal_embeds("![[a\" onerror=\"alert(1)]]\n"); + assert!(!out.contains("onerror=\""), "attribute injection: {out}"); + assert!(out.contains("""), "got: {out}"); -#[tauri::command] -fn is_win11() -> bool { - #[cfg(target_os = "windows")] - { - use winreg::enums::*; - use winreg::RegKey; + let sized = process_internal_embeds("![[p.png|300\" onload=\"x]]\n"); + assert!(!sized.contains("onload=\""), "attribute injection: {sized}"); - let hklim = RegKey::predef(HKEY_LOCAL_MACHINE); - if let Ok(current_version) = - hklim.open_subkey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion") - { - if let Ok(current_build) = current_version.get_value::("CurrentBuild") { - if let Ok(build_num) = current_build.parse::() { - return build_num >= 22000; - } - } - } + let single = process_internal_embeds("![[p.png|64\" onload=\"y]]\n"); + assert!(!single.contains("onload=\""), "attribute injection: {single}"); } - false -} - -/// Async because enumerating every installed font family is a slow, -/// filesystem-heavy call (fontconfig on Linux, DirectWrite on Windows, -/// CoreText on macOS) and the settings dialog invokes it on open. On the main -/// thread it stalls every window for the duration. -#[tauri::command] -async fn get_system_fonts() -> Vec { - tauri::async_runtime::spawn_blocking(|| { - use font_kit::source::SystemSource; - let source = SystemSource::new(); - let mut families = source.all_families().unwrap_or_default(); - families.sort(); - families.dedup(); - families - }) - .await - .unwrap_or_default() -} -#[tauri::command] -fn get_os_type() -> String { - #[cfg(target_os = "macos")] - { - "macos".to_string() - } - #[cfg(target_os = "windows")] - { - "windows".to_string() - } - #[cfg(target_os = "linux")] - { - "linux".to_string() - } - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] - { - "unknown".to_string() + #[test] + fn embed_attribute_escaping_keeps_ordinary_paths_readable() { + let out = process_internal_embeds("![[my photo.png]]\n"); + assert!(out.contains("src=\"my%20photo.png\""), "got: {out}"); + assert!(out.contains("alt=\"my photo.png\""), "got: {out}"); } -} - - -#[tauri::command] -fn clipboard_write_text(text: String) -> Result<(), String> { - let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; - clipboard.set_text(text).map_err(|e| e.to_string()) -} -#[tauri::command] -fn clipboard_read_text() -> Result { - let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; - clipboard.get_text().map_err(|e| e.to_string()) -} + #[test] + fn wikilink_anchors_match_the_ids_comrak_actually_renders() { + // Asserted against comrak's real output rather than a copy of its + // rules: if a comrak upgrade changes anchorization, this fails instead + // of silently producing wikilinks that jump nowhere. + for heading in [ + "1. 概述", + "Ticks aren't in", + "Hello, World!", + "Setup & Teardown", + "under_score here", + ] { + let html = convert_markdown(&format!("## {heading}\n")); + let rendered_id = html + .split("id=\"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .unwrap_or_else(|| panic!("no heading id rendered: {html}")); + assert_eq!( + heading_anchor_id(heading), + rendered_id, + "anchor for {heading:?} drifted from comrak: {html}", + ); + } + } -#[tauri::command] -fn clipboard_read_image(macos_image_scaling: bool) -> Result { - #[cfg(not(target_os = "macos"))] - let _ = macos_image_scaling; + #[test] + fn wikilink_targets_survive_punctuation_in_the_heading() { + // "1. 概述" used to become "1.-概述" while comrak rendered "1-概述", + // so the link resolved to nothing. + assert_eq!(heading_anchor_id("1. 概述"), "1-概述"); - let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; - let image = clipboard.get_image().map_err(|e| e.to_string())?; + let out = process_wikilinks("[[#1. 概述|Overview]]\n"); + assert!(out.contains("[Overview](#1-概述)"), "got: {out}"); + } - // encode as png - let mut png_data = Vec::new(); - { - let encoder = image::codecs::png::PngEncoder::new(&mut png_data); - use image::ImageEncoder; - - // Check if running on macOS and scale image if needed - #[cfg(target_os = "macos")] - { - if macos_image_scaling { - // Use image crate for high-quality scaling - use image::{DynamicImage, ImageBuffer, Rgba}; - - // Convert arboard Image to ImageBuffer - let mut img_buffer = ImageBuffer::new(image.width as u32, image.height as u32); - for (x, y, pixel) in img_buffer.enumerate_pixels_mut() { - let idx = (y * image.width as u32 + x) as usize * 4; - if idx + 3 < image.bytes.len() { - *pixel = Rgba([ - image.bytes[idx], - image.bytes[idx + 1], - image.bytes[idx + 2], - image.bytes[idx + 3] - ]); - } - } - - // Create DynamicImage - let dynamic_image = DynamicImage::ImageRgba8(img_buffer); - - // Resize with high-quality Lanczos3 filter - let resized = dynamic_image.resize( - (image.width / 2) as u32, - (image.height / 2) as u32, - image::imageops::FilterType::Lanczos3 - ); - - // Write the resized image - let resized_rgba = resized.to_rgba8(); - encoder - .write_image( - resized_rgba.as_raw(), - (image.width / 2) as u32, - (image.height / 2) as u32, - image::ExtendedColorType::Rgba8, - ) - .map_err(|e| e.to_string())?; - } else { - // Use original image if scaling is disabled - encoder - .write_image( - image.bytes.as_ref(), - image.width as u32, - image.height as u32, - image::ExtendedColorType::Rgba8, - ) - .map_err(|e| e.to_string())?; - } - } - - #[cfg(not(target_os = "macos"))] - { - // For other platforms, use the original image - encoder - .write_image( - image.bytes.as_ref(), - image.width as u32, - image.height as u32, - image::ExtendedColorType::Rgba8, - ) - .map_err(|e| e.to_string())?; - } + #[test] + fn multiline_wikilinks_are_left_literal() { + // A heading id can never contain a newline, so such a target cannot + // resolve; rewriting it would also collapse two source lines into one + // and shift every task checkbox below it. + let out = process_wikilinks("[[#first\nsecond|alias]]\n"); + assert!(out.contains("[[#first\nsecond|alias]]"), "got: {out}"); } - use base64::{engine::general_purpose, Engine as _}; - Ok(general_purpose::STANDARD.encode(&png_data)) -} - -#[tauri::command] -async fn save_image( - parent_dir: String, - filename: String, - base64_data: String, - image_directory: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - save_image_blocking(&parent_dir, &filename, &base64_data, &image_directory) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + // ---- [[file#heading]] wikilinks ------------------------------------- + // + // What these tests do NOT cover, and why: + // * Bare note links, "[[Notes]]" with no heading. Deliberately out of + // scope — see `wikilinks_without_a_heading_are_deliberately_left_literal`. + // * Whether the target file exists. Resolution is the frontend's job + // (`resolveMarkdownTargetPath` in src/lib/utils/markdownLinks.ts); the + // Rust side never touches the filesystem here, so a link to a missing + // note is emitted like any other and simply fails to open. + // * Obsidian's nested-heading paths (`[[file#H1#H2]]`). Everything after + // the first `#` is taken as one heading name, so such a target + // anchorizes to the two names run together and will not resolve. That + // matches the existing behaviour of the same-document form. + // * Duplicate headings. comrak appends `-1`, `-2`, … to the second and + // later headings with the same text; a wikilink can only ever address + // the first one (see the doc comment on `heading_anchor_id`). + // * The actual click-through. The href *shape* the frontend accepts is + // pinned from the TypeScript side in scripts/wikilinkFileTargets.test.ts. -fn save_image_blocking( - parent_dir: &str, - filename: &str, - base64_data: &str, - image_directory: &str, -) -> Result { - let filename = safe_path_component(filename, "image filename")?; - let (root, img_dir) = resolve_image_directory(parent_dir, image_directory)?; - let file_path = img_dir.join(filename); - ensure_path_within_root(&root, &file_path)?; + #[test] + fn copy_reference_output_becomes_a_real_link() { + // `[[Notes#Setup]]` is exactly what the app's own "Copy Reference" + // menu item writes to the clipboard (MarkdownViewer.svelte); it used + // to render as literal text because the pattern required `#` to + // follow `[[` immediately. + let out = process_wikilinks("[[Notes#Setup]]\n"); + assert!(out.contains("[Notes > Setup](Notes.md#setup)"), "got: {out}"); + } - // remove potential data:image/png;base64, prefix - let b64 = if let Some(pos) = base64_data.find("base64,") { - &base64_data[pos + 7..] - } else { - base64_data - }; + #[test] + fn file_wikilink_href_carries_a_markdown_extension_the_frontend_recognizes() { + // getMarkdownLinkTarget() only claims a link whose path has a known + // markdown extension, so a note name written without one — the way + // Copy Reference writes it — has to gain one here or the click falls + // through to the external-URL opener. + let out = process_wikilinks("[[docs/Guide#Setup]]\n"); + assert!(out.contains("(docs/Guide.md#setup)"), "got: {out}"); + } - use base64::{engine::general_purpose, Engine as _}; - let bytes = general_purpose::STANDARD - .decode(b64) - .map_err(|e: base64::DecodeError| e.to_string())?; + #[test] + fn file_wikilink_keeps_an_extension_it_was_already_given() { + let out = process_wikilinks("[[Notes.md#Setup]]\n"); + assert!(out.contains("(Notes.md#setup)"), "got: {out}"); + assert!(!out.contains("Notes.md.md"), "got: {out}"); - atomic_write(&file_path, &bytes).map_err(|e| e.to_string())?; + let txt = process_wikilinks("[[log.txt#Errors]]\n"); + assert!(txt.contains("(log.txt#errors)"), "got: {txt}"); + assert!(!txt.contains("log.txt.md"), "got: {txt}"); + } - let rel_path = if image_directory.is_empty() { - filename.to_string() - } else { - format!("{}/{}", image_directory, filename) - }; + #[test] + fn wikilinks_without_a_heading_are_deliberately_left_literal() { + // Obsidian's bare note link "[[Notes]]" is out of scope: this change + // fixes Copy Reference, whose every call site emits a "#". Claiming + // every "[[…]]" would also swallow bracketed citation numbering and + // pre-empt CommonMark reference links, neither of which is a wikilink. + // See the PR description. + for input in [ + "[[Notes]]\n", + "[[1]] Author, Title.\n", + "[[TODO]] revisit this.\n", + "[[foo]] and [[foo|bar]]\n", + "[[docs/Guide|Guide]]\n", + ] { + assert_eq!(process_wikilinks(input), input, "should be literal"); + } - Ok(rel_path) -} + // A "#" in the alias half does not make it a heading link either. + let aliased = "[[Notes|see #1]]\n"; + assert_eq!(process_wikilinks(aliased), aliased); -#[tauri::command] -async fn copy_file_to_img( - src_path: String, - parent_dir: String, - image_directory: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - copy_file_to_img_blocking(&src_path, &parent_dir, &image_directory) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} + // A reference definition must keep resolving the CommonMark way. + let html = convert_markdown("[[foo]] here.\n\n[foo]: https://example.com\n"); + assert!(html.contains("href=\"https://example.com\""), "got: {html}"); + assert!(!html.contains("foo.md"), "got: {html}"); + } -/// How many conflict names to try before giving up. Chromium's download path -/// reservation gives up after 100 for the same reason: past that, the user is -/// better served by an error than by an unbounded directory scan. -const MAX_IMG_NAME_ATTEMPTS: u32 = 100; + #[test] + fn file_wikilink_alias_and_subfolder_and_punctuated_heading() { + let out = process_wikilinks("[[docs/Guide#1. 概述|Overview]]\n"); + assert!(out.contains("[Overview](docs/Guide.md#1-概述)"), "got: {out}"); + } -/// Builds the `attempt`-th conflict name, e.g. `photo_1.png`, `photo_2.png`. -/// -/// Every mainstream implementation resolves a name conflict with an -/// incrementing counter — Chrome/Firefox downloads (`photo (1).png`), Windows -/// Explorer (`photo (2).png`), macOS Finder (`photo 2.png`) — and none uses a -/// timestamp. They disagree only on the decoration, so this picks the one that -/// survives the destination: the name is about to be pasted into a Markdown -/// link, where parentheses are metacharacters and spaces need escaping, while -/// `_` needs neither. It is also the separator this function already used. -/// -/// An empty extension gets no separator: `Path::extension()` is `None` for a -/// dotfile such as `.png`, and appending the dot unconditionally produced -/// `photo_1.` — a name Windows silently creates *without* the trailing dot, -/// leaving the link written into the document pointing at nothing. -fn img_conflict_name(stem: &str, ext: &str, attempt: u32) -> String { - if ext.is_empty() { - format!("{stem}_{attempt}") - } else { - format!("{stem}_{attempt}.{ext}") + #[test] + fn file_wikilink_percent_encodes_what_would_break_the_destination() { + // A space would end the destination and the rest would be read as a + // title; parentheses would close it early. decodeLinkPath() on the + // frontend undoes all of this. + let out = process_wikilinks("[[My Notes (v2)#Setup]]\n"); + assert!(out.contains("(My%20Notes%20%28v2%29.md#setup)"), "got: {out}"); + assert!(out.contains("[My Notes (v2) > Setup]"), "got: {out}"); } -} -fn copy_file_to_img_blocking( - src_path: &str, - parent_dir: &str, - image_directory: &str, -) -> Result { - let (root, img_dir) = resolve_image_directory(parent_dir, image_directory)?; + #[test] + fn file_wikilink_block_reference_targets_the_block_id_anchor() { + // `^abc123` at the end of a line becomes , and comrak's + // anchorizer drops the caret, so both sides agree on "abc123". + let out = process_wikilinks("[[Notes#^abc123]]\n"); + assert!(out.contains("(Notes.md#abc123)"), "got: {out}"); + } - let src = Path::new(src_path); - if !src.exists() { - return Err("Source file does not exist".to_string()); + #[test] + fn wikilinks_to_files_the_viewer_cannot_open_stay_literal() { + // A non-markdown target would not be claimed by getMarkdownLinkTarget, + // so the click would reach openUrl() with a relative path resolved + // against the webview origin. Leaving it as text is the honest result. + for input in ["[[report.pdf#Intro]]\n", "[[diagram.svg#part]]\n"] { + let out = process_wikilinks(input); + assert_eq!(out, input, "got: {out}"); + } } - let file_name = src - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| "Invalid source filename".to_string())?; - let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("image"); - let ext = src.extension().and_then(|e| e.to_str()).unwrap_or(""); + #[test] + fn same_document_wikilinks_are_unchanged_by_the_file_form() { + let out = process_wikilinks("[[#Some Heading|jump]]\n"); + assert!(out.contains("[jump](#some-heading)"), "got: {out}"); - let mut source = fs::File::open(src).map_err(|e| e.to_string())?; + let bare = process_wikilinks("[[#Setup]]\n"); + assert!(bare.contains("[Setup](#setup)"), "got: {bare}"); + } - // The destination name is claimed with `create_new`, which is a single - // atomic syscall (`O_EXCL` / `CREATE_NEW`): whoever creates the file wins - // and everyone else gets `AlreadyExists` and moves to the next name. The - // previous code tested `exists()` and then copied, so two drops that - // computed the same name — trivially, since the name carried a - // second-resolution timestamp that was never re-checked — both saw the - // name as free and the second overwrote an image the document already - // linked to. - // - // Residual races: `O_EXCL` is not reliable on old NFSv2 mounts, and - // nothing stops an outside process from deleting our file after we create - // it. Neither is a same-app data-loss path, which is what this guards. - // Streaming into the handle we just created, rather than `fs::copy`, also - // means the copy no longer inherits the source's permission bits — a - // read-only original used to produce a read-only file in `img/`. - let mut dest_name = file_name.to_string(); - let mut attempt: u32 = 0; - loop { - let candidate = img_dir.join(&dest_name); - ensure_path_within_root(&root, &candidate)?; - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&candidate) - { - Ok(mut dest) => { - // The name is ours; only the bytes can still fail. Drop the - // placeholder if they do, so a failed drop does not leave a - // truncated image behind under a name the user may reuse. - if let Err(e) = std::io::copy(&mut source, &mut dest) { - drop(dest); - let _ = fs::remove_file(&candidate); - return Err(e.to_string()); - } - break; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - attempt += 1; - if attempt > MAX_IMG_NAME_ATTEMPTS { - return Err(format!( - "Too many images named \"{}\" in this folder", - file_name - )); - } - dest_name = img_conflict_name(stem, ext, attempt); - } - Err(e) => return Err(e.to_string()), - } + #[test] + fn file_wikilinks_in_code_spans_and_fences_stay_literal() { + let span = process_wikilinks("`[[Notes#Setup]]` but [[Notes#Setup]]\n"); + assert!(span.contains("`[[Notes#Setup]]`"), "got: {span}"); + assert!(span.contains("(Notes.md#setup)"), "got: {span}"); + + let fence = process_wikilinks("```\n[[Notes#Setup]]\n```\n"); + assert!(fence.contains("```\n[[Notes#Setup]]\n```"), "got: {fence}"); } - let rel_path = if image_directory.is_empty() { - dest_name - } else { - format!("{}/{}", image_directory, dest_name) - }; + #[test] + fn embeds_are_not_also_treated_as_file_wikilinks() { + // process_internal_embeds runs first and consumes `![[…]]`; the guard + // matters for the standalone call and for an embed it declined. + let out = process_wikilinks("![[photo.png]]\n"); + assert!(out.contains("![[photo.png]]"), "got: {out}"); - Ok(rel_path) -} + let html = convert_markdown("![[photo.png]]\n"); + assert!(html.contains(" Result<(), String> { - let p = Path::new(&path); - if p.exists() { - fs::remove_file(p).map_err(|e| e.to_string())?; + #[test] + fn bracketed_link_text_is_still_a_commonmark_link() { + // "[[1]](https://example.com)" is a CommonMark link whose text is + // "[1]" — a common citation spelling in READMEs. Requiring a "#" + // already protects that spelling, so the first case here would pass + // without the trailing-"(" guard; the second would not, which is why + // the guard stays. + for input in [ + "See [[1]](https://example.com) for details.\n", + "See [[1#x]](https://example.com) for details.\n", + "[[Notes#Setup]](https://example.com)\n", + ] { + assert_eq!(process_wikilinks(input), input, "should be literal"); + let html = convert_markdown(input); + assert!(html.contains("href=\"https://example.com\""), "got: {html}"); + } } - Ok(()) -} -#[tauri::command] -fn copy_file(src: String, dest: String) -> Result<(), String> { - fs::copy(src, dest).map(|_| ()).map_err(|e| e.to_string()) -} + #[test] + fn file_wikilink_survives_the_full_render_pipeline() { + let html = convert_markdown("[[Notes#Setup]]\n"); + assert!(html.contains("href=\"Notes.md#setup\""), "got: {html}"); + } -#[tauri::command] -fn cleanup_empty_img_dir(parent_dir: String, image_directory: String) -> Result<(), String> { - let img_dir = Path::new(&parent_dir).join(&image_directory); - if img_dir.exists() && img_dir.is_dir() { - if fs::read_dir(&img_dir) - .map_err(|e| e.to_string())? - .next() - .is_none() - { - fs::remove_dir(img_dir).map_err(|e| e.to_string())?; - } + #[test] + fn attribute_escaping_covers_the_html_metacharacters() { + assert_eq!( + escape_html_attribute("a\"b'c&df"), + "a"b'c&d<e>f", + ); + assert_eq!(escape_html_attribute("plain.png"), "plain.png"); } - Ok(()) -} -#[tauri::command] -async fn list_directory_contents(path: String) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let dir = Path::new(&path); - if !dir.exists() || !dir.is_dir() { - return Err("Not a directory".to_string()); - } + /// Creates `/src/` holding `body` and returns its path. + fn drop_source(root: &Path, index: usize, name: &str, body: &[u8]) -> PathBuf { + let dir = root.join(format!("src{index}")); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join(name); + fs::write(&file, body).unwrap(); + file + } - let mut entries = Vec::new(); - for entry in fs::read_dir(dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let name = entry.file_name().to_string_lossy().to_string(); - let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); - if is_dir { - entries.push(format!("{}/", name)); - } else { - entries.push(name); - } + fn drop_into_img(src: &Path, doc_dir: &Path) -> String { + copy_file_to_img_blocking(src.to_str().unwrap(), doc_dir.to_str().unwrap(), "img").unwrap() + } + + #[test] + fn repeated_drops_of_the_same_name_never_overwrite_an_earlier_copy() { + // Three same-named images from different folders, dropped in the same + // second. The conflict name used to be a *second-resolution* timestamp + // that was never re-checked for existence, so drops #2 and #3 computed + // the identical name and #3 silently replaced the bytes behind a link + // the document had already been given. + let root = temp_path("imgcopy-repeat"); + let doc_dir = root.join("doc"); + fs::create_dir_all(&doc_dir).unwrap(); + let bodies: [&[u8]; 3] = [b"first", b"second", b"third"]; + let sources: Vec = bodies + .iter() + .enumerate() + .map(|(i, body)| drop_source(&root, i, "a.png", body)) + .collect(); + + let written: Vec = sources.iter().map(|src| drop_into_img(src, &doc_dir)).collect(); + + let distinct: std::collections::HashSet<&String> = written.iter().collect(); + assert_eq!(distinct.len(), written.len(), "two drops shared a name: {written:?}"); + for (rel, body) in written.iter().zip(bodies.iter()) { + assert_eq!( + fs::read(doc_dir.join(rel)).unwrap(), + *body, + "{rel} no longer holds the image that was dropped for it", + ); } - Ok(entries) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())) -} -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - #[cfg(target_os = "linux")] - { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + fs::remove_dir_all(root).unwrap(); } - #[cfg(target_os = "windows")] - { - std::env::set_var( - "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", - "--enable-features=SmoothScrolling", - ); + #[test] + fn a_conflicting_dotfile_name_does_not_grow_a_trailing_dot() { + // ".png" is a dotfile, not an extension: the frontend's drop filter + // reads the name with `split('.').pop()` and sees "png", so it lets it + // through, while Rust's `Path::extension()` returns None. The old + // format string appended the separator unconditionally, so the conflict + // name ended in a dot. Windows strips a trailing dot when creating the + // file, so the link written into the document named a file that does + // not exist on disk; mac/Linux keep the dot and the link resolves. + let root = temp_path("imgcopy-dotfile"); + let doc_dir = root.join("doc"); + fs::create_dir_all(&doc_dir).unwrap(); + let first = drop_source(&root, 0, ".png", b"first"); + let second = drop_source(&root, 1, ".png", b"second"); + + drop_into_img(&first, &doc_dir); + let rel = drop_into_img(&second, &doc_dir); + + assert!(!rel.ends_with('.'), "conflict name ends in a dot: {rel}"); + assert_eq!(fs::read(doc_dir.join(&rel)).unwrap(), b"second"); + + fs::remove_dir_all(root).unwrap(); } - tauri::Builder::default() - .manage(AppState::new()) - .manage(WatcherState::new()) - .manage(tab_transfer::TabTransferBroker::new()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - window_runtime::handle_single_instance(app, args, cwd); - })) - .plugin(tauri_plugin_prevent_default::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) - .plugin( - tauri_plugin_window_state::Builder::default() - .with_state_flags( - tauri_plugin_window_state::StateFlags::SIZE - | tauri_plugin_window_state::StateFlags::POSITION - | tauri_plugin_window_state::StateFlags::MAXIMIZED - | tauri_plugin_window_state::StateFlags::VISIBLE - | tauri_plugin_window_state::StateFlags::FULLSCREEN, - ) - // Detached tab windows share one saved state instead of - // accumulating a state entry per generated label. - .map_label(|label| { - if label.starts_with("window-") { - "secondary" - } else { - label - } + #[test] + fn concurrent_drops_of_the_same_name_each_get_their_own_file() { + // Two windows dropping the same image at the same moment: an + // `exists()` test followed by a separate copy leaves a window in which + // both callers see the name as free and one copy lands on top of the + // other. + const DROPS: usize = 8; + let root = temp_path("imgcopy-concurrent"); + let doc_dir = root.join("doc"); + fs::create_dir_all(&doc_dir).unwrap(); + let sources: Vec<(PathBuf, Vec)> = (0..DROPS) + .map(|i| { + let body = format!("image-{i}").into_bytes(); + (drop_source(&root, i, "a.png", &body), body) + }) + .collect(); + + let written: Vec<(String, Vec)> = std::thread::scope(|scope| { + let handles: Vec<_> = sources + .iter() + .map(|(src, body)| { + let doc_dir = doc_dir.clone(); + scope.spawn(move || (drop_into_img(src, &doc_dir), body.clone())) }) - .build(), - ) - .setup(|app| { - let args: Vec = std::env::args().collect(); - println!("Setup Args: {:?}", args); + .collect(); + handles.into_iter().map(|h| h.join().unwrap()).collect() + }); + + let distinct: std::collections::HashSet<&String> = written.iter().map(|(rel, _)| rel).collect(); + assert_eq!(distinct.len(), DROPS, "concurrent drops shared a name: {written:?}"); + for (rel, body) in &written { + assert_eq!( + &fs::read(doc_dir.join(rel)).unwrap(), + body, + "{rel} no longer holds the image that was dropped for it", + ); + } + + fs::remove_dir_all(root).unwrap(); + } + + // --------------------------------------------------------------------- + // The line-number contract of `convert_markdown` + // + // `convert_markdown` preprocesses the raw buffer, renders the *result* + // with `sourcepos = true`, and hands those line numbers to the frontend. + // The frontend then writes task-checkbox toggles back into the *raw* + // buffer at that line number. So every preprocessing step has to map + // input line N to output line N; a step that quietly eats or inserts a + // line makes the reading-mode checkbox rewrite a different line of the + // user's document (issue #352). + // + // A step MAY append after the last input line — the inline-footnote step + // parks its `[^ifn-N]: …` definitions there, which cannot shift the + // number of any line that already existed. It must never insert or drop + // a line inside the document. + // + // ⚠️ EVERY preprocessing step of `convert_markdown` MUST be registered in + // `line_preserving_transforms()` below. It is not optional and it is not + // best-effort: `every_convert_markdown_preprocessing_step_is_registered` + // re-reads this source file, extracts the calls `convert_markdown` + // actually makes, and fails if one of them is missing from the list. + // Adding a fifth transform without registering it turns that test red. + // --------------------------------------------------------------------- - let current_exe = std::env::current_exe().unwrap_or_default(); - let exe_name = current_exe - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(); - let is_installer_mode = - args.iter().any(|arg| arg == "--install") || exe_name.contains("installer"); + type LineTransform = fn(&str) -> String; - let label = if is_installer_mode { - "installer" - } else { - "main" - }; + /// The registry the contract test walks. Add every new preprocessing step. + fn line_preserving_transforms() -> Vec<(&'static str, LineTransform)> { + vec![ + ( + "process_parenthesized_autolinks", + (|s| process_parenthesized_autolinks(s).into_owned()) as LineTransform, + ), + ( + "process_internal_embeds", + (|s| process_internal_embeds(s).into_owned()) as LineTransform, + ), + ( + "process_wikilinks", + (|s| process_wikilinks(s).into_owned()) as LineTransform, + ), + ( + "mask_math_spans", + (|s| mask_math_spans(s).text) as LineTransform, + ), + ] + } - let mut window_builder = tauri::WebviewWindowBuilder::new( - app, - label, - tauri::WebviewUrl::App("index.html".into()), - ) - .title("Markpad") - .inner_size(900.0, 650.0) - .min_inner_size(400.0, 300.0) - .visible(false) - .resizable(true) - .shadow(false) - .center(); + /// Documents exercising every syntax the preprocessing steps claim, plus + /// the malformed spellings of each one — a lone `![[`, an unterminated + /// `^[`, a wikilink split over two lines — because those are exactly the + /// inputs where a lazy or newline-crossing pattern runs away. + const LINE_CONTRACT_CORPUS: &[&str] = &[ + // A stray embed opener with a real embed further down. + "Prose with ![[ a stray opener.\n\n- [ ] task one\n\nLater an image ![[real.png]] here.\n", + // An inline footnote whose text wraps onto a second line. + "Some claim^[See the long explanation\nthat wraps to a second line] and more.\n\n- [ ] task\n", + // A block id sitting on its own line, Obsidian's block-reference form. + "A quotable paragraph.\n^blockid\n\n- [ ] task\n", + // A block id at the end of its own line. + "A quotable paragraph. ^blockid\n\n- [ ] task\n", + // Every well-formed spelling at once. + "![[pic.png|300x200]] [[#Setup|jump]] [[Notes#Setup]] ==mark== text^[note]\n\n- [ ] task\n", + // A wikilink split over two lines (already guarded, kept as a pin). + "[[#first\nsecond|alias]]\n- [ ] task\n", + // Code fences and spans, which every step must leave alone. + "```\n![[inside.md]]\n^[inside]\n==inside==\n```\n\n`==x==` ![[out.png]]\n", + // Unclosed fence, longer fences, tilde fences. + "~~~\n![[a.md]]\n\n````\n```\n![[b.md]]\n````\n\n```\n![[never-closed.md]]\n", + // Parenthesized autolink with nested parentheses. + "See (https://example.com/a(b)c)text here\n\n- [ ] task\n", + // Display math with underscores. + "$$\na_b\nc_d\n$$\n\nx^[note] and $$y_1$$\n\n- [ ] task\n", + // Headings, quotes, tables, nested and quoted tasks. + "# Head\n\n> quote ^qid\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n- [ ] task\n - [x] nested\n\n> - [ ] quoted\n", + // Unbalanced brackets and carets in prose. + "A ^[ dangling footnote opener and a [[ dangling wikilink\n\n- [ ] task\n", + // Multibyte content — offsets are bytes, line numbers are not. + "中文段落 ![[图片.png]] ^[脚注]\n\n- [ ] 任务\n", + // Blank lines, CRLF, and no trailing newline. + "one\r\ntwo ![[x.png]]\r\n\r\n- [ ] task", + ]; - #[cfg(target_os = "macos")] - { - window_builder = window_builder - .decorations(true) - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - } + const LINE_CONTRACT_SENTINEL: &str = "MPLINECONTRACTSENTINEL"; - #[cfg(not(target_os = "macos"))] - { - window_builder = window_builder.decorations(false); - } + fn sentinel_line(text: &str) -> Option { + text.lines() + .position(|line| line.contains(LINE_CONTRACT_SENTINEL)) + } - let window = window_builder.build()?; + /// Asserts that `transform` keeps a marker line at the same line number. + /// + /// The marker is appended to every line-prefix of `input`, not just to + /// the whole document: a step that drops one line and inserts another + /// would leave the total unchanged, but no prefix boundary between the + /// two survives. Checking the sentinel rather than the raw line count is + /// what lets the inline-footnote step append its definitions afterwards. + fn assert_transform_preserves_line_numbers( + name: &str, + transform: LineTransform, + input: &str, + ) { + let lines: Vec<&str> = input.split_inclusive('\n').collect(); + for take in 0..=lines.len() { + let mut probe = lines[..take].concat(); + if !probe.is_empty() && !probe.ends_with('\n') { + probe.push('\n'); + } + probe.push_str(LINE_CONTRACT_SENTINEL); + probe.push('\n'); - #[cfg(target_os = "macos")] - { - use tauri::menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}; + let expected = sentinel_line(&probe).expect("the probe carries the sentinel"); + let output = transform(&probe); + let actual = sentinel_line(&output).unwrap_or_else(|| { + panic!( + "{name} swallowed the sentinel line entirely\n input: {probe:?}\n output: {output:?}" + ) + }); + assert_eq!( + expected, actual, + "{name} moved line {expected} to line {actual}\n input: {probe:?}\n output: {output:?}", + ); + } + } - let app_name = app.package_info().name.clone(); + #[test] + fn every_preprocessing_step_preserves_source_line_numbers() { + for (name, transform) in line_preserving_transforms() { + for input in LINE_CONTRACT_CORPUS { + assert_transform_preserves_line_numbers(name, transform, input); + } + } + } - let check_item = - MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(app)?; - let settings_item = MenuItemBuilder::with_id("menu-app-settings", "Settings…") - .accelerator("CmdOrCtrl+,") - .build(app)?; + #[test] + fn the_whole_preprocessing_pipeline_preserves_source_line_numbers() { + // Individually line-preserving steps could still compose badly: one + // step's output is the next one's input, so a rewrite that creates a + // new `^[` or `![[` opener would only show up here. + let pipeline: LineTransform = |content| { + let autolinks = process_parenthesized_autolinks(content); + let embeds = process_internal_embeds(&autolinks); + let links = process_wikilinks(&embeds); + mask_math_spans(&links).text + }; + for input in LINE_CONTRACT_CORPUS { + assert_transform_preserves_line_numbers("the preprocessing pipeline", pipeline, input); + } + } - let app_submenu = SubmenuBuilder::new(app, &app_name) - .item(&PredefinedMenuItem::about( - app, - Some(&format!("About {}", app_name)), - None, - )?) - .separator() - .item(&settings_item) - .item(&check_item) - .separator() - .item(&PredefinedMenuItem::services(app, None)?) - .separator() - .item(&PredefinedMenuItem::hide(app, None)?) - .separator() - .item( - &MenuItemBuilder::with_id( - "menu-app-quit", - format!("Quit {}", app_name), - ) - .accelerator("CmdOrCtrl+Q") - .build(app)?, - ) - .build()?; + /// The body of `convert_markdown`, read back out of this source file. + /// + /// The needle is assembled at runtime so that it does not match this + /// file's own source text. Line endings are normalised first: git checks + /// this file out with CRLF wherever `core.autocrlf` is on — the default + /// on Windows, and what the Windows CI runner does — and the `\n`-anchored + /// needles are about the shape of the source, not about how the working + /// tree happens to store it. + fn convert_markdown_body() -> String { + let source = include_str!("lib.rs").replace("\r\n", "\n"); + let needle = format!("\nfn {}(content: &str) -> String {{", "convert_markdown"); + let start = source + .find(&needle) + .expect("convert_markdown must keep its `&str -> String` signature"); + let rest = &source[start + needle.len()..]; + rest[..rest + .find("\n}\n") + .expect("convert_markdown must be terminated")] + .to_string() + } - let menu = MenuBuilder::new(app) - .items(&[&app_submenu]) - .build()?; + #[test] + fn convert_markdown_hands_the_fail_safe_the_raw_buffer() { + // `annotate_task_checkboxes` is a fail-safe only while what reaches it + // is the buffer the command was called with. The hazard is not the + // call — it is the *name*: adding a step the obvious way, + // + // let content = process_new_thing(content); + // + // near the top rebinds the parameter, and the unchanged call at the + // bottom starts handing over preprocessed text. Nothing about that + // edit looks wrong and no behavioural test can see it, because the two + // sides it is supposed to cross-check now agree by definition. + // + // "This string is the one the caller passed in" is provenance, not a + // type, so the compiler cannot be made to check it. What can be made + // structural is the shadowing: `convert_markdown` copies its input to + // `raw_buffer` before anything else runs, which turns the shadowing + // edit above into a harmless one. This test pins the three properties + // that copy depends on. + let body = convert_markdown_body(); - app.set_menu(menu)?; - } + let capture = "let raw_buffer = content;"; + let first_let = body + .find("\n let ") + .map(|i| i + "\n ".len()) + .expect("convert_markdown must bind something"); + assert!( + body[first_let..].starts_with(capture), + "the raw buffer must be captured before the first preprocessing \ + step, or the step can shadow `content` above it:\n{body}", + ); + assert_eq!( + body.matches(capture).count(), + 1, + "`raw_buffer` is bound more than once — a second binding is the \ + same hole under a new name:\n{body}", + ); + assert!( + Regex::new(r"annotate_task_checkboxes\([^;]*,\s*raw_buffer\s*\)") + .unwrap() + .is_match(&body), + "the fail-safe is no longer handed `raw_buffer`; whatever it now \ + receives can agree with the HTML by construction:\n{body}", + ); + } - let config_dir = app.path().app_config_dir()?; - let theme_path = config_dir.join("theme.txt"); - let theme_pref = - fs::read_to_string(theme_path).unwrap_or_else(|_| "system".to_string()); + #[test] + fn every_convert_markdown_preprocessing_step_is_registered() { + // Re-reads this file so that a fifth preprocessing step cannot be + // added to `convert_markdown` without also being put under the line + // contract. The needle is assembled at runtime so that it does not + // match this test's own source text. + // + // Line endings are normalised first. Git checks this file out with + // CRLF wherever `core.autocrlf` is on — the default on Windows, and + // what the Windows CI runner does — and the `\n`-anchored needles + // below are about the shape of the source, not about how the working + // tree happens to store it. + let source = include_str!("lib.rs").replace("\r\n", "\n"); + let needle = format!("\nfn {}(content: &str) -> String {{", "convert_markdown"); + let start = source + .find(&needle) + .expect("convert_markdown must keep its `&str -> String` signature"); + let rest = &source[start + needle.len()..]; + let body = &rest[..rest.find("\n}\n").expect("convert_markdown must be terminated")]; - let bg_color = match theme_pref.as_str() { - "dark" => Some(tauri::window::Color(24, 24, 24, 255)), - "light" => Some(tauri::window::Color(253, 253, 253, 255)), - _ => { - if let Ok(t) = window.theme() { - match t { - tauri::Theme::Dark => Some(tauri::window::Color(24, 24, 24, 255)), - _ => Some(tauri::window::Color(253, 253, 253, 255)), - } - } else { - Some(tauri::window::Color(253, 253, 253, 255)) - } - } - }; + // Bare `name(` calls: `.method(` and `Type::assoc(` are excluded by + // the leading character class. + let call = Regex::new(r"(?:^|[^A-Za-z0-9_:.])([a-z_][a-z0-9_]*)\s*\(").unwrap(); + // Calls in `convert_markdown` that are not preprocessing steps. + // `annotate_task_checkboxes` runs on the rendered HTML, after + // sourcepos numbers exist; it is the fail-safe for this contract + // rather than a participant in it. `restore_math_spans` also runs on + // the rendered HTML — it is the second half of `mask_math_spans`, + // which *is* registered, and it never sees the source buffer. + let not_a_transform = [ + "markdown_to_html", + "annotate_task_checkboxes", + "restore_math_spans", + ]; - let _ = window.set_background_color(bg_color); + let mut found: Vec = call + .captures_iter(body) + .map(|caps| caps[1].to_string()) + .filter(|name| !not_a_transform.contains(&name.as_str())) + .collect(); + found.sort(); + found.dedup(); - let _ = window.set_shadow(true); + let mut registered: Vec = line_preserving_transforms() + .into_iter() + .map(|(name, _)| name.to_string()) + .collect(); + registered.sort(); - let file_path = args.iter().skip(1).find(|arg| !arg.starts_with("-")); + assert_eq!( + found, registered, + "convert_markdown's preprocessing steps and the line-contract \ + registry have drifted apart — register every new step in \ + line_preserving_transforms() (or, if the call is not a \ + preprocessing step, add it to not_a_transform and say why)", + ); + } - if let Some(path) = file_path { - let _ = window.emit("file-path", path.as_str()); - window_runtime::bring_to_front(&window); - } + #[test] + fn a_stray_embed_opener_leaves_the_document_and_its_tasks_intact() { + let markdown = + "Prose with ![[ a stray opener.\n\n- [ ] task one\n\nLater an image ![[real.png]] here.\n"; + let html = convert_markdown(markdown); + assert!( + html.contains("task one"), + "the stray opener swallowed the prose: {html}", + ); + assert!( + html.contains("data-task-checkbox"), + "the stray opener shifted the task source position: {html}", + ); + // A real embed further down still renders. + assert!(html.contains("(); - window_runtime::lock_recover(&state.startup_files) - .push(path_str.clone()); + let mismatched = annotate_task_checkboxes( + rendered.clone(), + "- [ ] real task\n\n```\nnot a task\n```\n", + ); + assert!( + !mismatched.contains("data-task-checkbox"), + "the fail-safe let a checkbox through onto a line that is not a task: {mismatched}", + ); - if let Some(window) = pick_delivery_window(_app_handle) { - let _ = _app_handle.emit_to(window.label(), "file-path", path_str); - window_runtime::bring_to_front(&window); - } - } - } - } - }); + // Control: the same HTML against the buffer it was rendered from. + let matching = annotate_task_checkboxes(rendered, "intro paragraph\n\n- [ ] task\n"); + assert!( + matching.contains("data-task-checkbox"), + "the fail-safe rejected a genuine task line: {matching}", + ); + } }