checksmix 0.4.3

An assembler, emulator and source-level debugger for Knuth's MMIX.
Documentation
//! Source preprocessing: whole-line comment blanking, `debug` directive expansion, and `INCLUDE` resolution.

use super::DebugDirectiveOverflow;
use super::MMixAssembler;
use crate::mmix::TrapCode;
use std::path::Path;
use std::path::PathBuf;
use tracing::debug;

/// The U+FEFF a UTF-8 file may open with.
const BYTE_ORDER_MARK: char = '\u{FEFF}';

impl MMixAssembler {
    /// `source` without one leading byte order mark. A U+FEFF anywhere else
    /// is source text and stays.
    pub(super) fn strip_byte_order_mark(source: &str) -> &str {
        source.strip_prefix(BYTE_ORDER_MARK).unwrap_or(source)
    }

    /// Whether `segment`, which starts at `start_line`, holds only whitespace.
    /// A byte order mark opening line 1 is not text.
    fn is_blank_segment(segment: &str, start_line: usize) -> bool {
        let text = if start_line == 1 {
            Self::strip_byte_order_mark(segment)
        } else {
            segment
        };
        text.trim().is_empty()
    }

    /// Blank out every line whose first character (column 1, before any
    /// leading blank) is not a letter, digit, `:` or `_` -- the MMIXAL
    /// reference's whole-line comment rule. Line-count-preserving, like
    /// `preprocess_debug`, and run before it so a comment line's text can
    /// never be mistaken for a `debug` directive. An indented line is not
    /// covered: its content parses normally, blank or not.
    pub(super) fn blank_whole_line_comments(source: &str) -> String {
        let mut result = String::with_capacity(source.len());
        for line in source.split_inclusive('\n') {
            let content = line.strip_suffix('\n').unwrap_or(line);
            let is_comment_line = match content.chars().next() {
                Some(' ') | Some('\t') | None => false,
                Some(c) => !(c.is_ascii_alphanumeric() || c == ':' || c == '_'),
            };
            if is_comment_line {
                if content.len() != line.len() {
                    result.push('\n');
                }
            } else {
                result.push_str(line);
            }
        }
        result
    }

    /// Rewrite each `debug "text"` directive in `source` into
    /// `TRAP 0,#82,K` on the directive's own line and label, and collect
    /// its decoded text. The `TRAP` starts at the directive's column (column
    /// 2 for a directive in column 1, which needs a blank ahead of it), so a
    /// diagnostic on the rewritten line points where the user wrote the
    /// directive. The operand is the numeric trap code, so neither `PREFIX`
    /// nor a program's own `Debug` symbol can change it. `start_index` is the
    /// `K` the first directive here receives — the count of directives every
    /// earlier translation unit contributed. Nothing is written to guest
    /// memory and no label is generated, so `K` costs one byte and the
    /// directive costs one tetra.
    ///
    /// Returns the preprocessed source, the strings this source's
    /// directives contributed (in `K` order), and the file/line/column of
    /// the first directive to exceed the table's 256-entry limit, if any —
    /// `parse` turns that into an assembly error rather than emitting a `K`
    /// that cannot fit `TRAP`'s one-byte `Z`.
    pub(super) fn preprocess_debug(
        source: &str,
        filename: &str,
        start_index: usize,
    ) -> (String, Vec<Vec<u8>>, DebugDirectiveOverflow) {
        let mut result = String::new();
        let mut strings = Vec::new();
        let mut overflow = None;

        for (index, line) in source.lines().enumerate() {
            match match_debug_line(line) {
                Some((label, text)) => {
                    let k = start_index + strings.len();
                    if k > 255 {
                        if overflow.is_none() {
                            let col = label.map_or(0, |l| l.chars().count()) + 1;
                            overflow = Some((filename.to_string(), index + 1, col));
                        }
                    } else {
                        result.push_str(&debug_trap_line(label.unwrap_or(""), k));
                    }
                    // debug text keeps the source's UTF-8 bytes: it lives
                    // outside guest memory, going straight to the host's
                    // handle 1, not through a data directive's per-character
                    // value.
                    strings.push(text.as_bytes().to_vec());
                }
                None => {
                    result.push_str(line);
                    result.push('\n');
                }
            }
        }

        debug!("Preprocessed source:\n{}", result);
        (result, strings, overflow)
    }

