carve-lang 0.1.9 → 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 +51 -1
- data/README.md +61 -2
- data/ext/carve/Cargo.lock +3 -3
- data/ext/carve/Cargo.toml +2 -2
- data/ext/carve/src/lib.rs +242 -35
- data/lib/carve/version.rb +1 -1
- data/lib/carve.rb +95 -4
- metadata +2 -2
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,54 @@ 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
|
+
|
|
19
|
+
## [0.1.10] - 2026-10-23
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
|
|
23
|
+
- The engine is the published `carve-lang` 0.1.11 crate. A quote-prefixed `+`
|
|
24
|
+
needs one space after each `>`, so `>+` and `>>+` now render as text, and a
|
|
25
|
+
continuation marker reads only at column 0 or right after a block opener.
|
|
26
|
+
Re-check documents that place `+` elsewhere (#215, markup-carve/carve#3421).
|
|
27
|
+
- Includes of a file that is not valid UTF-8 are refused with an
|
|
28
|
+
`include-non-text` warning instead of being spliced (#215,
|
|
29
|
+
markup-carve/carve-rs#2885).
|
|
30
|
+
|
|
31
|
+
### Fixes
|
|
32
|
+
|
|
33
|
+
- The carve-lang 0.1.11 pin corrects container ownership for quotes, lazy lines,
|
|
34
|
+
colon divs and footnotes, fences inside nested lists and quotes, and list
|
|
35
|
+
tightness around captions (#215).
|
|
36
|
+
- Includes see through a byte order mark and keep link definitions and region
|
|
37
|
+
markers in place. Markdown import keeps link destinations, autolink brackets
|
|
38
|
+
and pipe escapes as written (#215).
|
|
39
|
+
- `Carve.render_with_includes` with the `markdown`, `plain` and `ansi` targets
|
|
40
|
+
keeps a link definition where it was written, as `Carve.to_markdown` does,
|
|
41
|
+
instead of moving it to the end (#221).
|
|
42
|
+
|
|
43
|
+
### Improvements
|
|
44
|
+
|
|
45
|
+
- Precompiled gems for `x86_64-linux`, `x86_64-linux-musl`, `aarch64-linux`,
|
|
46
|
+
`x86_64-darwin`, `arm64-darwin` and `x64-mingw-ucrt` install without a Rust
|
|
47
|
+
toolchain on Ruby 3.0 to 4.0 (#214).
|
|
48
|
+
- `Carve.to_carve(source, align_tables: true)` pads table columns to display
|
|
49
|
+
width (#215).
|
|
50
|
+
- `Carve.select_include_region(source, name, mode:)` resolves a named
|
|
51
|
+
`@region` to a Range of content lines and raises `Carve::IncludeRegionError`
|
|
52
|
+
for a missing or malformed region (#215).
|
|
53
|
+
- An include with `@code` or `@code:lang` splices a file or region as one code
|
|
54
|
+
block (#215).
|
|
55
|
+
- Include warnings carry `includedBy`, the chain of directives that reached the
|
|
56
|
+
file the warning arose in, with root-relative paths, lines and columns (#221).
|
|
57
|
+
|
|
10
58
|
## [0.1.9] - 2026-10-18
|
|
11
59
|
|
|
12
60
|
### Breaking
|
|
@@ -488,7 +536,9 @@ are not listed, because no release ever shipped them.
|
|
|
488
536
|
Arrays (every AST node type is covered), enabling custom renderers such as
|
|
489
537
|
[carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
|
|
490
538
|
|
|
491
|
-
[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
|
|
541
|
+
[0.1.10]: https://github.com/markup-carve/carve-rb/compare/v0.1.9...v0.1.10
|
|
492
542
|
[0.1.9]: https://github.com/markup-carve/carve-rb/compare/v0.1.8...v0.1.9
|
|
493
543
|
[0.1.8]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...v0.1.8
|
|
494
544
|
[0.1.7]: https://github.com/markup-carve/carve-rb/compare/v0.1.6...v0.1.7
|
data/README.md
CHANGED
|
@@ -16,7 +16,48 @@ bundle install
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
The distribution name is `carve-lang`, while the require path is `carve`.
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
Releases after 0.1.9 ship precompiled gems for `x86_64-linux`,
|
|
21
|
+
`x86_64-linux-musl`, `aarch64-linux`, `x86_64-darwin`, `arm64-darwin` and
|
|
22
|
+
`x64-mingw-ucrt`, with a binary for each Ruby from 3.0 to 4.0 (from 3.1 on
|
|
23
|
+
Windows, where Ruby 3.0 has no UCRT build). On those platforms `gem install`
|
|
24
|
+
needs no Rust toolchain or libclang. Everywhere else, including newer Rubies,
|
|
25
|
+
RubyGems installs the source gem and builds it as described below.
|
|
26
|
+
|
|
27
|
+
## Installing from source
|
|
28
|
+
|
|
29
|
+
Version 0.1.9 and earlier ship source only, and so does every release on a
|
|
30
|
+
platform or Ruby without a precompiled gem. There `gem install carve-lang`
|
|
31
|
+
compiles a Rust extension on your machine, so the install needs:
|
|
32
|
+
|
|
33
|
+
- A Rust toolchain with `cargo`, Rust 1.77 or newer. [rustup](https://rustup.rs)
|
|
34
|
+
is the usual way to get one.
|
|
35
|
+
- Ruby development headers (`ruby-dev` on Debian and Ubuntu).
|
|
36
|
+
- libclang and clang. The build runs `bindgen` through `rb-sys` to read Ruby's
|
|
37
|
+
C headers. On Debian and Ubuntu:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
sudo apt install libclang-dev clang
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If only libclang is installed, without the `clang` package, the build stops
|
|
44
|
+
with this error:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/usr/include/ruby-3.2.0/ruby/ruby.h:23:10: fatal error: 'stdarg.h' file not found
|
|
48
|
+
thread 'main' panicked at .../rb-sys-0.9.128/build/main.rs:52:6:
|
|
49
|
+
generate bindings: ClangDiagnostic("/usr/include/ruby-3.2.0/ruby/ruby.h:23:10: fatal error: 'stdarg.h' file not found\n")
|
|
50
|
+
error: failed to run custom build command for `rb-sys v0.9.128`
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
libclang cannot find its own builtin headers. Install `clang`, or point
|
|
54
|
+
bindgen at GCC's copy of those headers:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
BINDGEN_EXTRA_CLANG_ARGS="-I$(gcc -print-file-name=include)" gem install carve-lang
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
With Bundler, export the variable before running `bundle install`.
|
|
20
61
|
|
|
21
62
|
## Render Carve
|
|
22
63
|
|
|
@@ -28,6 +69,7 @@ Carve.to_markdown(source)
|
|
|
28
69
|
Carve.to_plain_text(source)
|
|
29
70
|
Carve.to_ansi(source)
|
|
30
71
|
Carve.to_carve(source)
|
|
72
|
+
Carve.to_carve(source, align_tables: true) # pad table columns
|
|
31
73
|
```
|
|
32
74
|
|
|
33
75
|
Options configure extensions, safe rendering, profiles, symbols, section
|
|
@@ -91,11 +133,28 @@ result = Carve.to_html_with_includes(
|
|
|
91
133
|
)
|
|
92
134
|
```
|
|
93
135
|
|
|
94
|
-
The result contains the value, warnings, and root-relative dependencies.
|
|
136
|
+
The result contains the value, warnings, and root-relative dependencies. A
|
|
137
|
+
warning from an included file names the chain of directives that reached it in
|
|
138
|
+
`:includedBy`.
|
|
95
139
|
Other render methods leave directives literal. See the
|
|
96
140
|
[include reference](docs/reference.md#file-includes) for budgets, AST expansion,
|
|
97
141
|
targets, and containment behavior.
|
|
98
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
|
+
|
|
99
158
|
## Security
|
|
100
159
|
|
|
101
160
|
Use `safe: true` and a restrictive profile for untrusted documents. Custom
|
data/ext/carve/Cargo.lock
CHANGED
|
@@ -37,9 +37,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
|
|
37
37
|
|
|
38
38
|
[[package]]
|
|
39
39
|
name = "carve-lang"
|
|
40
|
-
version = "0.1.
|
|
40
|
+
version = "0.1.11"
|
|
41
41
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
42
|
-
checksum = "
|
|
42
|
+
checksum = "753b90fbe80fab43887d5d559996c153347d9631452bbcb8d0283aad6dfa7ef8"
|
|
43
43
|
dependencies = [
|
|
44
44
|
"html5ever",
|
|
45
45
|
"markup5ever_rcdom",
|
|
@@ -54,7 +54,7 @@ dependencies = [
|
|
|
54
54
|
|
|
55
55
|
[[package]]
|
|
56
56
|
name = "carve-rb"
|
|
57
|
-
version = "0.1.
|
|
57
|
+
version = "0.1.11"
|
|
58
58
|
dependencies = [
|
|
59
59
|
"carve-lang",
|
|
60
60
|
"magnus",
|
data/ext/carve/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "carve-rb"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.11"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
rust-version = "1.75"
|
|
6
6
|
publish = false
|
|
@@ -22,4 +22,4 @@ serde_json = "1"
|
|
|
22
22
|
# The Carve engine. Imported under the Rust crate alias `carve_rs` so the
|
|
23
23
|
# binding's own crate name (`carve-rb`) does not collide with the engine's
|
|
24
24
|
# package name (`carve`) in the Cargo lockfile.
|
|
25
|
-
carve_rs = { package = "carve-lang", version = "=0.1.
|
|
25
|
+
carve_rs = { package = "carve-lang", version = "=0.1.11" }
|
data/ext/carve/src/lib.rs
CHANGED
|
@@ -226,8 +226,44 @@ fn to_ansi(source: String) -> String {
|
|
|
226
226
|
carve_rs::to_ansi(&source)
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
/// Canonical Carve source; a writer refusal returns the source as authored.
|
|
230
|
+
fn to_carve(source: String, align_tables: bool) -> String {
|
|
231
|
+
let options = Options::default().with_align_tables(align_tables);
|
|
232
|
+
carve_rs::try_to_carve_with_options(&source, &options).unwrap_or(source)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Resolve `@region:name` with the engine's marker rules (spec I1b).
|
|
236
|
+
///
|
|
237
|
+
/// Returns `[start, end]` (1-based, inclusive content lines) or, when the
|
|
238
|
+
/// region is invalid, the warning rule id as a String; `lib/carve.rb` turns
|
|
239
|
+
/// that into `Carve::IncludeRegionError`.
|
|
240
|
+
fn select_include_region(
|
|
241
|
+
ruby: &Ruby,
|
|
242
|
+
source: String,
|
|
243
|
+
name: String,
|
|
244
|
+
mode: String,
|
|
245
|
+
) -> Result<Value, Error> {
|
|
246
|
+
let mode = match mode.as_str() {
|
|
247
|
+
"carve" => carve_rs::IncludeRegionMode::Carve,
|
|
248
|
+
"code" => carve_rs::IncludeRegionMode::Code,
|
|
249
|
+
other => {
|
|
250
|
+
return Err(Error::new(
|
|
251
|
+
ruby.exception_arg_error(),
|
|
252
|
+
format!("mode must be :carve or :code, got {other:?}"),
|
|
253
|
+
))
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
Ok(
|
|
257
|
+
match carve_rs::select_include_region(&source, &name, mode) {
|
|
258
|
+
Ok(region) => ruby
|
|
259
|
+
.ary_new_from_values(&[
|
|
260
|
+
ruby.integer_from_u64(region.start as u64).as_value(),
|
|
261
|
+
ruby.integer_from_u64(region.end as u64).as_value(),
|
|
262
|
+
])
|
|
263
|
+
.as_value(),
|
|
264
|
+
Err(e) => ruby.str_new(e.rule()).as_value(),
|
|
265
|
+
},
|
|
266
|
+
)
|
|
231
267
|
}
|
|
232
268
|
|
|
233
269
|
/// Which renderer runs over the expanded document.
|
|
@@ -261,28 +297,16 @@ fn parse_include_target(ruby: &Ruby, name: &str) -> Result<IncludeTarget, Error>
|
|
|
261
297
|
}
|
|
262
298
|
}
|
|
263
299
|
|
|
264
|
-
|
|
265
|
-
|
|
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(
|
|
266
304
|
ruby: &Ruby,
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
mode: String,
|
|
273
|
-
renderers: RHash,
|
|
274
|
-
symbols: RHash,
|
|
275
|
-
safe: bool,
|
|
276
|
-
profile: Option<String>,
|
|
277
|
-
sections: bool,
|
|
278
|
-
max_depth: Option<usize>,
|
|
279
|
-
max_bytes: Option<usize>,
|
|
280
|
-
max_resolver_calls: Option<usize>,
|
|
281
|
-
max_warnings: Option<usize>,
|
|
282
|
-
) -> Result<String, Error> {
|
|
283
|
-
let which = parse_include_target(ruby, &target)?;
|
|
284
|
-
let root_path = Path::new(&root);
|
|
285
|
-
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);
|
|
286
310
|
if !root_path.is_absolute() || !source_path.is_absolute() {
|
|
287
311
|
return Err(Error::new(
|
|
288
312
|
ruby.exception_arg_error(),
|
|
@@ -307,6 +331,42 @@ fn render_with_includes_json(
|
|
|
307
331
|
"include source_path must be a file inside the include root".to_string(),
|
|
308
332
|
));
|
|
309
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)?;
|
|
310
370
|
|
|
311
371
|
let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
|
|
312
372
|
Error::new(
|
|
@@ -344,6 +404,12 @@ fn render_with_includes_json(
|
|
|
344
404
|
if !sections {
|
|
345
405
|
render_options = render_options.with_sections(false);
|
|
346
406
|
}
|
|
407
|
+
// Positions let the engine record the directive chain behind a warning
|
|
408
|
+
// (`includedBy`). The AST target keeps them off: spec I4 leaves position
|
|
409
|
+
// remapping out of scope, so a child's spans would point into the wrong file.
|
|
410
|
+
if which != IncludeTarget::Ast {
|
|
411
|
+
render_options = render_options.with_positions(true);
|
|
412
|
+
}
|
|
347
413
|
let mut include_options = carve_rs::IncludeOptions::new()
|
|
348
414
|
.with_resolver(&resolver)
|
|
349
415
|
.with_source_path(source_real.to_string_lossy());
|
|
@@ -401,16 +467,7 @@ fn render_with_includes_json(
|
|
|
401
467
|
} else {
|
|
402
468
|
serde_json::Value::String(rendered)
|
|
403
469
|
};
|
|
404
|
-
let safe_path = |value: &str|
|
|
405
|
-
let path = PathBuf::from(value);
|
|
406
|
-
if path.is_absolute() {
|
|
407
|
-
path.strip_prefix(&root_real)
|
|
408
|
-
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
|
|
409
|
-
.unwrap_or_else(|_| "[outside-root]".to_string())
|
|
410
|
-
} else {
|
|
411
|
-
value.to_string()
|
|
412
|
-
}
|
|
413
|
-
};
|
|
470
|
+
let safe_path = |value: &str| root_relative(&root_real, value);
|
|
414
471
|
let warnings = prepared
|
|
415
472
|
.warnings
|
|
416
473
|
.iter()
|
|
@@ -419,6 +476,17 @@ fn render_with_includes_json(
|
|
|
419
476
|
"rule": warning.rule,
|
|
420
477
|
"message": warning.message,
|
|
421
478
|
"file": warning.file.as_deref().map(&safe_path),
|
|
479
|
+
"includedBy": warning
|
|
480
|
+
.included_by
|
|
481
|
+
.iter()
|
|
482
|
+
.map(|site| {
|
|
483
|
+
serde_json::json!({
|
|
484
|
+
"file": safe_path(&site.file),
|
|
485
|
+
"line": site.line,
|
|
486
|
+
"column": site.column,
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
.collect::<Vec<_>>(),
|
|
422
490
|
})
|
|
423
491
|
})
|
|
424
492
|
.collect::<Vec<_>>();
|
|
@@ -442,6 +510,122 @@ fn render_with_includes_json(
|
|
|
442
510
|
.to_string())
|
|
443
511
|
}
|
|
444
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
|
+
|
|
445
629
|
fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, Error> {
|
|
446
630
|
let mode = carve_rs::HtmlImportMode::from_name(&mode).ok_or_else(|| {
|
|
447
631
|
Error::new(
|
|
@@ -897,7 +1081,11 @@ guarded!(g_to_html(source: Source) -> String => to_html);
|
|
|
897
1081
|
guarded!(g_to_markdown(source: Source) -> String => to_markdown);
|
|
898
1082
|
guarded!(g_to_plain_text(source: Source) -> String => to_plain_text);
|
|
899
1083
|
guarded!(g_to_ansi(source: Source) -> String => to_ansi);
|
|
900
|
-
guarded!(g_to_carve(source: Source) -> String => to_carve);
|
|
1084
|
+
guarded!(g_to_carve(source: Source, align_tables: bool) -> String => to_carve);
|
|
1085
|
+
guarded!(
|
|
1086
|
+
g_select_include_region(ruby: &Ruby, source: Source, name: String, mode: String) -> Value
|
|
1087
|
+
=>? select_include_region
|
|
1088
|
+
);
|
|
901
1089
|
guarded!(g_from_markdown_json(ruby: &Ruby, source: Source) -> String =>? from_markdown_json);
|
|
902
1090
|
guarded!(g_to_ast_json(source: Source) -> String => to_ast_json);
|
|
903
1091
|
guarded!(g_extension_names() -> Vec<String> => extension_names);
|
|
@@ -967,6 +1155,20 @@ guarded!(
|
|
|
967
1155
|
) -> String =>? render_with_includes_json
|
|
968
1156
|
);
|
|
969
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
|
+
|
|
970
1172
|
/// Entry point invoked by Ruby when the extension is loaded.
|
|
971
1173
|
///
|
|
972
1174
|
/// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
|
|
@@ -990,7 +1192,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
990
1192
|
module.define_singleton_method("to_markdown", function!(g_to_markdown, 1))?;
|
|
991
1193
|
module.define_singleton_method("to_plain_text", function!(g_to_plain_text, 1))?;
|
|
992
1194
|
module.define_singleton_method("to_ansi", function!(g_to_ansi, 1))?;
|
|
993
|
-
module.define_singleton_method("
|
|
1195
|
+
module.define_singleton_method("_to_carve", function!(g_to_carve, 2))?;
|
|
1196
|
+
module.define_singleton_method(
|
|
1197
|
+
"_select_include_region",
|
|
1198
|
+
function!(g_select_include_region, 3),
|
|
1199
|
+
)?;
|
|
994
1200
|
module.define_singleton_method(
|
|
995
1201
|
"_render_with_includes_json",
|
|
996
1202
|
function!(g_render_with_includes_json, 15),
|
|
@@ -1012,5 +1218,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
1012
1218
|
module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
|
|
1013
1219
|
module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
|
|
1014
1220
|
module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
|
|
1221
|
+
module.define_singleton_method("_lint_json", function!(g_lint_json, 8))?;
|
|
1015
1222
|
Ok(())
|
|
1016
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
|
@@ -8,11 +8,30 @@
|
|
|
8
8
|
require "json"
|
|
9
9
|
require_relative "carve/version"
|
|
10
10
|
|
|
11
|
-
# Load the compiled native extension.
|
|
12
|
-
# lib/carve
|
|
13
|
-
|
|
11
|
+
# Load the compiled native extension. A precompiled platform gem ships one per
|
|
12
|
+
# Ruby ABI under lib/carve/<major.minor>/; a source install and `rake compile`
|
|
13
|
+
# build lib/carve/carve.so (or .bundle on macOS).
|
|
14
|
+
# Chosen by directory rather than by rescuing LoadError, so a binary that is
|
|
15
|
+
# present but cannot load (an older glibc, say) reports its own error.
|
|
16
|
+
abi = RUBY_VERSION[/\A\d+\.\d+/]
|
|
17
|
+
if File.directory?(File.join(__dir__, "carve", abi))
|
|
18
|
+
require_relative "carve/#{abi}/carve"
|
|
19
|
+
else
|
|
20
|
+
require_relative "carve/carve"
|
|
21
|
+
end
|
|
14
22
|
|
|
15
23
|
module Carve
|
|
24
|
+
# Raised by .select_include_region when the region is missing or a marker is
|
|
25
|
+
# malformed, unpaired or duplicated. +rule+ is the engine's warning rule id.
|
|
26
|
+
class IncludeRegionError < ArgumentError
|
|
27
|
+
attr_reader :rule
|
|
28
|
+
|
|
29
|
+
def initialize(rule, name)
|
|
30
|
+
@rule = rule
|
|
31
|
+
super("#{rule}: region #{name.inspect} is missing or its markers are malformed")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
16
35
|
# Extension names the native binding understands.
|
|
17
36
|
#
|
|
18
37
|
# Taken from the engine itself rather than typed out here. This was a literal
|
|
@@ -55,7 +74,8 @@ module Carve
|
|
|
55
74
|
#
|
|
56
75
|
# Returns +{value:, warnings:, dependencies:, suppressedWarnings:}+. Paths
|
|
57
76
|
# in +warnings+ and +dependencies+ are relative to the root, so a report can
|
|
58
|
-
# be shown to a reader as it stands.
|
|
77
|
+
# be shown to a reader as it stands. Each warning's +includedBy+ lists the
|
|
78
|
+
# directives that reached its file, top-level first, as +{file:, line:, column:}+.
|
|
59
79
|
#
|
|
60
80
|
# Every render option +to_html+ takes is accepted and reaches the included
|
|
61
81
|
# children too: an extension, a symbol map or a profile means the same thing
|
|
@@ -130,6 +150,77 @@ module Carve
|
|
|
130
150
|
)
|
|
131
151
|
end
|
|
132
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
|
+
|
|
202
|
+
# Format +source+ into canonical Carve. A writer refusal returns the source
|
|
203
|
+
# as authored. +align_tables:+ pads table cells into aligned columns.
|
|
204
|
+
def to_carve(source, align_tables: false)
|
|
205
|
+
_to_carve(source, align_tables ? true : false)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Resolve <tt>@region:name</tt> in a file's text with the engine's marker
|
|
209
|
+
# rules, so an editor jumping to a region agrees with the render.
|
|
210
|
+
#
|
|
211
|
+
# Carve.select_include_region(text, "setup", mode: :carve) # => 3..4
|
|
212
|
+
#
|
|
213
|
+
# Returns the 1-based, inclusive Range of content lines; an empty region is
|
|
214
|
+
# an empty Range such as +2..1+. +mode: :carve+ accepts only
|
|
215
|
+
# <tt>%% #region</tt> markers, +mode: :code+ also the source-comment forms
|
|
216
|
+
# of an +@code+ include. Raises IncludeRegionError otherwise.
|
|
217
|
+
def select_include_region(source, name, mode:)
|
|
218
|
+
result = _select_include_region(source, name.to_s, mode.to_s)
|
|
219
|
+
raise IncludeRegionError.new(result, name.to_s) if result.is_a?(String)
|
|
220
|
+
|
|
221
|
+
result[0]..result[1]
|
|
222
|
+
end
|
|
223
|
+
|
|
133
224
|
# Import HTML or Markdown into canonical Carve. Both methods return the
|
|
134
225
|
# shared migration shape `{ value:, report: }`.
|
|
135
226
|
def from_html(source, mode: :safe)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: carve-lang
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.11
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- markup-carve
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|