today/src/files.rs

483 lines
14 KiB
Rust

use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::{fs, result};
use chrono::{DateTime, NaiveDate, Utc};
use codespan_reporting::files::SimpleFiles;
use tzfile::Tz;
use self::commands::{Command, Done, File, Log};
pub use self::error::{Error, ParseError, Result};
use self::primitives::Spanned;
pub mod arguments;
pub mod commands;
mod error;
mod format;
mod parse;
pub mod primitives;
// TODO Move file content from `File` to `LoadedFile`
#[derive(Debug)]
struct LoadedFile {
/// User-readable path for this file.
name: PathBuf,
/// Identifier for codespan-reporting.
cs_id: usize,
file: File,
/// Whether this file has been changed.
dirty: bool,
/// Commands that have been removed and are to be skipped during formatting.
///
/// They are not directly removed from the list of commands in order not to
/// change other commands' indices.
removed: HashSet<usize>,
}
impl LoadedFile {
pub fn new(name: PathBuf, cs_id: usize, file: File) -> Self {
Self {
name,
cs_id,
file,
dirty: false,
removed: HashSet::new(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Source {
file: usize,
command: usize,
}
// TODO Rename to `SourceFile`?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileSource(usize);
impl Source {
pub fn new(file: usize, command: usize) -> Self {
Self { file, command }
}
pub fn file(&self) -> FileSource {
FileSource(self.file)
}
}
#[derive(Debug)]
pub struct Sourced<'a, T> {
pub source: Source,
pub value: &'a T,
}
impl<'a, T> Sourced<'a, T> {
fn new(source: Source, value: &'a T) -> Self {
Self { source, value }
}
}
#[derive(Debug)]
pub struct Files {
files: Vec<LoadedFile>,
/// Codespan-reporting file database.
cs_files: SimpleFiles<String, String>,
timezone: Option<Tz>,
capture: Option<usize>,
logs: HashMap<NaiveDate, Source>,
}
impl<'a> codespan_reporting::files::Files<'a> for Files {
type FileId = FileSource;
type Name = String;
type Source = &'a str;
fn name(
&'a self,
id: Self::FileId,
) -> result::Result<Self::Name, codespan_reporting::files::Error> {
self.cs_files.name(self.cs_id(id))
}
fn source(
&'a self,
id: Self::FileId,
) -> result::Result<Self::Source, codespan_reporting::files::Error> {
self.cs_files.source(self.cs_id(id))
}
fn line_index(
&'a self,
id: Self::FileId,
byte_index: usize,
) -> result::Result<usize, codespan_reporting::files::Error> {
self.cs_files.line_index(self.cs_id(id), byte_index)
}
fn line_range(
&'a self,
id: Self::FileId,
line_index: usize,
) -> result::Result<std::ops::Range<usize>, codespan_reporting::files::Error> {
self.cs_files.line_range(self.cs_id(id), line_index)
}
}
impl Files {
/* Loading */
pub fn new() -> Self {
Self {
files: vec![],
cs_files: SimpleFiles::new(),
timezone: None,
capture: None,
logs: HashMap::new(),
}
}
/// Load a file and all its includes.
///
/// # Warning
///
/// - This function must be called before all other functions.
/// - This function must only be called once.
/// - If this function fails,
/// - it is safe to print the error using the [`codespan_reporting::files::Files`] instance and
/// - no other functions may be called.
pub fn load(&mut self, path: &Path) -> Result<()> {
if !self.files.is_empty() {
panic!("Files::load called multiple times");
}
// Track already loaded files by their normalized paths
let mut loaded = HashSet::new();
self.load_file(&mut loaded, path)?;
self.determine_timezone()?;
self.determine_capture()?;
self.collect_logs()?;
Ok(())
}
fn load_file(&mut self, loaded: &mut HashSet<PathBuf>, name: &Path) -> Result<()> {
let path = name.canonicalize().map_err(|e| Error::ResolvePath {
path: name.to_path_buf(),
error: e,
})?;
if loaded.contains(&path) {
// We've already loaded this exact file.
return Ok(());
}
let content = fs::read_to_string(name).map_err(|e| Error::ReadFile {
file: path.clone(),
error: e,
})?;
let cs_id = self
.cs_files
.add(name.to_string_lossy().to_string(), content.clone());
// Using `name` instead of `path` for the unwrap below.
let file = match parse::parse(name, &content) {
Ok(file) => file,
Err(error) => {
// Using a dummy file. This should be fine since we return an
// error immediately after and the user must never call `load`
// twice. Otherwise, we run the danger of overwriting a file
// with empty content.
self.files
.push(LoadedFile::new(name.to_owned(), cs_id, File::dummy()));
return Err(Error::Parse {
file: FileSource(self.files.len() - 1),
error,
});
}
};
let includes = file
.commands
.iter()
.filter_map(|c| match &c.value {
Command::Include(path) => Some(path.clone()),
_ => None,
})
.collect::<Vec<_>>();
loaded.insert(path);
self.files
.push(LoadedFile::new(name.to_owned(), cs_id, file));
for include in includes {
// Since we've successfully opened the file, its name can't be the
// root directory or empty string and it must thus have a parent.
let include_path = name.parent().unwrap().join(include.value);
self.load_file(loaded, &include_path)?;
}
Ok(())
}
fn determine_timezone(&mut self) -> Result<()> {
assert_eq!(self.timezone, None);
let mut found: Option<(Source, Spanned<String>)> = None;
for command in self.commands() {
if let Command::Timezone(tz) = &command.value.value {
if let Some((found_source, found_tz)) = &found {
if tz.value != found_tz.value {
return Err(Error::TzConflict {
file1: found_source.file(),
span1: found_tz.span,
tz1: found_tz.value.clone(),
file2: command.source.file(),
span2: tz.span,
tz2: tz.value.clone(),
});
}
} else {
found = Some((command.source, tz.clone()));
}
}
}
let timezone = if let Some((source, tz)) = found {
Tz::named(&tz.value).map_err(|error| Error::ResolveTz {
file: source.file(),
span: tz.span,
tz: tz.value,
error,
})?
} else {
Tz::local().map_err(|error| Error::LocalTz { error })?
};
self.timezone = Some(timezone);
Ok(())
}
fn determine_capture(&mut self) -> Result<()> {
assert_eq!(self.capture, None);
let mut found: Option<Source> = None;
for command in self.commands() {
if let Command::Capture = &command.value.value {
if let Some(found) = &found {
let found_cmd = self.command(*found);
return Err(Error::MultipleCapture {
file1: found.file(),
span1: found_cmd.value.span,
file2: command.source.file(),
span2: command.value.span,
});
} else {
found = Some(command.source);
}
}
}
self.capture = found.map(|s| s.file);
Ok(())
}
fn collect_logs(&mut self) -> Result<()> {
for command in Self::commands_of_files(&self.files) {
if let Command::Log(log) = &command.value.value {
match self.logs.entry(log.date.value) {
Entry::Vacant(e) => {
e.insert(command.source);
}
Entry::Occupied(e) => {
let other_cmd = Self::command_of_files(&self.files, *e.get());
let other_span = match &other_cmd.value.value {
Command::Log(log) => log.date.span,
_ => unreachable!(),
};
return Err(Error::LogConflict {
file1: other_cmd.source.file(),
span1: other_span,
file2: command.source.file(),
span2: log.date.span,
date: log.date.value,
});
}
}
}
}
Ok(())
}
/* Saving */
pub fn save(&self) -> Result<()> {
for file in &self.files {
if file.dirty {
self.save_file(file)?;
}
}
Ok(())
}
fn save_file(&self, file: &LoadedFile) -> Result<()> {
// TODO Sort commands within file
let previous = self
.cs_files
.get(file.cs_id)
.expect("cs id is valid")
.source();
let formatted = file.file.format(&file.removed);
if previous == &formatted {
println!("Unchanged file {:?}", file.name);
} else {
println!("Saving file {:?}", file.name);
fs::write(&file.name, &formatted).map_err(|e| Error::WriteFile {
file: file.name.to_path_buf(),
error: e,
})?;
}
Ok(())
}
/* Querying */
fn commands_of_files(files: &[LoadedFile]) -> Vec<Sourced<'_, Spanned<Command>>> {
let mut result = vec![];
for (file_index, file) in files.iter().enumerate() {
for (command_index, command) in file.file.commands.iter().enumerate() {
let source = Source::new(file_index, command_index);
result.push(Sourced::new(source, command));
}
}
result
}
pub fn commands(&self) -> Vec<Sourced<'_, Spanned<Command>>> {
Self::commands_of_files(&self.files)
}
fn command_of_files(files: &[LoadedFile], source: Source) -> Sourced<'_, Spanned<Command>> {
let command = &files[source.file].file.commands[source.command];
Sourced::new(source, command)
}
pub fn command(&self, source: Source) -> Sourced<'_, Spanned<Command>> {
Self::command_of_files(&self.files, source)
}
pub fn log(&self, date: NaiveDate) -> Option<Sourced<'_, Log>> {
let source = *self.logs.get(&date)?;
match &self.command(source).value.value {
Command::Log(log) => Some(Sourced::new(source, log)),
_ => unreachable!(),
}
}
fn latest_log(&self) -> Option<(NaiveDate, Source)> {
self.logs
.iter()
.map(|(d, s)| (*d, *s))
.max_by_key(|(d, _)| *d)
}
fn latest_log_before(&self, date: NaiveDate) -> Option<(NaiveDate, Source)> {
self.logs
.iter()
.map(|(d, s)| (*d, *s))
.filter(|(d, _)| d <= &date)
.max_by_key(|(d, _)| *d)
}
pub fn capture(&self) -> Option<FileSource> {
self.capture.map(FileSource)
}
pub fn now(&self) -> DateTime<&Tz> {
if let Some(tz) = &self.timezone {
Utc::now().with_timezone(&tz)
} else {
panic!("Called Files::now before Files::load");
}
}
/* Updating */
pub fn mark_all_dirty(&mut self) {
for file in self.files.iter_mut() {
file.dirty = true;
}
}
fn modify(&mut self, source: Source, edit: impl FnOnce(&mut Command)) {
let file = &mut self.files[source.file];
edit(&mut file.file.commands[source.command].value);
file.dirty = true;
}
fn insert(&mut self, file: FileSource, command: Command) {
let file = &mut self.files[file.0];
file.file.commands.push(Spanned::dummy(command));
file.dirty = true;
}
fn remove(&mut self, source: Source) {
let file = &mut self.files[source.file];
file.removed.insert(source.command);
file.dirty = true;
}
/// Add a [`Done`] statement to the task identified by `source`.
///
/// Returns whether the addition was successful. It can fail if the entry
/// identified by `source` is a note, not a task.
#[must_use]
pub fn add_done(&mut self, source: Source, done: Done) -> bool {
let file = &mut self.files[source.file];
match &mut file.file.commands[source.command].value {
Command::Task(t) => t.done.push(done),
_ => return false,
}
file.dirty = true;
true
}
pub fn set_log(&mut self, date: NaiveDate, desc: Vec<String>) {
if let Some(source) = self.logs.get(&date).cloned() {
if desc.is_empty() {
self.remove(source);
} else {
self.modify(source, |command| match command {
Command::Log(log) => log.desc = desc,
_ => unreachable!(),
});
}
} else if !desc.is_empty() {
let file = self
.latest_log_before(date)
.or_else(|| self.latest_log())
.map(|(_, source)| source.file())
.unwrap_or(FileSource(0));
let date = Spanned::dummy(date);
let command = Command::Log(Log { date, desc });
self.insert(file, command);
}
}
/* Errors */
fn cs_id(&self, file: FileSource) -> usize {
self.files[file.0].cs_id
}
}