#![warn(missing_docs)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]
mod comment_index;
mod line_index;
mod region_index;
pub use comment_index::{CommentIndex, IndexedComment};
pub use line_index::{LineEndingStyle, LineIndex};
pub use region_index::{IndexedHeredoc, RegionIndex, RegionKind};
use line_index::{RawContinuationCandidate, RawContinuationMode};
use shuck_ast::{File, TextSize};
use shuck_parser::parser::ParseResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct IndexerOptions {
source_layout_indexes: bool,
}
impl IndexerOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_source_layout_indexes(mut self, enabled: bool) -> Self {
self.source_layout_indexes = enabled;
self
}
pub fn source_layout_indexes(self) -> bool {
self.source_layout_indexes
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Indexer {
line_index: LineIndex,
comment_index: CommentIndex,
region_index: RegionIndex,
continuation_lines: Vec<TextSize>,
}
impl Indexer {
pub fn new(source: &str, output: &ParseResult) -> Self {
Self::for_file(source, &output.file)
}
pub fn new_with_options(source: &str, output: &ParseResult, options: IndexerOptions) -> Self {
Self::for_file_with_options(source, &output.file, options)
}
pub fn for_file(source: &str, file: &File) -> Self {
Self::for_file_with_options(source, file, IndexerOptions::default())
}
pub fn for_file_with_options(source: &str, file: &File, options: IndexerOptions) -> Self {
let raw_mode = if options.source_layout_indexes() {
RawContinuationMode::StoreAndReturn
} else {
RawContinuationMode::ReturnOnly
};
let (line_index, raw_continuations) = LineIndex::build(source, raw_mode);
let comment_index = CommentIndex::new(source, &line_index, file);
let region_index = RegionIndex::new_with_source_layout_indexes(
source,
file,
options.source_layout_indexes(),
);
let continuation_lines =
collect_continuation_lines(&raw_continuations, &comment_index, ®ion_index);
Self {
line_index,
comment_index,
region_index,
continuation_lines,
}
}
pub fn line_index(&self) -> &LineIndex {
&self.line_index
}
pub fn comment_index(&self) -> &CommentIndex {
&self.comment_index
}
pub fn region_index(&self) -> &RegionIndex {
&self.region_index
}
pub fn continuation_line_starts(&self) -> &[TextSize] {
&self.continuation_lines
}
pub fn is_continuation(&self, offset: TextSize) -> bool {
let line = self.line_index.line_number(offset);
let Some(line_start) = self.line_index.line_start(line) else {
return false;
};
contains_offset(&self.continuation_lines, line_start)
}
}
fn collect_continuation_lines(
raw_continuations: &[RawContinuationCandidate],
comment_index: &CommentIndex,
region_index: &RegionIndex,
) -> Vec<TextSize> {
let mut continuation_lines = Vec::new();
for continuation in raw_continuations {
if comment_index.is_comment(continuation.backslash)
|| region_index.is_heredoc(continuation.backslash)
|| region_index.is_quoted(continuation.backslash)
{
continue;
}
continuation_lines.push(continuation.line_start);
}
continuation_lines
}
fn contains_offset(offsets: &[TextSize], offset: TextSize) -> bool {
offsets.binary_search(&offset).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use shuck_parser::parser::Parser;
fn index(source: &str) -> Indexer {
let output = Parser::new(source).parse().unwrap();
Indexer::new(source, &output)
}
#[test]
fn detects_continuation_lines_without_allocating_source_copies() {
let source = "echo foo \\\n bar\necho \"foo\\\nbar\"\n";
let indexer = index(source);
assert_eq!(indexer.continuation_line_starts().len(), 1);
assert!(indexer.is_continuation(TextSize::new(11)));
assert!(!indexer.is_continuation(TextSize::new(28)));
assert!(
indexer
.line_index()
.raw_continuation_backslashes()
.is_empty()
);
assert!(indexer.region_index().heredocs().is_empty());
}
#[test]
fn retains_source_layout_indexes_only_when_requested() {
let source = "echo foo \\\n bar\ncat <<EOF\nbody\nEOF\n";
let output = Parser::new(source).parse().unwrap();
let indexer = Indexer::new_with_options(
source,
&output,
IndexerOptions::new().with_source_layout_indexes(true),
);
assert_eq!(
indexer.line_index().raw_continuation_backslashes(),
&[TextSize::new(source.find('\\').unwrap() as u32)]
);
assert_eq!(indexer.region_index().heredocs().len(), 1);
}
#[test]
fn round_trips_parser_output_into_regions_comments_and_lines() {
let source = "\
#!/bin/bash
echo \"$(printf '%s' \"$name\")\" # inline
cat <<'EOF'
literal $body
EOF
";
let indexer = index(source);
assert_eq!(indexer.line_index().line_count(), 6);
assert_eq!(indexer.comment_index().comments().len(), 2);
let heredoc_offset = TextSize::new(source.find("literal $body").unwrap() as u32);
assert_eq!(
indexer.region_index().region_at(heredoc_offset),
Some(RegionKind::Heredoc)
);
assert!(indexer.region_index().is_quoted(heredoc_offset));
let name_offset = TextSize::new(source.find("$name").unwrap() as u32);
assert_eq!(
indexer.region_index().region_at(name_offset),
Some(RegionKind::DoubleQuoted)
);
}
}