Skip to content

Commit af3f10e

Browse files
committed
fix(parser)!: preserve literal content in inline passthroughs
Inline passthroughs `+...+` and `++...++` now preserve literal content instead of applying typography replacements (e.g., `...` → ellipsis). Changed `Raw::escape_special_chars: bool` to `Raw::subs: Vec<Substitution>` so passthroughs carry their actual substitution list. The converter uses these subs directly instead of the enclosing block's substitutions. BREAKING CHANGE: `Raw::escape_special_chars` replaced with `Raw::subs`. Fixes #323
1 parent be13453 commit af3f10e

File tree

9 files changed

+275
-32
lines changed

9 files changed

+275
-32
lines changed

acdc-parser/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to `acdc-parser` will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- Inline passthroughs `+...+` and `++...++` no longer convert `...` to an ellipsis
13+
entity (`&#8230;&#8203;`). The root cause was that non-Quotes passthroughs were emitted
14+
as `PlainText` nodes, which got merged with adjacent text and lost their passthrough
15+
identity — the converter then applied the block's full substitutions (including
16+
Replacements). Passthroughs now carry their own substitution list on the `Raw` node
17+
(`subs: Vec<Substitution>`) instead of a boolean flag, so the converter applies exactly
18+
the right subs. ([#323])
19+
20+
### Changed
21+
22+
- **BREAKING**: `Raw::escape_special_chars: bool` replaced with `Raw::subs: Vec<Substitution>`.
23+
The new field carries the passthrough's actual substitution list rather than a lossy
24+
boolean encoding. An empty vec means raw output (no subs), matching `+++` and `pass:[]`.
25+
826
## [0.3.0] - 2026-02-01
927

1028
### Added
@@ -224,6 +242,7 @@ Initial release of acdc-parser, a PEG-based AsciiDoc parser with source location
224242
[#317]: https://github.com/nlopes/acdc/issues/317
225243
[#320]: https://github.com/nlopes/acdc/issues/320
226244
[#321]: https://github.com/nlopes/acdc/issues/321
245+
[#323]: https://github.com/nlopes/acdc/issues/323
227246

228247
[0.3.0]: https://github.com/nlopes/acdc/releases/tag/acdc-parser-v0.3.0
229248
[0.2.0]: https://github.com/nlopes/acdc/releases/tag/acdc-parser-v0.2.0

acdc-parser/fixtures/tests/constrained_passthrough_plus_valid.json

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,42 @@
99
{
1010
"name": "text",
1111
"type": "string",
12-
"value": "word passthrough word",
12+
"value": "word ",
1313
"location": [
1414
{
1515
"line": 1,
1616
"col": 1
1717
},
18+
{
19+
"line": 1,
20+
"col": 6
21+
}
22+
]
23+
},
24+
{
25+
"name": "raw",
26+
"type": "string",
27+
"value": "passthrough",
28+
"location": [
29+
{
30+
"line": 1,
31+
"col": 6
32+
},
33+
{
34+
"line": 1,
35+
"col": 19
36+
}
37+
]
38+
},
39+
{
40+
"name": "text",
41+
"type": "string",
42+
"value": " word",
43+
"location": [
44+
{
45+
"line": 1,
46+
"col": 19
47+
},
1848
{
1949
"line": 1,
2050
"col": 23
@@ -40,15 +70,30 @@
4070
{
4171
"name": "text",
4272
"type": "string",
43-
"value": "word passthrough",
73+
"value": "word ",
4474
"location": [
4575
{
4676
"line": 3,
4777
"col": 1
4878
},
4979
{
5080
"line": 3,
51-
"col": 56
81+
"col": 6
82+
}
83+
]
84+
},
85+
{
86+
"name": "raw",
87+
"type": "string",
88+
"value": "passthrough",
89+
"location": [
90+
{
91+
"line": 3,
92+
"col": 6
93+
},
94+
{
95+
"line": 3,
96+
"col": 19
5297
}
5398
]
5499
}
@@ -68,14 +113,29 @@
68113
"name": "paragraph",
69114
"type": "block",
70115
"inlines": [
116+
{
117+
"name": "raw",
118+
"type": "string",
119+
"value": "passthrough",
120+
"location": [
121+
{
122+
"line": 5,
123+
"col": 1
124+
},
125+
{
126+
"line": 5,
127+
"col": 14
128+
}
129+
]
130+
},
71131
{
72132
"name": "text",
73133
"type": "string",
74-
"value": "passthrough word",
134+
"value": " word",
75135
"location": [
76136
{
77137
"line": 5,
78-
"col": 53
138+
"col": 14
79139
},
80140
{
81141
"line": 5,
@@ -100,17 +160,17 @@
100160
"type": "block",
101161
"inlines": [
102162
{
103-
"name": "text",
163+
"name": "raw",
104164
"type": "string",
105165
"value": "passthrough",
106166
"location": [
107167
{
108168
"line": 7,
109-
"col": 73
169+
"col": 1
110170
},
111171
{
112172
"line": 7,
113-
"col": 86
173+
"col": 14
114174
}
115175
]
116176
}
@@ -133,12 +193,72 @@
133193
{
134194
"name": "text",
135195
"type": "string",
136-
"value": "(passthrough) and [passthrough]",
196+
"value": "(",
137197
"location": [
138198
{
139199
"line": 9,
140200
"col": 1
141201
},
202+
{
203+
"line": 9,
204+
"col": 2
205+
}
206+
]
207+
},
208+
{
209+
"name": "raw",
210+
"type": "string",
211+
"value": "passthrough",
212+
"location": [
213+
{
214+
"line": 9,
215+
"col": 2
216+
},
217+
{
218+
"line": 9,
219+
"col": 15
220+
}
221+
]
222+
},
223+
{
224+
"name": "text",
225+
"type": "string",
226+
"value": ") and [",
227+
"location": [
228+
{
229+
"line": 9,
230+
"col": 15
231+
},
232+
{
233+
"line": 9,
234+
"col": 22
235+
}
236+
]
237+
},
238+
{
239+
"name": "raw",
240+
"type": "string",
241+
"value": "passthrough",
242+
"location": [
243+
{
244+
"line": 9,
245+
"col": 22
246+
},
247+
{
248+
"line": 9,
249+
"col": 35
250+
}
251+
]
252+
},
253+
{
254+
"name": "text",
255+
"type": "string",
256+
"value": "]",
257+
"location": [
258+
{
259+
"line": 9,
260+
"col": 35
261+
},
142262
{
143263
"line": 9,
144264
"col": 35

acdc-parser/fixtures/tests/passthrough_at_line_start.json

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,102 @@
99
{
1010
"name": "text",
1111
"type": "string",
12-
"value": "Any ampersands (&) and angle brackets (< or\n>) will be translated into HTML entities.",
12+
"value": "Any ampersands (",
1313
"location": [
1414
{
1515
"line": 1,
1616
"col": 1
1717
},
18+
{
19+
"line": 1,
20+
"col": 17
21+
}
22+
]
23+
},
24+
{
25+
"name": "raw",
26+
"type": "string",
27+
"value": "&",
28+
"location": [
29+
{
30+
"line": 1,
31+
"col": 17
32+
},
33+
{
34+
"line": 1,
35+
"col": 20
36+
}
37+
]
38+
},
39+
{
40+
"name": "text",
41+
"type": "string",
42+
"value": ") and angle brackets (",
43+
"location": [
44+
{
45+
"line": 1,
46+
"col": 20
47+
},
48+
{
49+
"line": 1,
50+
"col": 42
51+
}
52+
]
53+
},
54+
{
55+
"name": "raw",
56+
"type": "string",
57+
"value": "<",
58+
"location": [
59+
{
60+
"line": 1,
61+
"col": 42
62+
},
63+
{
64+
"line": 1,
65+
"col": 45
66+
}
67+
]
68+
},
69+
{
70+
"name": "text",
71+
"type": "string",
72+
"value": " or\n",
73+
"location": [
74+
{
75+
"line": 1,
76+
"col": 45
77+
},
78+
{
79+
"line": 1,
80+
"col": 49
81+
}
82+
]
83+
},
84+
{
85+
"name": "raw",
86+
"type": "string",
87+
"value": ">",
88+
"location": [
89+
{
90+
"line": 2,
91+
"col": 1
92+
},
93+
{
94+
"line": 2,
95+
"col": 4
96+
}
97+
]
98+
},
99+
{
100+
"name": "text",
101+
"type": "string",
102+
"value": ") will be translated into HTML entities.",
103+
"location": [
104+
{
105+
"line": 1,
106+
"col": 52
107+
},
18108
{
19109
"line": 2,
20110
"col": 43

acdc-parser/src/grammar/document.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2151,12 +2151,14 @@ peg::parser! {
21512151
DelimitedBlockType::DelimitedPass(vec![InlineNode::RawText(Raw {
21522152
content: content.to_string(),
21532153
location: content_location,
2154+
subs: vec![],
21542155
})])
21552156
}
21562157
} else {
21572158
DelimitedBlockType::DelimitedPass(vec![InlineNode::RawText(Raw {
21582159
content: content.to_string(),
21592160
location: content_location,
2161+
subs: vec![],
21602162
})])
21612163
};
21622164

acdc-parser/src/grammar/passthrough_processing.rs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,6 @@ pub(crate) fn process_passthrough_with_quotes(
184184
content: &str,
185185
passthrough: &Pass,
186186
) -> Vec<InlineNode> {
187-
let has_special_chars = passthrough
188-
.substitutions
189-
.contains(&Substitution::SpecialChars);
190187
let has_quotes = passthrough.substitutions.contains(&Substitution::Quotes);
191188

192189
// If no quotes processing needed
@@ -195,18 +192,20 @@ pub(crate) fn process_passthrough_with_quotes(
195192
// This applies to: +text+ (Single), ++text++ (Double), pass:c[] (Macro with SpecialChars)
196193
// Otherwise output raw HTML (return RawText)
197194
// This applies to: +++text+++ (Triple), pass:[] (Macro without SpecialChars)
198-
return if has_special_chars {
199-
vec![InlineNode::PlainText(Plain {
200-
content: content.to_string(),
201-
location: passthrough.location.clone(),
202-
escaped: false,
203-
})]
204-
} else {
205-
vec![InlineNode::RawText(Raw {
206-
content: content.to_string(),
207-
location: passthrough.location.clone(),
208-
})]
209-
};
195+
// Use RawText for all passthroughs without Quotes to avoid merging with
196+
// adjacent PlainText nodes (which would lose the passthrough's substitution info).
197+
// Carry the passthrough's own subs (minus Quotes, already handled) so the
198+
// converter applies exactly those instead of the block's subs.
199+
return vec![InlineNode::RawText(Raw {
200+
content: content.to_string(),
201+
location: passthrough.location.clone(),
202+
subs: passthrough
203+
.substitutions
204+
.iter()
205+
.filter(|s| **s != Substitution::Quotes)
206+
.cloned()
207+
.collect(),
208+
})];
210209
}
211210

212211
tracing::debug!(content = ?content, "Parsing passthrough content with quotes");

0 commit comments

Comments
 (0)