Skip to main content

logicaffeine_web/ui/pages/
hardware_spec.rs

1//! The line grammar the Hardware-mode specs share — `pigeons: 6`, `registers: 3`, `tmp: 1-5`,
2//! and each line's `# …` comment — read one way by their parsers and by the editor, so what the
3//! editor paints as a comment is exactly what the parsers skip.
4
5/// The pigeonhole spec's one field: how many pigeons go into one fewer holes.
6pub const PIGEONS: &str = "pigeons";
7/// The register-allocation spec's budget: how many physical registers there are.
8pub const REGISTERS: &str = "registers";
9/// Every field a Hardware spec names before its value.
10pub const FIELDS: [&str; 2] = [PIGEONS, REGISTERS];
11/// How many pigeons a spec may ask for: one pigeon with no hole is degenerate, and past twenty
12/// the picture stops being legible.
13pub const PIGEONS_RANGE: std::ops::RangeInclusive<usize> = 2..=20;
14
15/// Whether a run of `#` whose next character is `after` opens a comment: it does when a space or
16/// the end of the line follows (`# note`, `## Title`). Verilog's `#5` and `#(10)` delays are code.
17pub fn opens_comment(after: Option<char>) -> bool {
18    after.map_or(true, char::is_whitespace)
19}
20
21/// Where `line`'s comment starts: the first run of `#` that [`opens_comment`].
22pub fn comment_start(line: &str) -> Option<usize> {
23    let bytes = line.as_bytes();
24    let mut i = 0;
25    while i < bytes.len() {
26        if bytes[i] == b'#' {
27            let run_end = i + bytes[i..].iter().take_while(|&&b| b == b'#').count();
28            if opens_comment(line[run_end..].chars().next()) {
29                return Some(i);
30            }
31            i = run_end;
32        } else {
33            i += 1;
34        }
35    }
36    None
37}
38
39/// A spec line without its comment, trimmed.
40pub fn content(line: &str) -> &str {
41    line[..comment_start(line).unwrap_or(line.len())].trim()
42}
43
44/// Whether `word` names `field` the way the parsers accept it: lowercase, or capitalised.
45pub fn is_field(word: &str, field: &str) -> bool {
46    let mut chars = field.chars();
47    let capitalised = chars.next().map(|first| first.to_uppercase().chain(chars).collect::<String>());
48    word == field || capitalised.is_some_and(|c| word == c)
49}
50
51/// The value after `field:` on a spec line, trimmed and without the line's comment, when the line
52/// names that field.
53pub fn field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> {
54    let (name, value) = content(line).split_once(':')?;
55    is_field(name, field).then(|| value.trim())
56}
57
58/// What the editor's hover says about the word at byte `offset` of a spec: the field it names,
59/// or the live range it reads. Markdown, as the language server's hovers are.
60pub fn hover(source: &str, offset: usize) -> Option<String> {
61    let kind = kind(source)?;
62    let (line_at, line) = lines(source).find(|(at, line)| (*at..=at + line.len()).contains(&offset))?;
63    let entry = entry(line)?;
64    let at = offset - line_at;
65    let on_name = (entry.name_at..entry.name_at + entry.name.len()).contains(&at);
66    let on_value = (entry.value_at..entry.value_at + entry.value.len()).contains(&at);
67    if !on_name && !on_value {
68        return None;
69    }
70    if is_field(entry.name, PIGEONS) {
71        let (low, high) = (PIGEONS_RANGE.start(), PIGEONS_RANGE.end());
72        return Some(format!(
73            "**pigeons**: how many pigeons fly into one fewer holes, from {low} to {high}. They can never all fit, and the prover certifies why, with no Z3."
74        ));
75    }
76    if is_field(entry.name, REGISTERS) {
77        return Some(
78            "**registers**: the physical register budget. Every other line is a variable's live range, `name: first-last`, and the allocator certifies whether they fit.".to_string(),
79        );
80    }
81    match (kind, live_range(entry.value)) {
82        (Kind::Registers, Some((first, last))) => Some(format!("**{}** is live from instruction {first} to {last}.", entry.name)),
83        _ => None,
84    }
85}
86
87/// What is wrong in a spec, placed under the value at fault: a field its parser would refuse, a
88/// live range that never starts or ends. A spec that names no field has nothing to check here.
89pub fn problems(source: &str) -> Vec<crate::ui::components::problems::Problem> {
90    use crate::ui::components::problems::{Problem, Severity};
91    let Some(kind) = kind(source) else { return Vec::new() };
92    let problem = |start: usize, end: usize, message: String| Problem {
93        start,
94        end,
95        severity: Severity::Error,
96        unnecessary: false,
97        message,
98        source: Some("spec".to_string()),
99        code: None,
100        docs: None,
101    };
102    let mut out = Vec::new();
103    for (line_at, line) in lines(source) {
104        let body = content(line);
105        if body.is_empty() {
106            continue;
107        }
108        let entry = entry(line);
109        // The value's span, or the name's when the value is blank, so a squiggle has text under it.
110        let value_span = |e: &Entry| {
111            if e.value.is_empty() {
112                (line_at + e.name_at, line_at + e.name_at + e.name.len())
113            } else {
114                (line_at + e.value_at, line_at + e.value_at + e.value.len())
115            }
116        };
117        match (kind, entry) {
118            (Kind::Pigeons, Some(e)) if is_field(e.name, PIGEONS) => {
119                let (start, end) = value_span(&e);
120                let (low, high) = (PIGEONS_RANGE.start(), PIGEONS_RANGE.end());
121                match e.value.parse::<usize>() {
122                    Err(_) => out.push(problem(start, end, format!("`pigeons` takes a whole number of pigeons, from {low} to {high}."))),
123                    Ok(n) if !PIGEONS_RANGE.contains(&n) => {
124                        out.push(problem(start, end, format!("`pigeons` takes from {low} to {high} pigeons; {n} is out of range.")))
125                    }
126                    Ok(_) => {}
127                }
128            }
129            (Kind::Pigeons, _) => {
130                let start = line_at + (line.len() - line.trim_start().len());
131                out.push(problem(start, start + body.len(), "A pigeonhole spec holds one line, `pigeons: N`; this line stops it being read as one.".to_string()));
132            }
133            (Kind::Registers, Some(e)) if is_field(e.name, REGISTERS) => {
134                if e.value.parse::<usize>().is_err() {
135                    let (start, end) = value_span(&e);
136                    out.push(problem(start, end, "`registers` takes a whole number of registers.".to_string()));
137                }
138            }
139            (Kind::Registers, Some(e)) => {
140                let (start, end) = value_span(&e);
141                match live_range(e.value) {
142                    None => out.push(problem(start, end, format!("`{}` needs a live range `first-last`, like `0-3`.", e.name))),
143                    Some((first, last)) if first >= last => out.push(problem(
144                        start,
145                        end,
146                        format!("`{}`'s live range must end after it starts; `{}` does not, so the allocator skips it.", e.name, e.value),
147                    )),
148                    Some(_) => {}
149                }
150            }
151            (Kind::Registers, None) => {}
152        }
153    }
154    out
155}
156
157/// Which spec `source` is, by the field it names.
158#[derive(Clone, Copy)]
159enum Kind {
160    Pigeons,
161    Registers,
162}
163
164fn kind(source: &str) -> Option<Kind> {
165    let names = || lines(source).filter_map(|(_, line)| entry(line)).map(|e| e.name);
166    if names().any(|name| is_field(name, PIGEONS)) {
167        Some(Kind::Pigeons)
168    } else if names().any(|name| is_field(name, REGISTERS)) {
169        Some(Kind::Registers)
170    } else {
171        None
172    }
173}
174
175/// Each line of `source` without its line break, with the byte offset it starts at.
176fn lines(source: &str) -> impl Iterator<Item = (usize, &str)> {
177    let mut at = 0;
178    source.split_inclusive('\n').map(move |raw| {
179        let here = at;
180        at += raw.len();
181        (here, raw.trim_end_matches(['\n', '\r']))
182    })
183}
184
185/// A spec line read as `name: value`, with each part's byte offset in the line; the comment is
186/// not part of either.
187struct Entry<'a> {
188    name: &'a str,
189    name_at: usize,
190    value: &'a str,
191    value_at: usize,
192}
193
194fn entry(line: &str) -> Option<Entry<'_>> {
195    let body = &line[..comment_start(line).unwrap_or(line.len())];
196    let colon = body.find(':')?;
197    let before = &body[..colon];
198    let name = before.trim();
199    if name.is_empty() {
200        return None;
201    }
202    let after = &body[colon + 1..];
203    Some(Entry {
204        name,
205        name_at: before.len() - before.trim_start().len(),
206        value: after.trim(),
207        value_at: colon + 1 + (after.len() - after.trim_start().len()),
208    })
209}
210
211/// A live range `first-last`, as the register parser reads it.
212fn live_range(value: &str) -> Option<(i64, i64)> {
213    let (first, last) = value.split_once('-')?;
214    Some((first.trim().parse().ok()?, last.trim().parse().ok()?))
215}
216
217#[cfg(test)]
218#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/hardware_spec/service_tests.rs"]
219mod service_tests;
220
221#[cfg(test)]
222#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/hardware_spec/tests.rs"]
223mod tests;