compmd 0.3.0

The CompMD processor: parses, validates and expands CompMD (Component Markdown) documents. In development.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Diagnostics (spec §10) in the machine form of spec §10.5.

use crate::named::named;
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use std::fmt;

named! {
    /// A diagnostic code from the catalog (spec §10.2): `F…` codes are errors, `W…` codes warnings.
    #[derive(PartialOrd, Ord, Hash)]
    pub enum Code {
        /// Unsupported major version.
        F001 = "F001",
        /// An unknown component name, or a signature fence with nothing to attach to.
        F002 = "F002",
        /// A closer names no open container while one is open.
        F003 = "F003",
        /// A container isn't closed before an enclosing closer or the end of the file.
        F004 = "F004",
        /// A closer while no container is open.
        F005 = "F005",
        /// An undeclared slot name.
        F006 = "F006",
        /// A named slot given twice in one container.
        F007 = "F007",
        /// An unknown param, or a breakpoint the param doesn't take.
        F008 = "F008",
        /// A param value that doesn't match its type or grammar.
        F009 = "F009",
        /// A required param is missing.
        F010 = "F010",
        /// A malformed param list.
        F011 = "F011",
        /// A continuation line that doesn't follow an opener, a leaf or a continuation line.
        F012 = "F012",
        /// A name declared twice, a keyword or `md` declared, or a name expressions can't use.
        F013 = "F013",
        /// An unknown interpolation path.
        F014 = "F014",
        /// A slot line outside a container.
        F015 = "F015",
        /// A malformed component line.
        F016 = "F016",
        /// A param given twice, or `id` together with `#name`.
        F017 = "F017",
        /// A body given to a component that declares no slots.
        F018 = "F018",
        /// A recursive component definition.
        F019 = "F019",
        /// An import cycle.
        F020 = "F020",
        /// An imported name its source doesn't export.
        F021 = "F021",
        /// `export.pack` or `export.version` disagrees with the registry manifest.
        F022 = "F022",
        /// A name two wildcard imports, or a wildcard import and `@compmd/core`, provide is used.
        F023 = "F023",
        /// An expression's parse, type or evaluation error, or a value that can't be written as text.
        F024 = "F024",
        /// An `each` iteration that doesn't have the shape its `join` needs.
        F025 = "F025",
        /// A data or import source missing, unreachable or not allowed.
        F026 = "F026",
        /// A data record that violates its schema.
        F027 = "F027",
        /// A remote data origin the processor's configuration doesn't allow.
        F028 = "F028",
        /// An exported host-language component without a signature fence.
        F029 = "F029",
        /// A bound input that isn't declared, or has another type.
        F030 = "F030",
        /// A `from` that interpolates what it may not.
        F031 = "F031",
        /// A closer names a container opened outside its list item or block quote.
        F032 = "F032",
        /// Frontmatter that isn't YAML 1.2 core, or uses tags, anchors, aliases or repeated keys.
        F033 = "F033",
        /// An expansion larger than the processor allows.
        F034 = "F034",
        /// A pack that doesn't match its import's `integrity`.
        F035 = "F035",
        /// A param list on a signature fence.
        F036 = "F036",
        /// A declaration whose type isn't in the type grammar.
        F037 = "F037",
        /// A leaf without fallback text.
        W101 = "W101",
        /// A definition language or runtime this processor doesn't support.
        W102 = "W102",
        /// An `each` whose default slot is a single list item, with `join` unset.
        W103 = "W103",
        /// A local host-language component whose params can't be typed.
        W105 = "W105",
        /// `{{` followed by something that isn't a path.
        W106 = "W106",
        /// An inline component followed by a space and a param list.
        W107 = "W107",
        /// A container opener or leaf directly after a paragraph line.
        W108 = "W108",
        /// An interpolated link or image URL with a scheme other than `https:`, `http:` or `mailto:`.
        W109 = "W109",
        /// A later minor version than the processor implements.
        W110 = "W110",
        /// An unknown key under `md`, or an unknown name in `md.syntax`.
        W111 = "W111",
        /// A local definition or explicit import shadows a `@compmd/core` component.
        W112 = "W112",
        /// A name from an ambient pack, or a `host:` data source, is used.
        W113 = "W113",
        /// A box whose blocks use breakpoints has no `width` where its width would come from its content.
        W114 = "W114",
        /// Host metadata given for a document that has its own frontmatter.
        W115 = "W115",
        /// A line starting with `::` inside an HTML block.
        W116 = "W116",
        /// An exported host-language component without `runtime`.
        W117 = "W117",
        /// A host-language component threw while mounting or updating: a renderer reports it (stage 5).
        W118 = "W118",
        /// A box that places blocks by position has no `height`, `aspect-ratio` or `fill` at some width.
        W119 = "W119",
        /// A param for a box given to a `display=contents` component.
        W120 = "W120",
        /// A host-language component's code can't load: a pack's `peerDependencies` the host can't meet, which the
        /// processor reports; an import it may not make, a compile error or a module that doesn't match its file,
        /// which a renderer reports (stage 5).
        W121 = "W121",
    }
}

