Skip to content

Commit 78bc66d

Browse files
authored
read extra positional args as files
Treat the first positional arg as a selector (as before), but any subsequent ones as files to read. This resolves #275. From the new docs: > An optional list of Markdown files to parse, by path. If not provided, > standard input will be used. > > If these are provided, mdq will act as if they were all concatenated > into a single file. For example, if you > use --link-pos=doc, the link definitions for all input files will be > at the very end of the output. > > A path of "-" represents standard input. > > If these are provided, standard input will not be used unless one of > the arguments is "-". Files will be > processed in the order you provide them. If you provide the same file > twice, mdq will process it twice, unless > that file is "-"; all but the first "-" paths are ignored. Now that I have the new `OsFacade`, there's an opportunity to have it include the output as well, such that we can remove the separate `run_in_memory` and `run_stdio` implementations. I'll leave that for #213, though.
1 parent b0c2667 commit 78bc66d

File tree

7 files changed

+246
-17
lines changed

7 files changed

+246
-17
lines changed

build.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ fn generate_integ_test_cases(out_dir: &String) -> Result<(), String> {
4646
}
4747
});
4848

49+
out.write("const FILES: [(&str, &str); ");
50+
out.write(&format!("{}", &spec_file_parsed.given.get_files_count()));
51+
out.write("] = [");
52+
if let Some(files) = &spec_file_parsed.given.files {
53+
out.with_indent(|out| {
54+
for (file_name, file_content) in files {
55+
out.writeln(&format!("({:?}, {:?}),", file_name, file_content));
56+
}
57+
});
58+
}
59+
out.writeln("];");
60+
4961
let mut found_chained_case = false;
5062
for case in spec_file_parsed.get_cases() {
5163
found_chained_case |= case.case_name.eq("chained");
@@ -124,6 +136,16 @@ struct TestSpecFile {
124136
#[derive(Deserialize)]
125137
struct TestGiven {
126138
md: String,
139+
files: Option<HashMap<String, String>>,
140+
}
141+
142+
impl TestGiven {
143+
fn get_files_count(&self) -> usize {
144+
match &self.files {
145+
None => 0,
146+
Some(files) => files.len(),
147+
}
148+
}
127149
}
128150

129151
#[derive(Deserialize)]
@@ -232,7 +254,8 @@ impl Case {
232254
out.write("expect_success: ")
233255
.write(&self.expect_success.to_string())
234256
.writeln(",");
235-
out.write("md: MD,");
257+
out.writeln("md: MD,");
258+
out.write("files: &FILES,");
236259
});
237260
out.write("}.check();");
238261
});

