Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

QuickCoffee 是一台以 Rust 编写、受 CoffeeScript 启发的字节码脚本引擎。它保留紧凑、可读的表达式语法,却不兼容 JavaScript:没有原型链、`this`、`eval` 或嵌入 JavaScript。

当前实现遵循 [RFCs/0000-project-scope.md](RFCs/0000-project-scope.md) 至 [RFCs/0100-signed-by-iteration.md](RFCs/0100-signed-by-iteration.md)。
当前实现遵循 [RFCs/0000-project-scope.md](RFCs/0000-project-scope.md) 至 [RFCs/0101-qdocco-atomic-output.md](RFCs/0101-qdocco-atomic-output.md)。

```coffee
square = (x) -> x * x
Expand Down
2 changes: 1 addition & 1 deletion RFCs/0000-project-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ QuickCoffee 是一个 Rust 实现的、受 CoffeeScript 2016 启发的脚本引

本仓库中的测试即 0.1 的语义基线。对语法或运行时的新增特性必须先以 RFC 补充定义,并至少添加:成功测试、错误测试及字节码验证测试。

当前已实现的后续语义与工具 RFC 延伸至 RFC 0100;其中 RFC 0077 定义 JSON 输出、RFC 0079 定义 TAP 输出、RFC 0080 定义 CLI 字节码指纹、RFC 0081 定义可机器读取的基准输出、RFC 0082 规范化指纹编码、RFC 0083 定义 Markdown 文学编程产物、RFC 0084 定义嵌入上下文 fuel 控制、RFC 0085 定义可执行 Rust 嵌入示例、RFC 0086 定义 crate 发布元数据、RFC 0094 定义 qdocco 最终值门禁、RFC 0095 定义字符串步进迭代、RFC 0096 定义其性能基准、RFC 0097 定义 `do` 参数转发、RFC 0098 定义 RFC 索引门禁、RFC 0099 定义 `!` 否定别名、RFC 0100 定义有符号 `by` 步长,均不改变脚本语言值模型的原型无关约束。
当前已实现的后续语义与工具 RFC 延伸至 RFC 0101;其中 RFC 0077 定义 JSON 输出、RFC 0079 定义 TAP 输出、RFC 0080 定义 CLI 字节码指纹、RFC 0081 定义可机器读取的基准输出、RFC 0082 规范化指纹编码、RFC 0083 定义 Markdown 文学编程产物、RFC 0084 定义嵌入上下文 fuel 控制、RFC 0085 定义可执行 Rust 嵌入示例、RFC 0086 定义 crate 发布元数据、RFC 0094 定义 qdocco 最终值门禁、RFC 0095 定义字符串步进迭代、RFC 0096 定义其性能基准、RFC 0097 定义 `do` 参数转发、RFC 0098 定义 RFC 索引门禁、RFC 0099 定义 `!` 否定别名、RFC 0100 定义有符号 `by` 步长、RFC 0101 定义 qdocco 原子输出,均不改变脚本语言值模型的原型无关约束。
8 changes: 8 additions & 0 deletions RFCs/0101-qdocco-atomic-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# RFC 0101:qdocco 原子输出

- 状态:已采纳
- 依赖:RFC 0083、RFC 0094

`qdocco` 生成 HTML 或 Markdown 时,先在目标文件同一目录建立带进程号的临时文件,完整写入并同步后,再以重命名替换目标文件。Unix 上替换后还同步父目录,故目录项在断电后可恢复;Windows 不支持同名目标的原子 `rename`,实现会在临时文件已同步后删除旧目标再重命名,因此该平台的替换不是原子的。写入或替换失败时清理临时文件,并保留原有目标产物(Windows 删除旧目标后的重命名失败除外);目标路径仍不得与源码相同(RFC 0083)。

该契约避免磁盘错误、进程中断或宿主终止时留下半份文档。临时文件名以 `OsString` 保留目标文件名,故 Unix 非 UTF-8 路径不会退化为共享的默认临时名。临时文件使用独占创建;同一目录中已有同名临时文件时本次生成失败而不覆盖它。
99 changes: 97 additions & 2 deletions src/bin/qdocco.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
//! Literate-programming renderer and checker for QuickCoffee sources.

use quickcoffee::{Context, Engine, Value};
use std::{env, fs, path::PathBuf, process::ExitCode};
use std::{
env,
ffi::{OsStr, OsString},
fs,
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
};

fn usage() {
eprintln!("Usage: qdocco [--check | --markdown] FILE [-o OUTPUT]\n qdocco --version");
Expand All @@ -12,6 +19,45 @@ fn same_path(left: &PathBuf, right: &PathBuf) -> bool {
_ => left == right,
}
}
fn temporary_path(destination: &Path) -> PathBuf {
let mut temporary_name = OsString::from(".");
temporary_name.push(
destination
.file_name()
.unwrap_or(OsStr::new("qdocco-output")),
);
temporary_name.push(format!(".quickcoffee-{}.tmp", std::process::id()));
destination.with_file_name(temporary_name)
}
fn write_output(destination: &Path, document: &str) -> io::Result<()> {
let temporary = temporary_path(destination);
let mut created = false;
let result = (|| {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
created = true;
file.write_all(document.as_bytes())?;
file.sync_all()?;
drop(file);
// Windows rename does not replace an existing destination. The write
// remains crash-safe in its temporary sibling; replacement there is
// necessarily remove-then-rename rather than Unix's atomic rename.
#[cfg(windows)]
if destination.exists() {
fs::remove_file(destination)?;
}
fs::rename(&temporary, destination)?;
#[cfg(unix)]
fs::File::open(destination.parent().unwrap_or(Path::new(".")))?.sync_all()?;
Ok(())
})();
Comment thread
tiye marked this conversation as resolved.
if result.is_err() && created {
let _ = fs::remove_file(&temporary);
}
result
}
fn escape(input: &str) -> String {
input
.replace('&', "&amp;")
Expand Down Expand Up @@ -142,11 +188,60 @@ fn main() -> ExitCode {
} else {
render(&source, &result.to_string())
};
if let Err(e) = fs::write(&destination, document) {
if let Err(e) = write_output(&destination, &document) {
eprintln!("write error: {e}");
return ExitCode::from(1);
}
println!("wrote {}", destination.display());
}
ExitCode::SUCCESS
}

#[cfg(test)]
mod tests {
use super::{temporary_path, write_output};
use std::{fs, path::PathBuf};

#[test]
fn output_replacement_is_exclusive_and_cleans_up_on_collision() {
let directory =
std::env::temp_dir().join(format!("quickcoffee-qdocco-output-{}", std::process::id()));
fs::create_dir_all(&directory).expect("temporary directory");
let destination = directory.join("document.html");
fs::write(&destination, "old").expect("seed output");
let temporary = PathBuf::from(format!(
".document.html.quickcoffee-{}.tmp",
std::process::id()
));
let temporary = directory.join(temporary);
fs::write(&temporary, "reserved").expect("reserve temporary output");

assert!(write_output(&destination, "new").is_err());
assert_eq!(fs::read_to_string(&destination).expect("old output"), "old");
assert_eq!(
fs::read_to_string(&temporary).expect("reserved temporary output"),
"reserved"
);

fs::remove_file(&temporary).expect("release temporary output");
write_output(&destination, "new").expect("replace output");
assert_eq!(fs::read_to_string(&destination).expect("new output"), "new");
fs::remove_dir_all(directory).expect("remove temporary directory");
}

#[cfg(unix)]
#[test]
fn temporary_path_keeps_non_utf8_destination_names_distinct() {
use std::{
ffi::OsString,
os::unix::ffi::{OsStrExt, OsStringExt},
};

let directory = PathBuf::from("qdocco-test-output");
let destination = directory.join(OsString::from_vec(b"document-\xff.html".to_vec()));
let temporary = temporary_path(&destination);
let file_name = temporary.file_name().expect("temporary file name");
assert!(file_name.as_bytes().contains(&0xff));
assert_ne!(temporary, destination);
}
}
5 changes: 0 additions & 5 deletions src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,6 @@ fn normalize_heredocs(source: &str) -> Result<String, Error> {
chars.next();
out.push('#');
block_comment = false;
// The lexer treats the entire closing-delimiter line as a
// comment, so heredoc markers after it must stay inert.
line_comment = true;
}
}
continue;
Expand Down Expand Up @@ -1026,8 +1023,6 @@ mod tests {
assert!(tokens.contains(&Token::Indent));
assert!(tokens.contains(&Token::Number(42.)));
assert!(lex("### one line ###\n42").is_ok());
let tokens = lex("### starts\nignored\n### \"\"\"\n42").unwrap();
assert!(tokens.contains(&Token::Number(42.)));
assert!(lex("### starts\n42").is_err());
}

Expand Down