impl Code {
    /// The code as the catalog writes it, `F004`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        self.name()
    }

    /// `Error` for `F` codes, `Warning` for `W` codes.
    #[must_use]
    pub fn severity(self) -> Severity {
        if self.as_str().starts_with('F') {
            Severity::Error
        } else {
            Severity::Warning
        }
    }
}

impl fmt::Display for Code {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl Serialize for Code {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(self.as_str())
    }
}

/// How serious a diagnostic is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    /// An `F` code: strict mode fails (spec §10.1).
    Error,
    /// A `W` code.
    Warning,
}

/// The stage that found a diagnostic (spec §11).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
    /// Stage 1, parsing.
    Parse,
    /// Stage 2, resolving names.
    Resolve,
    /// Stage 3, validating.
    Validate,
    /// Stage 4a, loading data.
    Load,
    /// Stage 4b, evaluating: expanding the tree.
    Evaluate,
}

/// A place in a document: 1-based line, 1-based column in code points.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct Location {
    /// The line.
    pub line: usize,
    /// The column, in code points.
    pub column: usize,
}

impl Location {
    /// The place at `line` and `column`.
    #[must_use]
    pub fn new(line: usize, column: usize) -> Self {
        Self { line, column }
    }
}

impl From<crate::tree::Point> for Location {
    fn from(point: crate::tree::Point) -> Self {
        Self::new(point.line, point.column)
    }
}

/// Where a diagnostic, or a place related to it, is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct Range {
    /// Where it starts.
    pub start: Location,
    /// Where it ends, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub end: Option<Location>,
}

impl Range {
    /// The range of `written`, which starts at `at` and is on one line.
    #[must_use]
    pub fn spanning(at: Location, written: &str) -> Self {
        Self {
            start: at,
            end: Some(Location::new(at.line, at.column + written.chars().count())),
        }
    }
}

impl From<crate::tree::Position> for Range {
    fn from(position: crate::tree::Position) -> Self {
        Self {
            start: position.start.into(),
            end: Some(position.end.into()),
        }
    }
}

impl From<Location> for Range {
    fn from(start: Location) -> Self {
        Self { start, end: None }
    }
}

/// A place related to a diagnostic, such as the opener of an unclosed container.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Related {
    /// The document it is in, when it isn't the processed one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub file: Option<String>,
    /// Where it is.
    pub range: Range,
    /// What it is.
    pub message: String,
}

/// An edit that fixes a diagnostic: the text at `range` becomes `text`, as a "did you mean" suggests.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Fix {
    /// The text it replaces, on one line.
    pub range: Range,
    /// What replaces it.
    pub text: String,
}

impl Fix {
    /// `written`, which starts at `at`, replaced by `text`.
    #[must_use]
    pub fn replacing(at: Location, written: &str, text: impl Into<String>) -> Self {
        Self {
            range: Range::spanning(at, written),
            text: text.into(),
        }
    }
}

/// A diagnostic (spec §10.5).
///
/// ```
/// use compmd::diagnostic::{Code, Diagnostic, Location};
///
/// let diagnostic = Diagnostic::new(Code::F005, Location::new(3, 1), "no container is open for `::: /card`");
/// assert_eq!(
///     serde_json::to_value(&diagnostic).unwrap()["severity"],
///     "error"
/// );
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
    /// The catalog code.
    pub code: Code,
    /// What is wrong, for people; not normative.
    pub message: String,
    /// The document it is in, when it isn't the processed one.
    pub file: Option<String>,
    /// Where it is.
    pub range: Range,
    /// Related places, innermost first.
    pub related: Vec<Related>,
    /// The stage that found it.
    pub stage: Stage,
    /// The edit that fixes it, when its message suggests one.
    pub fix: Option<Fix>,
}

