Skip to main content

ocomment_core/
profile.rs

1use crate::{
2    ByteSpan, Comment, CommentKind, Diagnostic, Language, Layout, PreparedScanner, ScanOptions,
3    ScanReport, Severity, TransformOptions, TransformPlan, TransformResult,
4    scanner::{DispositionPatterns, disposition},
5};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// A deliberately limited scanner profile for unambiguous comment syntaxes.
10///
11/// A profile describes a syntax whose comments and strings are literal
12/// delimiters and nothing more, so that one byte-oriented pass can find them
13/// with no grammar and no backtracking. That is the whole of what it can
14/// express, and the limits are enforced rather than assumed:
15///
16/// - Every delimiter is a literal token. It must not be empty and must not
17///   contain a line terminator.
18/// - No comment delimiter may be a prefix of another comment delimiter, no
19///   string delimiter of another string delimiter, and no comment delimiter of
20///   a string delimiter or the reverse. One position therefore never has two
21///   readings, which is what makes the single pass correct.
22/// - A nested block needs a start and an end that are distinct and neither
23///   contained in the other, so the depth count cannot be fooled.
24/// - A comment's [`CommentKind`] is whatever the delimiter declares. There is
25///   no classification by content the way a built-in scanner does it: a
26///   profile finds no shebang, no encoding line, and no license notice unless
27///   a [`ProtectedPattern`] says so.
28///
29/// A syntax that needs more than this — a regex literal, a heredoc, an
30/// indentation rule — needs a scanner plugin instead.
31///
32/// # Examples
33///
34/// ```
35/// use ocomment_core::{
36///     CommentKind, DeclarativeProfile, LineDelimiter, StringDelimiter, TransformOptions,
37///     transform_profile,
38/// };
39///
40/// let profile = DeclarativeProfile {
41///     name: "lisp".into(),
42///     extensions: vec!["lisp".into()],
43///     line_comments: vec![LineDelimiter {
44///         start: ";;".into(),
45///         requires_boundary: false,
46///         kind: CommentKind::Line,
47///     }],
48///     strings: vec![StringDelimiter {
49///         start: "\"".into(),
50///         end: "\"".into(),
51///         escape: Some("\\".into()),
52///         multiline: false,
53///     }],
54///     ..Default::default()
55/// };
56///
57/// let source = b"(print \";; not a comment\") ;; a comment\n";
58/// let result = transform_profile(source, &profile, TransformOptions::default()).unwrap();
59/// assert_eq!(result.output, b"(print \";; not a comment\") \n");
60/// ```
61#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
62#[serde(default, deny_unknown_fields)]
63pub struct DeclarativeProfile {
64    /// What to call the profile in a diagnostic. It must not be blank.
65    pub name: String,
66    /// The file extensions this profile claims, with or without the leading
67    /// dot and matched case-insensitively. The scanner never reads this; it
68    /// is for whoever picks a profile for a path.
69    #[serde(default)]
70    pub extensions: Vec<String>,
71    /// Tokens that open a comment running to the end of the line.
72    #[serde(default)]
73    pub line_comments: Vec<LineDelimiter>,
74    /// Tokens that open a comment running to a closing token.
75    #[serde(default)]
76    pub block_comments: Vec<BlockDelimiter>,
77    /// String forms to skip, so a comment token inside one is only text.
78    #[serde(default)]
79    pub strings: Vec<StringDelimiter>,
80    /// Substrings that turn a comment into a kept directive.
81    #[serde(default)]
82    pub protected_patterns: Vec<ProtectedPattern>,
83}
84
85/// A token that opens a comment running to the end of the line.
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct LineDelimiter {
89    /// The opening token.
90    pub start: String,
91    /// Only open a comment at the start of the source or after ASCII
92    /// whitespace, so a token that also occurs inside an identifier does not
93    /// swallow the rest of the line.
94    #[serde(default)]
95    pub requires_boundary: bool,
96    /// The kind to record, which is what the policy then judges.
97    #[serde(default)]
98    pub kind: CommentKind,
99}
100
101/// A token pair that opens and closes a delimited comment.
102#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct BlockDelimiter {
105    /// The opening token.
106    pub start: String,
107    /// The closing token.
108    pub end: String,
109    /// Count nesting, so an inner `start` needs its own `end`. Requires a
110    /// `start` and `end` that are distinct and neither contained in the other.
111    #[serde(default)]
112    pub nested: bool,
113    /// The kind to record, which is what the policy then judges.
114    #[serde(default)]
115    pub kind: CommentKind,
116}
117
118/// A string form the scan skips over, so a comment token inside one is text.
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct StringDelimiter {
122    /// The opening token.
123    pub start: String,
124    /// The closing token, which may be the same as `start`.
125    pub end: String,
126    /// A token that protects the byte after it, such as `\\`.
127    #[serde(default)]
128    pub escape: Option<String>,
129    /// Whether the string may cross a line terminator. When it may not, a
130    /// line terminator ends it and an `unterminated-profile-string`
131    /// diagnostic is raised.
132    #[serde(default)]
133    pub multiline: bool,
134}
135
136/// A substring that makes a comment a kept directive.
137///
138/// A comment whose text contains it is recorded as a
139/// [`CommentKind::Directive`], which every policy but
140/// [`Policy::All`](crate::Policy::All) keeps, and `reason` becomes the reason
141/// on its [`Disposition::Keep`](crate::Disposition::Keep).
142#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct ProtectedPattern {
145    /// The substring to look for, compared against the comment's text as
146    /// lossy UTF-8.
147    pub contains: String,
148    /// Why such a comment is kept, phrased for a human. It must not be blank.
149    pub reason: String,
150}
151
152/// Why a [`DeclarativeProfile`] cannot be interpreted.
153///
154/// Every variant is a limit of the single-pass design rather than a passing
155/// problem with the input, so the same profile always fails the same way.
156#[derive(Clone, Debug, Error, Eq, PartialEq)]
157pub enum ProfileError {
158    /// The profile has no name to put in a diagnostic.
159    #[error("profile name must not be empty")]
160    EmptyName,
161    /// The profile declares neither a line nor a block comment.
162    #[error("profile must define at least one comment delimiter")]
163    NoCommentDelimiter,
164    /// The named token is the empty string, which would match everywhere.
165    #[error("delimiter `{0}` must not be empty")]
166    EmptyDelimiter(&'static str),
167    /// Two comment delimiters where one is a prefix of the other.
168    #[error("ambiguous delimiter prefix: `{0}` and `{1}`")]
169    AmbiguousDelimiter(String, String),
170    /// Two string delimiters where one is a prefix of the other.
171    #[error("ambiguous string delimiter prefix: `{0}` and `{1}`")]
172    AmbiguousStringDelimiter(String, String),
173    /// A comment delimiter and a string delimiter where one is a prefix of
174    /// the other, so one position could open either.
175    #[error("ambiguous comment/string delimiter prefix: `{0}` and `{1}`")]
176    CommentStringCollision(String, String),
177    /// A nested block whose start and end are equal or overlap, which no
178    /// depth count can read.
179    #[error("nested block delimiters require distinct non-overlapping start and end tokens")]
180    InvalidNesting,
181    /// A delimiter spanning a line terminator, which a one-line token cannot.
182    #[error("delimiter contains a newline")]
183    NewlineDelimiter,
184    /// A [`ProtectedPattern`] with nothing to look for or no reason to give.
185    #[error("protected patterns need non-empty `contains` and `reason` values")]
186    EmptyProtectedPattern,
187    /// A `keep_regex` or `remove_regex` entry of the [`ScanOptions`] would not
188    /// compile.
189    #[error("invalid policy regex: {0}")]
190    InvalidPolicyRegex(String),
191}
192
193/// Check that a profile is one the single-pass interpreter can read.
194///
195/// [`scan_profile`] calls this first, so validating separately is only worth
196/// it to report a bad configuration before any file is opened.
197///
198/// # Errors
199///
200/// Returns the first [`ProfileError`] the profile runs into. The checks are
201/// on the profile alone and never on a source, so the answer is the same
202/// every time.
203pub fn validate_profile(profile: &DeclarativeProfile) -> Result<(), ProfileError> {
204    if profile.name.trim().is_empty() {
205        return Err(ProfileError::EmptyName);
206    }
207    if profile.line_comments.is_empty() && profile.block_comments.is_empty() {
208        return Err(ProfileError::NoCommentDelimiter);
209    }
210    let mut comments: Vec<&str> = Vec::new();
211    for delimiter in &profile.line_comments {
212        validate_token(&delimiter.start, "line start")?;
213        comments.push(&delimiter.start);
214    }
215    for delimiter in &profile.block_comments {
216        validate_token(&delimiter.start, "block start")?;
217        validate_token(&delimiter.end, "block end")?;
218        if delimiter.nested
219            && (delimiter.start == delimiter.end
220                || delimiter.start.contains(&delimiter.end)
221                || delimiter.end.contains(&delimiter.start))
222        {
223            return Err(ProfileError::InvalidNesting);
224        }
225        comments.push(&delimiter.start);
226    }
227    let mut strings: Vec<&str> = Vec::new();
228    for delimiter in &profile.strings {
229        validate_token(&delimiter.start, "string start")?;
230        validate_token(&delimiter.end, "string end")?;
231        if let Some(escape) = &delimiter.escape {
232            validate_token(escape, "string escape")?;
233        }
234        strings.push(&delimiter.start);
235    }
236    for (index, left) in comments.iter().enumerate() {
237        for right in comments.iter().skip(index + 1) {
238            if left.starts_with(*right) || right.starts_with(*left) {
239                return Err(ProfileError::AmbiguousDelimiter(
240                    (*left).into(),
241                    (*right).into(),
242                ));
243            }
244        }
245        if let Some(right) = strings
246            .iter()
247            .find(|right| left.starts_with(**right) || right.starts_with(*left))
248        {
249            return Err(ProfileError::CommentStringCollision(
250                (*left).into(),
251                (**right).into(),
252            ));
253        }
254    }
255    for (index, left) in strings.iter().enumerate() {
256        for right in strings.iter().skip(index + 1) {
257            if left.starts_with(*right) || right.starts_with(*left) {
258                return Err(ProfileError::AmbiguousStringDelimiter(
259                    (*left).into(),
260                    (*right).into(),
261                ));
262            }
263        }
264    }
265    if profile
266        .protected_patterns
267        .iter()
268        .any(|pattern| pattern.contains.is_empty() || pattern.reason.trim().is_empty())
269    {
270        return Err(ProfileError::EmptyProtectedPattern);
271    }
272    Ok(())
273}
274
275/// Interpret a validated declarative profile with a single byte-oriented pass.
276///
277/// Strings are matched first, then line comments, then block comments, so a
278/// comment token inside a string is never a comment. The report names
279/// [`Language::Unknown`] — a profile is not one of the built-in languages —
280/// and an unterminated string or block raises an
281/// `unterminated-profile-string` or `unterminated-profile-comment`
282/// diagnostic, which makes the report invalid.
283///
284/// # Errors
285///
286/// Returns a [`ProfileError`] when the profile itself is unreadable, which
287/// is checked before the source is touched.
288pub fn scan_profile(
289    source: &[u8],
290    profile: &DeclarativeProfile,
291    options: ScanOptions,
292) -> Result<ScanReport, ProfileError> {
293    let prepared = PreparedScanner::new(options)
294        .map_err(|error| ProfileError::InvalidPolicyRegex(error.to_string()))?;
295    prepared.scan_profile(source, profile)
296}
297
298impl PreparedScanner {
299    /// Scan a declarative profile with this scanner's already-compiled policy.
300    pub fn scan_profile(
301        &self,
302        source: &[u8],
303        profile: &DeclarativeProfile,
304    ) -> Result<ScanReport, ProfileError> {
305        scan_profile_with(source, profile, self.options(), &self.patterns)
306    }
307
308    /// Plan a declarative-profile transformation without materializing its
309    /// output bytes or source map.
310    pub fn transform_profile_plan(
311        &self,
312        source: &[u8],
313        profile: &DeclarativeProfile,
314        layout: Layout,
315    ) -> Result<TransformPlan, ProfileError> {
316        let report = self.scan_profile(source, profile)?;
317        Ok(crate::transform::plan_report(
318            source,
319            report,
320            layout,
321            self.options().force_invalid,
322        ))
323    }
324}
325
326fn scan_profile_with(
327    source: &[u8],
328    profile: &DeclarativeProfile,
329    options: &ScanOptions,
330    patterns: &DispositionPatterns,
331) -> Result<ScanReport, ProfileError> {
332    validate_profile(profile)?;
333    let mut comments = Vec::new();
334    let mut diagnostics = Vec::new();
335    let mut index = 0;
336    while index < source.len() {
337        if let Some(string) = profile
338            .strings
339            .iter()
340            .find(|string| starts(source, index, string.start.as_bytes()))
341        {
342            let start = index;
343            index += string.start.len();
344            let mut closed = false;
345            while index < source.len() {
346                if starts(source, index, string.end.as_bytes()) {
347                    index += string.end.len();
348                    closed = true;
349                    break;
350                }
351                if let Some(escape) = &string.escape
352                    && starts(source, index, escape.as_bytes())
353                {
354                    index = (index + escape.len() + 1).min(source.len());
355                    continue;
356                }
357                if !string.multiline && matches!(source[index], b'\r' | b'\n') {
358                    break;
359                }
360                index += 1;
361            }
362            if !closed {
363                diagnostics.push(Diagnostic {
364                    code: "unterminated-profile-string".into(),
365                    message: format!("unterminated string in profile `{}`", profile.name),
366                    severity: Severity::Error,
367                    span: ByteSpan::new(start, index),
368                });
369            }
370            continue;
371        }
372        if let Some(delimiter) = profile.line_comments.iter().find(|delimiter| {
373            starts(source, index, delimiter.start.as_bytes())
374                && (!delimiter.requires_boundary
375                    || index == 0
376                    || source[index - 1].is_ascii_whitespace())
377        }) {
378            let mut end = index + delimiter.start.len();
379            while end < source.len() && !matches!(source[end], b'\r' | b'\n') {
380                end += 1;
381            }
382            comments.push(profile_comment(
383                source,
384                index,
385                end,
386                delimiter.kind,
387                profile,
388                options,
389                patterns,
390            ));
391            index = end;
392            continue;
393        }
394        if let Some(delimiter) = profile
395            .block_comments
396            .iter()
397            .find(|delimiter| starts(source, index, delimiter.start.as_bytes()))
398        {
399            let start = index;
400            index += delimiter.start.len();
401            let mut depth = 1usize;
402            while index < source.len() {
403                if delimiter.nested && starts(source, index, delimiter.start.as_bytes()) {
404                    depth += 1;
405                    index += delimiter.start.len();
406                } else if starts(source, index, delimiter.end.as_bytes()) {
407                    depth -= 1;
408                    index += delimiter.end.len();
409                    if depth == 0 {
410                        break;
411                    }
412                } else {
413                    index += 1;
414                }
415            }
416            comments.push(profile_comment(
417                source,
418                start,
419                index,
420                delimiter.kind,
421                profile,
422                options,
423                patterns,
424            ));
425            if depth != 0 {
426                diagnostics.push(Diagnostic {
427                    code: "unterminated-profile-comment".into(),
428                    message: format!("unterminated block comment in profile `{}`", profile.name),
429                    severity: Severity::Error,
430                    span: ByteSpan::new(start, index),
431                });
432            }
433            continue;
434        }
435        index += 1;
436    }
437    let valid = diagnostics.is_empty();
438    Ok(ScanReport {
439        language: Language::Unknown,
440        comments,
441        diagnostics,
442        valid,
443    })
444}
445
446/// Scan under a profile and produce the bytes a removal would write.
447///
448/// [`scan_profile`] followed by the same layout, edit validation, and
449/// source-map engine the built-in languages go through, so the guarantees
450/// of [`transform`](crate::transform) hold here too.
451///
452/// # Errors
453///
454/// Returns a [`ProfileError`] when the profile itself is unreadable.
455pub fn transform_profile(
456    source: &[u8],
457    profile: &DeclarativeProfile,
458    options: TransformOptions,
459) -> Result<TransformResult, ProfileError> {
460    let prepared = PreparedScanner::new(options.scan)
461        .map_err(|error| ProfileError::InvalidPolicyRegex(error.to_string()))?;
462    Ok(prepared
463        .transform_profile_plan(source, profile, options.layout)?
464        .finish(source))
465}
466
467fn profile_comment(
468    source: &[u8],
469    start: usize,
470    end: usize,
471    mut kind: CommentKind,
472    profile: &DeclarativeProfile,
473    options: &ScanOptions,
474    patterns: &DispositionPatterns,
475) -> Comment {
476    let raw = String::from_utf8_lossy(&source[start..end]);
477    let protected = profile
478        .protected_patterns
479        .iter()
480        .find(|pattern| raw.contains(&pattern.contains));
481    if protected.is_some() {
482        kind = CommentKind::Directive;
483    }
484    let mut disposition = disposition(kind, options, &source[start..end], patterns);
485    if let (Some(pattern), crate::Disposition::Keep { reason }) = (protected, &mut disposition) {
486        *reason = pattern.reason.clone();
487    }
488    Comment {
489        span: ByteSpan::new(start, end),
490        kind,
491        disposition,
492    }
493}
494
495fn starts(source: &[u8], index: usize, token: &[u8]) -> bool {
496    source.get(index..index.saturating_add(token.len())) == Some(token)
497}
498
499fn validate_token(token: &str, name: &'static str) -> Result<(), ProfileError> {
500    if token.is_empty() {
501        return Err(ProfileError::EmptyDelimiter(name));
502    }
503    if token.contains(['\r', '\n']) {
504        return Err(ProfileError::NewlineDelimiter);
505    }
506    Ok(())
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    #[test]
513    fn rejects_prefix_ambiguity() {
514        let profile = DeclarativeProfile {
515            name: "x".into(),
516            line_comments: vec![
517                LineDelimiter {
518                    start: "/".into(),
519                    requires_boundary: false,
520                    kind: CommentKind::Line,
521                },
522                LineDelimiter {
523                    start: "//".into(),
524                    requires_boundary: false,
525                    kind: CommentKind::Line,
526                },
527            ],
528            ..Default::default()
529        };
530        assert!(matches!(
531            validate_profile(&profile),
532            Err(ProfileError::AmbiguousDelimiter(..))
533        ));
534    }
535
536    #[test]
537    fn scans_profile_without_looking_inside_strings() {
538        let profile = DeclarativeProfile {
539            name: "demo".into(),
540            line_comments: vec![LineDelimiter {
541                start: ";;".into(),
542                requires_boundary: false,
543                kind: CommentKind::Line,
544            }],
545            strings: vec![StringDelimiter {
546                start: "\"".into(),
547                end: "\"".into(),
548                escape: Some("\\".into()),
549                multiline: false,
550            }],
551            ..Default::default()
552        };
553        let report = scan_profile(b"\";; no\" ;; yes\n", &profile, ScanOptions::default()).unwrap();
554        assert_eq!(report.comments.len(), 1);
555        assert_eq!(
556            &b"\";; no\" ;; yes\n"[report.comments[0].span.start..report.comments[0].span.end],
557            b";; yes"
558        );
559    }
560
561    #[test]
562    fn rejects_empty_and_string_ambiguous_profiles() {
563        assert_eq!(
564            validate_profile(&DeclarativeProfile {
565                name: "empty".into(),
566                ..Default::default()
567            }),
568            Err(ProfileError::NoCommentDelimiter)
569        );
570        let profile = DeclarativeProfile {
571            name: "ambiguous".into(),
572            line_comments: vec![LineDelimiter {
573                start: "#".into(),
574                requires_boundary: false,
575                kind: CommentKind::Line,
576            }],
577            strings: vec![StringDelimiter {
578                start: "##".into(),
579                end: "##".into(),
580                escape: None,
581                multiline: false,
582            }],
583            ..Default::default()
584        };
585        assert!(matches!(
586            validate_profile(&profile),
587            Err(ProfileError::CommentStringCollision(..))
588        ));
589    }
590}