1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
//! Source preprocessing: whole-line comment blanking, `debug` directive expansion, and `INCLUDE` resolution.
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable
)]
use super::DebugDirectiveOverflow;
use super::MMixAssembler;
use std::path::Path;
use std::path::PathBuf;
use tracing::debug;
impl MMixAssembler {
/// 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,Debug,K` on the directive's own line and label, and collect
/// its decoded text. `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(label.map(str::trim).unwrap_or(""));
result.push_str(&format!("\tTRAP\t0,Debug,{k}\n"));
}
// 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`.
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 Some(operand) = Self::parse_include_operand(content) else {
segment.push_str(raw_line);
continue;
};
if !segment.trim().is_empty() {
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 !segment.trim().is_empty() {
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
}
}
/// Matches one line against a `debug "text"` directive: an optional
/// label (blanks included, for the caller to trim and to count 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])
}