impl Diagnostic {
    /// A parse diagnostic at `at` in the processed document.
    #[must_use]
    pub fn new(code: Code, at: Location, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
            file: None,
            range: at.into(),
            related: Vec::new(),
            stage: Stage::Parse,
            fix: None,
        }
    }

    /// The same diagnostic, fixed by `fix` when there is one.
    #[must_use]
    pub fn with_fix(self, fix: Option<Fix>) -> Self {
        Self { fix, ..self }
    }

    /// The same diagnostic, found by `stage`.
    #[must_use]
    pub fn in_stage(self, stage: Stage) -> Self {
        Self { stage, ..self }
    }

    /// The same diagnostic, in the document named `file`; `None` is the processed document.
    #[must_use]
    pub fn in_file(self, file: Option<String>) -> Self {
        Self { file, ..self }
    }

    /// The same diagnostic with a related place in the same document.
    #[must_use]
    pub fn with_related(mut self, at: Location, message: impl Into<String>) -> Self {
        self.related.push(Related {
            file: None,
            range: at.into(),
            message: message.into(),
        });
        self
    }

    /// The severity its code gives it.
    #[must_use]
    pub fn severity(&self) -> Severity {
        self.code.severity()
    }
}

impl Serialize for Diagnostic {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        let mut out = serializer.serialize_struct("Diagnostic", 8)?;
        out.serialize_field("code", &self.code)?;
        out.serialize_field("severity", &self.severity())?;
        out.serialize_field("message", &self.message)?;
        if let Some(file) = &self.file {
            out.serialize_field("file", file)?;
        }
        out.serialize_field("range", &self.range)?;
        if !self.related.is_empty() {
            out.serialize_field("related", &self.related)?;
        }
        out.serialize_field("stage", &self.stage)?;
        if let Some(fix) = &self.fix {
            out.serialize_field("fix", fix)?;
        }
        out.end()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn serializes_in_the_machine_form() {
        let diagnostic =
            Diagnostic::new(Code::F004, Location::new(1, 1), "`::: card` isn't closed")
                .with_related(Location::new(3, 1), "closed here");
        assert_eq!(
            serde_json::to_value(&diagnostic).unwrap(),
            json!({
                "code": "F004",
                "severity": "error",
                "message": "`::: card` isn't closed",
                "range": { "start": { "line": 1, "column": 1 } },
                "related": [{ "range": { "start": { "line": 3, "column": 1 } }, "message": "closed here" }],
                "stage": "parse",
            })
        );
    }

    #[test]
    fn names_a_file_that_is_not_the_processed_document() {
        let diagnostic = Diagnostic {
            file: Some("ui.comp.md".into()),
            range: Range {
                start: Location::new(7, 24),
                end: Some(Location::new(7, 32)),
            },
            ..Diagnostic::new(Code::F011, Location::new(1, 1), "")
        };
        let json = serde_json::to_value(&diagnostic).unwrap();
        assert_eq!(json["file"], "ui.comp.md");
        assert_eq!(json["range"]["end"], json!({ "line": 7, "column": 32 }));
    }

    #[test]
    fn carries_its_fix() {
        let diagnostic = Diagnostic::new(Code::F009, Location::new(2, 12), "").with_fix(Some(
            Fix::replacing(Location::new(2, 12), "subtl", "subtle"),
        ));
        assert_eq!(
            serde_json::to_value(&diagnostic).unwrap()["fix"],
            json!({ "range": { "start": { "line": 2, "column": 12 }, "end": { "line": 2, "column": 17 } }, "text": "subtle" })
        );
        assert!(
            serde_json::to_value(Diagnostic::new(Code::F005, Location::new(1, 1), ""))
                .unwrap()
                .get("fix")
                .is_none()
        );
    }

    #[test]
    fn warnings_are_w_codes() {
        assert_eq!(Code::W108.severity(), Severity::Warning);
        assert_eq!(Code::F016.to_string(), "F016");
    }
}