diff --git a/README.md b/README.md index 561a06d..017f314 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ unique within your project and valid in the source file you're annotating. (The added to the "safe" list in the future. If a character you want to use is not listed here, please file an issue on GitHub (or better yet, send a PR)! +Fragments can also be nested. This is particularly useful when you want to annotate a region of a source file that is already contained within a larger "outer" fragment. The annotations for the "inner" fragment will not appear in the output. This is best illustrated with an example. See the following [source](./examples/test/nested.rs), [prose](./examples/test/nested.md) and [output](./examples/reference/test/nested.md) files. + ### Referencing annotations In order to insert a fragment in another file, add a line containing the symbol `@@` followed by the @@ -149,10 +151,10 @@ the right.)_ - [@nickpascucci](https://github.com/nickpascucci/) - [@karlicoss](https://github.com/karlicoss/) +- [@elidhu](https://github.com/elidhu/) ## Future Work -- Add support for allow overlapping fragments. - Add support for custom formatting of annotation properties within the woven output. - Paralellize file processing in Verso, and both reading from `stdin` and file reading in Recto. - Add `--fragments-from` option to specify a source other than stdin for fragments. diff --git a/examples/check-examples.sh b/examples/check-examples.sh index abea4f8..e91a024 100755 --- a/examples/check-examples.sh +++ b/examples/check-examples.sh @@ -4,8 +4,8 @@ set -e DIFF=$(which colordiff || echo "diff") -SOURCE_FILES="example.py test/example-2.py" -PROSE_FILES="empty.md example.md test/example-2.md test/level-2/l2.md" +SOURCE_FILES="example.py test/example-2.py test/nested.rs" +PROSE_FILES="empty.md example.md test/example-2.md test/level-2/l2.md test/nested.md" OUTPUT_DIRECTORY=out cd "$(dirname "$0")/.." diff --git a/examples/out/test/nested.md b/examples/out/test/nested.md new file mode 100644 index 0000000..950ea1d --- /dev/null +++ b/examples/out/test/nested.md @@ -0,0 +1,22 @@ +# Out + +## See the `main fn` + +```rust +// test/nested.rs + +fn main() { + let stdout = stdout(); + let message = String::from("Hello fellow Rustaceans!"); + let width = message.chars().count(); + + let mut writer = BufWriter::new(stdout.lock()); + say(&message, width, &mut writer).unwrap(); +} +``` + +## Take note of this special line + +```rust + let message = String::from("Hello fellow Rustaceans!"); +``` diff --git a/examples/reference/test/nested.md b/examples/reference/test/nested.md new file mode 100644 index 0000000..950ea1d --- /dev/null +++ b/examples/reference/test/nested.md @@ -0,0 +1,22 @@ +# Out + +## See the `main fn` + +```rust +// test/nested.rs + +fn main() { + let stdout = stdout(); + let message = String::from("Hello fellow Rustaceans!"); + let width = message.chars().count(); + + let mut writer = BufWriter::new(stdout.lock()); + say(&message, width, &mut writer).unwrap(); +} +``` + +## Take note of this special line + +```rust + let message = String::from("Hello fellow Rustaceans!"); +``` diff --git a/examples/test/nested.md b/examples/test/nested.md new file mode 100644 index 0000000..85aa4e5 --- /dev/null +++ b/examples/test/nested.md @@ -0,0 +1,15 @@ +# Out + +## See the `main fn` + +```rust +// @?mainfn.file + +@@mainfn +``` + +## Take note of this special line + +```rust +@@mainfnmessage +``` diff --git a/examples/test/nested.rs b/examples/test/nested.rs new file mode 100644 index 0000000..180a3b6 --- /dev/null +++ b/examples/test/nested.rs @@ -0,0 +1,15 @@ +use ferris_says::say; +use std::io::{stdout, BufWriter}; + +// @@ + let width = message.chars().count(); + + let mut writer = BufWriter::new(stdout.lock()); + say(&message, width, &mut writer).unwrap(); +} +// >@ diff --git a/src/lib.rs b/src/lib.rs index fb2fe7c..378670a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,7 +89,7 @@ pub enum PatternExtractError { #[derive(Debug, PartialEq, Eq, Clone)] pub enum ParseError { - DoubleOpen, + UnclosedFragment, CloseBeforeOpen, MissingId, IdExtractError, @@ -155,118 +155,106 @@ pub fn extract_fragments( symbols: &SymbolKey, ) -> Result, FileError> { let mut fragments: Vec = vec![]; - - let mut fragment: Option = None; + let mut fragment_stack: Vec = vec![]; for (line, content) in contents.split('\n').enumerate().map(|(l, c)| (l + 1, c)) { - match &fragment { - None => { - if let Some(col) = content.find(&symbols.fragment_close) { - return Err(FileError { - err_type: ParseError::CloseBeforeOpen, - filename: filename.to_owned(), - line, - col, - message: Some(format!( - "found a fragment close symbol when no fragment is active: {}", - line - )), + if let Some(col) = content.find(&symbols.fragment_open) { + match extract_id(content, col + symbols.fragment_open.len()) { + Ok(id) => { + // Push a new Fragment onto the stack. + fragment_stack.push(Fragment { + body: String::new(), + id, + file: filename.to_owned(), + // The Fragment starts on the line after the opening symbol. + line: line + 1, + col: 0, }); } - - if content.contains(&symbols.halt) { - break; - } - - if let Some(col) = content.find(&symbols.fragment_open) { - // If the line contains a start marker, begin a fragment file. - match extract_id(content, col + symbols.fragment_open.len()) { - Ok(id) => { - fragment = Some(Fragment { - body: String::new(), - id, - // The fragment to extract starts at the beginning of the next line - line: line + 1, - col: 0, - file: filename.to_owned(), - }); - } - Err(IdExtractError::NoIdFound) => { - return Err(FileError { - err_type: ParseError::MissingId, - filename: filename.to_owned(), - line, - col, - message: Some(format!( - "no fragment identifier found in fragment open symbol: {}", - line - )), - }); - } - Err(IdExtractError::ReservedCharacterUsed(c)) => { - return Err(FileError { - err_type: ParseError::IdExtractError, - filename: filename.to_owned(), - line, - col, - message: Some(format!( - "error parsing fragment identifier in fragment open symbol: {} - (used reserved character {})", - line, c - )), - }); - } - } - } - } - - Some(f) => { - // If the line contains an end marker, end the fragment if one exists. - if content.contains(&symbols.fragment_close) { - fragments.push(f.to_owned()); - fragment = None; - continue; - } - - if let Some(col) = content.find(&symbols.fragment_open) { + Err(IdExtractError::NoIdFound) => { return Err(FileError { - err_type: ParseError::DoubleOpen, + err_type: ParseError::MissingId, filename: filename.to_owned(), line, col, message: Some(format!( - "found a fragment open symbol while a fragment is already opened: {}", + "no fragment identifier found in fragment open symbol: {}", line )), }); } - - if let Some(col) = content.find(&symbols.halt) { + Err(IdExtractError::ReservedCharacterUsed(c)) => { return Err(FileError { - err_type: ParseError::HaltWhileOpen, + err_type: ParseError::IdExtractError, filename: filename.to_owned(), line, col, message: Some(format!( - "halt symbol found while a fragment was open: {}", - line + "error parsing fragment identifier in fragment open symbol: {} + (used reserved character {})", + line, c )), }); } - - // If there no markers, append the line to the existing fragment. - fragment = fragment.map(|x| Fragment { - body: if x.body.is_empty() { - content.to_string() - } else { - x.body + "\n" + content - }, - ..x + } + } else if let Some(col) = content.find(&symbols.fragment_close) { + if let Some(closed_fragment) = fragment_stack.pop() { + let trimmed_body = closed_fragment.body.trim_end_matches('\n').to_string(); + if let Some(parent_fragment) = fragment_stack.last_mut() { + // Special handling of "empty" fragments. + if !trimmed_body.is_empty() { + // Add the child fragments body to the parent fragment. + parent_fragment.body.push_str(&trimmed_body); + parent_fragment.body.push('\n'); + } + } + // Add the closed fragment to the results list + fragments.push(Fragment { + body: trimmed_body, + ..closed_fragment + }); + } else { + return Err(FileError { + err_type: ParseError::CloseBeforeOpen, + filename: filename.to_owned(), + line, + col, + message: Some("fragment close symbol found without an open symbol".to_string()), + }); + } + } else if let Some(col) = content.find(&symbols.halt) { + // If the Fragment stack is not empty, we have an error as there is at least 1 open + // Fragment. + if !fragment_stack.is_empty() { + return Err(FileError { + err_type: ParseError::HaltWhileOpen, + filename: filename.to_owned(), + line, + col, + message: Some(format!( + "halt symbol found while a fragment was open: {}", + line + )), }); } + // Otherwise stop processing and break out. + break; + } else if let Some(fragment) = fragment_stack.last_mut() { + fragment.body.push_str(content); + fragment.body.push('\n'); } } + if !fragment_stack.is_empty() { + return Err(FileError { + err_type: ParseError::UnclosedFragment, + filename: filename.to_owned(), + line: contents.lines().count(), + col: 0, + message: Some("not all fragments were closed".to_string()), + }); + } + Ok(fragments) } @@ -742,6 +730,65 @@ def main(): ); } + #[test] + fn test_extract_fragments_nested() { + let fragments: Result, FileError> = extract_fragments( + "# This is an example. + # @@ + End of inner + # >@ + End of outer + # >@", + "test.py", + &SymbolKey::default(), + ); + + let fragments = fragments.expect("Expected no parse errors"); + assert!( + fragments.len() == 3, // Two fragments in addition to the full file + "Expected two fragments, found {}", + fragments.len() + ); + // Innermost, empty fragment + assert_eq!(fragments[0].body, "", "Expected fragment to be empty"); + assert!( + fragments[0].id == *"quux", + "Unexpected ID {:?}", + fragments[0].id + ); + // Middle fragment, that has content, including all child fragments with tags removed. + assert_eq!( + fragments[1].body, + " Start of inner + End of inner", + "Expected nested fragment markers to be removed" + ); + assert!( + fragments[1].id == *"qux", + "Unexpected ID {:?}", + fragments[1].id + ); + // Outer fragment, that has content, including all child fragments with tags removed. + assert_eq!( + fragments[2].body, + " Start of outer + Start of inner + End of inner + End of outer", + "Expected nested fragment markers to be removed" + ); + assert!( + fragments[2].id == *"foobarbaz", + "Unexpected ID {:?}", + fragments[2].id + ); + } + #[test] fn test_extract_fragments_close_before_open() { let fragments: Result, FileError> = extract_fragments( @@ -796,13 +843,13 @@ Fragment 1 } #[test] - fn test_extract_fragments_double_open() { + fn test_extract_fragments_halt_while_open() { let fragments: Result, FileError> = extract_fragments( "# This is an example. -# @@ This line ends the fragment.", +# @<1 +Fragment 1 +# @!halt This line causes an error as we have an open Fragment. +# >@", "test.py", &SymbolKey::default(), ); @@ -810,15 +857,15 @@ Fragment 1 let fragments = fragments.expect_err("Expected a parsing error"); match fragments { FileError { - err_type: ParseError::DoubleOpen, + err_type: ParseError::HaltWhileOpen, line, col, .. } => { - assert_eq!(line, 3, "Expected error on line 3, found line {:?}", line); + assert_eq!(line, 4, "Expected error on line 4, found line {:?}", line); assert_eq!(col, 2, "Expected error on col 2, found col {:?}", col); } - _ => panic!("Expected ParseError::DoubleOpen, got {:?}", fragments), + _ => panic!("Expected ParseError::HaltWhileOpen, got {:?}", fragments), } }