    /// Expand `INCLUDE <file>` directives into an ordered list of translation
    /// units, ready to feed to `new`/`add_source`. Host files are split at
    /// each `INCLUDE` into segments (each tagged with the host's filename and
    /// blank-padded to keep absolute line numbers); each included file is
    /// resolved recursively and its units inserted at that position. Paths
    /// resolve relative to the including file's directory. `read` supplies
    /// file contents -- injected so this logic is testable without real
    /// filesystem access and reusable by any frontend. A cycle (re-entry on
    /// the current include chain) or an unreadable file is an `Err`. A leading
    /// byte order mark does not hide an `INCLUDE` on a file's first line; the
    /// mark stays in the unit's text for `new`/`add_source` to drop.
    pub fn resolve_includes<R>(
        root_source: &str,
        root_filename: &str,
        base_dir: &Path,
        read: &R,
    ) -> Result<Vec<(String, String)>, String>
    where
        R: Fn(&Path) -> std::io::Result<String>,
    {
        let mut chain = Vec::new();
        Self::resolve_includes_chain(root_source, root_filename, base_dir, read, &mut chain)
    }

    /// Recursive worker behind `resolve_includes`. `chain` holds the
    /// lexically-normalized identity of every file currently being expanded
    /// (the ancestors on the path from the top-level call to here), used to
    /// detect a file re-entering itself before it finishes expanding.
    fn resolve_includes_chain<R>(
        root_source: &str,
        root_filename: &str,
        base_dir: &Path,
        read: &R,
        chain: &mut Vec<PathBuf>,
    ) -> Result<Vec<(String, String)>, String>
    where
        R: Fn(&Path) -> std::io::Result<String>,
    {
        let mut units = Vec::new();
        let mut segment = String::new();
        let mut segment_start_line: usize = 1;
        let mut current_line: usize = 0;

        for raw_line in root_source.split_inclusive('\n') {
            current_line += 1;
            let content = raw_line.strip_suffix('\n').unwrap_or(raw_line);
            let content = if current_line == 1 {
                Self::strip_byte_order_mark(content)
            } else {
                content
            };
            let Some(operand) = Self::parse_include_operand(content) else {
                segment.push_str(raw_line);
                continue;
            };

            if !Self::is_blank_segment(&segment, segment_start_line) {
                units.push((
                    root_filename.to_string(),
                    Self::pad_source(&segment, segment_start_line),
                ));
            }
            segment.clear();

            let target = Self::normalize_lexically(&base_dir.join(&operand));
            let include_col = content.chars().take_while(|c| c.is_whitespace()).count() + 1;
            if chain.contains(&target) {
                let mut names: Vec<String> =
                    chain.iter().map(|p| p.display().to_string()).collect();
                names.push(target.display().to_string());
                return Err(format!(
                    "{root_filename}:{current_line}:{include_col}: include cycle detected: {}",
                    names.join(" -> ")
                ));
            }

            let included_source = read(&target).map_err(|err| {
                format!(
                    "{root_filename}:{current_line}:{include_col}: cannot read included file '{}': {}",
                    target.display(),
                    err
                )
            })?;
            let included_filename = target.display().to_string();
            let included_base_dir = target
                .parent()
                .map(Path::to_path_buf)
                .unwrap_or_else(|| PathBuf::from(""));

            chain.push(target);
            let included_units = Self::resolve_includes_chain(
                &included_source,
                &included_filename,
                &included_base_dir,
                read,
                chain,
            )?;
            chain.pop();
            units.extend(included_units);

            segment_start_line = current_line + 1;
        }

        if !Self::is_blank_segment(&segment, segment_start_line) {
            units.push((
                root_filename.to_string(),
                Self::pad_source(&segment, segment_start_line),
            ));
        }

        Ok(units)
    }

