Skip to main content

logicaffeine_compile/codegen_sva/
quartus.rs

1//! Quartus Prime reports as data: a compile's flow, the resources it used, the slack of every clock, the retimer's
2//! limits, the router's peak wire demand, and the resources and logic depth of every entity, read from the text
3//! reports Quartus writes into a project's `output_files`.
4//!
5//! [`read`] gathers one compile into a [`Build`]: [`parse_flow`] over `<rev>.flow.rpt` (status, version, device,
6//! start, and each phase's elapsed time and peak memory), [`parse_summary`] over the fitter's summary (ALMs,
7//! registers, memory bits, RAM and DSP blocks as used over total), [`parse_synthesis`] over the synthesis report's
8//! resource estimate (with the fixed- and floating-point DSP split), [`parse_sta_summary`] over the timing summary
9//! (every analysis of every clock), [`parse_retime`] over the retimer's limit table, [`parse_wire_demand`] and
10//! [`parse_processors`] over the fitter's messages, [`parse_hold`] over the delay the router added for hold,
11//! [`clock_nets`] over the control-signal and global-signal tables (a clock off every global network rides general
12//! routing, its skew paid for in hold delay), and [`parse_hierarchy`] over the entity table, rolled up by
13//! [`by_entity`] so a unit instantiated a thousand times reads as one line. A fit still running is read from its
14//! plan and place reports. A report Quartus did not write (a synthesis-only run, a failed fit) is absent from the
15//! build, never zero.
16//!
17//! A number that does not parse is left out rather than read as zero: a malformed field is a missing field.
18
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23/// A compile's flow report.
24#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Flow {
26    pub status: Option<String>,
27    pub version: Option<String>,
28    pub revision: Option<String>,
29    pub top: Option<String>,
30    pub family: Option<String>,
31    pub device: Option<String>,
32    pub started: Option<String>,
33    pub phases: Vec<Phase>,
34}
35
36impl Flow {
37    pub fn succeeded(&self) -> bool {
38        self.status.as_deref().is_some_and(|s| s.starts_with("Successful"))
39    }
40
41    /// The phase named `name`.
42    pub fn phase(&self, name: &str) -> Option<&Phase> {
43        self.phases.iter().find(|p| p.name == name)
44    }
45}
46
47/// One module of the flow: its elapsed wall time and its peak virtual memory (none for the total).
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct Phase {
50    pub name: String,
51    pub seconds: u64,
52    pub peak_mb: Option<u64>,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Ratio {
57    pub used: u64,
58    pub total: u64,
59}
60
61/// A fit's resources as its summary states them.
62#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Usage {
64    pub status: Option<String>,
65    pub alms: Option<Ratio>,
66    pub registers: Option<u64>,
67    pub block_memory_bits: Option<Ratio>,
68    pub ram_blocks: Option<Ratio>,
69    pub dsp_blocks: Option<Ratio>,
70}
71
72/// Synthesis's estimate of a design's resources.
73#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Estimate {
75    pub alms: Option<u64>,
76    pub registers: Option<u64>,
77    pub memory_bits: Option<u64>,
78    pub dsp_blocks: Option<u64>,
79    pub fixed_point_dsp: Option<u64>,
80    pub floating_point_dsp: Option<u64>,
81}
82
83/// A partition's synthesis estimate: in a partial-reconfiguration build the static region and each persona apart.
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PartitionEstimate {
86    pub partition: String,
87    pub estimate: Estimate,
88}
89
90/// One timing analysis of one clock.
91#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92pub struct Slack {
93    pub analysis: String,
94    pub clock: String,
95    pub slack_ns: f64,
96    pub tns_ns: f64,
97    pub corner: String,
98}
99
100/// The router's estimated peak demand for one kind of wire.
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct WireDemand {
103    pub wire: String,
104    pub percent: u32,
105    pub direction: String,
106    pub region: String,
107}
108
109/// Routing delay the fitter added on one clock transfer to meet hold.
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111pub struct HoldDelay {
112    pub source: String,
113    pub destination: String,
114    pub ns: f64,
115}
116
117/// A net the fitter uses as a clock: its fan-out, and whether it rides a global network.
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ClockNet {
120    pub name: String,
121    pub fanout: u64,
122    pub global: bool,
123}
124
125/// One row of the entity table: totals of the node and everything under it.
126#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
127pub struct Node {
128    pub partition: String,
129    pub path: String,
130    pub entity: String,
131    pub depth: usize,
132    pub aluts: u64,
133    pub registers: u64,
134    pub memory_bits: u64,
135    pub dsp: u64,
136    /// Logic levels on the longest path through the node.
137    pub max_depth: Option<f64>,
138}
139
140/// An entity's instances, totalled over the outermost ones (an instance inside another instance of the same entity
141/// is already in its total).
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143pub struct EntityTotal {
144    pub entity: String,
145    pub instances: usize,
146    pub aluts: u64,
147    pub registers: u64,
148    pub memory_bits: u64,
149    pub dsp: u64,
150    pub max_depth: Option<f64>,
151}
152
153/// One compile, as its reports state it.
154#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
155pub struct Build {
156    pub flow: Flow,
157    pub synthesis: Vec<PartitionEstimate>,
158    pub fit: Option<Usage>,
159    pub clocks: Vec<Slack>,
160    pub retime: Vec<(String, String)>,
161    pub wire_demand: Vec<WireDemand>,
162    pub processors: Option<u32>,
163    /// Each partition's top entity and its largest children, at most [`TOP_ROWS`] a partition.
164    pub top: Vec<Node>,
165    /// The entities by total ALUTs, largest first, at most [`ENTITY_ROWS`].
166    pub entities: Vec<EntityTotal>,
167    /// The routing delay the fitter added for hold, by clock transfer, none when a routed fit names no transfer;
168    /// `None` when no fit report states it (a fit not yet routed, or a row recorded before it was read).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub hold: Option<Vec<HoldDelay>>,
171    /// Every net the fitter uses as a clock, those off every global network first, then by fan-out; `None` without
172    /// both the control-signal and the global-signal tables.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub clock_nets: Option<Vec<ClockNet>>,
175    /// Every Logic Lock region of the fit, what was placed in it over what it holds.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub regions: Vec<RegionUsage>,
178}
179
180/// How many entity totals a [`Build`] keeps.
181pub const ENTITY_ROWS: usize = 32;
182/// How many nodes of each partition's top two levels a [`Build`] keeps.
183pub const TOP_ROWS: usize = 32;
184
185impl Build {
186    /// The setup analysis with the least slack.
187    pub fn worst_setup(&self) -> Option<&Slack> {
188        worst_setup(&self.clocks)
189    }
190
191    /// The synthesis estimate of the partition named `partition`.
192    pub fn synthesis_of(&self, partition: &str) -> Option<&Estimate> {
193        self.synthesis.iter().find(|p| p.partition == partition).map(|p| &p.estimate)
194    }
195}
196
197/// The cells of a report table row (`; a ; b ; c ;`), trimmed, or `None` for a line that is not one.
198fn cells(line: &str) -> Option<Vec<&str>> {
199    let t = line.trim_end();
200    if !t.starts_with(';') || !t.ends_with(';') || t.len() < 2 {
201        return None;
202    }
203    Some(t[1..t.len() - 1].split(';').map(str::trim).collect())
204}
205
206pub(crate) fn number(s: &str) -> Option<u64> {
207    let digits: String = s.chars().filter(|&c| c != ',').collect();
208    digits.trim().parse().ok()
209}
210
211/// `453,119 / 933,120 ( 49 % )`.
212fn ratio(s: &str) -> Option<Ratio> {
213    let (used, rest) = s.split_once('/')?;
214    let total = rest.split('(').next()?;
215    Some(Ratio { used: number(used)?, total: number(total)? })
216}
217
218/// `02:47:28`.
219fn hms(s: &str) -> Option<u64> {
220    let parts: Vec<u64> = s.split(':').map(|p| p.trim().parse().ok()).collect::<Option<_>>()?;
221    match parts.as_slice() {
222        [h, m, sec] => Some(h * 3600 + m * 60 + sec),
223        _ => None,
224    }
225}
226
227/// A compile's flow report.
228pub fn parse_flow(text: &str) -> Flow {
229    let mut flow = Flow::default();
230    let mut in_elapsed = false;
231    for line in text.lines() {
232        if line.starts_with('+') {
233            continue;
234        }
235        let Some(c) = cells(line) else {
236            in_elapsed = false;
237            continue;
238        };
239        if c.len() == 1 {
240            in_elapsed = c[0] == "Flow Elapsed Time";
241            continue;
242        }
243        if in_elapsed {
244            if c.len() >= 3 && c[0] != "Module Name" {
245                if let Some(seconds) = hms(c[1]) {
246                    let peak_mb = c[2].strip_suffix("MB").and_then(|m| number(m));
247                    flow.phases.push(Phase { name: c[0].to_string(), seconds, peak_mb });
248                }
249            }
250            continue;
251        }
252        if c.len() >= 2 {
253            let slot = match c[0] {
254                "Flow Status" => &mut flow.status,
255                "Quartus Prime Version" => &mut flow.version,
256                "Revision Name" => &mut flow.revision,
257                "Top-level Entity Name" => &mut flow.top,
258                "Family" => &mut flow.family,
259                "Device" => &mut flow.device,
260                "Start date & time" => &mut flow.started,
261                _ => continue,
262            };
263            if slot.is_none() && !c[1].is_empty() {
264                *slot = Some(c[1].to_string());
265            }
266        }
267    }
268    flow
269}
270
271/// A `.fit.summary` (or any `Key : value` summary).
272pub fn parse_summary(text: &str) -> Usage {
273    let mut u = Usage::default();
274    for line in text.lines() {
275        let Some((key, value)) = line.split_once(" : ") else { continue };
276        let value = value.trim();
277        match key.trim() {
278            "Fitter Status" | "Synthesis Status" | "Flow Status" => u.status = Some(value.to_string()),
279            "Logic utilization (in ALMs)" => u.alms = ratio(value),
280            "Total dedicated logic registers" => u.registers = number(value),
281            "Total block memory bits" => u.block_memory_bits = ratio(value),
282            "Total RAM Blocks" => u.ram_blocks = ratio(value),
283            "Total DSP Blocks" => u.dsp_blocks = ratio(value),
284            _ => {}
285        }
286    }
287    u
288}
289
290/// The partition a report table's title names (`... Partition "persona1" ...`), or `root_partition` when it names
291/// none.
292fn partition_of(title: &str) -> String {
293    let mut quoted = title.split('"');
294    match (quoted.next(), quoted.next()) {
295        (Some(_), Some(name)) if title.contains("Partition") => name.to_string(),
296        _ => "root_partition".to_string(),
297    }
298}
299
300/// Each partition's synthesis resource usage summary, in report order.
301pub fn parse_synthesis(text: &str) -> Vec<PartitionEstimate> {
302    let mut out: Vec<PartitionEstimate> = Vec::new();
303    let mut current: Option<usize> = None;
304    for line in text.lines() {
305        if line.starts_with('+') {
306            continue;
307        }
308        let Some(c) = cells(line) else {
309            current = None;
310            continue;
311        };
312        if c.len() == 1 {
313            current = c[0].starts_with("Synthesis Resource Usage Summary").then(|| {
314                out.push(PartitionEstimate { partition: partition_of(c[0]), estimate: Estimate::default() });
315                out.len() - 1
316            });
317            continue;
318        }
319        let Some(k) = current else { continue };
320        if c.len() != 2 {
321            continue;
322        }
323        let e = &mut out[k].estimate;
324        let slot = match c[0] {
325            "Estimate of Logic utilization (ALMs needed)" => &mut e.alms,
326            "Dedicated logic registers" => &mut e.registers,
327            "Total block memory bits" => &mut e.memory_bits,
328            "Total DSP Blocks" => &mut e.dsp_blocks,
329            "-- [A] Total Fixed Point DSP Blocks" => &mut e.fixed_point_dsp,
330            "-- [B] Total Floating Point DSP Blocks" => &mut e.floating_point_dsp,
331            _ => continue,
332        };
333        if slot.is_none() {
334            *slot = number(c[1]);
335        }
336    }
337    out
338}
339
340/// A timing summary: `Type : <analysis> '<clock>'`, `Slack`, `TNS`, `Corner` a block.
341pub fn parse_sta_summary(text: &str) -> Vec<Slack> {
342    let mut out = Vec::new();
343    let lines: Vec<&str> = text.lines().collect();
344    let field = |line: &str, key: &str| -> Option<String> {
345        let (k, v) = line.split_once(':')?;
346        (k.trim() == key).then(|| v.trim().to_string())
347    };
348    for (i, line) in lines.iter().enumerate() {
349        let Some(ty) = field(line, "Type") else { continue };
350        let Some(q) = ty.find('\'') else { continue };
351        let analysis = ty[..q].trim().to_string();
352        let clock = ty[q + 1..].trim_end_matches('\'').to_string();
353        let rest = &lines[i + 1..(i + 4).min(lines.len())];
354        let get = |key: &str| rest.iter().find_map(|l| field(l, key));
355        let (Some(slack), Some(tns), Some(corner)) = (get("Slack"), get("TNS"), get("Corner")) else { continue };
356        let (Ok(slack_ns), Ok(tns_ns)) = (slack.parse::<f64>(), tns.parse::<f64>()) else { continue };
357        out.push(Slack { analysis, clock, slack_ns, tns_ns, corner });
358    }
359    out
360}
361
362/// The setup analysis with the least slack.
363pub fn worst_setup(clocks: &[Slack]) -> Option<&Slack> {
364    clocks.iter().filter(|s| s.analysis == "Setup").min_by(|a, b| a.slack_ns.total_cmp(&b.slack_ns))
365}
366
367/// The retimer's limit table: each clock transfer and the reason it stopped.
368pub fn parse_retime(text: &str) -> Vec<(String, String)> {
369    let mut out = Vec::new();
370    let mut inside = false;
371    for line in text.lines() {
372        if line.starts_with('+') {
373            continue;
374        }
375        let Some(c) = cells(line) else {
376            inside = false;
377            continue;
378        };
379        if c.len() == 1 {
380            inside = c[0] == "Retiming Limit Summary";
381            continue;
382        }
383        if inside && c.len() >= 2 && c[0] != "Clock Transfer" {
384            out.push((c[0].to_string(), c[1].to_string()));
385        }
386    }
387    out
388}
389
390/// The data rows of the report table titled `title`, each its cells: the rows after its header, to the table's end;
391/// `None` when the report has no such table.
392fn table<'a>(text: &'a str, title: &str) -> Option<Vec<Vec<&'a str>>> {
393    headed_table(text, title).map(|(_, rows)| rows)
394}
395
396/// [`table`] with the header row's cells.
397pub(crate) fn headed_table<'a>(text: &'a str, title: &str) -> Option<(Vec<&'a str>, Vec<Vec<&'a str>>)> {
398    let mut lines = text.lines();
399    lines.find(|l| cells(l).is_some_and(|c| c.len() == 1 && c[0] == title))?;
400    let mut rows = Vec::new();
401    let mut header: Option<Vec<&str>> = None;
402    for line in lines {
403        if line.starts_with('+') {
404            continue;
405        }
406        let Some(c) = cells(line) else { break };
407        if header.is_none() {
408            header = Some(c);
409            continue;
410        }
411        rows.push(c);
412    }
413    Some((header.unwrap_or_default(), rows))
414}
415
416/// A Logic Lock region as the fitter left it: what was placed in it needs (ALMs after dense packing, logic registers,
417/// M20Ks, DSP blocks after dense merging) over what the region holds, and the rectangles it is, each its corners.
418#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
419pub struct RegionUsage {
420    pub name: String,
421    pub alms_needed: f64,
422    pub alms: u64,
423    pub registers: Option<Ratio>,
424    pub m20k: Option<Ratio>,
425    pub dsp: Option<Ratio>,
426    pub placement: Vec<((u32, u32), (u32, u32))>,
427}
428
429/// Every region of the fit report's `Logic Lock Region Usage Summary`, a column each. A region's rectangles are
430/// separated by the table's own `;`, so with one region the placement row's every rectangle is its; with several, a
431/// region's cell is read alone.
432pub fn parse_region_usage(text: &str) -> Vec<RegionUsage> {
433    let Some((header, rows)) = headed_table(text, "Logic Lock Region Usage Summary") else { return Vec::new() };
434    let names = header.get(1..).unwrap_or_default();
435    let row = |stat: &str| rows.iter().find(|r| r.first() == Some(&stat));
436    let rectangles = |cells: &[&str]| -> Vec<((u32, u32), (u32, u32))> {
437        let text = cells.join(";");
438        let numbers: Vec<u32> = text
439            .split(|c: char| !c.is_ascii_digit())
440            .filter(|t| !t.is_empty())
441            .filter_map(|t| t.parse().ok())
442            .collect();
443        numbers.chunks_exact(4).map(|c| ((c[0], c[1]), (c[2], c[3]))).collect()
444    };
445    names
446        .iter()
447        .enumerate()
448        .filter_map(|(k, &name)| {
449            let cell = |stat: &str| row(stat).and_then(|r| r.get(k + 1)).copied();
450            let (needed, alms) = cell("ALMs needed [=A-B+C]")?.split_once('/')?;
451            let alms_needed: f64 = needed.trim().parse().ok()?;
452            let alms = number(alms.split('(').next()?)?;
453            let placement = match row("Region Placement") {
454                Some(r) if names.len() == 1 => rectangles(&r[1..]),
455                Some(r) => r.get(k + 1).map(|c| rectangles(&[c])).unwrap_or_default(),
456                None => Vec::new(),
457            };
458            Some(RegionUsage {
459                name: name.to_string(),
460                alms_needed,
461                alms,
462                registers: cell("Dedicated Logic Registers").and_then(ratio),
463                m20k: cell("M20Ks").and_then(ratio),
464                dsp: cell("DSP Blocks needed [=A-B]").and_then(ratio),
465                placement,
466            })
467        })
468        .collect()
469}
470
471/// The delay the router added to meet hold, a row a clock transfer (`Estimated Delay Added for Hold Timing Summary`).
472pub fn parse_hold(text: &str) -> Option<Vec<HoldDelay>> {
473    let rows = table(text, "Estimated Delay Added for Hold Timing Summary")?;
474    Some(
475        rows.iter()
476            .filter_map(|c| match c.as_slice() {
477                [source, destination, ns] => Some(HoldDelay { source: source.to_string(), destination: destination.to_string(), ns: ns.parse().ok()? }),
478                _ => None,
479            })
480            .collect(),
481    )
482}
483
484/// The nets the `control` report's table uses as clocks (usage `Clock`, not a clock's enable or clear), each marked
485/// global when the `globals` report's fast-signal summary names it or the buffer the fitter inserted to put it on one
486/// (`clk~FITTER_INSERTED_0`); those off every global first, then by fan-out.
487pub fn clock_nets(control: Option<&str>, globals: Option<&str>) -> Option<Vec<ClockNet>> {
488    let control = table(control?, "Control Signals")?;
489    let unbuffered = |name: &'_ str| -> String {
490        match name.rsplit_once("~FITTER_INSERTED_") {
491            Some((net, n)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => net.to_string(),
492            _ => name.to_string(),
493        }
494    };
495    let global: std::collections::HashSet<String> =
496        table(globals?, "Global & Other Fast Signals Summary")?.iter().filter_map(|c| c.first().map(|n| unbuffered(n))).collect();
497    let mut nets: Vec<ClockNet> = control
498        .iter()
499        .filter_map(|c| match c.as_slice() {
500            [name, _, fanout, "Clock", ..] => Some(ClockNet { name: name.to_string(), fanout: number(fanout)?, global: global.contains(*name) }),
501            _ => None,
502        })
503        .collect();
504    nets.sort_by(|a, b| a.global.cmp(&b.global).then(b.fanout.cmp(&a.fanout)).then_with(|| a.name.cmp(&b.name)));
505    nets.dedup();
506    Some(nets)
507}
508
509/// The router's headline peak demands (`Info (20215)`), one a kind of wire, each once however often it is printed.
510pub fn parse_wire_demand(text: &str) -> Vec<WireDemand> {
511    let mut out = Vec::new();
512    for line in text.lines() {
513        let Some(rest) = line.trim().strip_prefix("Info (20215): Router estimated peak ") else { continue };
514        let Some((wire, tail)) = rest.split_once(" demand : ") else { continue };
515        let Some((pct, tail)) = tail.split_once("% of ") else { continue };
516        let Some((direction, region)) = tail.split_once(" directional wire in region ") else { continue };
517        let Ok(percent) = pct.trim().parse() else { continue };
518        let w = WireDemand { wire: wire.to_string(), percent, direction: direction.to_string(), region: region.trim().to_string() };
519        if !out.contains(&w) {
520            out.push(w);
521        }
522    }
523    out
524}
525
526/// The processors parallel compilation was allowed: the fitter's message ("will use up to 8 processors", "will use 24
527/// of the 24 processors detected"), or its Parallel Compilation table.
528pub fn parse_processors(text: &str) -> Option<u32> {
529    text.lines().find_map(|l| {
530        if let Some(rest) = l.split("Parallel compilation is enabled and will use ").nth(1) {
531            return rest.trim_start_matches("up to ").split_whitespace().next()?.parse().ok();
532        }
533        match cells(l)?.as_slice() {
534            ["Maximum allowed", n] => n.parse().ok(),
535            _ => None,
536        }
537    })
538}
539
540/// `661602 (53012)`: the total.
541fn total(s: &str) -> Option<u64> {
542    number(s.split('(').next()?)
543}
544
545fn total_depth(s: &str) -> Option<f64> {
546    s.split('(').next()?.trim().parse().ok()
547}
548
549/// Every row of the entity table ("Resource Utilization by Entity"), depth from the node's indentation.
550pub fn parse_hierarchy(text: &str) -> Vec<Node> {
551    let mut out = Vec::new();
552    let mut columns: Option<Vec<String>> = None;
553    let mut partition = "root_partition".to_string();
554    for line in text.lines() {
555        let t = line.trim_end();
556        if !t.starts_with(';') {
557            if !t.starts_with('+') {
558                columns = None;
559            }
560            continue;
561        }
562        let raw: Vec<&str> = t[1..t.len().saturating_sub(1)].split(';').collect();
563        let c: Vec<&str> = raw.iter().map(|s| s.trim()).collect();
564        if c.len() == 1 && c[0].ends_with("Resource Utilization by Entity") {
565            partition = partition_of(c[0]);
566            continue;
567        }
568        if c.first() == Some(&"Compilation Hierarchy Node") {
569            columns = Some(c.iter().map(|s| s.to_string()).collect());
570            continue;
571        }
572        let Some(cols) = &columns else { continue };
573        let at = |name: &str| cols.iter().position(|c| c == name).and_then(|i| c.get(i).copied());
574        let (Some(aluts), Some(registers)) = (at("Combinational ALUTs").and_then(total), at("Dedicated Logic Registers").and_then(total))
575        else {
576            continue;
577        };
578        let indent = raw[0].len() - raw[0].trim_start().len();
579        out.push(Node {
580            partition: partition.clone(),
581            path: at("Full Hierarchy Name").unwrap_or("").to_string(),
582            entity: at("Entity Name").unwrap_or("").to_string(),
583            depth: indent.saturating_sub(1) / 3,
584            aluts,
585            registers,
586            memory_bits: at("Block Memory Bits").and_then(total).unwrap_or(0),
587            dsp: at("DSP Blocks").and_then(total).unwrap_or(0),
588            max_depth: at("Max Depth").and_then(total_depth),
589        });
590    }
591    out
592}
593
594/// The entity table rolled up by entity, largest total ALUTs first.
595pub fn by_entity(nodes: &[Node]) -> Vec<EntityTotal> {
596    let mut totals: Vec<EntityTotal> = Vec::new();
597    let mut ancestors: Vec<&str> = Vec::new();
598    for n in nodes {
599        ancestors.truncate(n.depth);
600        let nested = ancestors.contains(&n.entity.as_str());
601        let k = match totals.iter().position(|t| t.entity == n.entity) {
602            Some(k) => k,
603            None => {
604                totals.push(EntityTotal {
605                    entity: n.entity.clone(),
606                    instances: 0,
607                    aluts: 0,
608                    registers: 0,
609                    memory_bits: 0,
610                    dsp: 0,
611                    max_depth: None,
612                });
613                totals.len() - 1
614            }
615        };
616        let t = &mut totals[k];
617        t.instances += 1;
618        if !nested {
619            t.aluts += n.aluts;
620            t.registers += n.registers;
621            t.memory_bits += n.memory_bits;
622            t.dsp += n.dsp;
623        }
624        t.max_depth = match (t.max_depth, n.max_depth) {
625            (Some(a), Some(b)) => Some(a.max(b)),
626            (a, b) => a.or(b),
627        };
628        ancestors.push(&n.entity);
629    }
630    totals.sort_by(|a, b| b.aluts.cmp(&a.aluts).then_with(|| a.entity.cmp(&b.entity)));
631    totals
632}
633
634/// Each partition's top entity and its largest [`TOP_ROWS`] children, in report order.
635fn top_of(nodes: Vec<Node>) -> Vec<Node> {
636    let mut partitions: Vec<&str> = Vec::new();
637    for n in &nodes {
638        if !partitions.contains(&n.partition.as_str()) {
639            partitions.push(&n.partition);
640        }
641    }
642    let mut out = Vec::new();
643    for p in partitions {
644        let level: Vec<&Node> = nodes.iter().filter(|n| n.partition == p && n.depth <= 1).collect();
645        let mut children: Vec<usize> = (0..level.len()).filter(|&i| level[i].depth == 1).collect();
646        children.sort_by(|&a, &b| level[b].aluts.cmp(&level[a].aluts).then(a.cmp(&b)));
647        children.truncate(TOP_ROWS);
648        out.extend(
649            level
650                .iter()
651                .enumerate()
652                .filter(|&(i, n)| n.depth == 0 || children.contains(&i))
653                .map(|(_, n)| (*n).clone()),
654        );
655    }
656    out
657}
658
659/// Why a compile's reports could not be read.
660#[derive(Clone, Debug, PartialEq, Eq)]
661pub enum QuartusError {
662    /// The flow report, which every compile writes first, is missing.
663    NoFlowReport(String),
664    Io(String),
665    /// A ledger row of this name exists already.
666    Recorded(String),
667    /// No ledger row has this name.
668    Unrecorded(String),
669}
670
671impl std::fmt::Display for QuartusError {
672    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
673        match self {
674            QuartusError::NoFlowReport(path) => write!(f, "no flow report at {path}"),
675            QuartusError::Io(why) => write!(f, "{why}"),
676            QuartusError::Recorded(name) => write!(f, "a compile named {name} is recorded already"),
677            QuartusError::Unrecorded(name) => write!(f, "no compile named {name} is recorded"),
678        }
679    }
680}
681
682impl std::error::Error for QuartusError {}
683
684/// One compile of `revision`, read from its project's `output_files` directory.
685pub fn read(output_files: &Path, revision: &str) -> Result<Build, QuartusError> {
686    let path = |suffix: &str| output_files.join(format!("{revision}.{suffix}"));
687    let text = |suffix: &str| -> Result<Option<String>, QuartusError> {
688        let p = path(suffix);
689        match std::fs::read(&p) {
690            Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
691            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
692            Err(e) => Err(QuartusError::Io(format!("{}: {e}", p.display()))),
693        }
694    };
695    let flow_text = text("flow.rpt")?.ok_or_else(|| QuartusError::NoFlowReport(path("flow.rpt").display().to_string()))?;
696    let syn = text("syn.rpt")?;
697    let (fit_report, route) = (text("fit.rpt")?, text("fit.route.rpt")?);
698    // The fitter's report repeats the router's messages; the router's own report is read for them when it is there.
699    let router_messages = route.as_deref().or(fit_report.as_deref()).unwrap_or("");
700    let processors = [fit_report.as_deref(), route.as_deref(), Some(flow_text.as_str())].into_iter().flatten().find_map(parse_processors);
701    let nodes = syn.as_deref().map(parse_hierarchy).unwrap_or_default();
702    let mut entities = by_entity(&nodes);
703    entities.truncate(ENTITY_ROWS);
704    // A fit still running has written its plan and place reports; the finished fit's report holds every table.
705    let (plan, place) = (text("fit.plan.rpt")?, text("fit.place.rpt")?);
706    fn holding<'a>(title: &str, reports: [Option<&'a str>; 2]) -> Option<&'a str> {
707        reports.into_iter().flatten().find(|t| table(t, title).is_some())
708    }
709    let control = holding("Control Signals", [fit_report.as_deref(), plan.as_deref()]);
710    let globals = holding("Global & Other Fast Signals Summary", [fit_report.as_deref(), place.as_deref()]);
711    Ok(Build {
712        flow: parse_flow(&flow_text),
713        synthesis: syn.as_deref().map(parse_synthesis).unwrap_or_default(),
714        fit: text("fit.summary")?.map(|t| parse_summary(&t)),
715        clocks: text("sta.summary")?.map(|t| parse_sta_summary(&t)).unwrap_or_default(),
716        retime: text("fit.retime.rpt")?.map(|t| parse_retime(&t)).unwrap_or_default(),
717        wire_demand: parse_wire_demand(router_messages),
718        processors,
719        top: top_of(nodes),
720        entities,
721        hold: fit_report.as_deref().and_then(|t| parse_hold(t).or_else(|| table(t, "Routing Usage Summary").map(|_| Vec::new()))),
722        regions: fit_report.as_deref().map(parse_region_usage).unwrap_or_default(),
723        clock_nets: clock_nets(control, globals),
724    })
725}
726
727/// A file a compile read, by its SHA-256.
728#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
729pub struct Input {
730    pub path: String,
731    pub sha256: String,
732}
733
734/// One recorded compile: its name, when it was recorded, the inputs it was handed, its reports read, whether it
735/// passed, and the compile it resumed, when its job went on from another's: the modules that compile ran and this
736/// job did not are that compile's, judged in its row.
737#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
738pub struct Row {
739    pub name: String,
740    pub recorded: String,
741    pub inputs: Vec<Input>,
742    pub build: Build,
743    pub verdict: Verdict,
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub resumed: Option<String>,
746}
747
748impl Row {
749    /// The Verilog file and the settings file of a synthesis-only compile that ran to the end from one of each: the
750    /// compiles the timing model is calibrated against (`timing_graph::Calibration`).
751    pub fn synthesis_sources(&self) -> Option<(&Input, &Input)> {
752        if !self.verdict.passed || self.verdict.expected != ["Synthesis"] {
753            return None;
754        }
755        let pick = |ext: &str| self.inputs.iter().filter(|i| i.path.ends_with(ext)).collect::<Vec<_>>();
756        match (pick(".v").as_slice(), pick(".qsf").as_slice()) {
757            ([v], [q]) => Some((*v, *q)),
758            _ => None,
759        }
760    }
761}
762
763/// What a compile is judged by beyond its flow report, which a module that dies at startup leaves as the last one
764/// to finish wrote it.
765#[derive(Clone, Debug, Default, PartialEq, Eq)]
766pub struct Evidence {
767    /// The flow command's exit status.
768    pub exit: Option<i32>,
769    /// The flow modules the job runs, named as the flow report names them ("Synthesis", "Fitter", …).
770    pub expected: Vec<String>,
771    /// The flow's log.
772    pub log: Option<String>,
773    /// When the job started and when its flow report was last written, in seconds since the epoch.
774    pub started: Option<u64>,
775    pub flow_written: Option<u64>,
776}
777
778/// Whether a compile ran every module it runs to the end, every reason it did not, and the exit status and modules
779/// it was judged against (none when its flow report was all there was). Whether its timing closed is the slack's to
780/// say, not the verdict's.
781#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
782pub struct Verdict {
783    pub passed: bool,
784    pub failures: Vec<String>,
785    pub exit: Option<i32>,
786    pub expected: Vec<String>,
787}
788
789/// The first line of a Quartus log that says the flow failed: a fatal error, a module or shell that was
790/// unsuccessful, or the OFS build script's own failure line.
791pub fn log_failure(log: &str) -> Option<String> {
792    log.lines()
793        .map(str::trim)
794        .find(|l| l.starts_with("Fatal Error:") || l.starts_with("*** Fatal Error") || l.contains(" was unsuccessful") || *l == "Quartus build failed")
795        .map(str::to_string)
796}
797
798/// The verdict on `build` given `evidence`: it ran to the end when its flow report says Successful, every module the
799/// job runs is in that report, the flow exited 0, its log holds no failure line, and its report was written after
800/// the job started.
801pub fn judge(build: &Build, evidence: &Evidence) -> Verdict {
802    let mut failures = Vec::new();
803    match build.flow.status.as_deref() {
804        Some(s) if s.starts_with("Successful") => {}
805        Some(s) => failures.push(format!("the flow report says {s}")),
806        None => failures.push("the flow report has no status".to_string()),
807    }
808    for module in &evidence.expected {
809        if build.flow.phase(module).is_none() {
810            failures.push(format!("the job runs {module}, which the flow report does not hold"));
811        }
812    }
813    if let Some(code) = evidence.exit.filter(|&c| c != 0) {
814        failures.push(format!("the flow exited {code}"));
815    }
816    if let Some(line) = evidence.log.as_deref().and_then(log_failure) {
817        failures.push(format!("the log: {line}"));
818    }
819    if let (Some(started), Some(written)) = (evidence.started, evidence.flow_written) {
820        if written < started {
821            failures.push("the flow report was written before the job started".to_string());
822        }
823    }
824    Verdict { passed: failures.is_empty(), failures, exit: evidence.exit, expected: evidence.expected.clone() }
825}
826
827/// Every compile recorded, in recording order: the history every later build is priced from. Rows are written by
828/// [`Ledger::record`] from reports, never by hand.
829#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
830pub struct Ledger {
831    pub rows: Vec<Row>,
832}
833
834impl Ledger {
835    /// The ledger at `path`, or an empty one when no file is there yet.
836    pub fn load(path: &Path) -> Result<Ledger, QuartusError> {
837        match std::fs::read_to_string(path) {
838            Ok(text) => serde_json::from_str(&text).map_err(|e| QuartusError::Io(format!("{}: {e}", path.display()))),
839            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
840            Err(e) => Err(QuartusError::Io(format!("{}: {e}", path.display()))),
841        }
842    }
843
844    pub fn save(&self, path: &Path) -> Result<(), QuartusError> {
845        let mut text = serde_json::to_string_pretty(self).map_err(|e| QuartusError::Io(e.to_string()))?;
846        text.push('\n');
847        std::fs::write(path, text).map_err(|e| QuartusError::Io(format!("{}: {e}", path.display())))
848    }
849
850    /// Add `row`, refusing a name already recorded.
851    pub fn record(&mut self, row: Row) -> Result<(), QuartusError> {
852        if self.rows.iter().any(|r| r.name == row.name) {
853            return Err(QuartusError::Recorded(row.name));
854        }
855        self.rows.push(row);
856        Ok(())
857    }
858
859    /// Put `row` in the place of the row of its name, refusing a name not recorded.
860    pub fn replace(&mut self, row: Row) -> Result<(), QuartusError> {
861        let at = self.rows.iter().position(|r| r.name == row.name).ok_or_else(|| QuartusError::Unrecorded(row.name.clone()))?;
862        self.rows[at] = row;
863        Ok(())
864    }
865
866    pub fn row(&self, name: &str) -> Option<&Row> {
867        self.rows.iter().find(|r| r.name == name)
868    }
869}
870
871/// `seconds` since the Unix epoch as an RFC 3339 UTC timestamp.
872pub fn utc(seconds: u64) -> String {
873    let (days, rem) = (seconds / 86_400, seconds % 86_400);
874    // Days to a civil date (Hinnant, *chrono-compatible low-level date algorithms*).
875    let z = days as i64 + 719_468;
876    let era = z.div_euclid(146_097);
877    let doe = z - era * 146_097;
878    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
879    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
880    let mp = (5 * doy + 2) / 153;
881    let day = doy - (153 * mp + 2) / 5 + 1;
882    let month = if mp < 10 { mp + 3 } else { mp - 9 };
883    let year = yoe + era * 400 + (month <= 2) as i64;
884    format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem / 60 % 60, rem % 60)
885}
886
887#[cfg(test)]
888#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/quartus/tests.rs"]
889mod tests;