carve-lang 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -1
- data/README.md +15 -0
- data/ext/carve/Cargo.lock +1 -1
- data/ext/carve/Cargo.toml +1 -1
- data/ext/carve/src/lib.rs +177 -31
- data/lib/carve/version.rb +1 -1
- data/lib/carve.rb +49 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd1a19b5989abf287804723f52ff9b4f6ab7098be2d7a6f893caffa7e9ca4f26
|
|
4
|
+
data.tar.gz: 1c503d1890015fd5f129d8464405a6f66a594517026abbf588474fec96ad8e5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80e49b48040288e254718b55f452a0f0d8a3beab8246a7e50f4f8fb3db50d65597453389a0fcbbdb1610a54f99f51c2659da63b7a60482f700c82fe522b92642
|
|
7
|
+
data.tar.gz: 03e5140ca7a00cc0e71c5ca0db95cfabfd223cb15d77cfb456373f8538948c6ba6b12e882d0f81843d466b369a4b13fc64ae9cdd19e1fd59ed22ef9b2deac857
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.11] - 2026-10-23
|
|
11
|
+
|
|
12
|
+
### Improvements
|
|
13
|
+
|
|
14
|
+
- `Carve.lint(source, extensions:)` exposes the engine's linter: findings carry
|
|
15
|
+
the rule id, message, 1-based line and column, and character offsets. With
|
|
16
|
+
`root:` and `source_path:` it lints included files and reports each finding's
|
|
17
|
+
file and `includedBy` chain (#223).
|
|
18
|
+
|
|
10
19
|
## [0.1.10] - 2026-10-23
|
|
11
20
|
|
|
12
21
|
### Breaking
|
|
@@ -527,7 +536,8 @@ are not listed, because no release ever shipped them.
|
|
|
527
536
|
Arrays (every AST node type is covered), enabling custom renderers such as
|
|
528
537
|
[carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
|
|
529
538
|
|
|
530
|
-
[Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.
|
|
539
|
+
[Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.11...HEAD
|
|
540
|
+
[0.1.11]: https://github.com/markup-carve/carve-rb/compare/v0.1.10...v0.1.11
|
|
531
541
|
[0.1.10]: https://github.com/markup-carve/carve-rb/compare/v0.1.9...v0.1.10
|
|
532
542
|
[0.1.9]: https://github.com/markup-carve/carve-rb/compare/v0.1.8...v0.1.9
|
|
533
543
|
[0.1.8]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...v0.1.8
|
data/README.md
CHANGED
|
@@ -140,6 +140,21 @@ Other render methods leave directives literal. See the
|
|
|
140
140
|
[include reference](docs/reference.md#file-includes) for budgets, AST expansion,
|
|
141
141
|
targets, and containment behavior.
|
|
142
142
|
|
|
143
|
+
## Lint
|
|
144
|
+
|
|
145
|
+
`Carve.lint(source)` reports constructs that render but lose what the author
|
|
146
|
+
wrote, such as an unattached attribute or a link to a missing id:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
Carve.lint("é [x](#nope)\n").map { |f| [f[:rule], f[:line], f[:column]] }
|
|
150
|
+
# => [["broken-fragment-link", 1, 3]]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Columns and the `:start`/`:end` offsets count characters, not bytes. Pass
|
|
154
|
+
`root:` and `source_path:` to lint included files too; each such finding names
|
|
155
|
+
its file and the `:includedBy` chain. See the
|
|
156
|
+
[lint reference](docs/reference.md#linting).
|
|
157
|
+
|
|
143
158
|
## Security
|
|
144
159
|
|
|
145
160
|
Use `safe: true` and a restrictive profile for untrusted documents. Custom
|
data/ext/carve/Cargo.lock
CHANGED
data/ext/carve/Cargo.toml
CHANGED
data/ext/carve/src/lib.rs
CHANGED
|
@@ -297,28 +297,16 @@ fn parse_include_target(ruby: &Ruby, name: &str) -> Result<IncludeTarget, Error>
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
/// Canonicalize an include root and the document's own path, and refuse a
|
|
301
|
+
/// document outside the root. Both must be absolute: a relative one would root
|
|
302
|
+
/// containment at the process working directory.
|
|
303
|
+
fn contained_include_paths(
|
|
302
304
|
ruby: &Ruby,
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
mode: String,
|
|
309
|
-
renderers: RHash,
|
|
310
|
-
symbols: RHash,
|
|
311
|
-
safe: bool,
|
|
312
|
-
profile: Option<String>,
|
|
313
|
-
sections: bool,
|
|
314
|
-
max_depth: Option<usize>,
|
|
315
|
-
max_bytes: Option<usize>,
|
|
316
|
-
max_resolver_calls: Option<usize>,
|
|
317
|
-
max_warnings: Option<usize>,
|
|
318
|
-
) -> Result<String, Error> {
|
|
319
|
-
let which = parse_include_target(ruby, &target)?;
|
|
320
|
-
let root_path = Path::new(&root);
|
|
321
|
-
let source_path = Path::new(&source_path);
|
|
305
|
+
root: &str,
|
|
306
|
+
source_path: &str,
|
|
307
|
+
) -> Result<(PathBuf, PathBuf), Error> {
|
|
308
|
+
let root_path = Path::new(root);
|
|
309
|
+
let source_path = Path::new(source_path);
|
|
322
310
|
if !root_path.is_absolute() || !source_path.is_absolute() {
|
|
323
311
|
return Err(Error::new(
|
|
324
312
|
ruby.exception_arg_error(),
|
|
@@ -343,6 +331,42 @@ fn render_with_includes_json(
|
|
|
343
331
|
"include source_path must be a file inside the include root".to_string(),
|
|
344
332
|
));
|
|
345
333
|
}
|
|
334
|
+
Ok((root_real, source_real))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/// A path as a reader sees it: relative to the include root.
|
|
338
|
+
fn root_relative(root_real: &Path, value: &str) -> String {
|
|
339
|
+
let path = PathBuf::from(value);
|
|
340
|
+
if path.is_absolute() {
|
|
341
|
+
path.strip_prefix(root_real)
|
|
342
|
+
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
|
|
343
|
+
.unwrap_or_else(|_| "[outside-root]".to_string())
|
|
344
|
+
} else {
|
|
345
|
+
value.to_string()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#[allow(clippy::too_many_arguments)]
|
|
350
|
+
fn render_with_includes_json(
|
|
351
|
+
ruby: &Ruby,
|
|
352
|
+
source: String,
|
|
353
|
+
root: String,
|
|
354
|
+
source_path: String,
|
|
355
|
+
target: String,
|
|
356
|
+
names: RArray,
|
|
357
|
+
mode: String,
|
|
358
|
+
renderers: RHash,
|
|
359
|
+
symbols: RHash,
|
|
360
|
+
safe: bool,
|
|
361
|
+
profile: Option<String>,
|
|
362
|
+
sections: bool,
|
|
363
|
+
max_depth: Option<usize>,
|
|
364
|
+
max_bytes: Option<usize>,
|
|
365
|
+
max_resolver_calls: Option<usize>,
|
|
366
|
+
max_warnings: Option<usize>,
|
|
367
|
+
) -> Result<String, Error> {
|
|
368
|
+
let which = parse_include_target(ruby, &target)?;
|
|
369
|
+
let (root_real, source_real) = contained_include_paths(ruby, &root, &source_path)?;
|
|
346
370
|
|
|
347
371
|
let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
|
|
348
372
|
Error::new(
|
|
@@ -443,16 +467,7 @@ fn render_with_includes_json(
|
|
|
443
467
|
} else {
|
|
444
468
|
serde_json::Value::String(rendered)
|
|
445
469
|
};
|
|
446
|
-
let safe_path = |value: &str|
|
|
447
|
-
let path = PathBuf::from(value);
|
|
448
|
-
if path.is_absolute() {
|
|
449
|
-
path.strip_prefix(&root_real)
|
|
450
|
-
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
|
|
451
|
-
.unwrap_or_else(|_| "[outside-root]".to_string())
|
|
452
|
-
} else {
|
|
453
|
-
value.to_string()
|
|
454
|
-
}
|
|
455
|
-
};
|
|
470
|
+
let safe_path = |value: &str| root_relative(&root_real, value);
|
|
456
471
|
let warnings = prepared
|
|
457
472
|
.warnings
|
|
458
473
|
.iter()
|
|
@@ -495,6 +510,122 @@ fn render_with_includes_json(
|
|
|
495
510
|
.to_string())
|
|
496
511
|
}
|
|
497
512
|
|
|
513
|
+
/// Byte offset -> codepoint offset over `source`.
|
|
514
|
+
///
|
|
515
|
+
/// `LintWarning` reports `start`/`end` in bytes so a Rust caller can slice its
|
|
516
|
+
/// `&str`; a Ruby String indexes by character, so a byte offset would mis-slice
|
|
517
|
+
/// every document with a non-ASCII character before the finding. A byte inside a
|
|
518
|
+
/// character maps to that character, and the final slot is the total.
|
|
519
|
+
fn codepoint_table(source: &str) -> Vec<usize> {
|
|
520
|
+
let mut table = Vec::with_capacity(source.len() + 1);
|
|
521
|
+
let mut count = 0usize;
|
|
522
|
+
for ch in source.chars() {
|
|
523
|
+
for _ in 0..ch.len_utf8() {
|
|
524
|
+
table.push(count);
|
|
525
|
+
}
|
|
526
|
+
count += 1;
|
|
527
|
+
}
|
|
528
|
+
table.push(count);
|
|
529
|
+
table
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#[allow(clippy::too_many_arguments)]
|
|
533
|
+
fn lint_json(
|
|
534
|
+
ruby: &Ruby,
|
|
535
|
+
source: String,
|
|
536
|
+
names: RArray,
|
|
537
|
+
root: Option<String>,
|
|
538
|
+
source_path: Option<String>,
|
|
539
|
+
max_depth: Option<usize>,
|
|
540
|
+
max_bytes: Option<usize>,
|
|
541
|
+
max_resolver_calls: Option<usize>,
|
|
542
|
+
max_warnings: Option<usize>,
|
|
543
|
+
) -> Result<String, Error> {
|
|
544
|
+
let boxed = boxed_extensions(ruby, names)?;
|
|
545
|
+
let mut options = Options::new();
|
|
546
|
+
for extension in &boxed {
|
|
547
|
+
options = options.with_extension(extension.as_ref());
|
|
548
|
+
}
|
|
549
|
+
// The document's own findings come first in `lint_carve_with_options`, and
|
|
550
|
+
// they are the only ones whose offsets index `source`. Linting it alone
|
|
551
|
+
// first tells the two apart without guessing from a finding's fields.
|
|
552
|
+
let own = carve_rs::lint_carve_with_options(&source, &options);
|
|
553
|
+
let own_count = own.len();
|
|
554
|
+
let (warnings, root_real) = match (root, source_path) {
|
|
555
|
+
(None, None) => (own, None),
|
|
556
|
+
(Some(root), Some(source_path)) => {
|
|
557
|
+
let (root_real, source_real) = contained_include_paths(ruby, &root, &source_path)?;
|
|
558
|
+
let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
|
|
559
|
+
Error::new(
|
|
560
|
+
ruby.exception_arg_error(),
|
|
561
|
+
"include root is not usable".to_string(),
|
|
562
|
+
)
|
|
563
|
+
})?;
|
|
564
|
+
let mut include_options = carve_rs::IncludeOptions::new()
|
|
565
|
+
.with_resolver(&resolver)
|
|
566
|
+
.with_source_path(source_real.to_string_lossy());
|
|
567
|
+
if let Some(value) = max_depth {
|
|
568
|
+
include_options = include_options.with_max_depth(value);
|
|
569
|
+
}
|
|
570
|
+
if let Some(value) = max_bytes {
|
|
571
|
+
include_options = include_options.with_max_bytes(value);
|
|
572
|
+
}
|
|
573
|
+
if let Some(value) = max_resolver_calls {
|
|
574
|
+
include_options = include_options.with_max_resolver_calls(value);
|
|
575
|
+
}
|
|
576
|
+
if let Some(value) = max_warnings {
|
|
577
|
+
include_options = include_options.with_max_warnings(value);
|
|
578
|
+
}
|
|
579
|
+
let options = options.with_lint_includes(include_options);
|
|
580
|
+
(
|
|
581
|
+
carve_rs::lint_carve_with_options(&source, &options),
|
|
582
|
+
Some(root_real),
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
_ => {
|
|
586
|
+
return Err(Error::new(
|
|
587
|
+
ruby.exception_arg_error(),
|
|
588
|
+
"lint needs both root: and source_path: to follow includes".to_string(),
|
|
589
|
+
))
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
let table = codepoint_table(&source);
|
|
593
|
+
let total = table.len() - 1;
|
|
594
|
+
let to_codepoint = |byte: usize| table.get(byte).copied().unwrap_or(total);
|
|
595
|
+
let path = |value: &str| match &root_real {
|
|
596
|
+
Some(root_real) => root_relative(root_real, value),
|
|
597
|
+
None => value.to_string(),
|
|
598
|
+
};
|
|
599
|
+
let findings = warnings
|
|
600
|
+
.iter()
|
|
601
|
+
.enumerate()
|
|
602
|
+
.map(|(index, warning)| {
|
|
603
|
+
let own = index < own_count;
|
|
604
|
+
serde_json::json!({
|
|
605
|
+
"rule": warning.rule,
|
|
606
|
+
"message": warning.message,
|
|
607
|
+
"line": warning.line,
|
|
608
|
+
"column": warning.column,
|
|
609
|
+
"start": own.then(|| to_codepoint(warning.start)),
|
|
610
|
+
"end": own.then(|| to_codepoint(warning.end)),
|
|
611
|
+
"file": warning.file.as_deref().map(&path),
|
|
612
|
+
"includedBy": warning
|
|
613
|
+
.included_by
|
|
614
|
+
.iter()
|
|
615
|
+
.map(|site| {
|
|
616
|
+
serde_json::json!({
|
|
617
|
+
"file": path(&site.file),
|
|
618
|
+
"line": site.line,
|
|
619
|
+
"column": site.column,
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
.collect::<Vec<_>>(),
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
.collect::<Vec<_>>();
|
|
626
|
+
Ok(serde_json::Value::Array(findings).to_string())
|
|
627
|
+
}
|
|
628
|
+
|
|
498
629
|
fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, Error> {
|
|
499
630
|
let mode = carve_rs::HtmlImportMode::from_name(&mode).ok_or_else(|| {
|
|
500
631
|
Error::new(
|
|
@@ -1024,6 +1155,20 @@ guarded!(
|
|
|
1024
1155
|
) -> String =>? render_with_includes_json
|
|
1025
1156
|
);
|
|
1026
1157
|
|
|
1158
|
+
guarded!(
|
|
1159
|
+
g_lint_json(
|
|
1160
|
+
ruby: &Ruby,
|
|
1161
|
+
source: Source,
|
|
1162
|
+
names: RArray,
|
|
1163
|
+
root: Option<String>,
|
|
1164
|
+
source_path: Option<String>,
|
|
1165
|
+
max_depth: Option<usize>,
|
|
1166
|
+
max_bytes: Option<usize>,
|
|
1167
|
+
max_resolver_calls: Option<usize>,
|
|
1168
|
+
max_warnings: Option<usize>
|
|
1169
|
+
) -> String =>? lint_json
|
|
1170
|
+
);
|
|
1171
|
+
|
|
1027
1172
|
/// Entry point invoked by Ruby when the extension is loaded.
|
|
1028
1173
|
///
|
|
1029
1174
|
/// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
|
|
@@ -1073,5 +1218,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
1073
1218
|
module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
|
|
1074
1219
|
module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
|
|
1075
1220
|
module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
|
|
1221
|
+
module.define_singleton_method("_lint_json", function!(g_lint_json, 8))?;
|
|
1076
1222
|
Ok(())
|
|
1077
1223
|
}
|
data/lib/carve/version.rb
CHANGED
|
@@ -8,5 +8,5 @@ module Carve
|
|
|
8
8
|
# compares it against ext/carve/Cargo.toml and against the newest cut
|
|
9
9
|
# CHANGELOG section on every run, and .github/workflows/release.yml refuses to
|
|
10
10
|
# publish a gem whose version is not the tag being released.
|
|
11
|
-
VERSION = "0.1.
|
|
11
|
+
VERSION = "0.1.11"
|
|
12
12
|
end
|
data/lib/carve.rb
CHANGED
|
@@ -150,6 +150,55 @@ module Carve
|
|
|
150
150
|
)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
# Report constructs that parse and render, but not the way the author
|
|
154
|
+
# meant: an attribute that attaches to nothing, an unclosed fence, a
|
|
155
|
+
# fragment link with no target.
|
|
156
|
+
#
|
|
157
|
+
# Carve.lint("Text.\n\n{#gone}\n")
|
|
158
|
+
# # => [{rule: "unattached-block-attribute", message: "...", line: 3,
|
|
159
|
+
# # column: 1, start: 7, end: 14, file: nil, includedBy: []}]
|
|
160
|
+
#
|
|
161
|
+
# Returns an Array of Hashes, empty for a clean document:
|
|
162
|
+
#
|
|
163
|
+
# * +:rule+ - the engine's stable rule id, the same in every Carve engine.
|
|
164
|
+
# * +:message+ - what degrades, in prose.
|
|
165
|
+
# * +:line+, +:column+ - 1-based; the column counts characters, not bytes.
|
|
166
|
+
# * +:start+, +:end+ - 0-based character offsets into +source+, end
|
|
167
|
+
# exclusive, so <tt>source[f[:start]...f[:end]]</tt> is the offending text.
|
|
168
|
+
# +nil+ on a finding the include pass adds (below), whose offsets would
|
|
169
|
+
# index another text.
|
|
170
|
+
# * +:file+, +:includedBy+ - see below; +nil+ and +[]+ without includes.
|
|
171
|
+
#
|
|
172
|
+
# The engine reports no severity: every finding is a warning.
|
|
173
|
+
#
|
|
174
|
+
# Pass the same +extensions:+ you render with, since they change which
|
|
175
|
+
# constructs are elements and which rules apply.
|
|
176
|
+
#
|
|
177
|
+
# ==== Following includes
|
|
178
|
+
#
|
|
179
|
+
# With +root:+ and +source_path:+ (absolute, as for .to_html_with_includes)
|
|
180
|
+
# the pass also lints every included Carve file and reports include
|
|
181
|
+
# directives that stay literal (a missing target, a cycle, a budget). Such a
|
|
182
|
+
# finding names its +:file+ relative to the root and its +:includedBy+ chain,
|
|
183
|
+
# top-level first, as +{file:, line:, column:}+. The budgets
|
|
184
|
+
# (+max_depth:+ and the rest) mean what they mean for rendering.
|
|
185
|
+
def lint(source, extensions: nil, root: nil, source_path: nil, max_depth: nil,
|
|
186
|
+
max_bytes: nil, max_resolver_calls: nil, max_warnings: nil)
|
|
187
|
+
JSON.parse(
|
|
188
|
+
_lint_json(
|
|
189
|
+
source.to_s,
|
|
190
|
+
Array(extensions).map(&:to_s),
|
|
191
|
+
root&.to_s,
|
|
192
|
+
source_path&.to_s,
|
|
193
|
+
max_depth,
|
|
194
|
+
max_bytes,
|
|
195
|
+
max_resolver_calls,
|
|
196
|
+
max_warnings,
|
|
197
|
+
),
|
|
198
|
+
symbolize_names: true,
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
153
202
|
# Format +source+ into canonical Carve. A writer refusal returns the source
|
|
154
203
|
# as authored. +align_tables:+ pads table cells into aligned columns.
|
|
155
204
|
def to_carve(source, align_tables: false)
|