plantuml_parser/dsl/
content.rs

1use crate::Error;
2use crate::ParseContainer;
3use crate::PathResolver;
4use crate::PlantUmlFileData;
5use crate::{
6    FooterLine, HeaderLine, IncludeToken, PlantUmlBlock, PlantUmlBlockKind, PlantUmlLine,
7    PlantUmlLineKind, StartLine, TitleLine,
8};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13/// A token sequence that is a single PlantUML diagram, that is, from the [`StartLine`] to the [`EndLine`][`crate::EndLine`] (lines inclusive).
14///
15/// # Examples
16///
17/// ```
18/// use plantuml_parser::{IncludesCollections, PlantUmlContent, PlantUmlFileData};
19/// # use std::collections::HashMap;
20///
21/// # fn main() -> anyhow::Result<()> {
22/// let data_for_include = r#"
23/// @startuml
24/// title Example Title Included
25/// Bob -> Alice: Hi
26/// @enduml
27/// "#;
28///
29/// let data = r#"
30/// @startuml
31/// title Example Title
32/// Alice -> Bob: Hello
33/// !include foo.puml!0
34/// @enduml
35/// "#;
36///
37/// let filedata = PlantUmlFileData::parse_from_str(data)?;
38/// let content: &PlantUmlContent = filedata.get(0).unwrap();
39///
40/// // Parsed and collected `!include`, `title`, `header`, `footer`
41/// let include_0 = content.includes().get(0).unwrap();
42/// assert!(content.includes().get(1).is_none());
43/// assert_eq!(content.includes().len(), 1);
44/// assert_eq!(include_0.filepath(), "foo.puml");
45/// assert_eq!(include_0.index(), Some(0));
46/// assert_eq!(include_0.id(), Some("0"));
47///
48/// let title_0 = content.titles().get(0).unwrap();
49/// assert!(content.titles().get(1).is_none());
50/// assert_eq!(content.titles().len(), 1);
51/// assert_eq!(title_0.title(), "Example Title");
52///
53/// // `construct()`
54/// let filedata_for_include = PlantUmlFileData::parse_from_str(data_for_include)?;
55/// let includes = IncludesCollections::new(HashMap::from([
56///     ("bar/foo.puml".into(), filedata_for_include),
57/// ]));
58/// let constructed = content.construct("bar/x.puml".into(), &includes)?;
59/// assert_eq!(
60///     constructed,
61///     concat!(
62///         "@startuml\n",
63///         "title Example Title\n",
64///         "Alice -> Bob: Hello\n",
65///         "title Example Title Included\n",
66///         "Bob -> Alice: Hi\n",
67///         "@enduml\n",
68///     ),
69/// );
70///
71/// // Increased the element of titles in `constructed`
72/// let filedata = PlantUmlFileData::parse_from_str(constructed)?;
73/// let content: &PlantUmlContent = filedata.get(0).unwrap();
74/// let title_0 = content.titles().get(0).unwrap();
75/// let title_1 = content.titles().get(1).unwrap();
76/// assert!(content.titles().get(2).is_none());
77/// assert_eq!(content.titles().len(), 2);
78/// assert_eq!(title_0.title(), "Example Title");
79/// assert_eq!(title_1.title(), "Example Title Included");
80/// # Ok(())
81/// # }
82/// ```
83#[derive(Clone, Debug)]
84pub struct PlantUmlContent {
85    lines: Vec<Arc<PlantUmlLine>>,
86    blocks: Vec<PlantUmlBlock>,
87    includes: Vec<IncludeToken>,
88    titles: Vec<TitleLine>,
89    headers: Vec<HeaderLine>,
90    footers: Vec<FooterLine>,
91}
92
93/// Data collected on the file path and PlantUML diagrams in the file for the include process.
94#[derive(Clone, Debug)]
95pub struct IncludesCollections(HashMap<PathBuf, PlantUmlFileData>);
96
97impl PlantUmlContent {
98    /// Tries to parse a single PlantUML diagram, that is, from the [`StartLine`] to the [`EndLine`][`crate::EndLine`] (lines inclusive).
99    pub(crate) fn parse(input: ParseContainer) -> Result<(ParseContainer, Self), Error> {
100        let mut lines = vec![];
101        let mut blocks = vec![];
102        let mut includes = vec![];
103        let mut titles = vec![];
104        let mut headers = vec![];
105        let mut footers = vec![];
106
107        let (mut rest, (_, mut line)) = PlantUmlLine::parse(input.clone())?;
108        if line.start().is_none() {
109            StartLine::parse(input.clone())?;
110            // unreachable
111            return Err(Error::Unreachable("The first line is not `StartLine` provided to `PlantUmlContent::parse()`, but `PlantUmlContent::parse()` is `pub(crate)`.".into()));
112        }
113        let start_line = line.clone();
114        let diagram_kind = start_line.diagram_kind().unwrap();
115        lines.push(Arc::new(line.clone()));
116
117        while line.end().is_none() && !rest.is_empty() {
118            // TODO: refactor: too specialized to treat `BlockComment`
119            match PlantUmlBlock::parse(rest.clone()) {
120                Ok((tmp_rest, (_, block))) => {
121                    rest = tmp_rest;
122                    blocks.push(block.clone());
123
124                    match block.kind() {
125                        PlantUmlBlockKind::BlockComment(comment) => {
126                            let comment_lines = comment.lines().to_vec();
127                            lines.extend(comment_lines);
128                            continue;
129                        }
130                    }
131                }
132                _ => { // do nothing
133                }
134            }
135
136            let (tmp_rest, (_, tmp_line)) = PlantUmlLine::parse(rest)?;
137            (rest, line) = (tmp_rest, tmp_line);
138            if line.end().map(|x| x.eq_diagram_kind(diagram_kind)) == Some(false) {
139                let start_line = start_line.start().cloned().unwrap();
140                let end_line = line.end().cloned().unwrap();
141                return Err(Error::DiagramKindNotMatch(start_line, end_line));
142            }
143
144            match line.kind() {
145                PlantUmlLineKind::Include(line) => {
146                    includes.push(line.token().clone());
147                }
148
149                PlantUmlLineKind::Title(line) => {
150                    titles.push(line.clone());
151                }
152
153                PlantUmlLineKind::Header(line) => {
154                    headers.push(line.clone());
155                }
156
157                PlantUmlLineKind::Footer(line) => {
158                    footers.push(line.clone());
159                }
160
161                _ => {}
162            }
163
164            lines.push(Arc::new(line.clone()));
165        }
166
167        if rest.is_empty() && line.end().is_none() {
168            Err(Error::ContentUnclosed("".into()))
169        } else {
170            let ret = Self {
171                lines,
172                blocks,
173                includes,
174                titles,
175                headers,
176                footers,
177            };
178
179            Ok((rest, ret))
180        }
181    }
182
183    /// Returns the ID of the diagram.
184    pub fn id(&self) -> Option<&str> {
185        self.lines[0].start().unwrap().id()
186    }
187
188    /// Returns the string after the include process of [`PlantUmlContent`].
189    ///
190    /// * `base` - A base path of `self`.
191    /// * `includes` - A pre read [`PlantUmlFileData`] collection for the include process.
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use plantuml_parser::{IncludesCollections, PlantUmlContent, PlantUmlFileData};
197    /// # use std::collections::HashMap;
198    ///
199    /// # fn main() -> anyhow::Result<()> {
200    /// let data_for_include = r#"
201    /// @startuml
202    /// Bob -> Alice: Hi
203    /// @enduml
204    /// "#;
205    ///
206    /// let data = r#"
207    /// @startuml
208    /// Alice -> Bob: Hello
209    /// !include foo.puml!0
210    /// @enduml
211    /// "#;
212    ///
213    /// let filedata_for_include = PlantUmlFileData::parse_from_str(data_for_include)?;
214    /// let includes = IncludesCollections::new(HashMap::from([
215    ///     ("bar/foo.puml".into(), filedata_for_include),
216    /// ]));
217    ///
218    /// let filedata = PlantUmlFileData::parse_from_str(data)?;
219    /// let content: &PlantUmlContent = filedata.get(0).unwrap();
220    /// let constructed = content.construct("bar/x.puml".into(), &includes)?;
221    /// assert_eq!(
222    ///     constructed,
223    ///     concat!(
224    ///         "@startuml\n",
225    ///         "Alice -> Bob: Hello\n",
226    ///         "Bob -> Alice: Hi\n",
227    ///         "@enduml\n",
228    ///     ),
229    /// );
230    ///
231    /// // include paths are not matched
232    /// let constructed = content.construct("bar.puml".into(), &includes)?;
233    /// assert_eq!(
234    ///     constructed,
235    ///     concat!(
236    ///         "@startuml\n",
237    ///         "Alice -> Bob: Hello\n",
238    ///         "!include foo.puml!0\n",
239    ///         "@enduml\n",
240    ///     ),
241    /// );
242    /// # Ok(())
243    /// # }
244    /// ```
245    pub fn construct(
246        &self,
247        base: PathBuf,
248        includes: &IncludesCollections,
249    ) -> Result<String, Error> {
250        let constructed = [
251            self.lines[0].raw_str(),
252            &self.construct_inner(base, includes)?,
253            self.lines[self.lines.len() - 1].raw_str(),
254        ]
255        .join("");
256        Ok(constructed)
257    }
258
259    fn construct_inner(
260        &self,
261        base: PathBuf,
262        includes: &IncludesCollections,
263    ) -> Result<String, Error> {
264        let resolver = PathResolver::new(base);
265
266        let constructed = self.lines[1..self.lines.len() - 1]
267            .iter()
268            .map(|x| {
269                let Some(include) = x.include() else {
270                    // line is not `!include`
271                    return Ok(x.raw_str().to_string());
272                };
273
274                let mut inner_resolver = resolver.clone();
275                inner_resolver.add(include.filepath().into());
276                let path = inner_resolver.build()?;
277
278                let Some(data) = includes.0.get(&path) else {
279                    tracing::info!("file not loaded: path = {path:?}");
280                    // file not found
281                    return Ok(x.raw_str().to_string());
282                };
283
284                let Some(content) = data.get_by_token(include.token()) else {
285                    tracing::info!(
286                        "specified content not found in file: path = {path:?}, token.id = {:?}, token.index = {:?}",
287                        include.token().id(),
288                        include.token().index(),
289                    );
290                    // specified `PlantUmlContent` not found
291                    return Ok(x.raw_str().to_string());
292                };
293
294                // recursive construct
295                content.construct_inner(path, includes)
296            })
297            .collect::<Result<Vec<_>, Error>>()?
298            .join("");
299
300        Ok(constructed)
301    }
302
303    /// Returns the string in the [`PlantUmlContent`] without [`StartLine`] and [`EndLine`][`crate::EndLine`].
304    ///
305    /// # Examples
306    ///
307    /// ```
308    /// use plantuml_parser::{PlantUmlContent, PlantUmlFileData};
309    ///
310    /// # fn main() -> anyhow::Result<()> {
311    /// let data = r#"
312    /// @startuml
313    /// Alice -> Bob: Hello
314    /// @enduml
315    /// "#;
316    ///
317    /// let filedata = PlantUmlFileData::parse_from_str(data)?;
318    /// let content: &PlantUmlContent = filedata.get(0).unwrap();
319    /// assert_eq!(content.inner(), "Alice -> Bob: Hello\n");
320    /// # Ok(())
321    /// # }
322    /// ```
323    pub fn inner(&self) -> String {
324        self.lines[1..self.lines.len() - 1]
325            .iter()
326            .map(|x| x.raw_str())
327            .collect::<Vec<_>>()
328            .join("")
329    }
330
331    /// Returns the list of [`PlantUmlBlock`] in the [`PlantUmlContent`].
332    pub fn blocks(&self) -> &[PlantUmlBlock] {
333        &self.blocks
334    }
335
336    /// Returns the includes in the [`PlantUmlContent`].
337    pub fn includes(&self) -> &[IncludeToken] {
338        &self.includes
339    }
340
341    /// Returns the titles in the [`PlantUmlContent`].
342    pub fn titles(&self) -> &[TitleLine] {
343        &self.titles
344    }
345
346    /// Returns the headers in the [`PlantUmlContent`].
347    pub fn headers(&self) -> &[HeaderLine] {
348        &self.headers
349    }
350
351    /// Returns the footers in the [`PlantUmlContent`].
352    pub fn footers(&self) -> &[FooterLine] {
353        &self.footers
354    }
355}
356
357impl IncludesCollections {
358    /// Creates a new [`IncludesCollections`].
359    pub fn new(inner: HashMap<PathBuf, PlantUmlFileData>) -> Self {
360        Self(inner)
361    }
362}
363
364impl From<HashMap<PathBuf, PlantUmlFileData>> for IncludesCollections {
365    fn from(inner: HashMap<PathBuf, PlantUmlFileData>) -> Self {
366        Self(inner)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_parse_plant_uml_content() -> anyhow::Result<()> {
376        // OK
377        let testdata = r#"@startuml
378        @enduml"#;
379        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
380        assert_eq!(rest, "");
381        assert_eq!(parsed.lines.len(), 2);
382        assert_eq!(parsed.inner(), "");
383
384        // OK
385        let testdata = r#"@startuml
386            a -> b
387        @enduml"#;
388        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
389        assert_eq!(rest, "");
390        assert_eq!(parsed.lines.len(), 3);
391        assert_eq!(parsed.inner(), "            a -> b\n");
392
393        // OK (with id)
394        let testdata = r#"@startuml aaa
395            a -> b
396        @enduml"#;
397        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
398        assert_eq!(rest, "");
399        assert_eq!(parsed.lines.len(), 3);
400        assert_eq!(parsed.inner(), "            a -> b\n");
401        assert_eq!(parsed.id(), Some("aaa"));
402
403        // OK (with id 2)
404        let testdata = r#"@startuml(id=bbb)
405            a -> b
406        @enduml"#;
407        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
408        assert_eq!(rest, "");
409        assert_eq!(parsed.lines.len(), 3);
410        assert_eq!(parsed.inner(), "            a -> b\n");
411        assert_eq!(parsed.id(), Some("bbb"));
412
413        // NG. diagram_kind not match
414        let testdata = r#"@startuml
415            a -> b
416        @endfoo"#;
417        assert!(PlantUmlContent::parse(testdata.into()).is_err());
418
419        // OK
420        let testdata = r#"@startuml(id=ccc)
421            a -> b
422            !include shared.iuml
423            a -> b
424            !include functions.puml!aaa
425            a -> b
426        @enduml"#;
427        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
428        assert_eq!(rest, "");
429        assert_eq!(parsed.lines.len(), 7);
430        assert_eq!(
431            parsed.inner(),
432            "            a -> b\n            !include shared.iuml\n            a -> b\n            !include functions.puml!aaa\n            a -> b\n"
433        );
434        assert_eq!(parsed.id(), Some("ccc"));
435        let mut includes = parsed.includes().iter();
436        let include = includes.next().unwrap();
437        assert_eq!(include.filepath(), "shared.iuml");
438        assert_eq!(include.index(), None);
439        assert_eq!(include.id(), None);
440        let include = includes.next().unwrap();
441        assert_eq!(include.filepath(), "functions.puml");
442        assert_eq!(include.index(), None);
443        assert_eq!(include.id(), Some("aaa"));
444        assert!(includes.next().is_none());
445
446        Ok(())
447    }
448
449    #[test]
450    fn test_parse_block_comment() -> anyhow::Result<()> {
451        // OK
452        let testdata = r#"@startuml(id=ccc)
453            a -> b: 1
454            /' begin
455            !include shared.iuml
456            end '/
457            a -> b: 2
458            !include functions.puml!aaa
459            a -> b: 3
460        @enduml"#;
461        let expected_inner = r#"            a -> b: 1
462            /' begin
463            !include shared.iuml
464            end '/
465            a -> b: 2
466            !include functions.puml!aaa
467            a -> b: 3
468"#;
469        let (rest, parsed) = PlantUmlContent::parse(testdata.into())?;
470        assert_eq!(rest, "");
471        println!("parsed.blocks() = {:?}", parsed.blocks());
472        for line in parsed.lines.iter() {
473            println!("parsed.lines[].kind() = {:?}", line.kind());
474            println!("parsed.lines[].raw_str() = {}", line.raw_str());
475        }
476        assert_eq!(parsed.lines.len(), 9);
477        assert_eq!(parsed.inner(), expected_inner);
478        assert_eq!(parsed.id(), Some("ccc"));
479        let mut includes = parsed.includes().iter();
480        let include = includes.next().unwrap();
481        assert_eq!(include.filepath(), "functions.puml");
482        assert_eq!(include.index(), None);
483        assert_eq!(include.id(), Some("aaa"));
484        assert!(includes.next().is_none());
485
486        Ok(())
487    }
488}