scripts/system_test

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ function run_test_spec() {
6363
local stdin
6464
pushd "$(mktemp -d)"
6565

66+
while read -r md_test_file; do
67+
local write_to="$md_test_file"
68+
jq -r '.given.files[$name]' <<<"$spec" --arg name "$md_test_file" > "$write_to"
69+
done <<<"$(jq -r '.given.files | keys | .[]' <<<"$spec")"
70+
6671
if jq -e '.expect.ignore' <<<"$spec" &>/dev/null ; then
6772
msg warning "$full_name" 'skipping test case'
6873
return 0
@@ -81,7 +86,7 @@ function run_test_spec() {
8186

8287
local actual_success=true
8388
set -x
84-
"$mdq" <<<"$stdin" "${cli_args[@]}" >actual_out.txt 2>actual_err.txt || actual_success=false
89+
MDQ_PORTABLE_ERRORS=1 "$mdq" <<<"$stdin" "${cli_args[@]}" >actual_out.txt 2>actual_err.txt || actual_success=false
8590
set +x
8691

8792
if [[ "$output_json" == true ]]; then

src/cli.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ pub struct Cli {
7979
// See: tree.rs > Lookups::unknown_markdown.
8080
#[arg(long, hide = true)]
8181
pub(crate) allow_unknown_markdown: bool,
82+
83+
/// An optional list of Markdown files to parse, by path. If not provided, standard input will be used.
84+
///
85+
/// If these are provided, mdq will act as if they were all concatenated into a single file. For example, if you
86+
/// use --link-pos=doc, the link definitions for all input files will be at the very end of the output.
87+
///
88+
/// A path of "-" represents standard input.
89+
///
90+
/// If these are provided, standard input will not be used unless one of the arguments is "-". Files will be
91+
/// processed in the order you provide them. If you provide the same file twice, mdq will process it twice, unless
92+
/// that file is "-"; all but the first "-" paths are ignored.
93+
#[arg()]
94+
pub(crate) markdown_file_paths: Vec<String>,
8295
}
8396

8497
impl Cli {
@@ -206,17 +219,28 @@ mod tests {
206219
Cli::command().debug_assert();
207220
}
208221

222+
#[test]
223+
fn no_args() {
224+
let result = Cli::try_parse_from(["mdq"]);
225+
unwrap!(result, Ok(cli));
226+
assert_eq!(cli.selector_string().as_str(), "");
227+
assert!(cli.markdown_file_paths.is_empty());
228+
}
229+
209230
#[test]
210231
fn standard_selectors() {
211232
let result = Cli::try_parse_from(["mdq", "# hello"]);
212233
unwrap!(result, Ok(cli));
213234
assert_eq!(cli.selector_string().as_str(), "# hello");
235+
assert!(cli.markdown_file_paths.is_empty());
214236
}
215237

216238
#[test]
217-
fn two_standard_selectors() {
218-
let result = Cli::try_parse_from(["mdq", "# hello", "# world"]);
219-
check_err(&result, "unexpected argument '# world' found");
239+
fn selector_and_file() {
240+
let result = Cli::try_parse_from(["mdq", "# hello", "file.txt"]);
241+
unwrap!(result, Ok(cli));
242+
assert_eq!(cli.selector_string().as_str(), "# hello");
243+
assert_eq!(cli.markdown_file_paths, ["file.txt"]);
220244
}
221245

222246
#[test]

src/lib.rs

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ use pest::error::ErrorVariant;
1212
use pest::Span;
1313
use std::borrow::Cow;
1414
use std::fmt::{Display, Formatter};
15-
use std::io;
16-
use std::io::{stdin, Read, Write};
15+
use std::io::Write;
16+
use std::{env, io};
1717

1818
pub mod cli;
1919
mod fmt_md;
@@ -40,6 +40,28 @@ mod words_buffer;
4040
pub enum Error {
4141
QueryParse { query_string: String, error: ParseError },
4242
MarkdownParse(InvalidMd),
43+
FileReadError(File, io::Error),
44+
}
45+
46+
#[derive(Debug)]
47+
pub enum File {
48+
Stdin,
49+
Path(String),
50+
}
51+
52+
impl Error {
53+
pub fn from_io_error(error: io::Error, file: File) -> Self {
54+
Error::FileReadError(file, error)
55+
}
56+
}
57+
58+
impl Display for File {
59+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
60+
match self {
61+
File::Stdin => f.write_str("stdin"),
62+
File::Path(file) => write!(f, "file {file:?}"),
63+
}
64+
}
4365
}
4466

4567
impl Display for Error {
@@ -66,36 +88,82 @@ impl Display for Error {
6688
writeln!(f, "Markdown parse error:")?;
6789
writeln!(f, "{err}")
6890
}
91+
Error::FileReadError(file, err) => {
92+
if env::var("MDQ_PORTABLE_ERRORS").unwrap_or(String::new()).is_empty() {
93+
writeln!(f, "{err} while reading {file}")
94+
} else {
95+
writeln!(f, "{} while reading {file}", err.kind())
96+
}
97+
}
98+
}
99+
}
100+
}
101+
102+
pub trait OsFacade {
103+
fn read_stdin(&self) -> io::Result<String>;
104+
fn read_file(&self, path: &str) -> io::Result<String>;
105+
106+
fn read_all(&self, cli: &Cli) -> Result<String, Error> {
107+
if cli.markdown_file_paths.is_empty() {
108+
return self.read_stdin().map_err(|err| Error::from_io_error(err, File::Stdin));
69109
}
110+
let mut contents = String::new();
111+
let mut have_read_stdin = false;
112+
for path in &cli.markdown_file_paths {
113+
if path == "-" {
114+
if !have_read_stdin {
115+
contents.push_str(
116+
&self
117+
.read_stdin()
118+
.map_err(|err| Error::from_io_error(err, File::Stdin))?,
119+
);
120+
have_read_stdin = true
121+
}
122+
} else {
123+
let path_contents = self
124+
.read_file(path)
125+
.map_err(|err| Error::from_io_error(err, File::Path(path.to_string())))?;
126+
contents.push_str(&path_contents);
127+
}
128+
contents.push('\n');
129+
}
130+
Ok(contents)
70131
}
71132
}
72133

73-
pub fn run_in_memory(cli: &Cli, contents: &str) -> Result<(bool, String), Error> {
134+
pub fn run_in_memory(cli: &Cli, os: impl OsFacade) -> Result<(bool, String), Error> {
135+
let contents_str = os.read_all(&cli)?;
74136
let mut out = Vec::with_capacity(256); // just a guess
75137

76-
let result = run(&cli, contents.to_string(), || &mut out)?;
138+
let result = run(&cli, contents_str, || &mut out)?;
77139
let out_str = String::from_utf8(out).unwrap_or_else(|err| String::from_utf8_lossy(err.as_bytes()).into_owned());
78140
Ok((result, out_str))
79141
}
80142

81-
pub fn run_stdio(cli: &Cli) -> bool {
143+
pub fn run_stdio(cli: &Cli, os: impl OsFacade) -> bool {
82144
if !cli.extra_validation() {
83145
return false;
84146
}
85-
let mut contents = String::new();
86-
stdin().read_to_string(&mut contents).expect("invalid input (not utf8)");
147+
let contents = match os.read_all(&cli) {
148+
Ok(s) => s,
149+
Err(err) => {
150+
eprint!("{err}");
151+
return false;
152+
}
153+
};
154+
87155
run(&cli, contents, || io::stdout().lock()).unwrap_or_else(|err| {
88156
eprint!("{err}");
89157
false
90158
})
91159
}
92160

93-
fn run<W, F>(cli: &Cli, contents: String, get_out: F) -> Result<bool, Error>
161+
fn run<W, F>(cli: &Cli, stdin_content: String, get_out: F) -> Result<bool, Error>
94162
where
95163
F: FnOnce() -> W,
96164
W: Write,
97165
{
98-
let ast = markdown::to_mdast(&contents, &markdown::ParseOptions::gfm()).unwrap();
166+
let ast = markdown::to_mdast(&stdin_content, &markdown::ParseOptions::gfm()).unwrap();
99167
let read_options = ReadOptions {
100168
validate_no_conflicting_links: false,
101169
allow_unknown_markdown: cli.allow_unknown_markdown,

src/main.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
use clap::Parser;
22
use mdq::cli::Cli;
3-
use mdq::run_stdio;
3+
use mdq::{run_stdio, OsFacade};
4+
use std::io;
5+
use std::io::{stdin, Read};
46
use std::process::ExitCode;
57

8+
struct RealOs;
9+
10+
impl OsFacade for RealOs {
11+
fn read_stdin(&self) -> io::Result<String> {
12+
let mut contents = String::new();
13+
stdin().read_to_string(&mut contents)?;
14+
Ok(contents)
15+
}
16+
17+
fn read_file(&self, path: &str) -> io::Result<String> {
18+
std::fs::read_to_string(path)
19+
}
20+
}
21+
622
fn main() -> ExitCode {
723
let cli = Cli::parse();
824

9-
if run_stdio(&cli) {
25+
if run_stdio(&cli, RealOs) {
1026
ExitCode::SUCCESS
1127
} else {
1228
ExitCode::FAILURE

tests/integ_test.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
use clap::Parser;
2+
use std::ffi::OsString;
3+
use std::io::ErrorKind;
4+
use std::{env, io};
25

36
#[derive(Debug)]
47
struct Case<const N: usize> {
@@ -7,9 +10,25 @@ struct Case<const N: usize> {
710
expect_error: &'static str,
811
expect_output_json: bool,
912
md: &'static str,
13+
files: &'static [(&'static str, &'static str)],
1014
expect_success: bool,
1115
}
1216

17+
impl<const N: usize> mdq::OsFacade for &Case<N> {
18+
fn read_stdin(&self) -> io::Result<String> {
19+
Ok(self.md.to_string())
20+
}
21+
22+
fn read_file(&self, path: &str) -> io::Result<String> {
23+
for (name, content) in self.files {
24+
if path == *name {
25+
return Ok(content.to_string());
26+
}
27+
}
28+
Err(io::Error::new(ErrorKind::NotFound, format!("File not found: {}", path)))
29+
}
30+
}
31+
1332
impl<const N: usize> Case<N> {
1433
fn check(&self) {
1534
let (actual_success, actual_out, actual_err) = self.run();
@@ -31,9 +50,31 @@ impl<const N: usize> Case<N> {
3150
fn run(&self) -> (bool, String, String) {
3251
let all_cli_args = ["cmd"].iter().chain(&self.cli_args);
3352
let cli = mdq::cli::Cli::try_parse_from(all_cli_args).unwrap();
34-
match mdq::run_in_memory(&cli, self.md) {
53+
let restore = EnvVarRestore::set_var("MDQ_PORTABLE_ERRORS", "1");
54+
let result = match mdq::run_in_memory(&cli, self) {
3555
Ok((found, stdout)) => (found, stdout, String::new()),
3656
Err(err) => (false, String::new(), err.to_string()),
57+
};
58+
drop(restore);
59+
result
60+
}
61+
}
62+
63+
struct EnvVarRestore(&'static str, Option<OsString>);
64+
65+
impl EnvVarRestore {
66+
fn set_var(key: &'static str, value: &str) -> Self {
67+
let restore = Self(key, env::var_os(key));
68+
env::set_var(key, value);
69+
restore
70+
}
71+
}
72+
73+
impl Drop for EnvVarRestore {
74+
fn drop(&mut self) {
75+
match (&self.0, &self.1) {
76+
(key, None) => env::remove_var(key),
77+
(key, Some(old)) => env::set_var(key, old),
3778
}
3879
}
3980
}

tests/md_cases/file_args.toml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
[given]
2+
md = '''
3+
- from stdin
4+
'''
5+
files."one.txt" = '''
6+
- from one.txt
7+
'''
8+
files."two.txt" = '''
9+
- from two.txt
10+
'''
11+
12+
[chained]
13+
needed = false
14+
15+
[expect."read one file"]
16+
cli_args = ['-', '-oplain', 'one.txt']
17+
output = '''
18+
from one.txt
19+
'''
20+
21+
[expect."read two files"]
22+
cli_args = ['-', '-oplain', 'one.txt', 'two.txt']
23+
output = '''
24+
from one.txt
25+
from two.txt
26+
'''
27+
28+
[expect."read a file twice"]
29+
cli_args = ['-', '-oplain', 'one.txt', 'one.txt']
30+
output = '''
31+
from one.txt
32+
from one.txt
33+
'''
34+
35+
[expect."explicitly read stdin"]
36+
cli_args = ['-', '-oplain', '-']
37+
output = '''
38+
from stdin
39+
'''
40+
41+
[expect."explicitly read stdin twice"] # will only read it once!
42+
cli_args = ['-', '-oplain', '-', '-']
43+
output = '''
44+
from stdin
45+
'''
46+
47+
[expect."file is missing"] # will only read it once!
48+
cli_args = ['-', '-oplain', 'missing-err.txt']
49+
expect_success = false
50+
output = ''
51+
output_err = '''entity not found while reading file "missing-err.txt"
52+
'''

0 commit comments

Comments
 (0)