Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/hint.rs

//! **One line, and a "?" for the rest** — the rule every page is held to, on
//! the host side of the wasm boundary (owner, 2026-10-04: "long hard-coded
//! texts → one line + ?").
//!
//! A paragraph under every panel is read once and skipped ever after, and it
//! pushes the one line somebody came for below the fold. So a page says ONE
//! line, and whatever else is worth saying waits behind the "?" of
//! `ui::Hint` (`<Hint line=… more=…/>`), or in a tooltip (`title=`, `why=`),
//! which the reader asks for.
//!
//! The rule is a scan rather than a review because the next long sentence
//! will be typed into a page by somebody who has not read this: every string
//! literal in `src/pages/*.rs` longer than [`ONE_LINE`] characters must be the
//! argument of `more=`, `title=` or `why=`. Comments are not scanned — they are
//! for the next reader of the code, not of the page.

/// The longest literal a page may put on screen as is: about one line of a
/// panel's hint at its own width.
pub const ONE_LINE: usize = 110;

/// The attributes whose text the reader has to ASK for.
pub const ASKED_FOR: &[&str] = &["more=", "title=", "why="];

/// Every string literal in `src` that a page would put on screen unasked and
/// that is longer than [`ONE_LINE`], as `(line number, the text)`.
///
/// A small lexer, not a regex: a `//` inside a URL is not a comment, a `"`
/// inside a char literal is not a string, and a `\` at a line's end continues
/// the literal without its leading blanks — exactly as rustc reads them.
pub fn long_unasked(src: &str) -> Vec<(usize, String)> {
    let b: Vec<char> = src.chars().collect();
    let mut out = Vec::new();
    let mut i = 0;
    let mut line = 1;
    let mut code_before = String::new();
    while i < b.len() {
        let c = b[i];
        if c == '\n' {
            line += 1;
        }
        // Comments.
        if c == '/' && b.get(i + 1) == Some(&'/') {
            while i < b.len() && b[i] != '\n' {
                i += 1;
            }
            continue;
        }
        if c == '/' && b.get(i + 1) == Some(&'*') {
            i += 2;
            while i + 1 < b.len() && !(b[i] == '*' && b[i + 1] == '/') {
                if b[i] == '\n' {
                    line += 1;
                }
                i += 1;
            }
            i += 2;
            continue;
        }
        // Char literals ('"', '\'', 'x') and lifetimes ('a).
        if c == '\'' {
            if b.get(i + 1) == Some(&'\\') && b.get(i + 3) == Some(&'\'') {
                i += 4;
                continue;
            }
            if b.get(i + 2) == Some(&'\'') {
                i += 3;
                continue;
            }
            i += 1;
            continue;
        }
        // Raw strings: r"…", r#"…"#.
        if c == 'r' && (b.get(i + 1) == Some(&'"') || b.get(i + 1) == Some(&'#')) && !b.get(i.wrapping_sub(1)).is_some_and(|p| p.is_alphanumeric() || *p == '_') {
            let mut j = i + 1;
            let mut hashes = 0;
            while b.get(j) == Some(&'#') {
                hashes += 1;
                j += 1;
            }
            if b.get(j) == Some(&'"') {
                let start_line = line;
                let mut text = String::new();
                j += 1;
                loop {
                    if j >= b.len() {
                        break;
                    }
                    if b[j] == '"' && (0..hashes).all(|k| b.get(j + 1 + k) == Some(&'#')) {
                        j += 1 + hashes;
                        break;
                    }
                    if b[j] == '\n' {
                        line += 1;
                    }
                    text.push(b[j]);
                    j += 1;
                }
                judge(&mut out, &code_before, start_line, text);
                code_before.clear();
                i = j;
                continue;
            }
        }
        if c == '"' {
            let start_line = line;
            let mut text = String::new();
            let mut j = i + 1;
            while j < b.len() && b[j] != '"' {
                if b[j] == '\\' {
                    match b.get(j + 1) {
                        // A continued line: the newline and the next line's
                        // leading blanks are not part of the literal.
                        Some('\n') => {
                            line += 1;
                            j += 2;
                            while b.get(j).is_some_and(|c| c.is_whitespace()) {
                                if b[j] == '\n' {
                                    line += 1;
                                }
                                j += 1;
                            }
                            continue;
                        }
                        Some(&e) => {
                            text.push(e);
                            j += 2;
                            continue;
                        }
                        None => break,
                    }
                }
                if b[j] == '\n' {
                    line += 1;
                }
                text.push(b[j]);
                j += 1;
            }
            judge(&mut out, &code_before, start_line, text);
            code_before.clear();
            i = j + 1;
            continue;
        }
        if !c.is_whitespace() {
            code_before.push(c);
            if code_before.len() > 64 {
                code_before.drain(..code_before.len() - 64);
            }
        }
        i += 1;
    }
    out
}

fn judge(out: &mut Vec<(usize, String)>, code_before: &str, line: usize, text: String) {
    if text.chars().count() <= ONE_LINE {
        return;
    }
    if ASKED_FOR.iter().any(|a| code_before.ends_with(a)) {
        return;
    }
    out.push((line, text));
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ **Every page says one line, and the rest only when asked.** A long
    /// literal anywhere in a page — a hint, an empty state, a `format!` — is
    /// a paragraph on screen; it goes behind `<Hint more=…/>`'s "?" or into a
    /// tooltip.
    #[test]
    fn pages_say_one_line_and_hide_the_rest() {
        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pages");
        let mut found = Vec::new();
        let mut files: Vec<_> = std::fs::read_dir(&dir).expect("src/pages").filter_map(|e| e.ok()).map(|e| e.path()).collect();
        files.sort();
        for p in files.into_iter().filter(|p| p.extension().is_some_and(|x| x == "rs")) {
            let src = std::fs::read_to_string(&p).unwrap();
            for (line, text) in long_unasked(&src) {
                found.push(format!("{}:{line}: {} chars: {:.60}…", p.file_name().unwrap().to_string_lossy(), text.chars().count(), text));
            }
        }
        assert!(found.is_empty(), "long texts on screen, unasked ({}):\n{}", found.len(), found.join("\n"));
    }

    /// The lexer reads literals as rustc does: comments are not literals, a
    /// `"` in a char literal opens nothing, a continued line loses its
    /// leading blanks, and an asked-for argument is let through.
    #[test]
    fn the_scanner_reads_literals_as_rustc_does() {
        let long = "x".repeat(ONE_LINE + 1);
        let src = format!(
            "// \"{long}\"\nlet q = '\"';\nlet a = \"{long}\";\n<Hint line=\"short\" more=\"{long}\"/>\n<span title=\"{long}\"/>\nlet b = \"{half}\\\n        {half}\";\nlet c = r#\"{long}\"#;\n",
            half = "y".repeat(ONE_LINE / 2 + 1),
        );
        let found: Vec<usize> = long_unasked(&src).into_iter().map(|(l, _)| l).collect();
        assert_eq!(found, [3, 6, 8], "{:?}", long_unasked(&src));
        let (_, joined) = &long_unasked(&src)[1];
        assert!(!joined.contains(' '), "a continued line keeps no leading blanks: {joined:?}");
    }
}