Compare commits
17 Commits
dbaa2feb48
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4f611de1 | |||
| 1c7504e3e0 | |||
| 7d602fffba | |||
| 1aa6af3b1a | |||
| f68b1bb276 | |||
| 7b300987b7 | |||
| 33bfff2e98 | |||
| 6fa26f8a33 | |||
| c0999b5e9f | |||
| be58e2eb79 | |||
| 8d47704b7e | |||
| 05a0b32d9b | |||
| ddd6d072f9 | |||
| 9d12ad5050 | |||
| 4a7f5eabe9 | |||
| 29688e07e4 | |||
| c10ad0f6a6 |
22
.gitea/workflows/cargo-test.yml
Normal file
22
.gitea/workflows/cargo-test.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Test the running changes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: rust-latest
|
||||
|
||||
steps:
|
||||
- run: apt-get update -y && apt-get install nodejs -y
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo build --verbose
|
||||
- run: cargo clippy -- -D warnings
|
||||
- run: cargo test --verbose
|
||||
32
.gitea/workflows/deploy.yml.notready
Normal file
32
.gitea/workflows/deploy.yml.notready
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Build and deploy Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: rust-latest
|
||||
|
||||
steps:
|
||||
- run: apt-get update -y && apt-get install nodejs -y
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo build --release
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
config-inline: |
|
||||
[registry."gitea-http.apps.svc.cluster.local:3000"]
|
||||
http = true
|
||||
insecure = true
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: "gitea-http.apps.svc.cluster.local:3000".....
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
html/
|
||||
web/
|
||||
pebbles/
|
||||
site/
|
||||
target/
|
||||
|
||||
result
|
||||
Cargo.lock
|
||||
flake.lock
|
||||
tarpaulin-report.html
|
||||
|
||||
36
Cargo.lock
generated
Normal file
36
Cargo.lock
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cracked_md"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fstools",
|
||||
"slogger",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fstools"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "gravel_cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cracked_md",
|
||||
"slogger",
|
||||
"stdsrv",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slogger"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "stdsrv"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fstools",
|
||||
"slogger",
|
||||
]
|
||||
@@ -3,6 +3,7 @@ members = [
|
||||
"stdsrv",
|
||||
"cracked_md",
|
||||
"fstools",
|
||||
"gravel_cli", "slogger",
|
||||
]
|
||||
|
||||
resolver = "3"
|
||||
|
||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM rust:1.91.1-bookworm AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:12-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/target/release/stdsrv .
|
||||
CMD ["./stdsrv"]
|
||||
132
bacon.toml
Normal file
132
bacon.toml
Normal file
@@ -0,0 +1,132 @@
|
||||
# This is a configuration file for the bacon tool
|
||||
#
|
||||
# Complete help on configuration: https://dystroy.org/bacon/config/
|
||||
#
|
||||
# You may check the current default at
|
||||
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
|
||||
|
||||
default_job = "check"
|
||||
env.CARGO_TERM_COLOR = "always"
|
||||
|
||||
[jobs.check]
|
||||
command = ["cargo", "check"]
|
||||
need_stdout = false
|
||||
|
||||
[jobs.check-all]
|
||||
command = ["cargo", "check", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on the default target
|
||||
[jobs.clippy]
|
||||
command = ["cargo", "clippy"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy on all targets
|
||||
# To disable some lints, you may change the job this way:
|
||||
# [jobs.clippy-all]
|
||||
# command = [
|
||||
# "cargo", "clippy",
|
||||
# "--all-targets",
|
||||
# "--",
|
||||
# "-A", "clippy::bool_to_int_with_if",
|
||||
# "-A", "clippy::collapsible_if",
|
||||
# "-A", "clippy::derive_partial_eq_without_eq",
|
||||
# ]
|
||||
# need_stdout = false
|
||||
[jobs.clippy-all]
|
||||
command = ["cargo", "clippy", "--all-targets"]
|
||||
need_stdout = false
|
||||
|
||||
# Run clippy in pedantic mode
|
||||
# The 'dismiss' feature may come handy
|
||||
[jobs.pedantic]
|
||||
command = [
|
||||
"cargo", "clippy",
|
||||
"--",
|
||||
"-W", "clippy::pedantic",
|
||||
]
|
||||
need_stdout = false
|
||||
|
||||
# This job lets you run
|
||||
# - all tests: bacon test
|
||||
# - a specific test: bacon test -- config::test_default_files
|
||||
# - the tests of a package: bacon test -- -- -p config
|
||||
[jobs.test]
|
||||
command = [
|
||||
"cargo", "nextest", "run",
|
||||
"--hide-progress-bar",
|
||||
"--failure-output", "final",
|
||||
"--no-fail-fast"
|
||||
]
|
||||
need_stdout = true
|
||||
analyzer = "nextest"
|
||||
|
||||
[jobs.nextest]
|
||||
command = [
|
||||
"cargo", "nextest", "run",
|
||||
"--hide-progress-bar",
|
||||
"--failure-output", "final",
|
||||
]
|
||||
need_stdout = true
|
||||
analyzer = "nextest"
|
||||
|
||||
[jobs.doc]
|
||||
command = ["cargo", "doc", "--no-deps"]
|
||||
need_stdout = false
|
||||
|
||||
# If the doc compiles, then it opens in your browser and bacon switches
|
||||
# to the previous job
|
||||
[jobs.doc-open]
|
||||
command = ["cargo", "doc", "--no-deps", "--open"]
|
||||
need_stdout = false
|
||||
on_success = "back" # so that we don't open the browser at each change
|
||||
|
||||
# You can run your application and have the result displayed in bacon,
|
||||
# if it makes sense for this crate.
|
||||
[jobs.run]
|
||||
command = [
|
||||
"cargo", "run",
|
||||
# put launch parameters for your program behind a `--` separator
|
||||
]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = true
|
||||
|
||||
# Run your long-running application (eg server) and have the result displayed in bacon.
|
||||
# For programs that never stop (eg a server), `background` is set to false
|
||||
# to have the cargo run output immediately displayed instead of waiting for
|
||||
# program's end.
|
||||
# 'on_change_strategy' is set to `kill_then_restart` to have your program restart
|
||||
# on every change (an alternative would be to use the 'F5' key manually in bacon).
|
||||
# If you often use this job, it makes sense to override the 'r' key by adding
|
||||
# a binding `r = job:run-long` at the end of this file .
|
||||
# A custom kill command such as the one suggested below is frequently needed to kill
|
||||
# long running programs (uncomment it if you need it)
|
||||
[jobs.run-long]
|
||||
command = [
|
||||
"cargo", "run",
|
||||
# put launch parameters for your program behind a `--` separator
|
||||
]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
background = false
|
||||
on_change_strategy = "kill_then_restart"
|
||||
# kill = ["pkill", "-TERM", "-P"]
|
||||
|
||||
# This parameterized job runs the example of your choice, as soon
|
||||
# as the code compiles.
|
||||
# Call it as
|
||||
# bacon ex -- my-example
|
||||
[jobs.ex]
|
||||
command = ["cargo", "run", "--example"]
|
||||
need_stdout = true
|
||||
allow_warnings = true
|
||||
|
||||
# You may define here keybindings that would be specific to
|
||||
# a project, for example a shortcut to launch a specific job.
|
||||
# Shortcuts to internal functions (scrolling, toggling, etc.)
|
||||
# should go in your personal global prefs.toml file instead.
|
||||
[keybindings]
|
||||
# alt-m = "job:my-job"
|
||||
c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target
|
||||
p = "job:pedantic"
|
||||
14
cracked_md/Cargo.lock
generated
Normal file
14
cracked_md/Cargo.lock
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cracked_md"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fstools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fstools"
|
||||
version = "0.1.0"
|
||||
@@ -5,3 +5,4 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
fstools = { path = "../fstools" }
|
||||
slogger = { path = "../slogger" }
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
use crate::to_html::ToHtml;
|
||||
//! Abstract syntax tree of "Markdown".
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Document {
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
impl ToHtml for Document {
|
||||
fn to_html(self) -> String {
|
||||
format!(
|
||||
"<!doctype html><html lang=en><head></head><body>{}</body></html>",
|
||||
self.blocks.to_html()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Block {
|
||||
Paragraph(Vec<Inline>),
|
||||
@@ -25,33 +16,10 @@ pub enum Block {
|
||||
language: Option<String>,
|
||||
content: String,
|
||||
},
|
||||
List(Vec<ListItem>),
|
||||
List(Vec<Block>),
|
||||
Quote(Vec<Block>),
|
||||
}
|
||||
|
||||
impl ToHtml for Block {
|
||||
fn to_html(self) -> String {
|
||||
match self {
|
||||
Self::Paragraph(content) => format!("<p>{}</p>", content.to_html()),
|
||||
Self::Heading { level, content } => {
|
||||
format!("<h{}>{}</h{}>", level, content.to_html(), level)
|
||||
}
|
||||
Self::Code {
|
||||
language: _,
|
||||
content,
|
||||
} => {
|
||||
format!("<pre><code>{}</code></pre>", content)
|
||||
}
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ListItem {
|
||||
pub blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Inline {
|
||||
Text(String),
|
||||
@@ -60,135 +28,3 @@ pub enum Inline {
|
||||
Code(String),
|
||||
Link { text: Vec<Inline>, href: String },
|
||||
}
|
||||
|
||||
impl ToHtml for Inline {
|
||||
fn to_html(self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s,
|
||||
Self::Bold(content) => format!("<b>{}</b>", content.to_html()),
|
||||
Self::Italic(content) => format!("<i>{}</i>", content.to_html()),
|
||||
Self::Code(s) => format!("<code>{}</code>", s),
|
||||
Self::Link { text, href } => format!("<a href=\"{}\">{}</a>", href, text.to_html()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ToHtml for Vec<T>
|
||||
where
|
||||
T: ToHtml,
|
||||
{
|
||||
fn to_html(self) -> String {
|
||||
let mut rendered = String::new();
|
||||
for i in self {
|
||||
rendered.push_str(&i.to_html());
|
||||
}
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// TESTS
|
||||
// --------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_header() {
|
||||
let ast = Document {
|
||||
blocks: vec![Block::Heading {
|
||||
level: 1,
|
||||
content: vec![Inline::Text("Heading 1".to_string())],
|
||||
}],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1>Heading 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_bold_header() {
|
||||
let ast = Document {
|
||||
blocks: vec![Block::Heading {
|
||||
level: 1,
|
||||
content: vec![
|
||||
Inline::Bold(vec![Inline::Text("Bold".to_string())]),
|
||||
Inline::Text(" heading 1".to_string()),
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_and_paragraph_nested_code() {
|
||||
let ast = Document {
|
||||
blocks: vec![
|
||||
Block::Heading {
|
||||
level: 1,
|
||||
content: vec![
|
||||
Inline::Bold(vec![Inline::Text("Bold".to_string())]),
|
||||
Inline::Text(" heading 1".to_string()),
|
||||
],
|
||||
},
|
||||
Block::Heading {
|
||||
level: 2,
|
||||
content: vec![Inline::Text("Heading 2".to_string())],
|
||||
},
|
||||
Block::Paragraph(vec![
|
||||
Inline::Text("run ".to_string()),
|
||||
Inline::Code("sudo rm -rf /".to_string()),
|
||||
Inline::Text(" on your computer".to_string()),
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1><h2>Heading 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod convert_md_to_html_test {
|
||||
use crate::parser::parse;
|
||||
use crate::to_html::ToHtml;
|
||||
|
||||
#[test]
|
||||
fn single_header() {
|
||||
let md = "# Header 1";
|
||||
|
||||
let html = parse(md).to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1>Header 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_bold_headers_and_nested_code_paragraph() {
|
||||
let md = "# *Bold* header 1\n## Header 2\nrun `sudo rm -rf /` on your computer";
|
||||
|
||||
let html = parse(md).to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> header 1</h1><h2>Header 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,113 @@
|
||||
//! A "Markdown" parser and HTML generator. Part of a static site generator `marksmith-rs`.
|
||||
//! Not following any standards, only vibes.
|
||||
|
||||
#![deny(dead_code, unused_imports)]
|
||||
#![allow(clippy::needless_pass_by_value)]
|
||||
|
||||
use fstools::crawl_fs;
|
||||
use parser::parse;
|
||||
use slogger::{Level, log};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
fs::{self, File},
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
time::Instant,
|
||||
};
|
||||
use to_html::ToHtml;
|
||||
|
||||
pub mod ast;
|
||||
mod parse_trait;
|
||||
pub mod parser;
|
||||
pub mod to_html;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MdParseError {
|
||||
file: Option<PathBuf>,
|
||||
line: Option<usize>,
|
||||
//col: Option<usize>,
|
||||
expected: String,
|
||||
got: String,
|
||||
}
|
||||
|
||||
impl MdParseError {
|
||||
pub fn new(expected: impl ToString, got: impl ToString) -> Self {
|
||||
Self {
|
||||
file: None,
|
||||
line: None,
|
||||
//col: None,
|
||||
expected: expected.to_string(),
|
||||
got: got.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_line(line: usize, expected: impl ToString, got: impl ToString) -> Self {
|
||||
Self {
|
||||
file: None,
|
||||
line: Some(line),
|
||||
//col: None,
|
||||
expected: expected.to_string(),
|
||||
got: got.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn from_col(col: usize, expected: impl ToString, got: impl ToString) -> Self {
|
||||
Self {
|
||||
file: None,
|
||||
line: None,
|
||||
col: Some(col),
|
||||
expected: expected.to_string(),
|
||||
got: got.to_string(),
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
#[must_use]
|
||||
pub fn set_line(self, line: usize) -> Self {
|
||||
Self {
|
||||
file: self.file,
|
||||
line: Some(line),
|
||||
//col: self.col,
|
||||
expected: self.expected,
|
||||
got: self.got,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn set_file(self, file: PathBuf) -> Self {
|
||||
Self {
|
||||
file: Some(file),
|
||||
line: self.line,
|
||||
//col: self.col,
|
||||
expected: self.expected,
|
||||
got: self.got,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for MdParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// no error message :/
|
||||
let file = self.file.clone().unwrap_or("<unknown>".into());
|
||||
write!(
|
||||
f,
|
||||
"Parse error in '{}' on line {}: expected '{}', got '{}'",
|
||||
file.display(),
|
||||
self.line.unwrap_or(0),
|
||||
//self.col.unwrap_or(0),
|
||||
self.expected,
|
||||
self.got
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MdParseError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
OutDirIsNotEmpty,
|
||||
OutDirFileDeleteNotAllowed,
|
||||
OutDirDirectoryInPlaceOfFile,
|
||||
FileRead,
|
||||
DirRead,
|
||||
FileWrite,
|
||||
FileCreate,
|
||||
DirCreate,
|
||||
FSError(String),
|
||||
Parse(MdParseError),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
@@ -32,45 +116,106 @@ impl Display for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MdParseError> for Error {
|
||||
fn from(value: MdParseError) -> Self {
|
||||
Error::Parse(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
type Result<T> = std::result::Result<T, crate::Error>;
|
||||
|
||||
/// Takes two directories and a force flag as parameters, generates html files to the outdir in the
|
||||
/// same directory structure as the md files in indir.
|
||||
///
|
||||
/// # Errors
|
||||
/// Anything wrong with reading files from the directories or parsing the files.
|
||||
pub fn generate(indir: &PathBuf, outdir: &PathBuf, force: bool) -> Result<()> {
|
||||
let start_time = Instant::now();
|
||||
let mut generated_files = 0;
|
||||
|
||||
if !indir.is_dir() {
|
||||
Err(Error::FSError("In directory not found".to_string()))?;
|
||||
}
|
||||
if !outdir.is_dir() {
|
||||
Err(Error::FSError("Out directory not found".to_string()))?;
|
||||
}
|
||||
let files = crawl_fs(indir);
|
||||
|
||||
for path in files {
|
||||
let fullpath = indir.as_path().join(&path);
|
||||
|
||||
// read and parse md file
|
||||
let content = fs::read_to_string(&fullpath).map_err(|_e| Error::FileRead)?;
|
||||
let html = parse(&content).to_html();
|
||||
let content = fs::read_to_string(&fullpath)
|
||||
.map_err(|_e| Error::FSError(format!("File `{}` read error", path.display())))?;
|
||||
let html = parse(&content)?.to_html();
|
||||
|
||||
// write html data to file
|
||||
let mut newpath = outdir.to_owned();
|
||||
newpath.push(path);
|
||||
newpath.push(&path);
|
||||
newpath.set_extension("html");
|
||||
|
||||
// check if path exists
|
||||
if newpath.exists() {
|
||||
// remove if is file and if force, otherwise error
|
||||
if newpath.is_file() && force {
|
||||
fs::remove_file(&newpath).map_err(|_e| Error::OutDirFileDeleteNotAllowed)?;
|
||||
if newpath.is_file() {
|
||||
if force {
|
||||
fs::remove_file(&newpath).map_err(|_e| {
|
||||
Error::FSError(format!("File `{}` deleting not allowed", newpath.display()))
|
||||
})?;
|
||||
} else {
|
||||
Err(Error::FSError(
|
||||
"File overwrite denied, enable force overwrite".to_string(),
|
||||
))?;
|
||||
}
|
||||
} else {
|
||||
Err(Error::OutDirDirectoryInPlaceOfFile)?;
|
||||
Err(Error::FSError(format!(
|
||||
"Directory `{}` in place of file in out directory",
|
||||
newpath.display()
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
|
||||
//println!("About to write file '{}'", newpath.display());
|
||||
|
||||
let parent = newpath.parent().ok_or(Error::DirCreate)?;
|
||||
fs::create_dir_all(parent).map_err(|_e| Error::DirCreate)?;
|
||||
let mut newfile = File::create_new(newpath).map_err(|_e| Error::FileCreate)?;
|
||||
let parent = newpath.parent().ok_or(Error::FSError(format!(
|
||||
"Access to parent directory of `{}` denied",
|
||||
newpath.display()
|
||||
)))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|_e| Error::FSError("Creating directory tree failed".to_string()))?;
|
||||
let mut newfile = File::create_new(&newpath).map_err(|_e| {
|
||||
Error::FSError(format!("Creating file `{}` failed", &newpath.display()))
|
||||
})?;
|
||||
|
||||
newfile
|
||||
.write(html.as_bytes())
|
||||
.map_err(|_e| Error::FileWrite)?;
|
||||
newfile.write(html.as_bytes()).map_err(|_e| {
|
||||
Error::FSError(format!("Writing to file `{}` failed", newpath.display()))
|
||||
})?;
|
||||
|
||||
log!(
|
||||
Level::Debug,
|
||||
"File `{}` generation to `{}` successful",
|
||||
path.display(),
|
||||
newpath.display()
|
||||
);
|
||||
generated_files += 1;
|
||||
}
|
||||
|
||||
let time = start_time.elapsed();
|
||||
|
||||
let time_report = if time.as_micros() < 10000 {
|
||||
format!("{} μs", time.as_micros())
|
||||
} else {
|
||||
format!("{} ms", time.as_millis())
|
||||
};
|
||||
|
||||
log!(
|
||||
Level::Info,
|
||||
"Generated {} files in {} without reported errors",
|
||||
generated_files,
|
||||
time_report
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
176
cracked_md/src/parse_trait.rs
Normal file
176
cracked_md/src/parse_trait.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
|
||||
use crate::MdParseError;
|
||||
|
||||
pub type Pattern<T> = Vec<PatternToken<T>>;
|
||||
|
||||
pub enum PatternToken<T> {
|
||||
Once(T),
|
||||
Optional(T),
|
||||
AtLeastOnce(T),
|
||||
NTimes(T),
|
||||
}
|
||||
|
||||
/// panics: on invalid pattern
|
||||
pub fn char_pattern(s: &str) -> Pattern<char> {
|
||||
let mut s_chars = s.chars().peekable();
|
||||
let mut pat: Pattern<char> = Vec::new();
|
||||
while let Some(token) = s_chars.next() {
|
||||
pat.push(if let Some(&next) = s_chars.peek() {
|
||||
match next {
|
||||
'?' => {
|
||||
s_chars.next().unwrap();
|
||||
PatternToken::Optional(token)
|
||||
}
|
||||
'+' => {
|
||||
s_chars.next().unwrap();
|
||||
PatternToken::AtLeastOnce(token)
|
||||
}
|
||||
'*' => {
|
||||
s_chars.next().unwrap();
|
||||
PatternToken::NTimes(token)
|
||||
}
|
||||
_ => PatternToken::Once(token),
|
||||
}
|
||||
} else {
|
||||
PatternToken::Once(token)
|
||||
});
|
||||
}
|
||||
pat
|
||||
}
|
||||
|
||||
pub trait ParsePattern: Iterator + Clone {
|
||||
fn parse<T>(&mut self, expect: Pattern<T>) -> Result<Vec<Self::Item>, MdParseError>
|
||||
where
|
||||
T: PartialEq<<Self as Iterator>::Item>,
|
||||
{
|
||||
let mut consumed = Vec::new();
|
||||
let mut cloned = self.clone();
|
||||
|
||||
for pat_token in expect {
|
||||
match pat_token {
|
||||
PatternToken::Once(c) => {
|
||||
if !cloned.next().map(|v| c == v).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
PatternToken::Optional(c) => if cloned.peek().map(|v| c == *v).unwrap_or(false) {},
|
||||
}
|
||||
}
|
||||
|
||||
*self = cloned;
|
||||
|
||||
Some(consumed)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
pub trait Parse: Iterator + Clone {
|
||||
fn follows(&mut self, token: char) -> bool;
|
||||
|
||||
fn parse_token(&mut self, token: char) -> bool {
|
||||
if self.follows(token) {
|
||||
let _ = self.next();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_str(&mut self, tokens: &str) -> bool {
|
||||
let mut cloned = self.clone();
|
||||
|
||||
for pat_token in tokens.chars() {
|
||||
if cloned.follows(pat_token) {
|
||||
cloned.next();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
*self = cloned;
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for std::iter::Peekable<std::str::Chars<'_>> {
|
||||
fn follows(&mut self, token: char) -> bool {
|
||||
self.peek().is_some_and(|c| c == &token)
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for std::iter::Peekable<std::iter::Enumerate<std::str::Chars<'_>>> {
|
||||
fn follows(&mut self, token: char) -> bool {
|
||||
self.peek().is_some_and(|&(_i, c)| c == token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chars_parse_follows_double() {
|
||||
let mut c = "abc".chars().peekable();
|
||||
|
||||
assert!(c.follows('a'));
|
||||
assert!(c.follows('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chars_parse_tokens() {
|
||||
let mut c = "abcdef".chars().peekable();
|
||||
|
||||
assert!(c.parse_token('a'));
|
||||
assert!(c.parse_token('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chars_parse_str() {
|
||||
let mut c = "abcdef".chars().peekable();
|
||||
|
||||
assert!(c.parse_str("abc"));
|
||||
assert!(c.parse_str("def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_parse_follows_double() {
|
||||
let mut c = "abc".chars().enumerate().peekable();
|
||||
|
||||
assert!(c.follows('a'));
|
||||
assert!(c.follows('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_parse_tokens() {
|
||||
let mut c = "abcdef".chars().enumerate().peekable();
|
||||
|
||||
assert!(c.parse_token('a'));
|
||||
assert!(c.parse_token('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_parse_str() {
|
||||
let mut c = "abcdef".chars().enumerate().peekable();
|
||||
|
||||
assert!(c.parse_str("abc"));
|
||||
assert!(c.parse_str("def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_parse_token_failed_not_consume() {
|
||||
let mut c = "abc".chars().enumerate().peekable();
|
||||
|
||||
assert!(!c.parse_token('b'));
|
||||
assert!(c.parse_token('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumerate_parse_str_failed_not_consume() {
|
||||
let mut c = "abcdef".chars().enumerate().peekable();
|
||||
|
||||
assert!(!c.parse_str("def"));
|
||||
assert!(c.parse_str("abc"));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
//! Parse "Markdown" to AST.
|
||||
|
||||
mod block;
|
||||
mod inline;
|
||||
|
||||
use block::parse_blocks;
|
||||
|
||||
use crate::ast::Document;
|
||||
use crate::{MdParseError, ast::Document};
|
||||
|
||||
pub fn parse(s: &str) -> Document {
|
||||
Document {
|
||||
blocks: parse_blocks(s),
|
||||
}
|
||||
/// Parses the incoming data to a Markdown abstract syntax tree.
|
||||
/// # Errors
|
||||
/// This function will return an `MdParseError` when any part of the input is invalid Markdown.
|
||||
pub fn parse(s: &str) -> Result<Document, MdParseError> {
|
||||
Ok(Document {
|
||||
blocks: parse_blocks(s)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -20,7 +25,7 @@ mod test {
|
||||
fn only_paragraph() {
|
||||
let md = "testing paragraph";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
assert_eq!(
|
||||
doc,
|
||||
Document {
|
||||
@@ -35,7 +40,7 @@ mod test {
|
||||
fn different_headers() {
|
||||
let md = "# Header 1\n## Header 2";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -58,7 +63,7 @@ mod test {
|
||||
fn inline_bold_and_italics() {
|
||||
let md = "some *bold* and _italic_ text";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -78,7 +83,7 @@ mod test {
|
||||
fn inline_code() {
|
||||
let md = "run command `sudo rm -rf /`";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -95,7 +100,7 @@ mod test {
|
||||
fn bold_header() {
|
||||
let md = "# Header is *bold*";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -115,7 +120,7 @@ mod test {
|
||||
fn anonymous_code_block() {
|
||||
let md = "```\necho hello\n```";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -128,11 +133,25 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_content_after_end() {
|
||||
let md = "```\necho hello\n```abc";
|
||||
let doc_res = parse(md);
|
||||
assert!(doc_res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_no_terminating() {
|
||||
let md = "```\nabc\n";
|
||||
let doc_res = parse(md);
|
||||
assert!(doc_res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_code_block() {
|
||||
let md = "```rust\nfn main() {\n\tprintln!(\"Hello world!\");\n}\n```";
|
||||
|
||||
let doc = parse(md);
|
||||
let doc = parse(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
doc,
|
||||
@@ -145,3 +164,4 @@ mod test {
|
||||
);
|
||||
}
|
||||
}
|
||||
// */
|
||||
|
||||
@@ -1,22 +1,125 @@
|
||||
use crate::ast::Block;
|
||||
|
||||
use super::inline::parse_inlines;
|
||||
use crate::{MdParseError, ast::Block};
|
||||
|
||||
pub fn parse_blocks(input: &str) -> Vec<Block> {
|
||||
use crate::parse_trait::Parse;
|
||||
|
||||
pub fn parse_blocks(input: &str) -> Result<Vec<Block>, MdParseError> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut lines = input.lines().enumerate().peekable();
|
||||
|
||||
while let Some((i, line)) = lines.next() {
|
||||
let mut line_chars = line.chars().peekable();
|
||||
|
||||
// empty line
|
||||
if line_chars.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// header
|
||||
let mut heading_level = 0;
|
||||
while line_chars.parse_token('#') {
|
||||
if heading_level < 6 {
|
||||
heading_level += 1;
|
||||
}
|
||||
}
|
||||
if heading_level > 0 {
|
||||
if !line_chars.parse_token(' ') {
|
||||
Err(MdParseError::from_line(
|
||||
i + 1,
|
||||
"<space> after #",
|
||||
"no <space>",
|
||||
))?;
|
||||
}
|
||||
let line_content: String = line_chars.collect();
|
||||
blocks.push(Block::Heading {
|
||||
level: heading_level,
|
||||
content: parse_inlines(&line_content)?,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// quote TODO
|
||||
/*
|
||||
if line_chars.parse_str("> ") {
|
||||
let content: String = line_chars.collect();
|
||||
let quote_blocks = parse_blocks(&content).map_err(|e| e.set_line(i + 1))?;
|
||||
blocks.push(Block::Quote(quote_blocks));
|
||||
continue;
|
||||
}
|
||||
*/
|
||||
|
||||
// unordered list TODO
|
||||
if line_chars.parse_str("- ") {
|
||||
todo!()
|
||||
}
|
||||
|
||||
// code
|
||||
if line_chars.parse_str("```") {
|
||||
let lang_line: String = line_chars.collect();
|
||||
let lang = if lang_line.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lang_line)
|
||||
};
|
||||
let mut code = String::new();
|
||||
|
||||
let mut successful = false;
|
||||
for (j, line) in lines.by_ref() {
|
||||
let mut code_line_chars = line.chars().peekable();
|
||||
// code block end
|
||||
if code_line_chars.parse_str("```") {
|
||||
let remaining: String = code_line_chars.collect();
|
||||
if remaining.is_empty() {
|
||||
blocks.push(Block::Code {
|
||||
language: lang,
|
||||
content: code,
|
||||
});
|
||||
successful = true;
|
||||
break;
|
||||
}
|
||||
Err(MdParseError::from_line(
|
||||
j + 1,
|
||||
"```",
|
||||
format!("```{remaining}"),
|
||||
))?;
|
||||
} else {
|
||||
code.push_str(line);
|
||||
code.push('\n');
|
||||
}
|
||||
}
|
||||
if successful {
|
||||
continue;
|
||||
}
|
||||
Err(MdParseError::from_line(i + 1, "a terminating '```'", ""))?;
|
||||
}
|
||||
|
||||
// lists TODO
|
||||
|
||||
// paragraph
|
||||
blocks.push(Block::Paragraph(
|
||||
parse_inlines(line).map_err(|e| e.set_line(i + 1))?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(blocks)
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn parse_blocks(input: &str) -> Result<Vec<Block>, MdParseError> {
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
let mut lines = input.lines().peekable();
|
||||
let mut lines = input.lines().enumerate().peekable();
|
||||
|
||||
while let Some(line) = lines.next() {
|
||||
while let Some((i, line)) = lines.next() {
|
||||
if line.starts_with("#") {
|
||||
let level = line.chars().take_while(|&c| c == '#').count() as u8;
|
||||
let text = line[level as usize..].trim();
|
||||
blocks.push(Block::Heading {
|
||||
level,
|
||||
content: parse_inlines(text),
|
||||
content: parse_inlines(text).map_err(|e| e.set_line(i + 1))?,
|
||||
});
|
||||
} else if let Some(quote_body) = line.strip_prefix(">") {
|
||||
let quote_blocks = parse_blocks(quote_body);
|
||||
let quote_blocks = parse_blocks(quote_body).map_err(|e| e.set_line(i + 1))?;
|
||||
blocks.push(Block::Quote(quote_blocks));
|
||||
} else if line.starts_with("```") {
|
||||
let lang_line = line.strip_prefix("```").unwrap().to_string();
|
||||
@@ -26,8 +129,16 @@ pub fn parse_blocks(input: &str) -> Vec<Block> {
|
||||
Some(lang_line)
|
||||
};
|
||||
let mut code = String::new();
|
||||
while lines.peek().is_some() && !lines.peek().unwrap().starts_with("```") {
|
||||
code.push_str(&format!("{}\n", lines.next().unwrap()));
|
||||
while lines.peek().is_some()
|
||||
&& !lines
|
||||
.peek()
|
||||
.ok_or(MdParseError::from_line(i + 1, "a line", ""))?
|
||||
.1
|
||||
.starts_with("```")
|
||||
{
|
||||
if let Some((_i, l)) = lines.next() {
|
||||
code.push_str(&format!("{}\n", l));
|
||||
}
|
||||
}
|
||||
lines.next();
|
||||
blocks.push(Block::Code {
|
||||
@@ -37,9 +148,12 @@ pub fn parse_blocks(input: &str) -> Vec<Block> {
|
||||
} else if line.trim().is_empty() {
|
||||
continue;
|
||||
} else {
|
||||
blocks.push(Block::Paragraph(parse_inlines(line)));
|
||||
blocks.push(Block::Paragraph(
|
||||
parse_inlines(line).map_err(|e| e.set_line(i + 1))?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
Ok(blocks)
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -1,61 +1,183 @@
|
||||
use crate::ast::Inline;
|
||||
use crate::{MdParseError, ast::Inline};
|
||||
|
||||
pub fn parse_inlines(input: &str) -> Vec<Inline> {
|
||||
pub fn parse_inlines(input: &str) -> Result<Vec<Inline>, MdParseError> {
|
||||
let mut inlines = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'*' => {
|
||||
let inner = collect_until(&mut chars, '*');
|
||||
inlines.push(Inline::Bold(parse_inlines(&inner)));
|
||||
let inner = collect_until(&mut chars, '*')?;
|
||||
inlines.push(Inline::Bold(parse_inlines(&inner)?));
|
||||
}
|
||||
'_' => {
|
||||
let inner = collect_until(&mut chars, '_');
|
||||
inlines.push(Inline::Italic(parse_inlines(&inner)));
|
||||
let inner = collect_until(&mut chars, '_')?;
|
||||
inlines.push(Inline::Italic(parse_inlines(&inner)?));
|
||||
}
|
||||
'`' => {
|
||||
let code = collect_until(&mut chars, '`');
|
||||
let code = collect_until(&mut chars, '`')?;
|
||||
inlines.push(Inline::Code(code));
|
||||
}
|
||||
'[' => {
|
||||
let text = collect_until(&mut chars, ']');
|
||||
if chars.next() == Some('(') {
|
||||
let href = collect_until(&mut chars, ')');
|
||||
let text = collect_until(&mut chars, ']')?;
|
||||
if let Some('(') = chars.next() {
|
||||
let href = collect_until(&mut chars, ')')?;
|
||||
inlines.push(Inline::Link {
|
||||
text: parse_inlines(&text),
|
||||
text: parse_inlines(&text)?,
|
||||
href,
|
||||
});
|
||||
} else {
|
||||
Err(MdParseError::new(
|
||||
"(<href>)",
|
||||
chars.next().unwrap_or_default(),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut text = String::new();
|
||||
text.push(c);
|
||||
let mut escaped = false;
|
||||
while let Some(&nc) = chars.peek() {
|
||||
if matches!(nc, '*' | '_' | '`' | '[') {
|
||||
if matches!(nc, '*' | '_' | '`' | '[') && !escaped {
|
||||
break;
|
||||
}
|
||||
text.push(chars.next().unwrap());
|
||||
let next_c = chars.next().ok_or(MdParseError::new("a character", ""))?;
|
||||
if next_c == '\\' && !escaped {
|
||||
escaped = true;
|
||||
} else {
|
||||
escaped = false;
|
||||
text.push(next_c);
|
||||
}
|
||||
}
|
||||
inlines.push(Inline::Text(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inlines
|
||||
Ok(inlines)
|
||||
}
|
||||
|
||||
fn collect_until<I: Iterator<Item = char>>(
|
||||
chars: &mut std::iter::Peekable<I>,
|
||||
end: char,
|
||||
) -> String {
|
||||
) -> Result<String, MdParseError> {
|
||||
let mut s = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
for c in chars.by_ref() {
|
||||
if c == end {
|
||||
chars.next();
|
||||
break;
|
||||
return Ok(s);
|
||||
}
|
||||
s.push(chars.next().unwrap());
|
||||
s.push(c);
|
||||
}
|
||||
Err(MdParseError::new(end, ""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::ast::Inline;
|
||||
|
||||
use super::{collect_until, parse_inlines};
|
||||
|
||||
#[test]
|
||||
fn collect_until_without_end() {
|
||||
let mut s = "abcdef".chars().peekable();
|
||||
let res = collect_until(&mut s, '.');
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_text() {
|
||||
let md = "*abc*";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
inl,
|
||||
vec![Inline::Bold(vec![Inline::Text("abc".to_string())])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italic_text() {
|
||||
let md = "_abc_";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
inl,
|
||||
vec![Inline::Italic(vec![Inline::Text("abc".to_string())])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_text() {
|
||||
let md = "*_abc_*";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
inl,
|
||||
vec![Inline::Bold(vec![Inline::Italic(vec![Inline::Text(
|
||||
"abc".to_string()
|
||||
)])])]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code() {
|
||||
let md = "`sudo rm -rf /`";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(inl, vec![Inline::Code("sudo rm -rf /".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_code() {
|
||||
let md = "run `sudo rm -rf /` on your computer";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
inl,
|
||||
vec![
|
||||
Inline::Text("run ".to_string()),
|
||||
Inline::Code("sudo rm -rf /".to_string()),
|
||||
Inline::Text(" on your computer".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_hyperlink() {
|
||||
let md = "a link to [my site](https://example.com)";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
inl,
|
||||
vec![
|
||||
Inline::Text("a link to ".to_string()),
|
||||
Inline::Link {
|
||||
text: vec![Inline::Text("my site".to_string())],
|
||||
href: "https://example.com".to_string()
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_without_link() {
|
||||
let md = "[abc]";
|
||||
let inl = parse_inlines(md);
|
||||
|
||||
assert!(inl.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_brackets() {
|
||||
let md = r"some \[text\]";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
assert_eq!(inl, vec![Inline::Text("some [text]".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_escape() {
|
||||
let md = r"backslash \\";
|
||||
let inl = parse_inlines(md).unwrap();
|
||||
assert_eq!(inl, vec![Inline::Text(r"backslash \".to_string())]);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1,4 +1,198 @@
|
||||
//! A trait + implementations for generating HTML.
|
||||
|
||||
use crate::ast::{Block, Document, Inline};
|
||||
|
||||
pub trait ToHtml {
|
||||
fn to_html(self) -> String;
|
||||
}
|
||||
|
||||
impl ToHtml for Document {
|
||||
fn to_html(self) -> String {
|
||||
format!(
|
||||
"<!doctype html><html lang=en><head></head><body>{}</body></html>",
|
||||
self.blocks.to_html()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToHtml for Block {
|
||||
fn to_html(self) -> String {
|
||||
match self {
|
||||
Self::Paragraph(content) => format!("<p>{}</p>", content.to_html()),
|
||||
Self::Heading { level, content } => {
|
||||
format!("<h{}>{}</h{}>", level, content.to_html(), level)
|
||||
}
|
||||
Self::Code {
|
||||
language: _,
|
||||
content,
|
||||
} => {
|
||||
format!("<pre><code>{content}</code></pre>")
|
||||
}
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToHtml for Inline {
|
||||
fn to_html(self) -> String {
|
||||
match self {
|
||||
Self::Text(s) => s,
|
||||
Self::Bold(content) => format!("<b>{}</b>", content.to_html()),
|
||||
Self::Italic(content) => format!("<i>{}</i>", content.to_html()),
|
||||
Self::Code(s) => format!("<code>{s}</code>"),
|
||||
Self::Link { text, href } => format!("<a href=\"{}\">{}</a>", href, text.to_html()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ToHtml for Vec<T>
|
||||
where
|
||||
T: ToHtml,
|
||||
{
|
||||
fn to_html(self) -> String {
|
||||
let mut rendered = String::new();
|
||||
for i in self {
|
||||
rendered.push_str(&i.to_html());
|
||||
}
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// TESTS
|
||||
// --------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_header() {
|
||||
let ast = Document {
|
||||
blocks: vec![Block::Heading {
|
||||
level: 1,
|
||||
content: vec![Inline::Text("Heading 1".to_string())],
|
||||
}],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1>Heading 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_bold_header() {
|
||||
let ast = Document {
|
||||
blocks: vec![Block::Heading {
|
||||
level: 1,
|
||||
content: vec![
|
||||
Inline::Bold(vec![Inline::Text("Bold".to_string())]),
|
||||
Inline::Text(" heading 1".to_string()),
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_and_paragraph_nested_code() {
|
||||
let ast = Document {
|
||||
blocks: vec![
|
||||
Block::Heading {
|
||||
level: 1,
|
||||
content: vec![
|
||||
Inline::Bold(vec![Inline::Text("Bold".to_string())]),
|
||||
Inline::Text(" heading 1".to_string()),
|
||||
],
|
||||
},
|
||||
Block::Heading {
|
||||
level: 2,
|
||||
content: vec![Inline::Text("Heading 2".to_string())],
|
||||
},
|
||||
Block::Paragraph(vec![
|
||||
Inline::Text("run ".to_string()),
|
||||
Inline::Code("sudo rm -rf /".to_string()),
|
||||
Inline::Text(" on your computer".to_string()),
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> heading 1</h1><h2>Heading 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod convert_md_to_html_test {
|
||||
use crate::parser::parse;
|
||||
use crate::to_html::ToHtml;
|
||||
|
||||
#[test]
|
||||
fn single_header() {
|
||||
let md = "# Header 1";
|
||||
let ast = match parse(md) {
|
||||
Ok(a) => a,
|
||||
Err(e) => panic!("{}", e),
|
||||
};
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1>Header 1</h1></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_header_wrong_format() {
|
||||
let md = "#Whoops";
|
||||
let ast = parse(md);
|
||||
|
||||
assert!(ast.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_bold_headers_and_nested_code_paragraph() {
|
||||
let md = "# *Bold* header 1\n## Header 2\nrun `sudo rm -rf /` on your computer";
|
||||
let ast = match parse(md) {
|
||||
Ok(a) => a,
|
||||
Err(e) => panic!("{}", e),
|
||||
};
|
||||
let html = ast.to_html();
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<!doctype html><html lang=en><head></head><body><h1><b>Bold</b> header 1</h1><h2>Header 2</h2><p>run <code>sudo rm -rf /</code> on your computer</p></body></html>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_real_md {
|
||||
use std::fs;
|
||||
|
||||
use crate::parser::parse;
|
||||
|
||||
#[test]
|
||||
fn go() {
|
||||
let file = "./test.md";
|
||||
let md = fs::read_to_string(file).expect("reading ./test.md failed");
|
||||
|
||||
let _ast = match parse(&md).map_err(|e| e.set_file(file.into())) {
|
||||
Ok(a) => a,
|
||||
Err(e) => panic!("{}", e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
7
cracked_md/test.md
Normal file
7
cracked_md/test.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Header *1kkkkkkkkkkkkkkkkkkkkkk*
|
||||
|
||||
this is some code: `abc`
|
||||
|
||||
```code
|
||||
oiajwefoijao089uaoisdjfoijasdfoijasdofij
|
||||
```
|
||||
60
flake.lock
generated
Normal file
60
flake.lock
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utlis": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762596750,
|
||||
"narHash": "sha256-rXXuz51Bq7DHBlfIjN7jO8Bu3du5TV+3DSADBX7/9YQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b6a8526db03f735b89dd5ff348f53f752e7ddc8e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utlis": "flake-utlis",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
20
flake.nix
20
flake.nix
@@ -1,5 +1,5 @@
|
||||
{
|
||||
description = "stdsrv Flake file";
|
||||
description = "gravel project flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-unstable";
|
||||
@@ -14,16 +14,18 @@
|
||||
flake-utlis.lib.eachDefaultSystem (
|
||||
system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
stdsrv = import ./package.nix {inherit pkgs;};
|
||||
gravel = import ./package.nix {inherit pkgs;};
|
||||
in {
|
||||
packages.default = stdsrv;
|
||||
packages.default = gravel;
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.rustup
|
||||
pkgs.bacon
|
||||
pkgs.cargo-nextest
|
||||
pkgs.cargo-expand
|
||||
pkgs.cargo-watch
|
||||
packages = with pkgs; [
|
||||
rustup
|
||||
helix
|
||||
bacon
|
||||
cargo-nextest
|
||||
cargo-expand
|
||||
cargo-watch
|
||||
cargo-tarpaulin
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
7
fstools/Cargo.lock
generated
Normal file
7
fstools/Cargo.lock
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "fstools"
|
||||
version = "0.1.0"
|
||||
@@ -2,13 +2,14 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Recursively crawl through the directory given and aggregate all file handles to a HashMap with
|
||||
/// Recursively crawl through the directory given and aggregate all file handles to a `HashMap` with
|
||||
/// their respective (relative) paths as keys.
|
||||
#[must_use]
|
||||
pub fn crawl_fs(root: &PathBuf) -> HashSet<PathBuf> {
|
||||
crawl_fs_rec(root, root)
|
||||
}
|
||||
|
||||
/// Helper function: Recursively crawl through the directory given and aggregate all file handles to a HashMap with
|
||||
/// Helper function: Recursively crawl through the directory given and aggregate all file handles to a `HashMap` with
|
||||
/// their respective (relative) paths as keys.
|
||||
fn crawl_fs_rec(root: &PathBuf, path: &PathBuf) -> HashSet<PathBuf> {
|
||||
let mut subdirs = Vec::with_capacity(100);
|
||||
|
||||
14
gravel_cli/Cargo.toml
Normal file
14
gravel_cli/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "gravel_cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
[[bin]]
|
||||
name = "gravel"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
cracked_md = { path = "../cracked_md" }
|
||||
stdsrv = { path = "../stdsrv" }
|
||||
slogger = { path = "../slogger" }
|
||||
173
gravel_cli/src/config.rs
Normal file
173
gravel_cli/src/config.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Simple and program specific command line argument parsing solution.
|
||||
|
||||
// todo: refactor to <command> <subcommand> [<options>]
|
||||
|
||||
use slogger::{LOG_LEVEL, Level, log};
|
||||
|
||||
use crate::error::Error;
|
||||
use std::env::Args;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub enum Command {
|
||||
Generate { force: bool, single: bool },
|
||||
Serve { addr: Ipv4Addr, port: u16 },
|
||||
Init,
|
||||
}
|
||||
|
||||
impl Default for Command {
|
||||
fn default() -> Self {
|
||||
Self::Generate {
|
||||
force: true,
|
||||
single: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Args> for Command {
|
||||
type Error = Error;
|
||||
fn try_from(mut value: Args) -> Result<Self, Self::Error> {
|
||||
let mut comm = Command::default();
|
||||
let _ = value.next(); // ignore executable
|
||||
let command = value.next();
|
||||
|
||||
// `gravel serve` command
|
||||
if let Some("serve") = command.as_deref() {
|
||||
let mut addr = Ipv4Addr::UNSPECIFIED;
|
||||
let mut port = 8080;
|
||||
while let Some(a) = value.next() {
|
||||
match a.as_str() {
|
||||
"-a" => {
|
||||
let address_str = value.next().ok_or(Error::CommandLineArgsParse(
|
||||
"Missing argument after `-a`. Expected IPv4 address.".to_string(),
|
||||
))?;
|
||||
addr = Ipv4Addr::parse_ascii(address_str.as_bytes()).map_err(|_e| {
|
||||
Error::CommandLineArgsParse("Parsing IP address failed".to_string())
|
||||
})?;
|
||||
}
|
||||
"-p" => {
|
||||
let port_str = value.next().ok_or(Error::CommandLineArgsParse(
|
||||
"Missing argument after `-p`. Expected TCP port number.".to_string(),
|
||||
))?;
|
||||
port = port_str.parse().map_err(|_e| {
|
||||
Error::CommandLineArgsParse("Parsing TCP port failed".to_string())
|
||||
})?;
|
||||
}
|
||||
&_ => Err(Error::CommandLineArgsParse(format!(
|
||||
"Unknown argument: `{a}`"
|
||||
)))?,
|
||||
}
|
||||
}
|
||||
comm = Command::Serve { addr, port };
|
||||
}
|
||||
// `gravel init` command
|
||||
else if let Some("init") = command.as_deref() {
|
||||
if let Some(a) = value.next() {
|
||||
Err(Error::CommandLineArgsParse(format!(
|
||||
"Unexpected argument: `{a}`"
|
||||
)))?;
|
||||
}
|
||||
comm = Command::Init;
|
||||
}
|
||||
// `gravel` command
|
||||
for a in value {
|
||||
match a.as_str() {
|
||||
"-s" => {
|
||||
comm = Command::Generate {
|
||||
force: true,
|
||||
single: true,
|
||||
}
|
||||
}
|
||||
_ => Err(Error::CommandLineArgsParse(format!(
|
||||
"Unknown argument: `{a}`"
|
||||
)))?,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(comm)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub struct ProgramConfig {
|
||||
pub outdir: PathBuf,
|
||||
pub indir: PathBuf,
|
||||
pub command: Command,
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl Default for ProgramConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
indir: PathBuf::from("./pebbles"),
|
||||
outdir: PathBuf::from("./site"),
|
||||
command: Command::default(),
|
||||
verbose: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgramConfig {
|
||||
pub fn new<P: AsRef<Path>>(_toml_file: P, args: Args) -> Result<Self, Error> {
|
||||
let conf = Self {
|
||||
command: args.try_into()?,
|
||||
..Default::default()
|
||||
};
|
||||
LOG_LEVEL.get_or_init(|| Level::Debug);
|
||||
log!(Level::Warn, "TOML parsing not implemented, skipping");
|
||||
Ok(conf)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
impl TryFrom<std::env::Args> for ProgramConfig {
|
||||
type Error = crate::error::Error;
|
||||
fn try_from(mut value: std::env::Args) -> Result<Self, Self::Error> {
|
||||
let mut a = Self::default();
|
||||
let _ = value.next(); // ignore executable path
|
||||
while let Some(v) = value.next() {
|
||||
match v.as_str() {
|
||||
"-i" => {
|
||||
a.indir = value
|
||||
.next()
|
||||
.ok_or(Error::CommandLineArgsParse(
|
||||
"Expected input directory after option `-i`".to_string(),
|
||||
))?
|
||||
.into();
|
||||
}
|
||||
"-a" => {
|
||||
let addr_string = value.next().ok_or(Error::CommandLineArgsParse(
|
||||
"Expected listener IPv4 address after option `-a`".to_string(),
|
||||
))?;
|
||||
a.addr = Ipv4Addr::parse_ascii(addr_string.as_bytes()).map_err(|_e| {
|
||||
Error::CommandLineArgsParse(
|
||||
"Invalid IPv4 address after option `-a`".to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"-p" => {
|
||||
let port_string = value.next().ok_or(Error::CommandLineArgsParse(
|
||||
"Expected listener port after option `-p`".to_string(),
|
||||
))?;
|
||||
a.port = port_string.parse().map_err(|_e| {
|
||||
Error::CommandLineArgsParse(
|
||||
"Invalid 16-bit port number after option `-p`".to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
"-g" => a.generate = true,
|
||||
"-f" => a.force = true,
|
||||
"-v" => {
|
||||
a.verbose = true;
|
||||
LOG_LEVEL.get_or_init(|| Level::Debug);
|
||||
}
|
||||
_ => {
|
||||
a.outdir = v.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
LOG_LEVEL.get_or_init(|| Level::Info);
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
*/
|
||||
32
gravel_cli/src/error.rs
Normal file
32
gravel_cli/src/error.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Server(stdsrv::error::Error),
|
||||
MdParse(cracked_md::Error),
|
||||
CommandLineArgsParse(String),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Server(e) => e.fmt(f),
|
||||
Error::MdParse(e) => e.fmt(f),
|
||||
Error::CommandLineArgsParse(s) => write!(f, "{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl From<cracked_md::Error> for Error {
|
||||
fn from(value: cracked_md::Error) -> Self {
|
||||
Self::MdParse(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<stdsrv::error::Error> for Error {
|
||||
fn from(value: stdsrv::error::Error) -> Self {
|
||||
Self::Server(value)
|
||||
}
|
||||
}
|
||||
37
gravel_cli/src/main.rs
Normal file
37
gravel_cli/src/main.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
#![feature(addr_parse_ascii, never_type)]
|
||||
|
||||
use std::process;
|
||||
|
||||
use config::{Command, ProgramConfig};
|
||||
use cracked_md::generate;
|
||||
use error::Error;
|
||||
use slogger::{Level, log};
|
||||
use stdsrv::serve;
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
|
||||
fn run() -> Result<(), Error> {
|
||||
let conf = ProgramConfig::new("gravel.toml", std::env::args())?;
|
||||
|
||||
match conf.command {
|
||||
Command::Init => todo!("project init"),
|
||||
Command::Serve { addr, port } => serve(addr, port, conf.outdir)?,
|
||||
Command::Generate {
|
||||
force,
|
||||
single: false,
|
||||
} => generate(&conf.indir, &conf.outdir, force)?,
|
||||
Command::Generate {
|
||||
force: _f,
|
||||
single: true,
|
||||
} => todo!("single file generation"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = run().map_err(|e| {
|
||||
log!(Level::Error, "{}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
}
|
||||
@@ -5,5 +5,6 @@ targets = [
|
||||
]
|
||||
components = [
|
||||
"clippy",
|
||||
"rustfmt"
|
||||
"rustfmt",
|
||||
"rust-analyzer"
|
||||
]
|
||||
|
||||
6
slogger/Cargo.toml
Normal file
6
slogger/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "slogger"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
49
slogger/src/lib.rs
Normal file
49
slogger/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::{fmt::Display, sync::OnceLock};
|
||||
|
||||
pub static LOG_LEVEL: OnceLock<Level> = OnceLock::new();
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Level {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl Display for Level {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}]\x1b[0m",
|
||||
match self {
|
||||
Self::Error => "\x1b[1;31m[ERROR",
|
||||
Self::Warn => "\x1b[1;33m[WARN",
|
||||
Self::Info => "\x1b[0;32m[INFO",
|
||||
Self::Debug => "\x1b[0;36m[DEBUG",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A logging macro. Takes a [`Level`] and a formatted string.
|
||||
#[macro_export]
|
||||
macro_rules! log {
|
||||
($level:expr, $($arg:tt)*) => {{
|
||||
let log_from = if &$level == &$crate::Level::Debug {
|
||||
format!(" {}:{}", std::file!(), std::line!())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if &$level <= $crate::LOG_LEVEL.get().unwrap_or(&$crate::Level::Info) {
|
||||
println!(
|
||||
"{}{}: {}",
|
||||
$level,
|
||||
log_from,
|
||||
format!($($arg)*)
|
||||
);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
// todo: implement clean verbose/short logging
|
||||
22
stdsrv/Cargo.lock
generated
Normal file
22
stdsrv/Cargo.lock
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cracked_md"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"fstools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fstools"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "stdsrv"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cracked_md",
|
||||
"fstools",
|
||||
]
|
||||
@@ -5,5 +5,5 @@ edition = "2024"
|
||||
|
||||
# local dependencies
|
||||
[dependencies]
|
||||
cracked_md = { path = "../cracked_md" }
|
||||
fstools = { path = "../fstools" }
|
||||
slogger = { path = "../slogger" }
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
use crate::error::ErrorKind;
|
||||
|
||||
pub struct ProgramArgs {
|
||||
pub outdir: PathBuf,
|
||||
pub indir: PathBuf,
|
||||
pub generate: bool,
|
||||
pub force: bool,
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
impl Default for ProgramArgs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
indir: PathBuf::from("./web"),
|
||||
outdir: PathBuf::from("./html"),
|
||||
generate: false,
|
||||
force: false,
|
||||
addr: "0.0.0.0:8080".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<std::env::Args> for ProgramArgs {
|
||||
type Error = crate::error::Error;
|
||||
fn try_from(mut value: std::env::Args) -> Result<Self, Self::Error> {
|
||||
let mut a = Self::default();
|
||||
let _ = value.next(); // ignore executable path
|
||||
while let Some(v) = value.next() {
|
||||
match v.as_str() {
|
||||
"-i" => {
|
||||
a.indir = value
|
||||
.next()
|
||||
.ok_or(Error::new(
|
||||
ErrorKind::CommandLineArgsParse,
|
||||
"Expected input directory after option `-i`",
|
||||
))?
|
||||
.into();
|
||||
}
|
||||
"-a" => {
|
||||
a.addr = value.next().ok_or(Error::new(
|
||||
ErrorKind::CommandLineArgsParse,
|
||||
"Expected listener address after option `-a`",
|
||||
))?;
|
||||
}
|
||||
"-g" => a.generate = true,
|
||||
"-f" => a.force = true,
|
||||
_ => {
|
||||
a.outdir = v.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub struct Error {
|
||||
}
|
||||
|
||||
impl Error {
|
||||
#[must_use]
|
||||
pub fn new(kind: ErrorKind, msg: &str) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! FileServer
|
||||
//! A simple server implementation that just responds with the contents of the file requested in
|
||||
//! the provided directory.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use crate::{
|
||||
error::*,
|
||||
error::{Error, ErrorKind, Result},
|
||||
request::{HttpMethod, HttpRequest},
|
||||
responder::Responder,
|
||||
response::{HttpResponse, HttpStatus},
|
||||
@@ -15,7 +16,7 @@ pub struct FileServer {
|
||||
}
|
||||
|
||||
impl FileServer {
|
||||
pub fn new(root: &PathBuf) -> Result<FileServer> {
|
||||
pub fn new(root: &Path) -> Result<FileServer> {
|
||||
if !root.is_dir() {
|
||||
return Err(Error::new(
|
||||
ErrorKind::DirNotFound,
|
||||
@@ -51,7 +52,6 @@ impl Responder for FileServer {
|
||||
let content_type = match req.path.extension() {
|
||||
Some(s) => match s.as_encoded_bytes() {
|
||||
b"html" | b"htm" => "text/html",
|
||||
b"txt" => "text/plain",
|
||||
b"css" => "text/css",
|
||||
b"js" => "text/javascript",
|
||||
b"pdf" => "application/pdf",
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
//! Wrapper type of a `HashMap` to contain HTTP headers and format them correctly.
|
||||
|
||||
use std::{collections::HashMap, fmt::Display};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HttpHeaders {
|
||||
_inner: HashMap<String, String>,
|
||||
inner: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl HttpHeaders {
|
||||
pub fn new() -> Self {
|
||||
HttpHeaders {
|
||||
_inner: HashMap::new(),
|
||||
inner: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, k: &str, v: &str) {
|
||||
self._inner.insert(k.to_string(), v.to_string());
|
||||
self.inner.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn get(&self, k: &str) -> Option<&String> {
|
||||
self._inner.get(k)
|
||||
self.inner.get(k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self._inner.len()
|
||||
self.inner.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for HttpHeaders {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for (k, v) in self._inner.iter() {
|
||||
write!(f, "{}: {}\r\n", k, v)?;
|
||||
for (k, v) in &self.inner {
|
||||
write!(f, "{k}: {v}\r\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
use crate::error::ErrorKind;
|
||||
use crate::log;
|
||||
use crate::logger::Level;
|
||||
use std::io::Read;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
use crate::{error::Error, request::HttpRequest};
|
||||
|
||||
pub struct HttpStream {
|
||||
tcp_listener: TcpListener,
|
||||
}
|
||||
|
||||
impl HttpStream {
|
||||
pub fn new(addr: &str) -> Self {
|
||||
let tcp_listener = TcpListener::bind(addr)
|
||||
.unwrap_or_else(|e| panic!("Failed to bind on address `{}`: {}", addr, e));
|
||||
log!(Level::Info, "Listening on `{}`", addr);
|
||||
|
||||
Self { tcp_listener }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for HttpStream {
|
||||
type Item = (HttpRequest, TcpStream);
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// safe to unwrap, because Incoming never returns None
|
||||
let mut stream = self.tcp_listener.incoming().next().unwrap().ok()?;
|
||||
|
||||
let mut buf = [0; 1024];
|
||||
let _read_bytes = stream
|
||||
.read(&mut buf)
|
||||
.or(Err(Error::new(
|
||||
ErrorKind::StreamReadFailed,
|
||||
"Reading from TCP stream failed",
|
||||
)))
|
||||
.ok()?;
|
||||
Some((
|
||||
String::from_utf8_lossy(&buf[..]).trim().try_into().ok()?,
|
||||
stream,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,41 @@
|
||||
//! This is my very monolithic file server with zero dependencies (other than rust
|
||||
//! stdlib).
|
||||
//!
|
||||
//! Documentation is a work in progress, go see my webpage at [jlux.dev](https://jlux.dev).
|
||||
//! A simple web server with 0 dependencies (other than Rust's stdlib).
|
||||
|
||||
#![feature(never_type)]
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{
|
||||
io::{BufReader, BufWriter},
|
||||
net::TcpListener,
|
||||
process,
|
||||
net::{Ipv4Addr, TcpListener},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use args::ProgramArgs;
|
||||
use cracked_md::generate;
|
||||
use error::Error;
|
||||
use fileserver::FileServer;
|
||||
use logger::Level;
|
||||
use request::HttpRequest;
|
||||
use responder::Responder;
|
||||
use slogger::{Level, log};
|
||||
|
||||
mod args;
|
||||
mod error;
|
||||
pub mod error;
|
||||
mod fileserver;
|
||||
mod http_header;
|
||||
//mod http_stream;
|
||||
mod logger;
|
||||
mod request;
|
||||
mod responder;
|
||||
mod response;
|
||||
|
||||
/// Entrypoint to the program.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// Opens a file server on a specified address and port which serves all files in dir.
|
||||
///
|
||||
/// # Errors
|
||||
/// Errors that come up while serving files. Look at [`Error`].
|
||||
///
|
||||
/// # Panics
|
||||
/// Never. Added to allow compiler to check for ! type.
|
||||
pub fn serve(addr: Ipv4Addr, port: u16, dir: PathBuf) -> Result<(), Error> {
|
||||
/*
|
||||
let args: ProgramArgs = std::env::args().try_into()?;
|
||||
|
||||
if args.generate {
|
||||
match generate(&args.indir, &args.outdir, args.force) {
|
||||
Ok(_) => log!(
|
||||
Ok(()) => log!(
|
||||
Level::Info,
|
||||
"HTML generation from `{}` to `{}` successful",
|
||||
args.indir.display(),
|
||||
@@ -50,21 +53,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log!(Level::Error, "HTML generation failed with error: {}", e,);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind(&args.addr)?;
|
||||
log!(Level::Info, "Listening on addr `{}`", &args.addr);
|
||||
*/
|
||||
let listener = TcpListener::bind((addr, port))?;
|
||||
log!(Level::Info, "Listening on addr `{}:{}`", addr, port);
|
||||
|
||||
// todo: refactor this
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
let outdir = args.outdir.to_owned();
|
||||
let outdir = dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
log!(Level::Debug, "TcpStream handler spawned");
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut writer = BufWriter::new(&stream);
|
||||
let server = match FileServer::new(&outdir) {
|
||||
let server = match FileServer::new(outdir.as_path()) {
|
||||
Ok(s) => s,
|
||||
Err(_e) => return,
|
||||
};
|
||||
@@ -83,6 +88,5 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err(e) => log!(Level::Warn, "Connection failed: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum Level {
|
||||
Error,
|
||||
Warn,
|
||||
Info,
|
||||
Debug,
|
||||
}
|
||||
|
||||
impl Display for Level {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}]\x1b[0m",
|
||||
match self {
|
||||
Self::Error => "\x1b[1;31m[ERROR",
|
||||
Self::Warn => "\x1b[1;33m[WARN",
|
||||
Self::Info => "\x1b[0;32m[INFO",
|
||||
Self::Debug => "\x1b[0;36m[DEBUG",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log {
|
||||
($level:expr, $($arg:tt)*) => {{
|
||||
println!(
|
||||
"{} {}:{}:{}: {}",
|
||||
$level,
|
||||
std::module_path!(),
|
||||
std::file!(),
|
||||
std::line!(),
|
||||
format!($($arg)*)
|
||||
);
|
||||
}};
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
use crate::error::{Error, ErrorKind, Result};
|
||||
use crate::http_header::HttpHeaders;
|
||||
use crate::log;
|
||||
use crate::logger::Level;
|
||||
use slogger::{Level, log};
|
||||
use std::fmt::Display;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpStream;
|
||||
@@ -32,7 +31,7 @@ impl TryFrom<&str> for HttpMethod {
|
||||
|
||||
impl Display for HttpMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +110,14 @@ impl TryFrom<&mut BufReader<&TcpStream>> for HttpRequest {
|
||||
}
|
||||
}
|
||||
|
||||
log!(
|
||||
Level::Info,
|
||||
"{} /{} {}",
|
||||
req.method,
|
||||
req.path.display(),
|
||||
req.version
|
||||
);
|
||||
|
||||
Ok(req)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +161,7 @@ impl TryFrom<&str> for HttpRequest {
|
||||
|
||||
for line in lines {
|
||||
if let Some(v) = line.split_once(": ") {
|
||||
headers.add(v.0, v.1)
|
||||
headers.add(v.0, v.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +1,12 @@
|
||||
//! Traits helping HTTP connections
|
||||
//! Helper trait(s).
|
||||
|
||||
use crate::request::HttpRequest;
|
||||
use crate::response::HttpResponse;
|
||||
|
||||
/// Responder trait. Just a respond method that turns a HttpRequest to a HttpResponse.
|
||||
/// Responder trait. Just a respond method that turns a [`HttpRequest`] to a [`HttpResponse`].
|
||||
///
|
||||
/// [`HttpRequest`]: ../request/struct.HttpRequest.html
|
||||
/// [`HttpResponse`]: ../response/struct.HttpResponse.html
|
||||
pub trait Responder {
|
||||
fn respond(&self, req: HttpRequest) -> HttpResponse;
|
||||
}
|
||||
|
||||
/*
|
||||
/// Size trait. Number of bytes when encoded.
|
||||
pub trait Size {
|
||||
fn size(&self) -> usize;
|
||||
}
|
||||
|
||||
// Standard implementations for Size trait
|
||||
|
||||
impl Size for u8 {
|
||||
fn size(&self) -> usize {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Size for Vec<T>
|
||||
where
|
||||
T: Size,
|
||||
{
|
||||
fn size(&self) -> usize {
|
||||
if let Some(elem) = self.first() {
|
||||
elem.size() * self.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Size for Option<T>
|
||||
where
|
||||
T: Size,
|
||||
{
|
||||
fn size(&self) -> usize {
|
||||
match self {
|
||||
Some(t) => t.size(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Size for String {
|
||||
fn size(&self) -> usize {
|
||||
self.len()
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::http_header::HttpHeaders;
|
||||
use crate::log;
|
||||
use crate::logger::Level;
|
||||
use slogger::{Level, log};
|
||||
use std::{fmt::Display, io::Write};
|
||||
|
||||
/// Macro for generating Http status codes (AI generated).
|
||||
@@ -119,7 +118,9 @@ impl HttpResponse {
|
||||
let _ = std::io::Read::read(stream, &mut [0u8; 1]);
|
||||
*/
|
||||
|
||||
log!(Level::Info, "\n{}", &self);
|
||||
// todo better verbose tracking
|
||||
log!(Level::Info, "{} {}", self.version, self.status);
|
||||
log!(Level::Debug, "\n{}", &self);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ impl Display for HttpResponse {
|
||||
write!(
|
||||
f,
|
||||
"{}\r\n",
|
||||
String::from_utf8(s.to_vec()).unwrap_or("<binary data>".to_string())
|
||||
String::from_utf8(s.clone()).unwrap_or("<binary data>".to_string())
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user