Skip to main content

zincite_syntax/
bytes.rs

1use crate::{Diagnostic, FileMode, ParsedFile, TokenKind, lex, parse_lexed, parse_with_mode};
2
3/// Parse model bytes, accepting invalid UTF-8 only inside comments.
4/// Syntax errors remain in the analysis file's diagnostics. Unsupported byte
5/// input returns a diagnostic at its original byte range.
6pub fn parse_bytes(source: Vec<u8>) -> Result<ByteParsedFile, Diagnostic> {
7    parse_bytes_with_mode(source, FileMode::Model)
8}
9
10/// Parse model or assignment-only data bytes without resolving includes.
11/// All ranges refer to the supplied bytes; a BOM is not stripped automatically.
12pub fn parse_bytes_with_mode(
13    source: Vec<u8>,
14    mode: FileMode,
15) -> Result<ByteParsedFile, Diagnostic> {
16    let source = match String::from_utf8(source) {
17        Ok(source) => {
18            return Ok(ByteParsedFile {
19                original_bytes: None,
20                analysis_file: parse_with_mode(source, mode),
21            });
22        }
23        Err(error) => error.into_bytes(),
24    };
25    let mut analysis_bytes = source.clone();
26    let mut invalid_ranges = Vec::new();
27    let mut position = 0;
28    while let Err(error) = std::str::from_utf8(&source[position..]) {
29        let start = position + error.valid_up_to();
30        let end = start + error.error_len().unwrap_or(source.len() - start);
31        // Keep delimiters and valid UTF-8 unchanged. A non-whitespace marker
32        // cannot turn a malformed comment directive into an accepted one.
33        analysis_bytes[start..end].fill(b'?');
34        invalid_ranges.push(start..end);
35        position = end;
36    }
37    let analysis_source =
38        String::from_utf8(analysis_bytes).expect("invalid UTF-8 was replaced with ASCII");
39    let lexed = lex(analysis_source);
40    let mut token_index = 0;
41    for range in invalid_ranges {
42        while lexed.tokens()[token_index].range.end <= range.start {
43            token_index += 1;
44        }
45        let token = &lexed.tokens()[token_index];
46        if !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment)
47            || range.end > token.range.end
48        {
49            return Err(Diagnostic {
50                range,
51                message: "invalid UTF-8 is only supported inside comments".into(),
52            });
53        }
54    }
55    Ok(ByteParsedFile {
56        original_bytes: Some(source),
57        analysis_file: parse_lexed(lexed, mode),
58    })
59}
60
61/// Exact source bytes and their syntax, with a separate UTF-8 analysis view.
62#[derive(Debug)]
63pub struct ByteParsedFile {
64    original_bytes: Option<Vec<u8>>,
65    analysis_file: ParsedFile,
66}
67
68impl ByteParsedFile {
69    /// Consume the file into its UTF-8 analysis view and optional original bytes.
70    /// When bytes are absent, the analysis source is the exact UTF-8 input.
71    pub fn into_parts(self) -> (ParsedFile, Option<Vec<u8>>) {
72        (self.analysis_file, self.original_bytes)
73    }
74
75    /// Return the exact supplied bytes, including opaque comment bytes.
76    pub fn source_bytes(&self) -> &[u8] {
77        self.original_bytes
78            .as_deref()
79            .unwrap_or_else(|| self.analysis_file.source().as_bytes())
80    }
81
82    /// Return one retained token's original spelling.
83    /// Panics if the token index is out of bounds.
84    pub fn token_bytes(&self, index: usize) -> &[u8] {
85        &self.source_bytes()[self.analysis_file.tokens()[index].range.clone()]
86    }
87
88    /// Return syntax and diagnostics at the original byte coordinates.
89    /// Invalid UTF-8 comment bytes appear as one-byte non-whitespace markers in
90    /// this view. Its source text is analysis input, not exact original source;
91    /// use `source_bytes` or `token_bytes` for original spelling.
92    pub fn analysis_file(&self) -> &ParsedFile {
93        &self.analysis_file
94    }
95
96    /// Return the one-based line and column at an original byte offset.
97    /// Valid UTF-8 scalars and opaque invalid bytes each occupy one column.
98    /// CR/LF handling and boundary panics follow `ParsedFile::line_column`.
99    pub fn line_column(&self, byte_offset: usize) -> (usize, usize) {
100        self.analysis_file.line_column(byte_offset)
101    }
102}
103
104/// Return whether an original-byte offset avoids splitting a valid UTF-8 scalar.
105/// Each invalid byte is a boundary; offsets beyond the source are not boundaries.
106pub fn is_utf8_boundary(source: &[u8], offset: usize) -> bool {
107    if offset > source.len() {
108        return false;
109    }
110    for start in offset.saturating_sub(3)..offset {
111        let width = match source[start] {
112            0xc2..=0xdf => 2,
113            0xe0..=0xef => 3,
114            0xf0..=0xf4 => 4,
115            _ => continue,
116        };
117        let end = start + width;
118        if offset < end && end <= source.len() && std::str::from_utf8(&source[start..end]).is_ok() {
119            return false;
120        }
121    }
122    true
123}
124
125/// Return one-based line and column at an original byte boundary.
126/// Valid UTF-8 scalars and invalid bytes each occupy one column; CRLF is one break.
127/// Panics for an out-of-range offset or one inside a valid UTF-8 scalar.
128pub fn byte_line_column(source: &[u8], byte_offset: usize) -> (usize, usize) {
129    assert!(
130        is_utf8_boundary(source, byte_offset),
131        "invalid byte boundary"
132    );
133    let mut prefix = &source[..byte_offset];
134    let mut line = 1;
135    let mut column = 1;
136    let mut after_cr = false;
137    loop {
138        let (text, invalid_bytes) = match std::str::from_utf8(prefix) {
139            Ok(text) => (text, 0),
140            Err(error) => (
141                std::str::from_utf8(&prefix[..error.valid_up_to()]).unwrap(),
142                error
143                    .error_len()
144                    .unwrap_or(prefix.len() - error.valid_up_to()),
145            ),
146        };
147        for character in text.chars() {
148            match character {
149                '\r' => {
150                    line += 1;
151                    column = 1;
152                }
153                '\n' if after_cr => {}
154                '\n' => {
155                    line += 1;
156                    column = 1;
157                }
158                _ => column += 1,
159            }
160            after_cr = character == '\r';
161        }
162        if invalid_bytes == 0 {
163            return (line, column);
164        }
165        column += invalid_bytes;
166        after_cr = false;
167        prefix = &prefix[text.len() + invalid_bytes..];
168    }
169}