1pub mod ast;
4mod lower;
5mod parse;
6mod program;
7
8use crate::action::Action;
9use crate::{BlockGraph, BlockGraphError, Direction};
10use glam::IVec3;
11use thiserror::Error;
12
13pub(crate) use parse::{
14 cube_height_from_str, is_valid_identifier, is_valid_resource_type, is_valid_simple_identifier,
15};
16
17use crate::parser::ast::Span;
18use std::path::Path;
19use std::sync::Arc;
20
21#[derive(Debug, Clone, Error)]
26#[non_exhaustive]
27pub enum ParseError {
28 #[error("syntax error: {message}")]
30 Syntax {
31 message: String,
33 span: Span,
35 },
36
37 #[error("undefined block ID {id}")]
39 UndefinedId {
40 id: u32,
42 span: Span,
44 },
45
46 #[error("duplicate block ID {id}")]
48 DuplicateId {
49 id: u32,
51 span: Span,
53 },
54
55 #[error("duplicate branch region '{name}'")]
57 DuplicateBranchName {
58 name: String,
60 span: Span,
62 },
63
64 #[error("undefined branch region '{name}'")]
66 UndefinedBranchName {
67 name: String,
69 span: Span,
71 },
72
73 #[error("invalid direction between {from} and {to}: {message}")]
75 InvalidDirection {
76 from: IVec3,
78 to: IVec3,
80 span: Span,
82 message: String,
84 },
85
86 #[error("invalid walking block from {start} to {end}: {message}")]
88 InvalidWalkingBlock {
89 start: IVec3,
91 end: IVec3,
93 span: Span,
95 message: String,
97 },
98
99 #[error("invalid patch rotation block from {start} to {end}: {message}")]
101 InvalidPatchRotationBlock {
102 start: IVec3,
104 end: IVec3,
106 span: Span,
108 message: String,
110 },
111
112 #[error("coordinate arithmetic overflow: {message}")]
114 CoordinateOverflow {
115 span: Span,
117 message: String,
119 },
120
121 #[error(
123 "conflicting cube heights in one spatial component: `height={height}` at {pos} and \
124 `height={other_height}` at {other_pos}; spatially merged cubes share their syndrome rounds, \
125 so one component has one height"
126 )]
127 ConflictingCubeHeights {
128 pos: IVec3,
130 height: crate::CubeHeight,
132 other_pos: IVec3,
134 other_height: crate::CubeHeight,
136 span: Span,
138 },
139
140 #[error("unsupported BLOG version {major}.{minor}")]
142 UnsupportedVersion {
143 major: u32,
145 minor: u32,
147 span: Span,
149 },
150
151 #[error("{source}")]
159 Graph {
160 source: Box<BlockGraphError>,
162 span: Option<Span>,
164 },
165}
166
167impl ParseError {
168 pub fn span(&self) -> Option<Span> {
170 match self {
171 ParseError::Syntax { span, .. }
172 | ParseError::UndefinedId { span, .. }
173 | ParseError::DuplicateId { span, .. }
174 | ParseError::DuplicateBranchName { span, .. }
175 | ParseError::UndefinedBranchName { span, .. }
176 | ParseError::InvalidDirection { span, .. }
177 | ParseError::InvalidWalkingBlock { span, .. }
178 | ParseError::InvalidPatchRotationBlock { span, .. }
179 | ParseError::CoordinateOverflow { span, .. }
180 | ParseError::ConflictingCubeHeights { span, .. }
181 | ParseError::UnsupportedVersion { span, .. } => Some(*span),
182 ParseError::Graph { span, .. } => *span,
183 }
184 }
185
186 pub fn render_diagnostic(&self, filename: &str, source: &str) -> String {
189 self.render(filename, source, true)
190 }
191
192 pub fn render_diagnostic_plain(&self, filename: &str, source: &str) -> String {
198 self.render(filename, source, false)
199 }
200
201 fn render(&self, filename: &str, source: &str, color: bool) -> String {
202 use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
203
204 let message = self.to_string();
205 let span = self.span();
206 let config = Config::default()
207 .with_color(color)
208 .with_index_type(IndexType::Byte);
209
210 let mut buf = Vec::new();
211
212 let report = match span {
213 Some(s) => {
214 let range = s.start as usize..s.end as usize;
215 Report::build(ReportKind::Error, (filename, range.clone()))
216 .with_config(config)
217 .with_message(&message)
218 .with_label(
219 Label::new((filename, range))
222 .with_message("here")
223 .with_color(Color::Red),
224 )
225 .finish()
226 }
227 None => Report::build(ReportKind::Error, (filename, 0..0))
228 .with_config(config)
229 .with_message(&message)
230 .finish(),
231 };
232
233 report
234 .write_for_stdout((filename, Source::from(source)), &mut buf)
235 .expect("write to Vec<u8> cannot fail");
236
237 String::from_utf8(buf).expect("ariadne output is valid UTF-8")
238 }
239}
240
241pub fn parse_blog_to_graph(input: &str) -> Result<BlockGraph, BlockGraphError> {
262 parse_blog_to_graph_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
263}
264
265pub fn parse_blog_to_graph_with_limits(
271 input: &str,
272 limits: crate::ModuleCertificationLimits,
273) -> Result<BlockGraph, BlockGraphError> {
274 parse_graph_input(input, limits)
275}
276
277#[cfg(test)]
283pub(super) fn parse_blog_body(input: &str) -> Result<BlockGraph, ParseError> {
284 parse_blog_body_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
285}
286
287fn parse_blog_body_with_limits(
288 input: &str,
289 limits: crate::ModuleCertificationLimits,
290) -> Result<BlockGraph, ParseError> {
291 let ast = parse::parse(input)?;
292 lower::lower_with_inputs_and_limits(&ast, std::iter::empty(), limits)
293}
294
295pub(crate) fn parse_graph_input(
299 input: &str,
300 limits: crate::ModuleCertificationLimits,
301) -> Result<BlockGraph, BlockGraphError> {
302 match parse::parse_program(input) {
303 Ok(source) if source.modules.is_empty() && source.imports.is_empty() => {
304 parse_blog_body_with_limits(input, limits).map_err(Into::into)
305 }
306 Ok(source) => program::lower(&source, limits).map_err(|error| match error {
307 crate::ModuleError::Parse(error) => BlockGraphError::Parse(error),
308 error => BlockGraphError::ModuleSource(Arc::new(error)),
309 }),
310 Err(program_error) => parse_blog_body_with_limits(input, limits).map_err(|error| {
311 if matches!(error, ParseError::Syntax { .. })
312 && program_error.span().map(|span| span.start)
313 >= error.span().map(|span| span.start)
314 {
315 program_error.into()
316 } else {
317 error.into()
318 }
319 }),
320 }
321}
322
323pub(crate) fn load_graph_input(
324 path: &Path,
325 limits: crate::ModuleCertificationLimits,
326) -> Result<BlockGraph, BlockGraphError> {
327 let text = std::fs::read_to_string(path).map_err(|source| BlockGraphError::Io {
328 path: path.to_path_buf(),
329 source: Arc::new(source),
330 })?;
331 if !parse::parse_program(&text).is_ok_and(|source| !source.imports.is_empty()) {
332 return parse_graph_input(&text, limits);
333 }
334 let mut root_text = Some(text);
337 let mut read_error = None;
338 let result = program::load(
339 path,
340 &mut |source: &Path| {
341 if let Some(text) = root_text.take() {
342 return Ok(text);
343 }
344 std::fs::read_to_string(source).map_err(|error| {
345 let error = Arc::new(error);
346 read_error = Some(BlockGraphError::Io {
347 path: source.to_path_buf(),
348 source: error.clone(),
349 });
350 error
351 })
352 },
353 limits,
354 );
355 if let Some(error) = read_error {
356 return Err(error);
357 }
358 result.map_err(|error| BlockGraphError::ModuleSource(Arc::new(error)))
359}
360
361pub fn lower_blog_ast_deferred(
367 source: &ast::SourceFile,
368 inputs: impl IntoIterator<Item = String>,
369) -> Result<BlockGraph, ParseError> {
370 lower::lower_deferred_with_inputs(source, inputs)
371}
372
373pub fn lower_blog_ast_lenient(
380 source: &ast::SourceFile,
381 inputs: impl IntoIterator<Item = String>,
382) -> Result<BlockGraph, ParseError> {
383 lower::lower_lenient_with_inputs(source, inputs)
384}
385
386pub fn lower_blog_graph_ast_deferred(
392 source: &ast::ModularSourceFile,
393) -> Result<crate::BlockGraph, crate::ModuleError> {
394 program::lower_deferred(source)
395}
396
397#[cfg(test)]
405pub(crate) fn parse_inline_graph(input: &str) -> Result<crate::BlockGraph, crate::ModuleError> {
406 parse_inline_graph_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
407}
408
409pub(crate) fn parse_inline_graph_with_limits(
415 input: &str,
416 limits: crate::ModuleCertificationLimits,
417) -> Result<crate::BlockGraph, crate::ModuleError> {
418 program::lower(&parse::parse_program(input)?, limits)
419}
420
421#[cfg(test)]
427pub(crate) fn load_graph_with_resolver_and_limits<E: std::fmt::Display>(
428 root: impl AsRef<Path>,
429 mut resolver: impl FnMut(&Path) -> Result<String, E>,
430 limits: crate::ModuleCertificationLimits,
431) -> Result<crate::BlockGraph, crate::ModuleError> {
432 program::load(root.as_ref(), &mut resolver, limits)
433}
434
435pub fn parse_blog_to_ast(input: &str) -> Result<ast::SourceFile, ParseError> {
451 parse::parse(input)
452}
453
454pub fn parse_blog_program_to_ast(input: &str) -> Result<ast::ModularSourceFile, ParseError> {
460 parse::parse_program(input)
461}
462
463pub fn parse_actions(
475 input: &str,
476 i2p: impl Fn(u32, Option<Direction>) -> Option<IVec3>,
477) -> Result<Vec<Action>, ParseError> {
478 let stmts = parse::parse_actions_only(input)?;
479 lower::lower_actions(&stmts, i2p)
480}
481
482#[cfg(test)]
483mod tests {
484 use crate::ModuleCertificationLimits;
485
486 #[test]
487 fn explicit_limits_apply_to_imported_source_before_expansion() {
488 let limits = ModuleCertificationLimits {
489 max_expanded_blocks: 1,
490 ..ModuleCertificationLimits::DEFAULT
491 };
492 let root = "BLOG 1.0\nimport \"child.blog\" as child\nmodule main {\n0: ZXZ [0,0,0]\n}\n";
493 let child = "BLOG 1.0\nmodule main {\n0: ZXZ [0,0,0]\n1: ZXZ [0,0,1]\n0 -> +Z\n}\n";
494 let load = |limits| {
495 super::load_graph_with_resolver_and_limits(
496 "root.blog",
497 |path| match path.to_str() {
498 Some("root.blog") => Ok(root.to_owned()),
499 Some("child.blog") => Ok(child.to_owned()),
500 _ => Err("unexpected import"),
501 },
502 limits,
503 )
504 };
505 let error = load(limits).expect_err("the imported source exceeds its block budget");
506 let message = error.to_string();
507 assert!(message.contains("2 > 1"), "{message}");
508 load(ModuleCertificationLimits::DEFAULT).expect("default limits load the same hierarchy");
509 }
510
511 #[test]
512 fn diagnostic_uses_byte_spans_after_unicode() {
513 let source = "BLOG 1.0\n# 中文中文中文中文中文\n0: INVALID [0,0,0]\n";
514 let error = super::parse_blog_body(source).unwrap_err();
515 let plain = error.render_diagnostic_plain("unicode.blog", source);
516 assert!(plain.contains("unicode.blog:3:"), "{plain}");
517 assert!(plain.contains("INVALID [0,0,0]"), "{plain}");
518 assert!(plain.contains("here"), "{plain}");
519 assert!(!plain.contains('\x1b'));
520 }
521}