feat: removed unsafe code from parser

This commit is contained in:
2026-08-07 21:45:39 +10:00
committed by professor
parent 24a8ad54ae
commit 078be9be76
5 changed files with 551 additions and 606 deletions
Generated
+446 -483
View File
File diff suppressed because it is too large Load Diff
+30 -41
View File
@@ -6,19 +6,16 @@ use crate::todo::editor::TodoEditor;
use cosmic::app::context_drawer; use cosmic::app::context_drawer;
use cosmic::cosmic_config::{self, ConfigSet, CosmicConfigEntry}; use cosmic::cosmic_config::{self, ConfigSet, CosmicConfigEntry};
use cosmic::iced::alignment::{Horizontal, Vertical}; use cosmic::iced::alignment::{Horizontal, Vertical};
use cosmic::iced::futures::channel::mpsc::Sender; use cosmic::iced::{Alignment, Length, Subscription};
use cosmic::iced::{Alignment, Length, Subscription, stream};
use cosmic::prelude::*; use cosmic::prelude::*;
use cosmic::widget::text_editor::{self, Content}; use cosmic::widget::text_editor;
use cosmic::widget::{self, about::About, menu}; use cosmic::widget::{self, about::About, menu};
use futures_util::SinkExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::path; use std::path;
use std::time::Duration;
use cosmic_files::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings}; use cosmic_files::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
use crate::todo::parser::Todo; use crate::todo::parser::{Todo, TodoError};
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY"); const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg"); const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg");
@@ -39,8 +36,6 @@ pub struct AppModel {
config: Config, config: Config,
/// Time active /// Time active
time: u32, time: u32,
/// Toggle the watch subscription
watch_is_active: bool,
/// Current Todo data /// Current Todo data
todo: Option<Todo>, todo: Option<Todo>,
editor: TodoEditor, editor: TodoEditor,
@@ -55,8 +50,10 @@ pub enum Message {
UpdateConfig(Config), UpdateConfig(Config),
WatchTick(u32), WatchTick(u32),
OpenFileDialog, OpenFileDialog,
DialogMessage(DialogMessage), Dialog(Box<DialogMessage>),
OpenFileResult(DialogResult), OpenFileResult(DialogResult),
TodoLoaded(Todo),
TodoLoadError(TodoError),
Edit(text_editor::Action, usize), Edit(text_editor::Action, usize),
} }
@@ -212,7 +209,7 @@ impl cosmic::Application for AppModel {
/// indefinitely. /// indefinitely.
fn subscription(&self) -> Subscription<Self::Message> { fn subscription(&self) -> Subscription<Self::Message> {
// Add subscriptions which are always active. // Add subscriptions which are always active.
let mut subscriptions = vec![ let subscriptions = vec![
// Watch for application configuration changes. // Watch for application configuration changes.
self.core() self.core()
.watch_config::<Config>(Self::APP_ID) .watch_config::<Config>(Self::APP_ID)
@@ -225,22 +222,6 @@ impl cosmic::Application for AppModel {
}), }),
]; ];
// Conditionally enables a timer that emits a message every second.
if self.watch_is_active {
subscriptions.push(Subscription::run(|| {
stream::channel(1, |mut emitter: Sender<Message>| async move {
let mut time = 1;
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
_ = emitter.send(Message::WatchTick(time)).await;
time += 1;
}
})
}));
}
Subscription::batch(subscriptions) Subscription::batch(subscriptions)
} }
@@ -276,21 +257,21 @@ impl cosmic::Application for AppModel {
if self.dialog_opt.is_none() { if self.dialog_opt.is_none() {
let (dialog, command) = Dialog::new( let (dialog, command) = Dialog::new(
DialogSettings::new().kind(DialogKind::OpenFile), DialogSettings::new().kind(DialogKind::OpenFile),
Message::DialogMessage, |msg| Message::Dialog(Box::new(msg)),
Message::OpenFileResult, Message::OpenFileResult,
); );
self.dialog_opt = Some(dialog); self.dialog_opt = Some(dialog);
return command; return command;
} }
} }
Message::DialogMessage(dialog_message) => { Message::Dialog(dialog_message) => {
if let Some(dialog) = &mut self.dialog_opt { if let Some(dialog) = &mut self.dialog_opt {
return dialog.update(dialog_message); return dialog.update(*dialog_message);
} }
} }
Message::OpenFileResult(dialog_result) => match dialog_result { Message::OpenFileResult(dialog_result) => match dialog_result {
DialogResult::Open(paths) => { DialogResult::Open(paths) => {
for path in paths { if let Some(path) = paths.into_iter().next() {
let cpath = match std::fs::canonicalize(&path) { let cpath = match std::fs::canonicalize(&path) {
Ok(ok) => ok, Ok(ok) => ok,
Err(err) => match path::absolute(&path) { Err(err) => match path::absolute(&path) {
@@ -303,27 +284,35 @@ impl cosmic::Application for AppModel {
}; };
let mut todo = Todo::new(cpath.clone()); let mut todo = Todo::new(cpath.clone());
if let Some(cfg) = &self.config_handler { if let Some(cfg) = &self.config_handler
if let Err(err) = cfg.set("current_file", Some(cpath)) { && let Err(err) = cfg.set("current_file", Some(cpath))
log::error!("failed to save config {:?}", err); {
} log::error!("failed to save config {:?}", err);
}
// self.config.current_file = Some(cpath);
// Todo: this might need to be more async
if let Err(err) = todo.load() {
log::error!("failed to load file {:?} {:?}", path, err);
} }
self.todo = Some(todo); return cosmic::task::future(async move {
if let Err(err) = todo.async_load().await {
Message::TodoLoadError(err)
} else {
Message::TodoLoaded(todo)
}
});
} }
} }
DialogResult::Cancel => todo!(), DialogResult::Cancel => {}
}, },
Message::Edit(action, line) => { Message::Edit(action, line) => {
if let Err(err) = self.editor.edit(action, line) { if let Err(err) = self.editor.edit(action, line) {
log::error!("error in update: {:?}", err); log::error!("error in update: {:?}", err);
} }
} }
Message::TodoLoaded(todo) => {
log::info!("Loaded {} todos", todo.todos.len());
self.todo = Some(todo);
}
Message::TodoLoadError(todo_error) => {
log::error!("Error loading todo: {:?}", todo_error);
}
} }
Task::none() Task::none()
} }
+1 -1
View File
@@ -98,7 +98,7 @@ where
.spacing(cosmic::theme::spacing().space_s) .spacing(cosmic::theme::spacing().space_s)
.align_x(Alignment::Center) .align_x(Alignment::Center)
.width(Length::Fill); .width(Length::Fill);
for (i, line) in (&value.lines).iter().enumerate() { for (i, line) in (value.lines).iter().enumerate() {
col = col.push( col = col.push(
TextEditor::new(&line.content) TextEditor::new(&line.content)
.on_action(move |action| Message::Edit(action, i)) .on_action(move |action| Message::Edit(action, i))
+11 -8
View File
@@ -19,7 +19,7 @@ static TODOSYNTAX: LazyLock<parsing::SyntaxSet> = LazyLock::new(|| {
builder.build() builder.build()
}); });
static TODOTHEME: LazyLock<highlighting::Theme> = LazyLock::new(|| get_current_theme()); static TODOTHEME: LazyLock<highlighting::Theme> = LazyLock::new(get_current_theme);
pub struct TodoHighlighter { pub struct TodoHighlighter {
syntax: &'static parsing::SyntaxReference, syntax: &'static parsing::SyntaxReference,
@@ -44,8 +44,8 @@ impl TodoHighlightSettings {
let mut parser = parsing::ParseState::new(syntax); let mut parser = parsing::ParseState::new(syntax);
parser parser
.parse_line( .parse_line(
"​", // Zero-width space unicode to force parser into desc context "\u{200B}", // Zero-width space unicode to force parser into desc context
&*TODOSYNTAX, &TODOSYNTAX,
) )
.expect("init static parser from TodoHighlightSettings"); .expect("init static parser from TodoHighlightSettings");
parser parser
@@ -54,8 +54,8 @@ impl TodoHighlightSettings {
let mut parser = parsing::ParseState::new(syntax); let mut parser = parsing::ParseState::new(syntax);
parser parser
.parse_line( .parse_line(
"x ​", // done header + Zero-width space unicode to force parser into desc context "x \u{200B}", // done header + Zero-width space unicode to force parser into desc context
&*TODOSYNTAX, &TODOSYNTAX,
) )
.expect("init static parser from TodoHighlightSettings"); .expect("init static parser from TodoHighlightSettings");
parser parser
@@ -84,8 +84,11 @@ impl Highlighter for TodoHighlighter {
type Iterator<'a> = Box<dyn Iterator<Item = (std::ops::Range<usize>, Self::Highlight)> + 'a>; type Iterator<'a> = Box<dyn Iterator<Item = (std::ops::Range<usize>, Self::Highlight)> + 'a>;
fn new(settings: &Self::Settings) -> Self { fn new(settings: &Self::Settings) -> Self {
let syntax = TODOSYNTAX.syntaxes().get(0).expect("Todo Syntax Reference"); let syntax = TODOSYNTAX
let highlighter = highlighting::Highlighter::new(&*TODOTHEME); .syntaxes()
.first()
.expect("Todo Syntax Reference");
let highlighter = highlighting::Highlighter::new(&TODOTHEME);
Self { Self {
syntax, syntax,
@@ -113,7 +116,7 @@ impl Highlighter for TodoHighlighter {
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
let ops = self let ops = self
.start_state .start_state
.parse_line(line, &*TODOSYNTAX) .parse_line(line, &TODOSYNTAX)
.unwrap_or_default(); .unwrap_or_default();
Box::new(scope_iterator( Box::new(scope_iterator(
+63 -73
View File
@@ -2,16 +2,19 @@ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs, fs,
hash::{BuildHasher, DefaultHasher, Hash, Hasher}, hash::{BuildHasher, DefaultHasher, Hash, Hasher},
io::{self, BufRead, BufReader}, io,
path::PathBuf, path::PathBuf,
pin::Pin, pin::Pin,
ptr::NonNull,
}; };
use tokio::io::AsyncBufReadExt;
use time::{Date, macros::format_description}; use time::{Date, macros::format_description};
const DATE_FMT: time::format_description::StaticFormatDescription = const DATE_FMT: time::format_description::StaticFormatDescription =
format_description!("[year]-[month]-[day]"); format_description!("[year]-[month]-[day]");
#[derive(Debug, Clone)]
pub struct Todo { pub struct Todo {
/// Location of todo.txt file /// Location of todo.txt file
location: PathBuf, location: PathBuf,
@@ -20,15 +23,15 @@ pub struct Todo {
pub todos: Vec<TodoItem>, pub todos: Vec<TodoItem>,
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub enum TodoError { pub enum TodoError {
Parse, Parse,
Io(io::Error), Io(String),
} }
impl From<io::Error> for TodoError { impl From<io::Error> for TodoError {
fn from(value: io::Error) -> Self { fn from(value: io::Error) -> Self {
Self::Io(value) Self::Io(value.to_string())
} }
} }
@@ -50,8 +53,9 @@ impl Todo {
pub fn reload(&mut self) -> Result<(), TodoError> { pub fn reload(&mut self) -> Result<(), TodoError> {
let file = fs::File::open(&self.location)?; let file = fs::File::open(&self.location)?;
let reader = BufReader::new(file); let reader = io::BufReader::new(file);
use io::BufRead;
let mut todos = Vec::new(); let mut todos = Vec::new();
for line in reader.lines() { for line in reader.lines() {
// Pin the string so it can be referenced but cruicially not modified // Pin the string so it can be referenced but cruicially not modified
@@ -62,42 +66,26 @@ impl Todo {
self.todos = todos; self.todos = todos;
Ok(()) Ok(())
} }
}
#[derive(Debug, Clone, Copy)]
pub enum TodoPart<'a> {
Description(&'a str),
Project(&'a str),
Context(&'a str),
Tag(&'a str),
}
impl<'a> Into<&'a str> for TodoPart<'a> { pub async fn async_load(&mut self) -> Result<(), TodoError> {
fn into(self) -> &'a str { self.async_reload().await
match self {
TodoPart::Description(s) => s,
TodoPart::Project(s) => s,
TodoPart::Context(s) => s,
TodoPart::Tag(s) => s,
}
} }
}
#[derive(Debug, Clone, Copy)] pub async fn async_reload(&mut self) -> Result<(), TodoError> {
enum TodoPartRaw { let file = tokio::fs::File::open(&self.location).await?;
Description(NonNull<str>), let reader = tokio::io::BufReader::new(file);
Project(NonNull<str>),
Context(NonNull<str>),
Tag(NonNull<str>),
}
impl<'a> Into<TodoPart<'a>> for TodoPartRaw { let mut todos = Vec::new();
fn into<'b>(self) -> TodoPart<'a> { let mut lines = reader.lines();
match self {
TodoPartRaw::Description(s) => TodoPart::Description(unsafe { s.as_ref() }), while let Some(line) = lines.next_line().await? {
TodoPartRaw::Project(s) => TodoPart::Project(unsafe { s.as_ref() }), // Pin the string so it can be referenced but cruicially not modified
TodoPartRaw::Context(s) => TodoPart::Context(unsafe { s.as_ref() }), let line = Pin::new(line);
TodoPartRaw::Tag(s) => TodoPart::Tag(unsafe { s.as_ref() }), todos.push(line.try_into()?);
} }
self.todos = todos;
Ok(())
} }
} }
@@ -156,20 +144,18 @@ pub struct TodoItem {
/// Optional - Creation Date (must be sepecified if completion date is) /// Optional - Creation Date (must be sepecified if completion date is)
date_made: Option<Date>, date_made: Option<Date>,
/// Description - raw str slice for description /// Description - raw str slice for description
raw_desc: NonNull<str>, desc_start_index: usize,
/// Formatted Description - list of description components
desc: Vec<TodoPartRaw>,
/// Project tags /// Project tags
projects: HashSet<TodoItemKey, TodoBuildHasher>, projects: HashSet<TodoItemKey, TodoBuildHasher>,
/// Context tags /// Context tags
contexts: HashSet<TodoItemKey, TodoBuildHasher>, contexts: HashSet<TodoItemKey, TodoBuildHasher>,
/// Special Key/Value tags /// Special Key/Value tags
tags: HashMap<TodoItemKey, NonNull<str>, TodoBuildHasher>, tags: HashMap<TodoItemKey, (usize, usize), TodoBuildHasher>,
} }
impl TodoItem { impl TodoItem {
pub fn get_raw(&self) -> &str { pub fn get_raw(&self) -> &str {
&*self.raw &self.raw
} }
pub fn is_done(&self) -> bool { pub fn is_done(&self) -> bool {
@@ -217,13 +203,9 @@ impl TodoItem {
} }
pub fn get_desc(&self) -> &str { pub fn get_desc(&self) -> &str {
// Safety assert!(self.desc_start_index <= self.raw.len());
// ensure that the pointer is convertible to a reference.
// The pointee will always be valid for the pointer's lifetime &self.raw[self.desc_start_index..]
// as it points to a pinned string owned by the same object.
// i.e., the String will only be dropped when the pointer is
// Additionally the String is never mutated.
unsafe { self.raw_desc.as_ref() }
} }
fn has_project(&self, project: TodoItemKey) -> bool { fn has_project(&self, project: TodoItemKey) -> bool {
@@ -234,9 +216,12 @@ impl TodoItem {
where where
T: Into<TodoItemKey>, T: Into<TodoItemKey>,
{ {
let tag_pointer = self.tags.get(&tag.into())?; let tag_bounds = self.tags.get(&tag.into())?;
// Safety: see get_desc
Some(unsafe { tag_pointer.as_ref() }) assert!(tag_bounds.0 <= self.raw.len());
assert!(tag_bounds.1 <= self.raw.len());
Some(&self.raw[tag_bounds.0..tag_bounds.1])
} }
} }
@@ -259,29 +244,31 @@ impl TryFrom<Pin<String>> for TodoItem {
let mut projects = HashSet::with_hasher(TodoBuildHasher); let mut projects = HashSet::with_hasher(TodoBuildHasher);
let mut contexts = HashSet::with_hasher(TodoBuildHasher); let mut contexts = HashSet::with_hasher(TodoBuildHasher);
let mut tags = HashMap::with_hasher(TodoBuildHasher); let mut tags = HashMap::with_hasher(TodoBuildHasher);
let raw_desc = NonNull::from_ref(&raw[pos..]); let desc_start_index = pos;
let mut desc = Vec::new();
while let Some((size, word)) = get_word(&raw[pos..]) { while let Some((start, end, skip)) = get_word_indices(&raw[pos..]) {
let word = &raw[start..end];
if word.starts_with('@') { if word.starts_with('@') {
if word.len() > 1 { if word.len() > 1 {
contexts.insert(word.into()); contexts.insert(word.into());
desc.push(TodoPartRaw::Context(NonNull::from_ref(word)));
} }
} else if word.starts_with('+') { } else if word.starts_with('+') {
if word.len() > 1 { if word.len() > 1 {
projects.insert(word.into()); projects.insert(word.into());
desc.push(TodoPartRaw::Project(NonNull::from_ref(word)));
} }
} else if let Some((k, v)) = word.split_once(':') { } else if let Some((k, v)) = word.split_once(':')
if !(k.contains(':') || v.contains(':') || k.len() == 0 || v.len() == 0) { && !k.contains(':')
tags.insert(k.into(), NonNull::from_ref(v)); && !v.contains(':')
desc.push(TodoPartRaw::Tag(NonNull::from_ref(word))); && !k.is_empty()
} && !v.is_empty()
} else { {
desc.push(TodoPartRaw::Description(NonNull::from_ref(word))); // <-v:3->
// <-- size:7 -->
// 0 1 2 3 4 5 6 7 8 9
// k e y : v a l
tags.insert(k.into(), (pos + start + k.len(), pos + end - 1));
} }
pos += size; pos += skip;
} }
Ok(Self { Ok(Self {
@@ -290,8 +277,7 @@ impl TryFrom<Pin<String>> for TodoItem {
prio, prio,
date_done, date_done,
date_made, date_made,
raw_desc, desc_start_index,
desc,
projects, projects,
contexts, contexts,
tags, tags,
@@ -300,6 +286,10 @@ impl TryFrom<Pin<String>> for TodoItem {
} }
fn get_word(raw: &str) -> Option<(usize, &str)> { fn get_word(raw: &str) -> Option<(usize, &str)> {
get_word_indices(raw).map(|(start, end, skip)| (skip, &raw[start..end]))
}
fn get_word_indices(raw: &str) -> Option<(usize, usize, usize)> {
let mut chars = raw.char_indices(); let mut chars = raw.char_indices();
let (next_char, _) = chars.find(|(_, c)| !c.is_whitespace())?; let (next_char, _) = chars.find(|(_, c)| !c.is_whitespace())?;
let next_ws = chars.find(|(_, c)| c.is_whitespace()); let next_ws = chars.find(|(_, c)| c.is_whitespace());
@@ -308,11 +298,11 @@ fn get_word(raw: &str) -> Option<(usize, &str)> {
Some((end, _)) => { Some((end, _)) => {
let next_word = chars.find(|(_, c)| !c.is_whitespace()); let next_word = chars.find(|(_, c)| !c.is_whitespace());
match next_word { match next_word {
Some((nw_start, _)) => Some((nw_start, &raw[next_char..end])), Some((nw_start, _)) => Some((next_char, end, nw_start)),
None => Some((end, &raw[next_char..end])), None => Some((next_char, end, end)),
} }
} }
None => Some((raw.len(), &raw[next_char..])), None => Some((next_char, raw.len(), raw.len())),
} }
} }
@@ -381,10 +371,10 @@ mod tests {
use super::*; use super::*;
use time::macros::date; use time::macros::date;
#[test] #[tokio::test]
fn parse_from_file() { async fn parse_from_file() {
let mut todo = Todo::new("samples/todo.txt".into()); let mut todo = Todo::new("samples/todo.txt".into());
todo.load().expect("Error loading todo file"); todo.async_load().await.expect("Error loading todo file");
} }
#[test] #[test]