pub mod ast; pub mod attributes; pub mod mini_template; pub mod pull; use std::ops::Range; use attributes::Timestamps; use codespan_reporting::diagnostic::{Diagnostic, Label, LabelStyle, Severity}; use tracing::instrument; use crate::{ config::Config, state::{FileId, Source, TomlError, Treehouse, toml_error_to_diagnostic}, tree::{ ast::{Branch, Roots}, attributes::{Attributes, Content}, pull::BranchKind, }, }; use self::attributes::RootAttributes; #[derive(Debug, Default, Clone)] pub struct SemaTree { branches: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SemaBranchId(usize); impl SemaTree { pub fn add_branch(&mut self, branch: SemaBranch) -> SemaBranchId { let id = self.branches.len(); self.branches.push(branch); SemaBranchId(id) } pub fn branch(&self, id: SemaBranchId) -> &SemaBranch { &self.branches[id.0] } } #[derive(Debug, Clone)] pub struct SemaRoots { pub attributes: RootAttributes, pub branches: Vec, } impl SemaRoots { #[instrument( name = "SemaRoots::from_roots", skip(treehouse, diagnostics, config, roots) )] pub fn from_roots( treehouse: &mut Treehouse, diagnostics: &mut Vec>, config: &Config, file_id: FileId, roots: Roots, ) -> Self { let mut sema_roots = Self { attributes: Self::parse_attributes(treehouse, diagnostics, config, file_id, &roots), branches: roots .branches .into_iter() .map(|branch| { SemaBranch::from_branch(treehouse, diagnostics, config, file_id, branch) }) .collect(), }; if sema_roots.attributes.timestamps.is_none() { let mut timestamps = None; for &branch_id in &sema_roots.branches { let branch = treehouse.tree.branch(branch_id); if let Some(timestamp) = branch.attributes.timestamp() { let timestamps = timestamps.get_or_insert(Timestamps { created: timestamp, updated: timestamp, }); timestamps.created = timestamps.created.min(timestamp); timestamps.updated = timestamps.updated.max(timestamp); } } sema_roots.attributes.timestamps = timestamps; } sema_roots } fn parse_attributes( treehouse: &mut Treehouse, diagnostics: &mut Vec>, config: &Config, file_id: FileId, roots: &Roots, ) -> RootAttributes { let source = treehouse.source(file_id); let mut successfully_parsed = true; let mut attributes = if let Some(attributes) = &roots.attributes { toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else( |error| { diagnostics.push(toml_error_to_diagnostic(TomlError { message: error.message().to_owned(), span: error.span(), file_id, input_range: attributes.data.clone(), })); successfully_parsed = false; RootAttributes::default() }, ) } else { RootAttributes::default() }; let successfully_parsed = successfully_parsed; if successfully_parsed { let _attribute_warning_span = roots .attributes .as_ref() .map(|attributes| attributes.percent.clone()) .unwrap_or(0..1); if attributes.title.is_empty() { attributes.title = match treehouse.source(file_id) { Source::Tree { tree_path, .. } => tree_path.to_string(), _ => panic!("parse_attributes called for a non-.tree file"), } } } if let Some(thumbnail) = &attributes.thumbnail { if thumbnail.alt.is_none() { diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("sema".into()), message: "thumbnail without alt text".into(), labels: vec![Label { style: LabelStyle::Primary, file_id, range: roots.attributes.as_ref().unwrap().percent.clone(), message: "".into(), }], notes: vec![ "note: alt text is important for people using screen readers".into(), "help: add alt text using the thumbnail.alt key".into(), ], }) } if !config.pics.contains_key(&thumbnail.id) { diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("sema".into()), message: format!( "thumbnail picture with id '{}' does not exist", thumbnail.id ), labels: vec![Label { style: LabelStyle::Primary, file_id, range: roots.attributes.as_ref().unwrap().percent.clone(), message: "".into(), }], notes: vec!["note: check your id for typos".into()], }) } } attributes } } /// Analyzed branch. #[derive(Debug, Clone)] pub struct SemaBranch { pub file_id: FileId, pub indent_level: usize, pub kind: BranchKind, pub kind_span: Range, pub content: Range, pub html_id: String, pub named_id: String, pub attributes: Attributes, pub children: Vec, } impl SemaBranch { pub fn from_branch( treehouse: &mut Treehouse, diagnostics: &mut Vec>, config: &Config, file_id: FileId, branch: Branch, ) -> SemaBranchId { let attributes = Self::parse_attributes(treehouse, diagnostics, config, file_id, &branch); let named_id = attributes.id.to_owned(); let html_id = format!("b-{}", attributes.id); let redirect_here = attributes.redirect_here.clone(); let branch = Self { file_id, indent_level: branch.indent_level, kind: branch.kind, kind_span: branch.kind_span, content: branch.content, html_id, named_id: named_id.clone(), attributes, children: branch .children .into_iter() .map(|child| Self::from_branch(treehouse, diagnostics, config, file_id, child)) .collect(), }; let new_branch_id = treehouse.tree.add_branch(branch); if let Some(old_branch_id) = treehouse .branches_by_named_id .insert(named_id.clone(), new_branch_id) { let new_branch = treehouse.tree.branch(new_branch_id); let old_branch = treehouse.tree.branch(old_branch_id); diagnostics.push( Diagnostic::warning() .with_code("sema") .with_message(format!("two branches share the same id `{named_id}`")) .with_labels(vec![ Label { style: LabelStyle::Primary, file_id, range: new_branch.kind_span.clone(), message: String::new(), }, Label { style: LabelStyle::Primary, file_id: old_branch.file_id, range: old_branch.kind_span.clone(), message: String::new(), }, ]), ) } for source_branch_named_id in redirect_here { if let Some(old_branch_id) = treehouse .branch_redirects .insert(source_branch_named_id.clone(), new_branch_id) { let new_branch = treehouse.tree.branch(new_branch_id); let old_branch = treehouse.tree.branch(old_branch_id); diagnostics.push( Diagnostic::warning() .with_code("sema") .with_message(format!( "two branches serve as redirect targets for `{source_branch_named_id}`" )) .with_labels(vec![ Label { style: LabelStyle::Primary, file_id, range: new_branch.kind_span.clone(), message: String::new(), }, Label { style: LabelStyle::Primary, file_id: old_branch.file_id, range: old_branch.kind_span.clone(), message: String::new(), }, ]), ) } } new_branch_id } fn parse_attributes( treehouse: &mut Treehouse, diagnostics: &mut Vec>, config: &Config, file_id: FileId, branch: &Branch, ) -> Attributes { let source = treehouse.source(file_id); let mut successfully_parsed = true; let mut attributes = if let Some(attributes) = &branch.attributes { toml_edit::de::from_str(&source.input()[attributes.data.clone()]).unwrap_or_else( |error| { diagnostics.push(toml_error_to_diagnostic(TomlError { message: error.message().to_owned(), span: error.span(), file_id, input_range: attributes.data.clone(), })); successfully_parsed = false; Attributes::default() }, ) } else { Attributes::default() }; let successfully_parsed = successfully_parsed; // Only check for attribute validity if the attributes were parsed successfully. if successfully_parsed { let attribute_warning_span = branch .attributes .as_ref() .map(|attributes| attributes.percent.clone()) .unwrap_or(branch.kind_span.clone()); // Check that every block has an ID. if attributes.id.is_empty() { attributes.id = format!("treehouse-missingno-{}", treehouse.next_missingno()); diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("attr".into()), message: "branch does not have an `id` attribute".into(), labels: vec![Label { style: LabelStyle::Primary, file_id, range: attribute_warning_span.clone(), message: String::new(), }], notes: vec![ format!( "note: a generated id `{}` will be used, but this id is unstable and will not persist across generations", attributes.id ), format!("help: run `treehouse fix {}` to add missing ids to branches", treehouse.path(file_id)), ], }); } // Check that link-type blocks are `+`-type to facilitate lazy loading. if let Content::Link(_) = &attributes.content && branch.kind == BranchKind::Expanded { diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("attr".into()), message: "`content.link` branch is expanded by default".into(), labels: vec![Label { style: LabelStyle::Primary, file_id, range: branch.kind_span.clone(), message: String::new(), }], notes: vec![ "note: `content.link` branches should normally be collapsed to allow for lazy loading".into(), ], }); } // Resolve content.links. if let Content::Link(tree_path) = &attributes.content { if let Some(file_id) = treehouse.files_by_tree_path.get(tree_path) { attributes.content = Content::ResolvedLink(*file_id); } else { diagnostics.push(Diagnostic { severity: Severity::Error, code: Some("attr".into()), message: format!("linked tree `{tree_path}` does not exist"), labels: vec![Label { style: LabelStyle::Primary, file_id, range: attribute_warning_span.clone(), message: "".into(), }], notes: vec![], }) } } // Check that each tag belongs to the allowed set. for tag in &attributes.tags { if !config.feed.tags.contains(tag) { diagnostics.push(Diagnostic { severity: Severity::Warning, code: Some("attr".into()), message: format!("tag `{tag}` is not within the set of allowed tags"), labels: vec![Label { style: LabelStyle::Primary, file_id, range: attribute_warning_span.clone(), message: "".into(), }], notes: vec![ "note: tag should be one from the set defined in `feed.tags` in treehouse.toml".into(), ], }) } } } attributes } } #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] pub enum ParseErrorKind { #[error("branch kind (`+` or `-`) expected")] BranchKindExpected, #[error("root branches must not be indented")] RootIndentLevel, #[error("at least {expected} spaces of indentation were expected, but got {got}")] InconsistentIndentation { got: usize, expected: usize }, #[error("unterminated code block")] UnterminatedCodeBlock, } #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[error("{range:?}: {kind}")] pub struct ParseError { pub kind: ParseErrorKind, pub range: Range, } impl ParseErrorKind { pub fn at(self, range: Range) -> ParseError { ParseError { kind: self, range } } }