Skip to content

Commit 1c3eb59

Browse files
authoredMar 8, 2025··
Add support for source tracking (#16)
Adds the `track-source` feature flag that, when enabled: - Allows `RawValue` to store the file and line number it originated from. - Makes `ConfigParser` generate `RawValue`s that store their sources. Adds flags to `ec4rs-parse` to allow printing sources. Closes #15
1 parent fd11621 commit 1c3eb59

File tree

9 files changed

+222
-36
lines changed

9 files changed

+222
-36
lines changed
 

‎CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 1.2.0 (Next)
44

5+
- Added feature `track-source` to track where any given value came from.
6+
- Added `-0Hl` flags to `ec4rs-parse` for displaying value sources.
57
- Added `RawValue::to_lowercase`.
68
- Implemented `Display` for `RawValue`.
79
- Changed `ec4rs-parse` to support empty values for compliance with

‎Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ members = ["tools"]
2121

2222
[features]
2323
allow-empty-values = []
24+
track-source = []
2425

2526
[dependencies]
2627
language-tags = { version = "0.3.2", optional = true }

‎rustdoc.md

+4
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,7 @@ as invalid, necessitating this feature flag to reduce behavioral breakage.
5555

5656
**language-tags**: Use the `language-tags` crate, which adds parsing for the
5757
`spelling_language` property.
58+
59+
**track-source**: Allow [`RawValue`][crate::rawvalue::RawValue]
60+
to store the file and line number it originates from.
61+
[`ConfigParser`] will add this information where applicable.

‎src/file.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::{ConfigParser, Error, ParseError, Properties, PropertiesSource, Secti
44

55
/// Convenience wrapper for an [`ConfigParser`] that reads files.
66
pub struct ConfigFile {
7+
// TODO: Arc<Path>. It's more important to have cheap clones than mutability.
78
/// The path to the open file.
89
pub path: PathBuf,
910
/// A [`ConfigParser`] that reads from the file.
@@ -17,7 +18,7 @@ impl ConfigFile {
1718
pub fn open(path: impl Into<PathBuf>) -> Result<ConfigFile, ParseError> {
1819
let path = path.into();
1920
let file = std::fs::File::open(&path).map_err(ParseError::Io)?;
20-
let reader = ConfigParser::new_buffered(file)?;
21+
let reader = ConfigParser::new_buffered_with_path(file, Some(path.as_ref()))?;
2122
Ok(ConfigFile { path, reader })
2223
}
2324

‎src/parser.rs

+45-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::linereader::LineReader;
22
use crate::ParseError;
33
use crate::Section;
44
use std::io;
5+
use std::path::Path;
56

67
/// Parser for the text of an EditorConfig file.
78
///
@@ -13,6 +14,8 @@ pub struct ConfigParser<R: io::BufRead> {
1314
pub is_root: bool,
1415
eof: bool,
1516
reader: LineReader<R>,
17+
#[cfg(feature = "track-source")]
18+
path: Option<std::sync::Arc<Path>>,
1619
}
1720

1821
impl<R: io::Read> ConfigParser<io::BufReader<R>> {
@@ -22,14 +25,33 @@ impl<R: io::Read> ConfigParser<io::BufReader<R>> {
2225
pub fn new_buffered(source: R) -> Result<ConfigParser<io::BufReader<R>>, ParseError> {
2326
Self::new(io::BufReader::new(source))
2427
}
28+
/// Convenience function for construction using an unbuffered [`io::Read`]
29+
/// which is assumed to be a file at `path`.
30+
///
31+
/// See [`ConfigParser::new_with_path`].
32+
pub fn new_buffered_with_path(
33+
source: R,
34+
path: Option<impl Into<std::sync::Arc<Path>>>,
35+
) -> Result<ConfigParser<io::BufReader<R>>, ParseError> {
36+
Self::new_with_path(io::BufReader::new(source), path)
37+
}
2538
}
2639

2740
impl<R: io::BufRead> ConfigParser<R> {
28-
/// Constructs a new [`ConfigParser`] and reads the preamble from the provided source.
41+
/// Constructs a new [`ConfigParser`] and reads the preamble from the provided source,
42+
/// which is assumed to be a file at `path`.
2943
///
3044
/// Returns `Ok` if the preamble was parsed successfully,
3145
/// otherwise returns `Err` with the error that occurred during reading.
32-
pub fn new(buf_source: R) -> Result<ConfigParser<R>, ParseError> {
46+
///
47+
/// If the `track-source` feature is enabled and `path` is `Some`,
48+
/// [`RawValue`][crate::rawvalue::RawValue]s produced by this parser will
49+
/// have their sources set appropriately.
50+
/// Otherwise, `path` is unused.
51+
pub fn new_with_path(
52+
buf_source: R,
53+
#[allow(unused)] path: Option<impl Into<std::sync::Arc<Path>>>,
54+
) -> Result<ConfigParser<R>, ParseError> {
3355
let mut reader = LineReader::new(buf_source);
3456
let mut is_root = false;
3557
let eof = loop {
@@ -49,12 +71,23 @@ impl<R: io::BufRead> ConfigParser<R> {
4971
}
5072
}
5173
};
74+
#[cfg(feature = "track-source")]
75+
let path = path.map(Into::into);
5276
Ok(ConfigParser {
5377
is_root,
5478
eof,
5579
reader,
80+
#[cfg(feature = "track-source")]
81+
path,
5682
})
5783
}
84+
/// Constructs a new [`ConfigParser`] and reads the preamble from the provided source.
85+
///
86+
/// Returns `Ok` if the preamble was parsed successfully,
87+
/// otherwise returns `Err` with the error that occurred during reading.
88+
pub fn new(buf_source: R) -> Result<ConfigParser<R>, ParseError> {
89+
Self::new_with_path(buf_source, Option::<std::sync::Arc<Path>>::None)
90+
}
5891

5992
/// Returns `true` if there may be another section to read.
6093
pub fn has_more(&self) -> bool {
@@ -75,6 +108,9 @@ impl<R: io::BufRead> ConfigParser<R> {
75108
if let Ok(Line::Section(header)) = self.reader.reparse() {
76109
let mut section = Section::new(header);
77110
loop {
111+
// Get line_no here to avoid borrowing issues, increment for 1-based indices.
112+
#[cfg(feature = "track-source")]
113+
let line_no = self.reader.line_no() + 1;
78114
match self.reader.next_line() {
79115
Err(e) => {
80116
self.eof = true;
@@ -87,7 +123,13 @@ impl<R: io::BufRead> ConfigParser<R> {
87123
Ok(Line::Section(_)) => break Ok(section),
88124
Ok(Line::Nothing) => (),
89125
Ok(Line::Pair(k, v)) => {
90-
section.insert(k, v.to_owned());
126+
#[allow(unused_mut)]
127+
let mut v = crate::rawvalue::RawValue::from(v.to_owned());
128+
#[cfg(feature = "track-source")]
129+
if let Some(path) = self.path.as_ref() {
130+
v.set_source(path.clone(), line_no);
131+
}
132+
section.insert(k, v);
91133
}
92134
}
93135
}

‎src/rawvalue.rs

+85-12
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,93 @@ use std::borrow::Cow;
88
///
99
/// Not all unset `&RawValues` returned by this library are referentially equal to this one.
1010
/// This exists to provide an unset raw value for whenever a reference to one is necessary.
11-
pub static UNSET: RawValue = RawValue(Cow::Borrowed(""));
11+
pub static UNSET: RawValue = RawValue {
12+
value: Cow::Borrowed(""),
13+
#[cfg(feature = "track-source")]
14+
source: None,
15+
};
1216

1317
/// An unparsed property value.
1418
///
1519
/// This is conceptually an optional non-empty string with some convenience methods.
16-
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
17-
pub struct RawValue(Cow<'static, str>);
20+
/// With the `track-source` feature,
21+
/// objects of this type can also track the file and line number they originate from.
22+
#[derive(Clone, Debug, Default)]
23+
pub struct RawValue {
24+
value: Cow<'static, str>,
25+
#[cfg(feature = "track-source")]
26+
source: Option<(std::sync::Arc<std::path::Path>, usize)>,
27+
}
28+
29+
// Manual-impl (Partial)Eq, (Partial)Ord, and Hash so that the source isn't considered.
30+
31+
impl PartialEq for RawValue {
32+
fn eq(&self, other: &Self) -> bool {
33+
self.value == other.value
34+
}
35+
}
36+
impl Eq for RawValue {}
37+
impl PartialOrd for RawValue {
38+
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
39+
Some(self.value.cmp(&other.value))
40+
}
41+
}
42+
impl Ord for RawValue {
43+
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
44+
self.value.cmp(&other.value)
45+
}
46+
}
47+
impl std::hash::Hash for RawValue {
48+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
49+
state.write(self.value.as_bytes());
50+
state.write_u8(0);
51+
}
52+
}
1853

1954
impl RawValue {
2055
#[must_use]
2156
fn detect_unset(&self) -> Option<bool> {
2257
if self.is_unset() {
2358
Some(false)
24-
} else if "unset".eq_ignore_ascii_case(self.0.as_ref()) {
59+
} else if "unset".eq_ignore_ascii_case(self.value.as_ref()) {
2560
Some(true)
2661
} else {
2762
None
2863
}
2964
}
3065

66+
#[cfg(feature = "track-source")]
67+
/// Returns the path to the file and the line number that this value originates from.
68+
///
69+
/// The line number is 1-indexed to match convention;
70+
/// the first line will have a line number of 1 rather than 0.
71+
pub fn source(&self) -> Option<(&std::path::Path, usize)> {
72+
self.source
73+
.as_ref()
74+
.map(|(path, line)| (std::sync::Arc::as_ref(path), *line))
75+
}
76+
77+
#[cfg(feature = "track-source")]
78+
/// Sets the path and line number from which this value originated.
79+
///
80+
/// The line number should be 1-indexed to match convention;
81+
/// the first line should have a line number of 1 rather than 0.
82+
pub fn set_source(&mut self, path: impl Into<std::sync::Arc<std::path::Path>>, line: usize) {
83+
self.source = Some((path.into(), line))
84+
}
85+
86+
#[cfg(feature = "track-source")]
87+
/// Clears the path and line number from which this value originated.
88+
pub fn clear_source(&mut self) {
89+
self.source = None;
90+
}
91+
3192
/// Returns true if the value is unset.
3293
///
3394
/// Does not handle values of "unset".
3495
/// See [`RawValue::filter_unset`].
3596
pub fn is_unset(&self) -> bool {
36-
self.0.is_empty()
97+
self.value.is_empty()
3798
}
3899

39100
/// Returns a reference to.an unset `RawValue`
@@ -66,13 +127,13 @@ impl RawValue {
66127
if let Some(v) = self.detect_unset() {
67128
Err(v)
68129
} else {
69-
Ok(self.0.as_ref())
130+
Ok(self.value.as_ref())
70131
}
71132
}
72133

73134
/// Converts this `RawValue` into an [`Option`].
74135
pub fn into_option(&self) -> Option<&str> {
75-
Some(self.0.as_ref()).filter(|v| !v.is_empty())
136+
Some(self.value.as_ref()).filter(|v| !v.is_empty())
76137
}
77138

78139
/// Converts this `RawValue` into `&str`.
@@ -82,7 +143,7 @@ impl RawValue {
82143
if self.is_unset() {
83144
"unset"
84145
} else {
85-
self.0.as_ref()
146+
self.value.as_ref()
86147
}
87148
}
88149

@@ -111,19 +172,27 @@ impl RawValue {
111172
/// Returns a lowercased version of `self`.
112173
#[must_use]
113174
pub fn to_lowercase(&self) -> Self {
114-
Self(Cow::Owned(self.0.to_lowercase()))
175+
Self {
176+
value: Cow::Owned(self.value.to_lowercase()),
177+
#[cfg(feature = "track-source")]
178+
source: self.source.clone(),
179+
}
115180
}
116181
}
117182

118183
impl std::fmt::Display for RawValue {
119184
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120-
write!(f, "{}", self.0.as_ref())
185+
write!(f, "{}", self.value.as_ref())
121186
}
122187
}
123188

124189
impl From<String> for RawValue {
125190
fn from(value: String) -> Self {
126-
RawValue(Cow::Owned(value))
191+
RawValue {
192+
value: Cow::Owned(value),
193+
#[cfg(feature = "track-source")]
194+
source: None,
195+
}
127196
}
128197
}
129198

@@ -132,7 +201,11 @@ impl From<&'static str> for RawValue {
132201
if value.is_empty() {
133202
UNSET.clone()
134203
} else {
135-
RawValue(Cow::Borrowed(value))
204+
RawValue {
205+
value: Cow::Borrowed(value),
206+
#[cfg(feature = "track-source")]
207+
source: None,
208+
}
136209
}
137210
}
138211
}

‎src/tests/ecparser.rs

+29-14
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
11
fn validate<'a>(
22
text: &str,
33
should_be_root: bool,
4-
expected: impl IntoIterator<Item = &'a [(&'a str, &'a str)]>,
4+
expected: impl IntoIterator<Item = &'a [(&'a str, &'a str, usize)]>,
55
) {
6-
let mut parser =
7-
crate::ConfigParser::new(text.as_bytes()).expect("Should have created the parser");
6+
let path = std::sync::Arc::<std::path::Path>::from(std::path::Path::new(".editorconfig"));
7+
let mut parser = crate::ConfigParser::new_buffered_with_path(text.as_bytes(), Some(path))
8+
.expect("Should have created the parser");
89
assert_eq!(parser.is_root, should_be_root);
910
for section_expected in expected {
1011
let section = parser.next().unwrap().unwrap();
11-
let mut iter = section.props().iter().map(|(k, v)| (k, v.into_str()));
12-
for (key, value) in section_expected {
13-
assert_eq!(iter.next(), Some((*key, *value)))
12+
let mut iter = section.props().iter();
13+
#[allow(unused)]
14+
for (key, value, line_no) in section_expected {
15+
let (key_test, value_test) = iter.next().expect("Unexpected end of section");
16+
assert_eq!(key_test, *key, "unexpected key");
17+
assert_eq!(value_test.into_str(), *value, "unexpected value");
18+
#[cfg(feature = "track-source")]
19+
assert_eq!(
20+
value_test.source().map(|(_, idx)| idx),
21+
Some(*line_no),
22+
"unexpected line number"
23+
)
1424
}
1525
assert!(iter.next().is_none());
1626
}
1727
assert!(parser.next().is_none());
1828
}
1929

2030
macro_rules! expect {
21-
[$([$(($key:literal, $value:literal)),*]),*] => {
22-
[$(&[$(($key, $value)),*][..]),*]
31+
[$([$(($key:literal, $value:literal, $line_no:literal)),*]),*] => {
32+
[$(&[$(($key, $value, $line_no)),*][..]),*]
2333
}
2434
}
2535

@@ -53,31 +63,36 @@ fn sections() {
5363
validate(
5464
"[foo]\nbk=bv\nak=av",
5565
false,
56-
expect![[("bk", "bv"), ("ak", "av")]],
66+
expect![[("bk", "bv", 2), ("ak", "av", 3)]],
5767
);
5868
validate(
5969
"[foo]\nbk=bv\n[bar]\nak=av",
6070
false,
61-
expect![[("bk", "bv")], [("ak", "av")]],
71+
expect![[("bk", "bv", 2)], [("ak", "av", 4)]],
6272
);
6373
validate(
6474
"[foo]\nk=a\n[bar]\nk=b",
6575
false,
66-
expect![[("k", "a")], [("k", "b")]],
76+
expect![[("k", "a", 2)], [("k", "b", 4)]],
6777
);
6878
}
6979

7080
#[test]
7181
fn trailing_newline() {
72-
validate("[foo]\nbar=baz\n", false, expect![[("bar", "baz")]]);
73-
validate("[foo]\nbar=baz\n\n", false, expect![[("bar", "baz")]]);
82+
validate("[foo]\nbar=baz\n", false, expect![[("bar", "baz", 2)]]);
83+
validate("[foo]\nbar=baz\n\n", false, expect![[("bar", "baz", 2)]]);
7484
}
7585

7686
#[test]
7787
fn section_with_comment_after_it() {
7888
validate(
7989
"[/*] # ignore this comment\nk=v",
8090
false,
81-
expect![[("k", "v")]],
91+
expect![[("k", "v", 2)]],
8292
);
8393
}
94+
95+
#[test]
96+
fn duplicate_key() {
97+
validate("[*]\nfoo=bar\nfoo=baz", false, expect![[("foo", "baz", 3)]]);
98+
}

‎tools/Cargo.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[package]
22
name = "ec4rs_tools"
3-
version = "1.0.1"
3+
version = "1.1.0"
44
edition = "2021"
55

66
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
77

88
[dependencies]
9-
ec4rs = { path = "..", features = ["allow-empty-values"] }
9+
ec4rs = { path = "..", features = ["allow-empty-values", "track-source"] }
1010
semver = "1.0"
1111
clap = { version = "3.1", features = ["derive"] }
1212

‎tools/src/bin/ec4rs-parse.rs

+52-4
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@ use std::path::PathBuf;
33
use clap::Parser;
44
use semver::{Version, VersionReq};
55

6+
#[derive(Parser)]
7+
struct DisplayArgs {
8+
/// Prefix each line with the path to the file where the value originated
9+
#[clap(short = 'H', long)]
10+
with_filename: bool,
11+
/// Prefix each line with the line number where the value originated
12+
#[clap(short = 'n', long)]
13+
line_number: bool,
14+
/// Use the NUL byte as a field delimiter instead of ':'
15+
#[clap(short = '0', long)]
16+
null: bool,
17+
}
18+
619
#[derive(Parser)]
720
#[clap(disable_version_flag = true)]
821
struct Args {
22+
#[clap(flatten)]
23+
display: DisplayArgs,
924
/// Override config filename
1025
#[clap(short)]
1126
filename: Option<PathBuf>,
@@ -18,7 +33,21 @@ struct Args {
1833
files: Vec<PathBuf>,
1934
}
2035

21-
fn print_config(path: &std::path::Path, filename: Option<&PathBuf>, legacy_fallbacks: bool) {
36+
fn print_empty_prefix(display: &DisplayArgs) {
37+
if display.with_filename {
38+
print!("{}", if display.null { '\0' } else { ':' });
39+
}
40+
if display.line_number {
41+
print!("{}", if display.null { '\0' } else { ':' });
42+
}
43+
}
44+
45+
fn print_config(
46+
path: &std::path::Path,
47+
filename: Option<&PathBuf>,
48+
legacy_fallbacks: bool,
49+
display: &DisplayArgs,
50+
) {
2251
match ec4rs::properties_from_config_of(path, filename) {
2352
Ok(mut props) => {
2453
if legacy_fallbacks {
@@ -27,11 +56,27 @@ fn print_config(path: &std::path::Path, filename: Option<&PathBuf>, legacy_fallb
2756
props.use_fallbacks();
2857
}
2958
for (key, value) in props.iter() {
30-
if ec4rs::property::STANDARD_KEYS.contains(&key) {
31-
println!("{}={}", key, value.to_lowercase())
59+
let mut lc_value: Option<ec4rs::rawvalue::RawValue> = None;
60+
let value_ref = if ec4rs::property::STANDARD_KEYS.contains(&key) {
61+
lc_value.get_or_insert(value.to_lowercase())
62+
} else {
63+
value
64+
};
65+
if let Some((path, line_no)) = value_ref.source() {
66+
if display.with_filename {
67+
print!(
68+
"{}{}",
69+
path.to_string_lossy(),
70+
if display.null { '\0' } else { ':' }
71+
);
72+
}
73+
if display.line_number {
74+
print!("{}{}", line_no, if display.null { '\0' } else { ':' });
75+
}
3276
} else {
33-
println!("{}={}", key, value);
77+
print_empty_prefix(display);
3478
}
79+
println!("{}={}", key, value_ref)
3580
}
3681
}
3782
Err(e) => eprintln!("{}", e),
@@ -52,14 +97,17 @@ fn main() {
5297
args.files.first().unwrap(),
5398
args.filename.as_ref(),
5499
legacy_ver.matches(&args.ec_version),
100+
&args.display,
55101
);
56102
} else {
57103
for path in args.files {
104+
print_empty_prefix(&args.display);
58105
println!("[{}]", path.to_string_lossy());
59106
print_config(
60107
&path,
61108
args.filename.as_ref(),
62109
legacy_ver.matches(&args.ec_version),
110+
&args.display,
63111
);
64112
}
65113
}

0 commit comments

Comments
 (0)
Please sign in to comment.