    /// If `line`, after stripping a trailing `%` comment and trimming
    /// whitespace, is an `INCLUDE` directive matched in upper case only,
    /// returns its operand (unquoted if wrapped in matching double quotes).
    /// `;` is not a comment character here: it lands inside the operand and
    /// fails as an unreadable file naming the whole text, since `INCLUDE`
    /// occupies its own line.
    fn parse_include_operand(line: &str) -> Option<String> {
        let without_comment = match line.find('%') {
            Some(idx) => &line[..idx],
            None => line,
        };
        let trimmed = without_comment.trim();
        let mut parts = trimmed.splitn(2, |c: char| c.is_whitespace());
        let keyword = parts.next()?;
        if keyword != "INCLUDE" {
            return None;
        }
        let operand = parts.next().unwrap_or("").trim();
        if operand.is_empty() {
            return None;
        }
        let unquoted = if operand.len() >= 2 && operand.starts_with('"') && operand.ends_with('"') {
            &operand[1..operand.len() - 1]
        } else {
            operand
        };
        Some(unquoted.to_string())
    }

    /// Prepend `start_line - 1` newlines to `text` so absolute line numbers
    /// survive being embedded at `start_line` of some larger file (the
    /// grammar ignores leading blank lines).
    fn pad_source(text: &str, start_line: usize) -> String {
        let padding = start_line.saturating_sub(1);
        let mut padded = String::with_capacity(text.len() + padding);
        for _ in 0..padding {
            padded.push('\n');
        }
        padded.push_str(text);
        padded
    }

    /// Lexically collapse `.`/`..` components without touching the
    /// filesystem (unlike `fs::canonicalize`, which would defeat the
    /// injected reader in unit tests and can fail on a nonexistent path).
    fn normalize_lexically(path: &Path) -> PathBuf {
        use std::path::Component;

        let mut result = PathBuf::new();
        for component in path.components() {
            match component {
                Component::CurDir => {}
                Component::ParentDir => {
                    if !result.pop() {
                        result.push("..");
                    }
                }
                other => result.push(other.as_os_str()),
            }
        }
        result
    }
}

/// The `TRAP` line replacing a directive: `prefix` (the label and the blanks
/// ahead of the keyword) with each blank written as a space, so the
/// grammar's whitespace covers it and `TRAP` keeps the keyword's column.
fn debug_trap_line(prefix: &str, k: usize) -> String {
    let mut line: String = prefix
        .chars()
        .map(|c| if c.is_whitespace() { ' ' } else { c })
        .collect();
    if line.is_empty() {
        line.push(' ');
    }
    let code = TrapCode::Debug as u8;
    format!("{line}TRAP\t0,#{code:02X},{k}\n")
}

/// Matches one line against a `debug "text"` directive: an optional
/// label (blanks included, which the caller keeps as spaces and counts
/// toward its overflow column) and the quoted text. A blank is any
/// `char::is_whitespace` character.
fn match_debug_line(line: &str) -> Option<(Option<&str>, &str)> {
    if let Some((label_end, text)) = match_labeled_debug(line) {
        return Some((Some(&line[..label_end]), text));
    }
    let text = match_debug_after(line, 0)?;
    Some((None, text))
}

/// The labeled form: a run of blanks, alone or after a leading token,
/// directly ahead of the keyword. Returns the label's byte length and
/// the captured text.
fn match_labeled_debug(line: &str) -> Option<(usize, &str)> {
    let ws_start = line.char_indices().find(|(_, c)| c.is_whitespace())?.0;
    let label_end = line[ws_start..]
        .char_indices()
        .find(|(_, c)| !c.is_whitespace())
        .map_or(line.len(), |(i, _)| ws_start + i);
    let text = match_debug_after(line, label_end)?;
    Some((label_end, text))
}

/// The keyword, its required trailing blank, and the quoted text,
/// starting at `start`. Rejects a missing blank or opening quote, an
/// unterminated text, and any non-blank text after the closing quote.
fn match_debug_after(line: &str, start: usize) -> Option<&str> {
    let rest = line.get(start..)?.strip_prefix("debug")?;
    let ws_end = rest
        .char_indices()
        .find(|(_, c)| !c.is_whitespace())
        .map_or(rest.len(), |(i, _)| i);
    if ws_end == 0 {
        return None;
    }
    let after_quote = rest[ws_end..].strip_prefix('"')?;
    let close = after_quote.find('"')?;
    if after_quote[close + 1..].chars().any(|c| !c.is_whitespace()) {
        return None;
    }
    Some(&after_quote[..close])
}