initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
plans/
|
||||
@@ -0,0 +1,106 @@
|
||||
# Gitea Actions: run security checks on merge requests (and pushes to default branches).
|
||||
# Requires: Gitea Actions enabled, at least one runner with Docker (for container scanners).
|
||||
# Your PowerShell script targets Windows/Podman; CI here uses Linux + Docker — same tools, different glue.
|
||||
#
|
||||
# If `uses: actions/...` fails on your instance, configure the runner’s action mirror
|
||||
# (see https://docs.gitea.com/usage/actions/overview) or replace with full URLs from your mirror.
|
||||
|
||||
name: Security scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rust-and-policy:
|
||||
runs-on: ubuntu-latest
|
||||
# Self-hosted: e.g. runs-on: [self-hosted, linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: cargo fmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Install cargo-deny and cargo-audit
|
||||
run: |
|
||||
cargo install cargo-deny --locked --no-track
|
||||
cargo install cargo-audit --locked --no-track
|
||||
|
||||
- name: cargo deny
|
||||
run: cargo deny check
|
||||
|
||||
- name: cargo audit
|
||||
run: cargo audit
|
||||
|
||||
container-scanners:
|
||||
runs-on: ubuntu-latest
|
||||
needs: rust-and-policy
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Trivy (filesystem)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/src:ro" \
|
||||
aquasec/trivy:latest fs \
|
||||
--skip-dirs target \
|
||||
--severity HIGH,CRITICAL \
|
||||
--scanners vuln \
|
||||
--exit-code 0 \
|
||||
/src
|
||||
|
||||
- name: Gitleaks
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/src:ro" \
|
||||
ghcr.io/gitleaks/gitleaks:latest detect \
|
||||
--source /src \
|
||||
--verbose
|
||||
|
||||
- name: OSV-Scanner
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/src:ro" \
|
||||
ghcr.io/google/osv-scanner:latest scan -r /src
|
||||
|
||||
- name: Semgrep (Rust ruleset)
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "$PWD:/src:ro" \
|
||||
semgrep/semgrep:latest semgrep \
|
||||
--error \
|
||||
--config p/rust \
|
||||
/src
|
||||
|
||||
# Optional: Qryon is slow to install in CI (~minutes). Uncomment when runners are fast enough or you cache ~/.cargo.
|
||||
# qryon:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: rust-and-policy
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: dtolnay/rust-toolchain@stable
|
||||
# - uses: Swatinem/rust-cache@v2
|
||||
# - name: Install qryon (rma-cli)
|
||||
# run: cargo install rma-cli --locked --no-track
|
||||
# - name: Qryon scan
|
||||
# run: |
|
||||
# mkdir -p security/reports
|
||||
# qryon scan . --no-color -p default -f json -o security/reports/qryon-report.json
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
# Local Qryon index/cache (created by `qryon scan`)
|
||||
.qryon/
|
||||
/security/reports/
|
||||
/security/tools/
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="EMPTY_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/crates/byte_draft_desktop/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/ByteDraft.iml" filepath="$PROJECT_DIR$/.idea/ByteDraft.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+4908
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/byte_draft",
|
||||
"crates/byte_draft_desktop",
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
# cargo-audit (optional). Transitive egui/eframe stack; revisit when upgrading eframe.
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2024-0384",
|
||||
"RUSTSEC-2024-0436",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "byte_draft"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
quick-xml = "0.37"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
thiserror = "1"
|
||||
toml = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Find / replace helpers (shared by the GUI and unit tests).
|
||||
|
||||
use regex::RegexBuilder;
|
||||
|
||||
/// Expands backslash escapes in the replacement string before it is passed to the regex engine.
|
||||
/// This matches common editor behavior when "regex" is enabled (e.g. `\n` → newline).
|
||||
///
|
||||
/// - `\n` `\r` `\t` — line break, carriage return, tab
|
||||
/// - `\0` — NUL
|
||||
/// - `\\` — literal `\`
|
||||
/// - `\$` — becomes `$$` so the regex crate emits a literal `$` after substitution
|
||||
/// - Any other `\x` — backslash and `x` are kept as two characters
|
||||
pub fn expand_regex_replacement(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\\' {
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some('0') => out.push('\0'),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('$') => out.push_str("$$"),
|
||||
Some(other) => {
|
||||
out.push('\\');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('\\'),
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// All non-overlapping match spans (byte offsets) for the find widget.
|
||||
pub fn collect_find_matches(
|
||||
text: &str,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
) -> Result<Vec<(usize, usize)>, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if use_regex {
|
||||
let mut b = RegexBuilder::new(query);
|
||||
b.case_insensitive(!case_sensitive);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
Ok(re.find_iter(text).map(|m| (m.start(), m.end())).collect())
|
||||
} else if case_sensitive {
|
||||
Ok(text
|
||||
.match_indices(query)
|
||||
.map(|(i, m)| (i, i + m.len()))
|
||||
.collect())
|
||||
} else {
|
||||
let mut b = RegexBuilder::new(®ex::escape(query));
|
||||
b.case_insensitive(true);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
Ok(re.find_iter(text).map(|m| (m.start(), m.end())).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the leftmost match; returns whether anything changed.
|
||||
pub fn replace_first_match(
|
||||
text: &mut String,
|
||||
query: &str,
|
||||
with: &str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
) -> Result<bool, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
if use_regex {
|
||||
let mut b = RegexBuilder::new(query);
|
||||
b.case_insensitive(!case_sensitive);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
let expanded = expand_regex_replacement(with);
|
||||
let new = re.replacen(text.as_str(), 1, expanded.as_str());
|
||||
let changed = new.as_ref() != text.as_str();
|
||||
*text = new.into_owned();
|
||||
Ok(changed)
|
||||
} else if case_sensitive {
|
||||
if let Some(pos) = text.find(query) {
|
||||
let end = pos + query.len();
|
||||
text.replace_range(pos..end, with);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
} else {
|
||||
let mut b = RegexBuilder::new(®ex::escape(query));
|
||||
b.case_insensitive(true);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
let new = re.replacen(text.as_str(), 1, with);
|
||||
let changed = new.as_ref() != text.as_str();
|
||||
*text = new.into_owned();
|
||||
Ok(changed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace all matches; returns the number of matches that were replaced.
|
||||
pub fn replace_all_matches(
|
||||
text: &mut String,
|
||||
query: &str,
|
||||
with: &str,
|
||||
case_sensitive: bool,
|
||||
use_regex: bool,
|
||||
) -> Result<usize, String> {
|
||||
if query.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let n = collect_find_matches(text, query, case_sensitive, use_regex)?.len();
|
||||
if n == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
if use_regex {
|
||||
let mut b = RegexBuilder::new(query);
|
||||
b.case_insensitive(!case_sensitive);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
let expanded = expand_regex_replacement(with);
|
||||
*text = re.replace_all(text.as_str(), expanded.as_str()).into_owned();
|
||||
} else if case_sensitive {
|
||||
*text = text.replace(query, with);
|
||||
} else {
|
||||
let mut b = RegexBuilder::new(®ex::escape(query));
|
||||
b.case_insensitive(true);
|
||||
let re = b.build().map_err(|e| e.to_string())?;
|
||||
*text = re.replace_all(text.as_str(), with).into_owned();
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_newline_tab_cr() {
|
||||
assert_eq!(expand_regex_replacement(r"\n"), "\n");
|
||||
assert_eq!(expand_regex_replacement(r"\r"), "\r");
|
||||
assert_eq!(expand_regex_replacement(r"\t"), "\t");
|
||||
assert_eq!(expand_regex_replacement(r"a\nb"), "a\nb");
|
||||
assert_eq!(expand_regex_replacement(r"\n\n"), "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_backslash_and_dollar() {
|
||||
assert_eq!(expand_regex_replacement(r"\\"), "\\");
|
||||
assert_eq!(expand_regex_replacement(r"\\n"), "\\n");
|
||||
assert_eq!(expand_regex_replacement(r"\\\n"), "\\\n");
|
||||
assert_eq!(expand_regex_replacement(r"\$"), "$$");
|
||||
assert_eq!(expand_regex_replacement(r"\$1"), "$$1");
|
||||
assert_eq!(expand_regex_replacement(r"x\$y"), "x$$y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_unknown_escape_preserved() {
|
||||
assert_eq!(expand_regex_replacement(r"\z"), "\\z");
|
||||
assert_eq!(expand_regex_replacement("trailing\\"), "trailing\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_capture_refs_untouched() {
|
||||
assert_eq!(expand_regex_replacement("$1"), "$1");
|
||||
assert_eq!(expand_regex_replacement("$&"), "$&");
|
||||
assert_eq!(expand_regex_replacement(r"$1\n$2"), "$1\n$2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all_inserts_newline() {
|
||||
let mut s = "a,b,c".to_string();
|
||||
let n = replace_all_matches(&mut s, r",", r"\n", true, true).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(s, "a\nb\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_inserts_tab_and_cr() {
|
||||
let mut s = "x".to_string();
|
||||
replace_first_match(&mut s, "x", r"\t\r", true, true).unwrap();
|
||||
assert_eq!(s, "\t\r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all_no_matches_returns_zero() {
|
||||
let mut s = "abc".to_string();
|
||||
let n = replace_all_matches(&mut s, "z", "y", true, true).unwrap();
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(s, "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_first_only() {
|
||||
let mut s = "foo foo foo".to_string();
|
||||
let changed = replace_first_match(&mut s, "foo", "bar", true, true).unwrap();
|
||||
assert!(changed);
|
||||
assert_eq!(s, "bar foo foo");
|
||||
let changed2 = replace_first_match(&mut s, "foo", "baz", true, true).unwrap();
|
||||
assert!(changed2);
|
||||
assert_eq!(s, "bar baz foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all_multiple_words() {
|
||||
let mut s = "cat dog cat bird cat".to_string();
|
||||
let n = replace_all_matches(&mut s, r"\bcat\b", "X", true, true).unwrap();
|
||||
assert_eq!(n, 3);
|
||||
assert_eq!(s, "X dog X bird X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_with_capture_and_newline() {
|
||||
let mut s = "p:q;r:s".to_string();
|
||||
let n = replace_all_matches(&mut s, r"(\w):(\w)", r"$1=\n$2", true, true).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(s, "p=\nq;r=\ns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_literal_backslash_n_not_newline() {
|
||||
let mut s = "x".to_string();
|
||||
replace_first_match(&mut s, "x", r"\\n", true, true).unwrap();
|
||||
assert_eq!(s, "\\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_mode_does_not_expand_escapes() {
|
||||
let mut s = "a,a,a".to_string();
|
||||
let n = replace_all_matches(&mut s, ",", r"\n", true, false).unwrap();
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(s, r"a\na\na");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_literal_replace_all() {
|
||||
let mut s = "Ab ab AB".to_string();
|
||||
let n = replace_all_matches(&mut s, "ab", "z", false, false).unwrap();
|
||||
assert_eq!(n, 3);
|
||||
assert_eq!(s, "z z z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern_errors() {
|
||||
let mut s = "hi".to_string();
|
||||
assert!(replace_all_matches(&mut s, "(", "x", true, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_no_op() {
|
||||
let mut s = "abc".to_string();
|
||||
assert_eq!(
|
||||
replace_all_matches(&mut s, "", "x", true, true).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(s, "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all_dollar_literal_via_escape() {
|
||||
let mut s = "price: 5".to_string();
|
||||
let n = replace_all_matches(&mut s, r"\d", r"\$", true, true).unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(s, "price: $");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Parse, format, and diagnose structured text.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::document::language::LanguageId;
|
||||
|
||||
/// Editor diagnostic (error or warning).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
pub message: String,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// Message returned when formatting plain or unknown buffers (no structured formatter).
|
||||
pub const NO_FORMATTER_PLAIN_MSG: &str = "No formatter for plain/unknown text";
|
||||
|
||||
impl Diagnostic {
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
line: None,
|
||||
column: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_line_col(mut self, line: u32, column: u32) -> Self {
|
||||
self.line = Some(line);
|
||||
self.column = Some(column);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `text` as `language`. On failure returns diagnostics instead of panicking.
|
||||
pub fn format_document(language: LanguageId, text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
match language {
|
||||
LanguageId::Json => format_json(text),
|
||||
LanguageId::Yaml => format_yaml(text),
|
||||
LanguageId::Toml => format_toml(text),
|
||||
LanguageId::Xml => format_xml(text),
|
||||
LanguageId::Plain | LanguageId::Unknown => {
|
||||
Err(vec![Diagnostic::new(NO_FORMATTER_PLAIN_MSG)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_json(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| {
|
||||
vec![Diagnostic::new(e.to_string()).with_line_col(
|
||||
e.line() as u32,
|
||||
e.column() as u32,
|
||||
)]
|
||||
})?;
|
||||
serde_json::to_string_pretty(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
|
||||
}
|
||||
|
||||
fn format_yaml(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
let v: serde_yaml::Value =
|
||||
serde_yaml::from_str(text).map_err(|e| vec![Diagnostic::new(e.to_string())])?;
|
||||
serde_yaml::to_string(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
|
||||
}
|
||||
|
||||
fn format_toml(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
let v: toml::Value =
|
||||
toml::from_str(text).map_err(|e| vec![Diagnostic::new(format!("{e}"))])?;
|
||||
toml::to_string_pretty(&v).map_err(|e| vec![Diagnostic::new(e.to_string())])
|
||||
}
|
||||
|
||||
fn format_xml(text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
let mut reader = Reader::from_str(text);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut out = String::new();
|
||||
let mut last_was_text = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
if last_was_text {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&" ".repeat(depth.max(0) as usize));
|
||||
out.push('<');
|
||||
out.push_str(&String::from_utf8_lossy(e.name().as_ref()));
|
||||
for a in e.attributes().flatten() {
|
||||
out.push(' ');
|
||||
out.push_str(&String::from_utf8_lossy(a.key.as_ref()));
|
||||
out.push('=');
|
||||
out.push('"');
|
||||
out.push_str(&String::from_utf8_lossy(&a.value));
|
||||
out.push('"');
|
||||
}
|
||||
out.push('>');
|
||||
out.push('\n');
|
||||
depth += 1;
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::End(_)) => {
|
||||
depth = (depth - 1).max(0);
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::Empty(e)) => {
|
||||
if last_was_text {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&" ".repeat(depth.max(0) as usize));
|
||||
out.push('<');
|
||||
out.push_str(&String::from_utf8_lossy(e.name().as_ref()));
|
||||
for a in e.attributes().flatten() {
|
||||
out.push(' ');
|
||||
out.push_str(&String::from_utf8_lossy(a.key.as_ref()));
|
||||
out.push('=');
|
||||
out.push('"');
|
||||
out.push_str(&String::from_utf8_lossy(&a.value));
|
||||
out.push('"');
|
||||
}
|
||||
out.push_str("/>\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::Text(t)) => {
|
||||
let s = t
|
||||
.unescape()
|
||||
.map_err(|e| vec![Diagnostic::new(format!("XML unescape: {e}"))])?;
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
out.push_str(&" ".repeat(depth.max(0) as usize));
|
||||
out.push_str(trimmed);
|
||||
out.push('\n');
|
||||
last_was_text = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Comment(c)) => {
|
||||
out.push_str(&" ".repeat(depth.max(0) as usize));
|
||||
out.push_str("<!--");
|
||||
out.push_str(&String::from_utf8_lossy(c.as_ref()));
|
||||
out.push_str("-->\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::Decl(_)) => {
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::PI(p)) => {
|
||||
out.push_str("<?");
|
||||
out.push_str(&String::from_utf8_lossy(p.as_ref()));
|
||||
out.push_str("?>\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::DocType(d)) => {
|
||||
out.push_str("<!DOCTYPE");
|
||||
out.push_str(&String::from_utf8_lossy(d.as_ref()));
|
||||
out.push_str(">\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::CData(c)) => {
|
||||
out.push_str(&" ".repeat(depth.max(0) as usize));
|
||||
out.push_str("<![CDATA[");
|
||||
out.push_str(&String::from_utf8_lossy(c.as_ref()));
|
||||
out.push_str("]]>\n");
|
||||
last_was_text = false;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(vec![Diagnostic::new(e.to_string())]),
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
Ok(out.trim_end().to_string() + "\n")
|
||||
}
|
||||
|
||||
/// Collect diagnostics without formatting (parse-only).
|
||||
///
|
||||
/// Plain / unknown buffers have no structured rules to check — returns an empty list (no
|
||||
/// "no formatter" noise in the UI).
|
||||
#[must_use]
|
||||
pub fn lint_document(language: LanguageId, text: &str) -> Vec<Diagnostic> {
|
||||
match language {
|
||||
LanguageId::Plain | LanguageId::Unknown => Vec::new(),
|
||||
_ => format_document(language, text).err().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Human-facing language / format identifiers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Languages supported for detection, formatting, and UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LanguageId {
|
||||
#[default]
|
||||
Plain,
|
||||
Json,
|
||||
Yaml,
|
||||
Toml,
|
||||
Xml,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl LanguageId {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plain => "Plain Text",
|
||||
Self::Json => "JSON",
|
||||
Self::Yaml => "YAML",
|
||||
Self::Toml => "TOML",
|
||||
Self::Xml => "XML",
|
||||
Self::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Document language, find/replace, and formatting utilities.
|
||||
|
||||
pub mod find_replace;
|
||||
pub mod format;
|
||||
pub mod language;
|
||||
|
||||
pub use find_replace::{
|
||||
collect_find_matches, expand_regex_replacement, replace_all_matches, replace_first_match,
|
||||
};
|
||||
pub use format::{format_document, lint_document, Diagnostic, NO_FORMATTER_PLAIN_MSG};
|
||||
pub use language::LanguageId;
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Editor workspace model: sessions, tabs, project root.
|
||||
|
||||
pub mod session;
|
||||
pub mod tab;
|
||||
pub mod view_kind;
|
||||
pub mod workspace;
|
||||
|
||||
pub use session::{load_session, save_session, SessionData, MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION};
|
||||
pub use tab::{two_tabs_mut, Tab};
|
||||
pub use view_kind::ViewKind;
|
||||
pub use workspace::Workspace;
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Session persistence (atomic save, size limits).
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::system::error::{ByteDraftError, Result};
|
||||
use crate::editor::tab::Tab;
|
||||
|
||||
pub const SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
/// Maximum session file size before parse (deny large allocations).
|
||||
pub const MAX_SESSION_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Serializable session snapshot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionData {
|
||||
pub schema: u32,
|
||||
pub active_tab: usize,
|
||||
/// Optional second tab index for split view (`None` = no split).
|
||||
pub split_secondary: Option<usize>,
|
||||
pub tabs: Vec<Tab>,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
schema: SESSION_SCHEMA_VERSION,
|
||||
active_tab: 0,
|
||||
split_secondary: None,
|
||||
tabs: vec![Tab::new_untitled()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix indices and ensure at least one tab.
|
||||
pub fn normalize(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
self.tabs.push(Tab::new_untitled());
|
||||
self.active_tab = 0;
|
||||
self.split_secondary = None;
|
||||
return;
|
||||
}
|
||||
if self.active_tab >= self.tabs.len() {
|
||||
self.active_tab = self.tabs.len().saturating_sub(1);
|
||||
}
|
||||
if let Some(s) = self.split_secondary {
|
||||
if s >= self.tabs.len() || s == self.active_tab {
|
||||
self.split_secondary = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write session atomically: temp file in same directory, then rename.
|
||||
pub fn save_session(path: &Path, data: &SessionData) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)?;
|
||||
f.write_all(json.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load session with size cap and schema handling.
|
||||
pub fn load_session(path: &Path) -> Result<SessionData> {
|
||||
let meta = fs::metadata(path)?;
|
||||
let len = meta.len();
|
||||
if len > MAX_SESSION_FILE_BYTES {
|
||||
return Err(ByteDraftError::SessionTooLarge {
|
||||
size: len,
|
||||
max: MAX_SESSION_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
let f = fs::File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(MAX_SESSION_FILE_BYTES + 1).read_to_end(&mut buf)?;
|
||||
if buf.len() as u64 > MAX_SESSION_FILE_BYTES {
|
||||
return Err(ByteDraftError::SessionTooLarge {
|
||||
size: buf.len() as u64,
|
||||
max: MAX_SESSION_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
let mut data: SessionData = serde_json::from_slice(&buf)?;
|
||||
if data.schema != SESSION_SCHEMA_VERSION {
|
||||
return Err(ByteDraftError::UnsupportedSessionSchema(data.schema));
|
||||
}
|
||||
data.normalize();
|
||||
Ok(data)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Open document tab model.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::document::format::Diagnostic;
|
||||
use crate::document::language::LanguageId;
|
||||
use crate::editor::view_kind::ViewKind;
|
||||
|
||||
/// One open editor tab.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tab {
|
||||
pub text: String,
|
||||
pub path: Option<PathBuf>,
|
||||
pub view_kind: ViewKind,
|
||||
#[serde(default)]
|
||||
pub language_override: Option<LanguageId>,
|
||||
/// Last auto-detected language (recomputed when text changes).
|
||||
#[serde(default)]
|
||||
pub detected_language: LanguageId,
|
||||
#[serde(default)]
|
||||
pub dirty: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Two distinct mutable tabs in one slice (for split editor).
|
||||
///
|
||||
/// # Panics
|
||||
/// Never; returns `None` if indices invalid or equal.
|
||||
#[must_use]
|
||||
pub fn two_tabs_mut(tabs: &mut [Tab], i: usize, j: usize) -> Option<(&mut Tab, &mut Tab)> {
|
||||
if i == j || i >= tabs.len() || j >= tabs.len() {
|
||||
return None;
|
||||
}
|
||||
if i < j {
|
||||
let (left, right) = tabs.split_at_mut(j);
|
||||
Some((&mut left[i], &mut right[0]))
|
||||
} else {
|
||||
let (left, right) = tabs.split_at_mut(i);
|
||||
Some((&mut right[0], &mut left[j]))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tab {
|
||||
#[must_use]
|
||||
pub fn new_untitled() -> Self {
|
||||
Self {
|
||||
text: String::new(),
|
||||
path: None,
|
||||
view_kind: ViewKind::default(),
|
||||
language_override: None,
|
||||
detected_language: LanguageId::Plain,
|
||||
dirty: false,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_path_and_text(path: PathBuf, text: String) -> Self {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_string);
|
||||
let detected = crate::system::detect::detect_language(&text, ext.as_deref());
|
||||
Self {
|
||||
text,
|
||||
path: Some(path),
|
||||
view_kind: ViewKind::default(),
|
||||
language_override: None,
|
||||
detected_language: detected,
|
||||
dirty: false,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn effective_language(&self) -> LanguageId {
|
||||
self.language_override.unwrap_or(self.detected_language)
|
||||
}
|
||||
|
||||
/// File extension hint for the path (e.g. `"toml"`), if any.
|
||||
#[must_use]
|
||||
pub fn extension_hint(&self) -> Option<&str> {
|
||||
self.path
|
||||
.as_ref()
|
||||
.and_then(|p| p.extension())
|
||||
.and_then(|e| e.to_str())
|
||||
}
|
||||
|
||||
/// Tab label without sibling context (file name, or `"Untitled"` when unsaved).
|
||||
#[must_use]
|
||||
pub fn title(&self) -> String {
|
||||
if let Some(p) = &self.path {
|
||||
return p
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "untitled".to_string());
|
||||
}
|
||||
"Untitled".to_string()
|
||||
}
|
||||
|
||||
/// Tab title in the tab strip: disambiguates multiple unsaved buffers as `Untitled`, `Untitled 2`, …
|
||||
#[must_use]
|
||||
pub fn tab_label(tabs: &[Tab], index: usize) -> String {
|
||||
let Some(tab) = tabs.get(index) else {
|
||||
return "Untitled".to_string();
|
||||
};
|
||||
if tab.path.is_some() {
|
||||
return tab.title();
|
||||
}
|
||||
let pathless: Vec<usize> = tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| t.path.is_none())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if pathless.len() <= 1 {
|
||||
return "Untitled".to_string();
|
||||
}
|
||||
let rank = pathless.iter().position(|&i| i == index).unwrap_or(0);
|
||||
if rank == 0 {
|
||||
"Untitled".to_string()
|
||||
} else {
|
||||
format!("Untitled {}", rank + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Tab view mode (text now, hex later).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ViewKind {
|
||||
#[default]
|
||||
Text,
|
||||
/// Reserved for binary / hex viewer (not implemented in MVP).
|
||||
Hex,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Optional project folder root (sidebar / future tree).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Workspace: opened folder, if any.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Workspace {
|
||||
pub root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
#[must_use]
|
||||
pub fn display_name(&self) -> String {
|
||||
self.root
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "No folder".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Logical editor actions (GUI maps input here).
|
||||
|
||||
/// High-level commands decoupled from any GUI framework.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
NewTab,
|
||||
CloseTab,
|
||||
NextTab,
|
||||
PrevTab,
|
||||
OpenFile,
|
||||
Save,
|
||||
SaveAs,
|
||||
FormatDocument,
|
||||
ToggleSidebar,
|
||||
ToggleLanguagePicker,
|
||||
OpenFolder,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Logical input mapping (framework-agnostic).
|
||||
|
||||
pub mod keymap;
|
||||
|
||||
pub use keymap::Action;
|
||||
@@ -0,0 +1,26 @@
|
||||
//! ByteDraft — tabbed scratch editor core (no GUI).
|
||||
#![allow(clippy::module_name_repetitions)]
|
||||
|
||||
pub mod document;
|
||||
pub mod editor;
|
||||
pub mod input;
|
||||
pub mod platform;
|
||||
pub mod plugins;
|
||||
pub mod system;
|
||||
|
||||
pub use document::{
|
||||
collect_find_matches, expand_regex_replacement, format_document, lint_document,
|
||||
replace_all_matches, replace_first_match, Diagnostic, LanguageId, NO_FORMATTER_PLAIN_MSG,
|
||||
};
|
||||
pub use editor::{
|
||||
load_session, save_session, two_tabs_mut, SessionData, Tab, ViewKind, Workspace,
|
||||
MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION,
|
||||
};
|
||||
pub use input::Action;
|
||||
pub use platform::{
|
||||
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
|
||||
};
|
||||
pub use system::{
|
||||
detect_from_content, detect_language, read_text_file_capped, write_text_file_atomic,
|
||||
ByteDraftError, Formatter, HandlerRegistry, LanguageDetector, Result, MAX_TEXT_FILE_BYTES,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Platform-specific constants and detection (no GUI framework).
|
||||
|
||||
pub mod title_bar;
|
||||
|
||||
pub use title_bar::{
|
||||
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Frameless window title bar: host detection and layout constants (no GUI framework).
|
||||
|
||||
/// Target look-and-feel for embedded window controls (UI convention only).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum TitleBarHost {
|
||||
Windows,
|
||||
Macos,
|
||||
Linux,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn detect_title_bar_host() -> TitleBarHost {
|
||||
if cfg!(target_os = "macos") {
|
||||
TitleBarHost::Macos
|
||||
} else if cfg!(target_os = "windows") {
|
||||
TitleBarHost::Windows
|
||||
} else {
|
||||
TitleBarHost::Linux
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame title bar configuration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TitleBarPlan {
|
||||
pub host: TitleBarHost,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl Default for TitleBarPlan {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: detect_title_bar_host(),
|
||||
title: "ByteDraft".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const TITLE_BAR_H: f32 = 32.0;
|
||||
/// Second row under the title strip (Windows / Linux): menu bar height.
|
||||
pub const WINDOWS_MENU_BAR_ROW_H: f32 = 26.0;
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Built-in formatters and detectors registered at startup.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::system::detect;
|
||||
use crate::document::format::{format_document, Diagnostic};
|
||||
use crate::document::language::LanguageId;
|
||||
use crate::system::registry::HandlerRegistry;
|
||||
use crate::system::traits::{Formatter, LanguageDetector};
|
||||
|
||||
fn first_diag(err: Vec<Diagnostic>) -> Diagnostic {
|
||||
err.into_iter().next().unwrap_or_else(|| Diagnostic::new("format failed"))
|
||||
}
|
||||
|
||||
macro_rules! delegating_formatter {
|
||||
($name:ident, $lang:expr) => {
|
||||
pub struct $name;
|
||||
|
||||
impl Formatter for $name {
|
||||
fn language(&self) -> LanguageId {
|
||||
$lang
|
||||
}
|
||||
fn format(&self, text: &str) -> Result<String, Diagnostic> {
|
||||
format_document($lang, text).map_err(first_diag)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
delegating_formatter!(JsonFormatter, LanguageId::Json);
|
||||
delegating_formatter!(YamlFormatter, LanguageId::Yaml);
|
||||
delegating_formatter!(TomlFormatter, LanguageId::Toml);
|
||||
delegating_formatter!(XmlFormatter, LanguageId::Xml);
|
||||
|
||||
/// Uses [`detect::detect_language`] heuristics.
|
||||
pub struct DefaultDetector;
|
||||
|
||||
impl LanguageDetector for DefaultDetector {
|
||||
fn detect(&self, content: &str, extension_hint: Option<&str>) -> Option<LanguageId> {
|
||||
Some(detect::detect_language(content, extension_hint))
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all built-in detectors and formatters.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns if duplicate formatter IDs would be registered.
|
||||
pub fn register_builtins(registry: &mut HandlerRegistry) -> Result<(), String> {
|
||||
registry.register_detector(Arc::new(DefaultDetector));
|
||||
registry.register_formatter(Arc::new(JsonFormatter))?;
|
||||
registry.register_formatter(Arc::new(YamlFormatter))?;
|
||||
registry.register_formatter(Arc::new(TomlFormatter))?;
|
||||
registry.register_formatter(Arc::new(XmlFormatter))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Plugin / add-in hooks (built-in registration only for MVP).
|
||||
|
||||
pub mod builtin;
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Language detection heuristics.
|
||||
|
||||
use crate::document::language::LanguageId;
|
||||
|
||||
/// Detect language from optional file extension (e.g. `"json"`) and buffer content.
|
||||
///
|
||||
/// For ambiguous or invalid content, prefers more popular formats:
|
||||
/// - JSON-like content (starts with `{` or `[`) is detected as JSON even if invalid/incomplete
|
||||
/// - JSONC (JSON with comments) is treated as JSON
|
||||
#[must_use]
|
||||
pub fn detect_language(content: &str, extension_hint: Option<&str>) -> LanguageId {
|
||||
if let Some(ext) = extension_hint {
|
||||
let e = ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
match e.as_str() {
|
||||
"json" | "jsonc" => return LanguageId::Json,
|
||||
"yml" | "yaml" => return LanguageId::Yaml,
|
||||
"toml" => return LanguageId::Toml,
|
||||
"xml" | "xhtml" | "svg" | "xsl" | "xslt" => return LanguageId::Xml,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
detect_from_content(content)
|
||||
}
|
||||
|
||||
/// Detect language purely from content, ignoring file extension.
|
||||
#[must_use]
|
||||
pub fn detect_from_content(content: &str) -> LanguageId {
|
||||
let trimmed = content.trim_start();
|
||||
let first = trimmed.lines().next().unwrap_or("").trim();
|
||||
|
||||
// Shebang → plain script
|
||||
if first.starts_with("#!/") {
|
||||
return LanguageId::Plain;
|
||||
}
|
||||
|
||||
// XML declaration or DOCTYPE
|
||||
if first.starts_with("<?xml")
|
||||
|| trimmed.starts_with("<?xml")
|
||||
|| first.starts_with("<!DOCTYPE")
|
||||
|| first.starts_with("<!")
|
||||
{
|
||||
return LanguageId::Xml;
|
||||
}
|
||||
|
||||
// YAML front matter or directive
|
||||
if first == "---" || first.starts_with("%YAML") {
|
||||
return LanguageId::Yaml;
|
||||
}
|
||||
|
||||
// TOML table header with key=value somewhere
|
||||
if first.starts_with('[') && first.contains(']') && trimmed.contains('=') {
|
||||
return LanguageId::Toml;
|
||||
}
|
||||
|
||||
// JSON-like: starts with { or [ — detect as JSON even if invalid/incomplete
|
||||
// This handles broken JSON, JSONC (comments), trailing commas, etc.
|
||||
let t = trimmed.trim_start();
|
||||
if t.starts_with('{') || t.starts_with('[') {
|
||||
return LanguageId::Json;
|
||||
}
|
||||
|
||||
// XML-like: starts with < and contains closing tag or self-closing
|
||||
if t.starts_with('<') && (t.contains("</") || t.contains("/>") || t.contains('>')) {
|
||||
return LanguageId::Xml;
|
||||
}
|
||||
|
||||
// YAML-like: contains colon-space patterns typical of YAML mappings
|
||||
// but only if it doesn't look like JSON
|
||||
if looks_like_yaml(trimmed) {
|
||||
return LanguageId::Yaml;
|
||||
}
|
||||
|
||||
// TOML-like: key = value patterns without looking like other formats
|
||||
if looks_like_toml(trimmed) {
|
||||
return LanguageId::Toml;
|
||||
}
|
||||
|
||||
LanguageId::Plain
|
||||
}
|
||||
|
||||
/// Heuristic: content looks like YAML (key: value patterns, not JSON)
|
||||
fn looks_like_yaml(content: &str) -> bool {
|
||||
let lines: Vec<&str> = content.lines().take(10).collect();
|
||||
if lines.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut yaml_score = 0;
|
||||
for line in &lines {
|
||||
let trimmed = line.trim();
|
||||
// Skip empty lines and comments
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// YAML list item
|
||||
if trimmed.starts_with("- ") {
|
||||
yaml_score += 1;
|
||||
}
|
||||
// key: value (but not inside braces which would be JSON)
|
||||
if let Some(colon_pos) = trimmed.find(':') {
|
||||
let before = &trimmed[..colon_pos];
|
||||
let after = &trimmed[colon_pos + 1..];
|
||||
// Key should be simple (no quotes typically in YAML keys at start of line)
|
||||
if !before.contains('"')
|
||||
&& !before.contains('{')
|
||||
&& !before.contains('[')
|
||||
&& (after.starts_with(' ') || after.is_empty())
|
||||
{
|
||||
yaml_score += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
yaml_score >= 2
|
||||
}
|
||||
|
||||
/// Heuristic: content looks like TOML (key = value patterns)
|
||||
fn looks_like_toml(content: &str) -> bool {
|
||||
let lines: Vec<&str> = content.lines().take(10).collect();
|
||||
if lines.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut toml_score = 0;
|
||||
for line in &lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// [section] header
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
toml_score += 2;
|
||||
}
|
||||
// key = value (with spaces around =)
|
||||
if trimmed.contains(" = ") {
|
||||
toml_score += 1;
|
||||
}
|
||||
}
|
||||
toml_score >= 2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// === JSON detection tests ===
|
||||
|
||||
#[test]
|
||||
fn valid_json_object() {
|
||||
let content = r#"{"key": "value"}"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_json_array() {
|
||||
let content = r#"[1, 2, 3]"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_json_object() {
|
||||
let content = r#"{"key": 1,"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_json_array() {
|
||||
let content = r#"[1, 2,"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_with_trailing_comma() {
|
||||
let content = r#"{"key": 1,}"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_with_comments_jsonc() {
|
||||
let content = r#"{
|
||||
// this is a comment
|
||||
"key": "value"
|
||||
}"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_with_block_comments() {
|
||||
let content = r#"{
|
||||
/* block comment */
|
||||
"key": "value"
|
||||
}"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_broken_json() {
|
||||
let content = r#"{"a": {"b": {"c": {"d":"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_missing_closing_brace() {
|
||||
let content = r#"{"name": "test", "value": 42"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_with_whitespace_prefix() {
|
||||
let content = " \n\n {\"key\": 1}";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_json_object() {
|
||||
let content = "{}";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_json_array() {
|
||||
let content = "[]";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
|
||||
// === XML detection tests ===
|
||||
|
||||
#[test]
|
||||
fn xml_declaration() {
|
||||
let content = r#"<?xml version="1.0"?><root/>"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_doctype() {
|
||||
let content = r#"<!DOCTYPE html><html></html>"#;
|
||||
assert_eq!(detect_from_content(content), LanguageId::Xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_simple_element() {
|
||||
let content = "<root><child>text</child></root>";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_self_closing() {
|
||||
let content = "<element attr=\"value\"/>";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_xml_unclosed() {
|
||||
let content = "<root><child>";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Xml);
|
||||
}
|
||||
|
||||
// === YAML detection tests ===
|
||||
|
||||
#[test]
|
||||
fn yaml_front_matter() {
|
||||
let content = "---\nkey: value\n---";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_directive() {
|
||||
let content = "%YAML 1.2\n---\nkey: value";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_simple_mapping() {
|
||||
let content = "name: test\nversion: 1.0\nauthor: someone";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_with_list() {
|
||||
let content = "items:\n - one\n - two\n - three";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
// === TOML detection tests ===
|
||||
|
||||
#[test]
|
||||
fn toml_section_header() {
|
||||
let content = "[package]\nname = \"test\"";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Toml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_key_value() {
|
||||
let content = "name = \"test\"\nversion = \"1.0\"";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Toml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_nested_section() {
|
||||
let content = "[dependencies]\nserde = \"1.0\"\n\n[dev-dependencies]\ntempfile = \"3\"";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Toml);
|
||||
}
|
||||
|
||||
// === Extension hint tests ===
|
||||
|
||||
#[test]
|
||||
fn extension_json_overrides_content() {
|
||||
let content = "not json at all";
|
||||
assert_eq!(detect_language(content, Some("json")), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_jsonc_detected_as_json() {
|
||||
let content = "";
|
||||
assert_eq!(detect_language(content, Some("jsonc")), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_yaml() {
|
||||
let content = "";
|
||||
assert_eq!(detect_language(content, Some("yaml")), LanguageId::Yaml);
|
||||
assert_eq!(detect_language(content, Some("yml")), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_xml_variants() {
|
||||
let content = "";
|
||||
assert_eq!(detect_language(content, Some("xml")), LanguageId::Xml);
|
||||
assert_eq!(detect_language(content, Some("svg")), LanguageId::Xml);
|
||||
assert_eq!(detect_language(content, Some("xhtml")), LanguageId::Xml);
|
||||
}
|
||||
|
||||
// === Edge cases ===
|
||||
|
||||
#[test]
|
||||
fn empty_content() {
|
||||
assert_eq!(detect_from_content(""), LanguageId::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only() {
|
||||
assert_eq!(detect_from_content(" \n\n "), LanguageId::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shebang_is_plain() {
|
||||
let content = "#!/bin/bash\necho hello";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text() {
|
||||
let content = "This is just some plain text.\nNothing special here.";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_prefers_json_over_yaml() {
|
||||
// Content that starts with [ could be JSON array or YAML flow sequence
|
||||
// We prefer JSON as it's more common
|
||||
let content = "[item1, item2]";
|
||||
assert_eq!(detect_from_content(content), LanguageId::Json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Error types for library operations.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ByteDraftError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ByteDraftError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
SerdeJson(#[from] serde_json::Error),
|
||||
|
||||
#[error("YAML error: {0}")]
|
||||
SerdeYaml(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("TOML error: {0}")]
|
||||
TomlSer(#[from] toml::ser::Error),
|
||||
|
||||
#[error("TOML parse error: {0}")]
|
||||
TomlDe(#[from] toml::de::Error),
|
||||
|
||||
#[error("XML error: {0}")]
|
||||
Xml(String),
|
||||
|
||||
#[error("Session file too large: {size} bytes (max {max})")]
|
||||
SessionTooLarge { size: u64, max: u64 },
|
||||
|
||||
#[error("Invalid path: {0:?}")]
|
||||
InvalidPath(PathBuf),
|
||||
|
||||
#[error("Unsupported session schema version: {0}")]
|
||||
UnsupportedSessionSchema(u32),
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Size-limited file reads for security and stability.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::system::error::{ByteDraftError, Result};
|
||||
|
||||
/// Maximum text file size for open / drag-drop (32 MiB).
|
||||
pub const MAX_TEXT_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Read a UTF-8 text file with a size cap. Path is not executed as shell.
|
||||
pub fn read_text_file_capped(path: &Path) -> Result<String> {
|
||||
let meta = fs::metadata(path)?;
|
||||
let len = meta.len();
|
||||
if len > MAX_TEXT_FILE_BYTES {
|
||||
return Err(ByteDraftError::SessionTooLarge {
|
||||
size: len,
|
||||
max: MAX_TEXT_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
let f = fs::File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(MAX_TEXT_FILE_BYTES + 1).read_to_end(&mut buf)?;
|
||||
if buf.len() as u64 > MAX_TEXT_FILE_BYTES {
|
||||
return Err(ByteDraftError::SessionTooLarge {
|
||||
size: buf.len() as u64,
|
||||
max: MAX_TEXT_FILE_BYTES,
|
||||
});
|
||||
}
|
||||
String::from_utf8(buf).map_err(|e| ByteDraftError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("file is not valid UTF-8: {e}"),
|
||||
)))
|
||||
}
|
||||
|
||||
/// Write text atomically (best-effort) via temp + rename in same directory.
|
||||
pub fn write_text_file_atomic(path: &Path, content: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, content.as_bytes())?;
|
||||
fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! System services: error handling, file IO, detection, registry, and traits.
|
||||
|
||||
pub mod detect;
|
||||
pub mod error;
|
||||
pub mod fs_util;
|
||||
pub mod registry;
|
||||
pub mod traits;
|
||||
|
||||
pub use detect::{detect_from_content, detect_language};
|
||||
pub use error::{ByteDraftError, Result};
|
||||
pub use fs_util::{read_text_file_capped, write_text_file_atomic, MAX_TEXT_FILE_BYTES};
|
||||
pub use registry::HandlerRegistry;
|
||||
pub use traits::{Formatter, LanguageDetector};
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Pluggable formatters and detectors (built-in only in MVP).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::document::format::{format_document, Diagnostic};
|
||||
use crate::document::language::LanguageId;
|
||||
use crate::system::traits::{Formatter, LanguageDetector};
|
||||
|
||||
/// Central registry for formatters and language detectors.
|
||||
#[derive(Default)]
|
||||
pub struct HandlerRegistry {
|
||||
detectors: Vec<Arc<dyn LanguageDetector>>,
|
||||
formatters: Vec<Arc<dyn Formatter>>,
|
||||
}
|
||||
|
||||
impl HandlerRegistry {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Returns error if a formatter for this language is already registered.
|
||||
pub fn register_formatter(&mut self, f: Arc<dyn Formatter>) -> Result<(), String> {
|
||||
let lang = f.language();
|
||||
if self
|
||||
.formatters
|
||||
.iter()
|
||||
.any(|x| x.language() == lang)
|
||||
{
|
||||
return Err(format!("duplicate formatter for {:?}", lang));
|
||||
}
|
||||
self.formatters.push(f);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_detector(&mut self, d: Arc<dyn LanguageDetector>) {
|
||||
self.detectors.push(d);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn detect(&self, content: &str, extension_hint: Option<&str>) -> LanguageId {
|
||||
for d in &self.detectors {
|
||||
if let Some(lang) = d.detect(content, extension_hint) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
LanguageId::Plain
|
||||
}
|
||||
|
||||
/// Format using the registered formatter for `language`, else built-in [`format_document`].
|
||||
pub fn format(&self, language: LanguageId, text: &str) -> Result<String, Vec<Diagnostic>> {
|
||||
for f in &self.formatters {
|
||||
if f.language() == language {
|
||||
return f.format(text).map_err(|d| vec![d]);
|
||||
}
|
||||
}
|
||||
format_document(language, text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Extension traits for formatters and language detectors.
|
||||
|
||||
use crate::document::format::Diagnostic;
|
||||
use crate::document::language::LanguageId;
|
||||
|
||||
/// Formats buffer text for a language (pure, no IO).
|
||||
pub trait Formatter: Send + Sync {
|
||||
fn language(&self) -> LanguageId;
|
||||
/// Returns formatted text or a single diagnostic on failure.
|
||||
fn format(&self, text: &str) -> Result<String, Diagnostic>;
|
||||
}
|
||||
|
||||
/// Contributes language detection (first non-`None` wins in registration order).
|
||||
pub trait LanguageDetector: Send + Sync {
|
||||
fn detect(&self, content: &str, extension_hint: Option<&str>) -> Option<LanguageId>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Unit tests for plugins builtin API.
|
||||
|
||||
use byte_draft::system::detect;
|
||||
use byte_draft::plugins::builtin::{
|
||||
register_builtins, DefaultDetector, JsonFormatter, TomlFormatter, XmlFormatter, YamlFormatter,
|
||||
};
|
||||
use byte_draft::system::registry::HandlerRegistry;
|
||||
use byte_draft::system::traits::{Formatter, LanguageDetector};
|
||||
|
||||
#[test]
|
||||
fn register_builtins_succeeds() {
|
||||
let mut r = HandlerRegistry::new();
|
||||
register_builtins(&mut r).expect("once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_detector_matches_detect_crate() {
|
||||
let d = DefaultDetector;
|
||||
assert_eq!(
|
||||
d.detect(r#"{"a":1}"#, None),
|
||||
Some(detect::detect_language(r#"{"a":1}"#, None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_formatter_surfaces_parse_error() {
|
||||
let f = JsonFormatter;
|
||||
let err = f.format("{").unwrap_err();
|
||||
assert!(!err.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_formatter_surfaces_parse_error() {
|
||||
let err = YamlFormatter.format("foo: [").unwrap_err();
|
||||
assert!(!err.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_formatter_surfaces_parse_error() {
|
||||
let err = TomlFormatter.format("a = ").unwrap_err();
|
||||
assert!(!err.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_formatter_surfaces_parse_error() {
|
||||
let err = XmlFormatter.format("<bad").unwrap_err();
|
||||
assert!(!err.message.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Unit tests for system detect API.
|
||||
|
||||
use byte_draft::system::detect::detect_language;
|
||||
use byte_draft::document::language::LanguageId;
|
||||
|
||||
#[test]
|
||||
fn extension_json() {
|
||||
assert_eq!(
|
||||
detect_language("not json", Some("json")),
|
||||
LanguageId::Json
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_json_object() {
|
||||
assert_eq!(
|
||||
detect_language(r#"{"a": 1}"#, None),
|
||||
LanguageId::Json
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_yaml_header() {
|
||||
assert_eq!(
|
||||
detect_language("---\nfoo: bar\n", None),
|
||||
LanguageId::Yaml
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_xml() {
|
||||
assert_eq!(
|
||||
detect_language(r#"<?xml version="1.0"?><r/>"#, None),
|
||||
LanguageId::Xml
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensions_map_before_content() {
|
||||
assert_eq!(
|
||||
detect_language("not valid json", Some("json")),
|
||||
LanguageId::Json
|
||||
);
|
||||
assert_eq!(detect_language("x", Some(".YAML")), LanguageId::Yaml);
|
||||
assert_eq!(detect_language("x", Some("toml")), LanguageId::Toml);
|
||||
assert_eq!(detect_language("x", Some("jsonc")), LanguageId::Json);
|
||||
assert_eq!(detect_language("x", Some("yml")), LanguageId::Yaml);
|
||||
assert_eq!(detect_language("x", Some("xhtml")), LanguageId::Xml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shebang_is_plain() {
|
||||
assert_eq!(
|
||||
detect_language("#!/bin/bash\necho hi", None),
|
||||
LanguageId::Plain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_percent_directive() {
|
||||
assert_eq!(
|
||||
detect_language("%YAML 1.1\n---\n", None),
|
||||
LanguageId::Yaml
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_table_header() {
|
||||
assert_eq!(
|
||||
detect_language("[section]\nkey = 1\n", None),
|
||||
LanguageId::Toml
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_array_content() {
|
||||
assert_eq!(detect_language("[1, 2, 3]", None), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_object_detected_as_json() {
|
||||
assert_eq!(detect_language("{ not json", None), LanguageId::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_extension_falls_through_to_content() {
|
||||
assert_eq!(
|
||||
detect_language(r#"{"a":1}"#, Some("txt")),
|
||||
LanguageId::Json
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Unit tests for system error API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use byte_draft::system::error::ByteDraftError;
|
||||
|
||||
#[test]
|
||||
fn session_too_large_display() {
|
||||
let e = ByteDraftError::SessionTooLarge {
|
||||
size: 10,
|
||||
max: 5,
|
||||
};
|
||||
let s = e.to_string();
|
||||
assert!(s.contains("10"));
|
||||
assert!(s.contains("5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schema_display() {
|
||||
let e = ByteDraftError::UnsupportedSessionSchema(42);
|
||||
assert!(e.to_string().contains("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_path_display() {
|
||||
let e = ByteDraftError::InvalidPath(PathBuf::from("weird"));
|
||||
assert!(!e.to_string().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Unit tests for document format API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use byte_draft::document::format::{format_document, lint_document, Diagnostic};
|
||||
use byte_draft::document::language::LanguageId;
|
||||
|
||||
#[test]
|
||||
fn json_format_roundtrip() {
|
||||
let ugly = r#"{"a":1,"b":[2,3]}"#;
|
||||
let pretty = format_document(LanguageId::Json, ugly).expect("format");
|
||||
assert!(pretty.contains('\n'));
|
||||
let v2: BTreeMap<String, serde_json::Value> =
|
||||
serde_json::from_str(&pretty).expect("reparse");
|
||||
assert!(v2.contains_key("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_invalid_line_col() {
|
||||
let bad = "{\n \"x\": ,\n}";
|
||||
let err = format_document(LanguageId::Json, bad).expect_err("should fail");
|
||||
assert!(!err.is_empty());
|
||||
assert!(err[0].line.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_format() {
|
||||
let src = "a=1\nb=\"x\"\n";
|
||||
let out = format_document(LanguageId::Toml, src).expect("format");
|
||||
assert!(out.contains('a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_decl_preserved() {
|
||||
let src = r#"<?xml version="1.0"?><root><a/></root>"#;
|
||||
let out = format_document(LanguageId::Xml, src).expect("format");
|
||||
assert!(out.contains("<?xml"));
|
||||
assert!(out.contains("<root>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_and_unknown_reject_format() {
|
||||
for lang in [LanguageId::Plain, LanguageId::Unknown] {
|
||||
let err = format_document(lang, "anything").unwrap_err();
|
||||
assert_eq!(err.len(), 1);
|
||||
assert!(err[0].message.contains("formatter"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_format_roundtrip() {
|
||||
let src = "foo: bar\nlist:\n - 1\n";
|
||||
let out = format_document(LanguageId::Yaml, src).expect("format");
|
||||
let again = format_document(LanguageId::Yaml, &out).expect("reformat");
|
||||
let v1: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
|
||||
let v2: serde_yaml::Value = serde_yaml::from_str(&again).unwrap();
|
||||
assert_eq!(v1, v2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_invalid_returns_diagnostic() {
|
||||
let err = format_document(LanguageId::Yaml, "foo: [").unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_empty_on_success() {
|
||||
assert!(lint_document(LanguageId::Json, "{}").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_plain_and_unknown_empty() {
|
||||
assert!(lint_document(LanguageId::Plain, "anything").is_empty());
|
||||
assert!(lint_document(LanguageId::Unknown, "anything").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_collects_on_failure() {
|
||||
let d = lint_document(LanguageId::Json, "{");
|
||||
assert!(!d.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_comment_and_cdata() {
|
||||
let src = r#"<?xml version="1.0"?><r><!--hi--><![CDATA[x&y]]></r>"#;
|
||||
let out = format_document(LanguageId::Xml, src).expect("format");
|
||||
assert!(out.contains("<!--hi-->"));
|
||||
assert!(out.contains("CDATA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_malformed_returns_error() {
|
||||
let err = format_document(LanguageId::Xml, "<unclosed").unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_builder() {
|
||||
let d = Diagnostic::new("m").with_line_col(2, 3);
|
||||
assert_eq!(d.line, Some(2));
|
||||
assert_eq!(d.column, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_invalid_returns_diagnostic() {
|
||||
let err = format_document(LanguageId::Toml, "a = ").unwrap_err();
|
||||
assert!(!err.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_pi_and_doctype_emitted() {
|
||||
let src = concat!(
|
||||
r#"<?xml version="1.0"?>"#,
|
||||
"<!DOCTYPE r [<!ELEMENT r EMPTY>]>",
|
||||
"<?xml-stylesheet href=\"s\"?>",
|
||||
"<r/>",
|
||||
);
|
||||
let out = format_document(LanguageId::Xml, src).expect("format");
|
||||
assert!(out.contains("<!DOCTYPE"));
|
||||
assert!(out.contains("xml-stylesheet") || out.contains("stylesheet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_empty_element_with_attributes() {
|
||||
let src = r#"<?xml version="1.0"?><box a="1" b="two"/>"#;
|
||||
let out = format_document(LanguageId::Xml, src).expect("format");
|
||||
assert!(out.contains("a=\""));
|
||||
assert!(out.contains("/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_format_preserves_array_root() {
|
||||
let src = "[true, null, 2]";
|
||||
let out = format_document(LanguageId::Json, src).expect("format");
|
||||
assert!(out.contains('\n'));
|
||||
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
|
||||
assert!(v.is_array());
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Unit tests for system fs_util API.
|
||||
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
|
||||
use byte_draft::system::fs_util::{
|
||||
read_text_file_capped, write_text_file_atomic, MAX_TEXT_FILE_BYTES,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn read_text_file_roundtrip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("f.txt");
|
||||
write_text_file_atomic(&path, "hello 世界").unwrap();
|
||||
let s = read_text_file_capped(&path).unwrap();
|
||||
assert_eq!(s, "hello 世界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_non_utf8() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("b.bin");
|
||||
fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
|
||||
let err = read_text_file_capped(&path).unwrap_err();
|
||||
assert!(err.to_string().contains("UTF-8") || err.to_string().contains("utf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_rejects_too_large_by_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("huge.txt");
|
||||
let f = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
f.set_len(MAX_TEXT_FILE_BYTES + 1).unwrap();
|
||||
drop(f);
|
||||
let err = read_text_file_capped(&path).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("large") || msg.contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_parent_dirs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("nested").join("a.txt");
|
||||
write_text_file_atomic(&path, "x").unwrap();
|
||||
assert_eq!(read_text_file_capped(&path).unwrap(), "x");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Unit tests for input keymap API.
|
||||
|
||||
use byte_draft::input::keymap::Action;
|
||||
|
||||
#[test]
|
||||
fn action_copy_eq() {
|
||||
let a = Action::NewTab;
|
||||
assert_eq!(a, Action::NewTab);
|
||||
assert_ne!(a, Action::CloseTab);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Unit tests for document language API.
|
||||
|
||||
use byte_draft::document::language::LanguageId;
|
||||
|
||||
#[test]
|
||||
fn as_str_all_variants() {
|
||||
assert_eq!(LanguageId::Plain.as_str(), "Plain Text");
|
||||
assert_eq!(LanguageId::Json.as_str(), "JSON");
|
||||
assert_eq!(LanguageId::Yaml.as_str(), "YAML");
|
||||
assert_eq!(LanguageId::Toml.as_str(), "TOML");
|
||||
assert_eq!(LanguageId::Xml.as_str(), "XML");
|
||||
assert_eq!(LanguageId::Unknown.as_str(), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_known() {
|
||||
let v = serde_json::to_string(&LanguageId::Yaml).unwrap();
|
||||
assert_eq!(v, "\"yaml\"");
|
||||
let id: LanguageId = serde_json::from_str(&v).unwrap();
|
||||
assert_eq!(id, LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_unknown_tag_maps_to_unknown() {
|
||||
let id: LanguageId = serde_json::from_str("\"fortran\"").unwrap();
|
||||
assert_eq!(id, LanguageId::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_plain() {
|
||||
assert_eq!(LanguageId::default(), LanguageId::Plain);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Unit tests for system registry API.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use byte_draft::document::format::Diagnostic;
|
||||
use byte_draft::document::language::LanguageId;
|
||||
use byte_draft::plugins::builtin::{self, JsonFormatter};
|
||||
use byte_draft::system::registry::HandlerRegistry;
|
||||
use byte_draft::system::traits::Formatter;
|
||||
|
||||
struct UpperJsonFormatter;
|
||||
|
||||
impl Formatter for UpperJsonFormatter {
|
||||
fn language(&self) -> LanguageId {
|
||||
LanguageId::Json
|
||||
}
|
||||
|
||||
fn format(&self, text: &str) -> Result<String, Diagnostic> {
|
||||
Ok(text.to_ascii_uppercase())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_duplicate_builtin_formatters() {
|
||||
let mut r = HandlerRegistry::new();
|
||||
builtin::register_builtins(&mut r).expect("builtins");
|
||||
let dup = Arc::new(JsonFormatter);
|
||||
assert!(r.register_formatter(dup).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_detect_is_plain() {
|
||||
let r = HandlerRegistry::new();
|
||||
assert_eq!(r.detect(r#"{"a":1}"#, None), LanguageId::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_falls_back_to_builtin_without_plugin() {
|
||||
let r = HandlerRegistry::new();
|
||||
let out = r
|
||||
.format(LanguageId::Json, r#"{"z":1}"#)
|
||||
.expect("builtin json");
|
||||
assert!(out.contains('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_formatter_takes_precedence() {
|
||||
let mut r = HandlerRegistry::new();
|
||||
r.register_formatter(Arc::new(UpperJsonFormatter))
|
||||
.expect("register");
|
||||
let out = r.format(LanguageId::Json, r#"{"a":true}"#).unwrap();
|
||||
assert!(out.contains("TRUE") || out.contains("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_duplicate_formatter_err_message() {
|
||||
let mut r = HandlerRegistry::new();
|
||||
r.register_formatter(Arc::new(UpperJsonFormatter))
|
||||
.expect("first");
|
||||
let err = r
|
||||
.register_formatter(Arc::new(UpperJsonFormatter))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("duplicate") || err.contains("Json"));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Unit tests for editor session API.
|
||||
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use byte_draft::editor::session::{
|
||||
load_session, save_session, SessionData, MAX_SESSION_FILE_BYTES, SESSION_SCHEMA_VERSION,
|
||||
};
|
||||
use byte_draft::editor::tab::Tab;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_session() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
let mut data = SessionData::empty();
|
||||
data.tabs[0].text = "hello".to_string();
|
||||
data.tabs[0].dirty = true;
|
||||
save_session(&path, &data).unwrap();
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(loaded.tabs[0].text, "hello");
|
||||
assert!(loaded.tabs[0].dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_json_errors() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("bad.json");
|
||||
fs::write(&path, b"{ not json").unwrap();
|
||||
assert!(load_session(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_empty_tabs() {
|
||||
let mut s = SessionData {
|
||||
schema: SESSION_SCHEMA_VERSION,
|
||||
active_tab: 0,
|
||||
split_secondary: None,
|
||||
tabs: vec![],
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_clamps_active_tab() {
|
||||
let mut s = SessionData {
|
||||
schema: SESSION_SCHEMA_VERSION,
|
||||
active_tab: 99,
|
||||
split_secondary: None,
|
||||
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.active_tab, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_clears_invalid_split() {
|
||||
let mut s = SessionData {
|
||||
schema: SESSION_SCHEMA_VERSION,
|
||||
active_tab: 0,
|
||||
split_secondary: Some(0),
|
||||
tabs: vec![Tab::new_untitled(), Tab::new_untitled()],
|
||||
};
|
||||
s.normalize();
|
||||
assert_eq!(s.split_secondary, None);
|
||||
|
||||
let mut s2 = SessionData {
|
||||
schema: SESSION_SCHEMA_VERSION,
|
||||
active_tab: 0,
|
||||
split_secondary: Some(5),
|
||||
tabs: vec![Tab::new_untitled()],
|
||||
};
|
||||
s2.normalize();
|
||||
assert_eq!(s2.split_secondary, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_file_errors() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("nope.json");
|
||||
assert!(load_session(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_oversized_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("big.json");
|
||||
let f = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
f.set_len(MAX_SESSION_FILE_BYTES + 1).unwrap();
|
||||
drop(f);
|
||||
let err = load_session(&path).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("large") || err.to_string().contains("bytes"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_tabs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path: PathBuf = dir.path().join("session.json");
|
||||
let mut data = SessionData::empty();
|
||||
data.tabs[0].text = "{\"a\": 1}".to_string();
|
||||
data.active_tab = 0;
|
||||
save_session(&path, &data).unwrap();
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(loaded.tabs[0].text, "{\"a\": 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schema_returns_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
let raw = r#"{"schema":999,"active_tab":0,"split_secondary":null,"tabs":[]}"#;
|
||||
fs::write(&path, raw).unwrap();
|
||||
let err = load_session(&path).unwrap_err();
|
||||
let s = err.to_string();
|
||||
assert!(s.contains("999") || s.contains("schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_secondary_roundtrip_when_valid() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path: PathBuf = dir.path().join("session.json");
|
||||
let mut data = SessionData::empty();
|
||||
data.tabs.push(Tab::new_untitled());
|
||||
data.tabs[0].text = "left".into();
|
||||
data.tabs[1].text = "right".into();
|
||||
data.active_tab = 0;
|
||||
data.split_secondary = Some(1);
|
||||
save_session(&path, &data).unwrap();
|
||||
let loaded = load_session(&path).unwrap();
|
||||
assert_eq!(loaded.split_secondary, Some(1));
|
||||
assert_eq!(loaded.tabs[1].text, "right");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Unit tests for editor tab API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use byte_draft::document::language::LanguageId;
|
||||
use byte_draft::editor::tab::{two_tabs_mut, Tab};
|
||||
use byte_draft::editor::view_kind::ViewKind;
|
||||
|
||||
#[test]
|
||||
fn title_from_path() {
|
||||
let t = Tab::from_path_and_text(PathBuf::from("/foo/bar.json"), "{}".to_string());
|
||||
assert_eq!(t.title(), "bar.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_untitled_empty() {
|
||||
let t = Tab::new_untitled();
|
||||
assert_eq!(t.title(), "Untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_language_override() {
|
||||
let mut t = Tab::from_path_and_text(PathBuf::from("x.json"), "{}".to_string());
|
||||
t.language_override = Some(LanguageId::Yaml);
|
||||
assert_eq!(t.effective_language(), LanguageId::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_hint_from_path() {
|
||||
let t = Tab::from_path_and_text(PathBuf::from("demo.toml"), String::new());
|
||||
assert_eq!(t.extension_hint(), Some("toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_hint_untitled_is_none() {
|
||||
let t = Tab::new_untitled();
|
||||
assert_eq!(t.extension_hint(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tabs_disjoint() {
|
||||
let mut tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
|
||||
tabs[0].text = "a".into();
|
||||
tabs[1].text = "b".into();
|
||||
let (x, y) = two_tabs_mut(&mut tabs, 0, 1).unwrap();
|
||||
assert_eq!(x.text, "a");
|
||||
assert_eq!(y.text, "b");
|
||||
y.text = "c".into();
|
||||
assert_eq!(tabs[1].text, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tabs_mut_reverse_order_same_as_forward() {
|
||||
let mut tabs = vec![
|
||||
Tab::new_untitled(),
|
||||
Tab::new_untitled(),
|
||||
Tab::new_untitled(),
|
||||
];
|
||||
tabs[0].text = "a".into();
|
||||
tabs[2].text = "c".into();
|
||||
let (x, y) = two_tabs_mut(&mut tabs, 2, 0).unwrap();
|
||||
assert_eq!(x.text, "c");
|
||||
assert_eq!(y.text, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tabs_mut_invalid_returns_none() {
|
||||
let mut tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
|
||||
assert!(two_tabs_mut(&mut tabs, 0, 0).is_none());
|
||||
assert!(two_tabs_mut(&mut tabs, 0, 9).is_none());
|
||||
assert!(two_tabs_mut(&mut tabs, 9, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_untitled_ignores_first_line() {
|
||||
let mut t = Tab::new_untitled();
|
||||
let line: String = "x".repeat(50);
|
||||
t.text = format!("{line}\nmore");
|
||||
assert_eq!(t.title(), "Untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_label_single_untitled() {
|
||||
let tabs = vec![Tab::new_untitled()];
|
||||
assert_eq!(Tab::tab_label(&tabs, 0), "Untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_label_two_untitled() {
|
||||
let tabs = vec![Tab::new_untitled(), Tab::new_untitled()];
|
||||
assert_eq!(Tab::tab_label(&tabs, 0), "Untitled");
|
||||
assert_eq!(Tab::tab_label(&tabs, 1), "Untitled 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_label_mixed_path_and_untitled() {
|
||||
let tabs = vec![
|
||||
Tab::from_path_and_text(PathBuf::from("/a/x.rs"), String::new()),
|
||||
Tab::new_untitled(),
|
||||
];
|
||||
assert_eq!(Tab::tab_label(&tabs, 0), "x.rs");
|
||||
assert_eq!(Tab::tab_label(&tabs, 1), "Untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_untitled_whitespace_only() {
|
||||
let mut t = Tab::new_untitled();
|
||||
t.text = " \n \t ".into();
|
||||
assert_eq!(t.title(), "Untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_path_without_file_name_falls_back() {
|
||||
let t = Tab::from_path_and_text(PathBuf::from(""), "hi".into());
|
||||
assert_eq!(t.title(), "untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_kind_roundtrip_in_json() {
|
||||
let mut t = Tab::new_untitled();
|
||||
t.view_kind = ViewKind::Hex;
|
||||
let v = serde_json::to_string(&t).unwrap();
|
||||
assert!(v.contains("hex"));
|
||||
let t2: Tab = serde_json::from_str(&v).unwrap();
|
||||
assert_eq!(t2.view_kind, ViewKind::Hex);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Unit tests for platform title_bar API.
|
||||
|
||||
use byte_draft::platform::title_bar::{detect_title_bar_host, TitleBarHost, TitleBarPlan};
|
||||
|
||||
#[test]
|
||||
fn detect_title_bar_host_matches_cfg() {
|
||||
let h = detect_title_bar_host();
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(h, TitleBarHost::Windows);
|
||||
#[cfg(target_os = "macos")]
|
||||
assert_eq!(h, TitleBarHost::Macos);
|
||||
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
|
||||
assert_eq!(h, TitleBarHost::Linux);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_bar_plan_default_title() {
|
||||
let p = TitleBarPlan::default();
|
||||
assert_eq!(p.title, "ByteDraft");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Unit tests for editor view_kind API.
|
||||
|
||||
use byte_draft::editor::view_kind::ViewKind;
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
for vk in [ViewKind::Text, ViewKind::Hex] {
|
||||
let s = serde_json::to_string(&vk).unwrap();
|
||||
let back: ViewKind = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back, vk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_text() {
|
||||
assert_eq!(ViewKind::default(), ViewKind::Text);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Unit tests for editor workspace API.
|
||||
|
||||
use byte_draft::editor::workspace::Workspace;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn display_name_no_root() {
|
||||
let w = Workspace::default();
|
||||
assert_eq!(w.display_name(), "No folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_uses_final_component() {
|
||||
let dir = tempdir().unwrap();
|
||||
let w = Workspace {
|
||||
root: Some(dir.path().to_path_buf()),
|
||||
};
|
||||
let expected = dir
|
||||
.path()
|
||||
.file_name()
|
||||
.expect("tempdir has file name")
|
||||
.to_string_lossy();
|
||||
assert_eq!(w.display_name(), expected);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "byte_draft_desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "ByteDraft"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
byte_draft = { path = "../byte_draft" }
|
||||
eframe = { version = "0.27", default-features = true, features = ["wgpu"] }
|
||||
egui = "0.27"
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
directories = "5"
|
||||
|
||||
# `rfd` features are target-specific so a Windows host build does not link GTK, and WSL/Linux builds do not
|
||||
# pull Windows-only flags. RustRover/WSL compiles for `*-linux-gnu`: install `libgtk-3-dev` and `pkg-config`
|
||||
# in WSL (`sudo apt install libgtk-3-dev pkg-config`). To produce a native `.exe` on Windows without WSL, use
|
||||
# the MSVC toolchain: `rustup default stable-x86_64-pc-windows-msvc` and build outside WSL (or `--target x86_64-pc-windows-msvc`).
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
rfd = { version = "0.14", default-features = false, features = ["common-controls-v6"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rfd = { version = "0.14", default-features = false, features = ["gtk3"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
rfd = { version = "0.14", default-features = false }
|
||||
|
||||
[target.'cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
rfd = { version = "0.14", default-features = false, features = ["gtk3"] }
|
||||
@@ -0,0 +1,13 @@
|
||||
//! ByteDraft — native scratch editor binary.
|
||||
|
||||
mod ui;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
// File dialog tracing: `RUST_LOG=byte_draft_desktop=info` or `=debug`
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or("warn,byte_draft_desktop=info"),
|
||||
)
|
||||
.init();
|
||||
|
||||
ui::run()
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Window chrome: frameless title bar with host-appropriate controls.
|
||||
|
||||
pub use byte_draft::{
|
||||
detect_title_bar_host, TitleBarHost, TitleBarPlan, TITLE_BAR_H, WINDOWS_MENU_BAR_ROW_H,
|
||||
};
|
||||
|
||||
use eframe::egui::{
|
||||
self, Align2, Color32, Context, Id, LayerId, Order, PointerButton, Sense, Stroke, Ui,
|
||||
ViewportCommand,
|
||||
};
|
||||
|
||||
use super::resize_geometry;
|
||||
|
||||
const WIN_BTN_W: f32 = 46.0;
|
||||
|
||||
/// Invisible hit target for edge resize (wide enough for easy targeting).
|
||||
pub const RESIZE_RIM_THICKNESS: f32 = 6.0;
|
||||
/// Corner resize areas are larger so diagonal resize is easy to hit.
|
||||
pub const RESIZE_CORNER_SIZE: f32 = 16.0;
|
||||
/// Inset for main panels from the window edge (resize strip sits in the outer pixels).
|
||||
pub const WINDOW_CONTENT_GUTTER: f32 = 1.0;
|
||||
/// Extra space above the bottom resize strip so status / diagnostics stay readable.
|
||||
pub const STATUS_PAD_ABOVE_RESIZE_STRIP: f32 = 2.0;
|
||||
|
||||
const MIN_INNER_SIZE: egui::Vec2 = egui::vec2(320.0, 240.0);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ResizeAnchor {
|
||||
dir: egui::ResizeDirection,
|
||||
ptr0: egui::Pos2,
|
||||
inner0: egui::Vec2,
|
||||
/// Screen-space top-left of the **client** area at press (never `screen_rect.min`).
|
||||
inner_min0: egui::Pos2,
|
||||
outer_min0: egui::Pos2,
|
||||
}
|
||||
|
||||
fn resize_anchor_id() -> Id {
|
||||
Id::new("byte_draft_resize_anchor")
|
||||
}
|
||||
|
||||
/// Thin 1px outline on the window perimeter.
|
||||
pub fn draw_viewport_border(ctx: &Context) {
|
||||
let p = ctx.layer_painter(LayerId::new(
|
||||
Order::Foreground,
|
||||
Id::new("byte_draft_viewport_border"),
|
||||
));
|
||||
let r = ctx.screen_rect().shrink(0.5);
|
||||
p.rect_stroke(
|
||||
r,
|
||||
0.0,
|
||||
Stroke::new(1.0, Color32::from_rgb(72, 74, 82)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drag region + window controls. `leading` is typically a hamburger; menus render on the row below when `show_menu_row`.
|
||||
/// Use with `ViewportBuilder::with_decorations(false)`.
|
||||
pub fn draw_custom_title_bar(
|
||||
ui: &mut Ui,
|
||||
ctx: &Context,
|
||||
plan: &TitleBarPlan,
|
||||
show_menu_row: bool,
|
||||
leading: impl FnOnce(&mut Ui),
|
||||
add_menus: impl FnOnce(&mut Ui),
|
||||
) {
|
||||
match plan.host {
|
||||
TitleBarHost::Macos => title_bar_macos(ui, ctx, plan, show_menu_row, leading, add_menus),
|
||||
TitleBarHost::Windows | TitleBarHost::Linux => {
|
||||
title_bar_windows_like(ui, ctx, plan, show_menu_row, leading, add_menus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw resize handles at window edges and corners.
|
||||
///
|
||||
/// - **macOS / Linux:** `ViewportCommand::BeginResize` lets the OS / winit own the drag (matches
|
||||
/// [egui's custom_window_frame](https://github.com/emilk/egui/tree/master/examples/custom_window_frame) style).
|
||||
/// - **Windows:** undecorated windows often ignore OS edge-drag ([winit #4186](https://github.com/rust-windowing/winit/issues/4186),
|
||||
/// [egui #4345](https://github.com/emilk/egui/issues/4345)); we use manual `InnerSize` /
|
||||
/// `OuterPosition` with screen-anchored west math (see `resize_geometry`).
|
||||
pub fn draw_resize_handles(ctx: &Context, edge_thickness: f32, corner_size: f32, top_chrome_h: f32) {
|
||||
let screen = ctx.screen_rect();
|
||||
let t = edge_thickness.max(4.0);
|
||||
let c = corner_size.max(t);
|
||||
let y0 = top_chrome_h;
|
||||
|
||||
let (pointer_pos, primary_pressed, primary_down) = ctx.input(|i| {
|
||||
(
|
||||
i.pointer.hover_pos(),
|
||||
i.pointer.primary_pressed(),
|
||||
i.pointer.primary_down(),
|
||||
)
|
||||
});
|
||||
|
||||
let Some(pos) = pointer_pos else {
|
||||
return;
|
||||
};
|
||||
|
||||
let regions: &[(egui::Rect, egui::CursorIcon, egui::ResizeDirection)] = &[
|
||||
(
|
||||
egui::Rect::from_min_size(
|
||||
egui::pos2(screen.right() - c, screen.bottom() - c),
|
||||
egui::vec2(c, c),
|
||||
),
|
||||
egui::CursorIcon::ResizeSouthEast,
|
||||
egui::ResizeDirection::SouthEast,
|
||||
),
|
||||
(
|
||||
egui::Rect::from_min_size(
|
||||
egui::pos2(screen.left(), screen.bottom() - c),
|
||||
egui::vec2(c, c),
|
||||
),
|
||||
egui::CursorIcon::ResizeSouthWest,
|
||||
egui::ResizeDirection::SouthWest,
|
||||
),
|
||||
(
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(screen.left() + c, screen.bottom() - t),
|
||||
egui::pos2(screen.right() - c, screen.bottom()),
|
||||
),
|
||||
egui::CursorIcon::ResizeSouth,
|
||||
egui::ResizeDirection::South,
|
||||
),
|
||||
(
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(screen.right() - t, y0),
|
||||
egui::pos2(screen.right(), screen.bottom() - c),
|
||||
),
|
||||
egui::CursorIcon::ResizeEast,
|
||||
egui::ResizeDirection::East,
|
||||
),
|
||||
(
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(screen.left(), y0),
|
||||
egui::pos2(screen.left() + t, screen.bottom() - c),
|
||||
),
|
||||
egui::CursorIcon::ResizeWest,
|
||||
egui::ResizeDirection::West,
|
||||
),
|
||||
];
|
||||
|
||||
for (rect, cursor, _) in regions {
|
||||
if rect.contains(pos) {
|
||||
ctx.set_cursor_icon(*cursor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let aid = resize_anchor_id();
|
||||
|
||||
if primary_pressed {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
for (rect, _, direction) in regions {
|
||||
if rect.contains(pos) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::BeginResize(*direction));
|
||||
ctx.data_mut(|d| d.insert_temp::<Option<ResizeAnchor>>(aid, None));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
for (rect, _, direction) in regions {
|
||||
if rect.contains(pos) {
|
||||
let anchor = ctx.input(|i| {
|
||||
let ir = i.viewport().inner_rect?;
|
||||
let inner_sz = ir.size();
|
||||
let inner_min0 = ir.min;
|
||||
let outer_min0 = i
|
||||
.viewport()
|
||||
.outer_rect
|
||||
.map(|r| r.min)
|
||||
.unwrap_or(inner_min0);
|
||||
Some(ResizeAnchor {
|
||||
dir: *direction,
|
||||
ptr0: pos,
|
||||
inner0: inner_sz,
|
||||
inner_min0,
|
||||
outer_min0,
|
||||
})
|
||||
});
|
||||
if let Some(a) = anchor {
|
||||
ctx.data_mut(|d| d.insert_temp(aid, Some(a)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !primary_down {
|
||||
ctx.data_mut(|d| d.insert_temp::<Option<ResizeAnchor>>(aid, None));
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(anchor) = ctx
|
||||
.data(|d| d.get_temp::<Option<ResizeAnchor>>(aid))
|
||||
.flatten()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let send_inner = |w: f32, h: f32| {
|
||||
let w = w.max(MIN_INNER_SIZE.x);
|
||||
let h = h.max(MIN_INNER_SIZE.y);
|
||||
ctx.send_viewport_cmd(ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||
ctx.request_repaint();
|
||||
};
|
||||
|
||||
match anchor.dir {
|
||||
egui::ResizeDirection::South => {
|
||||
let v = resize_geometry::resize_south(anchor.inner0, anchor.ptr0, pos);
|
||||
send_inner(v.x, v.y.max(MIN_INNER_SIZE.y));
|
||||
}
|
||||
egui::ResizeDirection::East => {
|
||||
let v = resize_geometry::resize_east(anchor.inner0, anchor.ptr0, pos);
|
||||
send_inner(v.x.max(MIN_INNER_SIZE.x), v.y);
|
||||
}
|
||||
egui::ResizeDirection::SouthEast => {
|
||||
let v = resize_geometry::resize_south_east(anchor.inner0, anchor.ptr0, pos);
|
||||
send_inner(
|
||||
v.x.max(MIN_INNER_SIZE.x),
|
||||
v.y.max(MIN_INNER_SIZE.y),
|
||||
);
|
||||
}
|
||||
egui::ResizeDirection::West => {
|
||||
let r = resize_geometry::resize_west(
|
||||
anchor.inner0,
|
||||
anchor.inner_min0,
|
||||
anchor.outer_min0,
|
||||
anchor.ptr0,
|
||||
pos,
|
||||
MIN_INNER_SIZE,
|
||||
);
|
||||
ctx.send_viewport_cmd(ViewportCommand::InnerSize(r.inner));
|
||||
if let Some(o) = r.outer {
|
||||
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(o));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
egui::ResizeDirection::SouthWest => {
|
||||
let r = resize_geometry::resize_south_west(
|
||||
anchor.inner0,
|
||||
anchor.inner_min0,
|
||||
anchor.outer_min0,
|
||||
anchor.ptr0,
|
||||
pos,
|
||||
MIN_INNER_SIZE,
|
||||
);
|
||||
ctx.send_viewport_cmd(ViewportCommand::InnerSize(r.inner));
|
||||
if let Some(o) = r.outer {
|
||||
ctx.send_viewport_cmd(ViewportCommand::OuterPosition(o));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn title_bar_macos(
|
||||
ui: &mut Ui,
|
||||
ctx: &Context,
|
||||
plan: &TitleBarPlan,
|
||||
show_menu_row: bool,
|
||||
leading: impl FnOnce(&mut Ui),
|
||||
add_menus: impl FnOnce(&mut Ui),
|
||||
) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_height(TITLE_BAR_H);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
ui.add_space(8.0);
|
||||
traffic_lights(ui, ctx);
|
||||
ui.separator();
|
||||
leading(ui);
|
||||
let h = ui.available_height();
|
||||
let (drag_rect, drag) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width().max(0.0), h),
|
||||
Sense::drag(),
|
||||
);
|
||||
paint_centered_title(ui, drag_rect, &plan.title);
|
||||
if drag.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
});
|
||||
if show_menu_row {
|
||||
egui::Frame::none()
|
||||
.fill(ui.visuals().widgets.noninteractive.bg_fill)
|
||||
.inner_margin(egui::Margin::symmetric(6.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_height(WINDOWS_MENU_BAR_ROW_H);
|
||||
egui::menu::bar(ui, add_menus);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn title_bar_windows_like(
|
||||
ui: &mut Ui,
|
||||
ctx: &Context,
|
||||
plan: &TitleBarPlan,
|
||||
show_menu_row: bool,
|
||||
leading: impl FnOnce(&mut Ui),
|
||||
add_menus: impl FnOnce(&mut Ui),
|
||||
) {
|
||||
let fill = ui.visuals().widgets.noninteractive.bg_fill;
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_height(TITLE_BAR_H);
|
||||
ui.set_max_height(TITLE_BAR_H);
|
||||
let row = ui.max_rect();
|
||||
ui.painter().rect_filled(row, 0.0, fill);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let h = TITLE_BAR_H;
|
||||
let btn_reserve = WIN_BTN_W * 3.0;
|
||||
leading(ui);
|
||||
let drag_w = (ui.available_width() - btn_reserve).max(48.0);
|
||||
let (drag_rect, drag) =
|
||||
ui.allocate_exact_size(egui::vec2(drag_w, h), Sense::drag());
|
||||
paint_centered_title(ui, drag_rect, &plan.title);
|
||||
if drag.drag_started_by(PointerButton::Primary) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
windows_window_buttons(ui, ctx);
|
||||
});
|
||||
|
||||
if show_menu_row {
|
||||
egui::Frame::none()
|
||||
.fill(fill)
|
||||
.inner_margin(egui::Margin::symmetric(4.0, 0.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_min_height(WINDOWS_MENU_BAR_ROW_H);
|
||||
egui::menu::bar(ui, add_menus);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn paint_centered_title(ui: &Ui, drag_rect: egui::Rect, title: &str) {
|
||||
let painter = ui.painter_at(drag_rect);
|
||||
let font_id = egui::TextStyle::Body.resolve(ui.style());
|
||||
let color = ui.visuals().widgets.inactive.text_color();
|
||||
painter.text(
|
||||
drag_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
title,
|
||||
font_id,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
fn traffic_lights(ui: &mut Ui, ctx: &Context) {
|
||||
let size = 12.0;
|
||||
let gap = 8.0;
|
||||
let colors = [
|
||||
Color32::from_rgb(255, 95, 87),
|
||||
Color32::from_rgb(255, 189, 46),
|
||||
Color32::from_rgb(39, 201, 63),
|
||||
];
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), Sense::click());
|
||||
ui.painter_at(rect).circle_filled(rect.center(), size * 0.5, *c);
|
||||
if resp.clicked() {
|
||||
match i {
|
||||
0 => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
|
||||
1 => {
|
||||
let maxed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maxed));
|
||||
}
|
||||
2 => ctx.send_viewport_cmd(ViewportCommand::Close),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if i < 2 {
|
||||
ui.add_space(gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_window_buttons(ui: &mut Ui, ctx: &Context) {
|
||||
let h = TITLE_BAR_H;
|
||||
let stroke = Stroke::new(1.0, ui.visuals().widgets.inactive.text_color());
|
||||
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter_at(r)
|
||||
.rect_filled(r, 0.0, Color32::from_white_alpha(25));
|
||||
}
|
||||
let c = r.center();
|
||||
ui.painter_at(r).hline(c.x - 6.0..=c.x + 6.0, c.y, stroke);
|
||||
if resp.clicked() {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Minimized(true));
|
||||
}
|
||||
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter_at(r)
|
||||
.rect_filled(r, 0.0, Color32::from_white_alpha(25));
|
||||
}
|
||||
let maxed = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
let rr = egui::Rect::from_center_size(r.center(), egui::vec2(10.0, 10.0));
|
||||
if maxed {
|
||||
let back = rr.translate(egui::vec2(-2.0, 2.0));
|
||||
ui.painter_at(r).rect_stroke(back, 0.0, stroke);
|
||||
let front = rr.translate(egui::vec2(2.0, -2.0));
|
||||
ui.painter_at(r).rect_stroke(front, 0.0, stroke);
|
||||
} else {
|
||||
ui.painter_at(r).rect_stroke(rr, 0.0, stroke);
|
||||
}
|
||||
if resp.clicked() {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maxed));
|
||||
}
|
||||
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(WIN_BTN_W, h), Sense::click());
|
||||
let hover_close = Color32::from_rgb(232, 17, 35);
|
||||
if resp.hovered() {
|
||||
ui.painter_at(r).rect_filled(r, 0.0, hover_close);
|
||||
}
|
||||
let c = r.center();
|
||||
let arm = 5.0;
|
||||
let x_color = if resp.hovered() {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
ui.visuals().widgets.inactive.text_color()
|
||||
};
|
||||
let xs = Stroke::new(1.0, x_color);
|
||||
ui.painter_at(r).line_segment(
|
||||
[c - egui::vec2(arm, arm), c + egui::vec2(arm, arm)],
|
||||
xs,
|
||||
);
|
||||
ui.painter_at(r).line_segment(
|
||||
[c - egui::vec2(arm, -arm), c + egui::vec2(arm, -arm)],
|
||||
xs,
|
||||
);
|
||||
if resp.clicked() {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
//! Pure math for manual window-edge resize (screen-anchored west / south-west).
|
||||
//!
|
||||
//! Pointer positions are in **client** space. `ViewportCommand::OuterPosition` uses **screen**
|
||||
//! logical coordinates. West resize must use the client area's **screen-space** `min` at press
|
||||
//! time — never `screen_rect.min` (always `0,0`), which would break `OuterPosition`.
|
||||
|
||||
use eframe::egui::{Pos2, Vec2};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ResizeResult {
|
||||
pub inner: Vec2,
|
||||
/// New top-left **outer** position in screen logical coordinates, if it must change.
|
||||
pub outer: Option<Pos2>,
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resize_south(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
|
||||
Vec2::new(inner0.x, inner0.y + (pos.y - ptr0.y))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resize_east(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
|
||||
Vec2::new(inner0.x + (pos.x - ptr0.x), inner0.y)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resize_south_east(inner0: Vec2, ptr0: Pos2, pos: Pos2) -> Vec2 {
|
||||
Vec2::new(
|
||||
inner0.x + (pos.x - ptr0.x),
|
||||
inner0.y + (pos.y - ptr0.y),
|
||||
)
|
||||
}
|
||||
|
||||
/// West edge: keep the **right** edge of the client area fixed in screen space.
|
||||
pub fn resize_west(
|
||||
inner0: Vec2,
|
||||
inner_min_screen: Pos2,
|
||||
outer_min_screen: Pos2,
|
||||
ptr0: Pos2,
|
||||
pos: Pos2,
|
||||
min_inner: Vec2,
|
||||
) -> ResizeResult {
|
||||
let new_w = (inner0.x - (pos.x - ptr0.x)).max(min_inner.x);
|
||||
let right_screen = inner_min_screen.x + inner0.x;
|
||||
let new_inner_left_screen = right_screen - new_w;
|
||||
let dx_outer_inner = outer_min_screen.x - inner_min_screen.x;
|
||||
let new_outer_x = new_inner_left_screen + dx_outer_inner;
|
||||
ResizeResult {
|
||||
inner: Vec2::new(new_w, inner0.y),
|
||||
outer: Some(Pos2::new(new_outer_x, outer_min_screen.y)),
|
||||
}
|
||||
}
|
||||
|
||||
/// South-west corner: west on X, south on Y (client top edge stays at the same screen Y).
|
||||
pub fn resize_south_west(
|
||||
inner0: Vec2,
|
||||
inner_min_screen: Pos2,
|
||||
outer_min_screen: Pos2,
|
||||
ptr0: Pos2,
|
||||
pos: Pos2,
|
||||
min_inner: Vec2,
|
||||
) -> ResizeResult {
|
||||
let new_w = (inner0.x - (pos.x - ptr0.x)).max(min_inner.x);
|
||||
let new_h = (inner0.y + (pos.y - ptr0.y)).max(min_inner.y);
|
||||
let right_screen = inner_min_screen.x + inner0.x;
|
||||
let new_inner_left_screen = right_screen - new_w;
|
||||
let dx_outer_inner = outer_min_screen.x - inner_min_screen.x;
|
||||
let new_outer_x = new_inner_left_screen + dx_outer_inner;
|
||||
ResizeResult {
|
||||
inner: Vec2::new(new_w, new_h),
|
||||
outer: Some(Pos2::new(new_outer_x, outer_min_screen.y)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MIN: Vec2 = Vec2::new(50.0, 50.0);
|
||||
|
||||
#[test]
|
||||
fn west_shrink_moves_outer_right_preserves_right_edge() {
|
||||
let inner0 = Vec2::new(400.0, 300.0);
|
||||
let inner_min = Pos2::new(120.0, 80.0);
|
||||
let outer_min = Pos2::new(120.0, 80.0);
|
||||
let ptr0 = Pos2::new(0.0, 100.0);
|
||||
let pos = Pos2::new(80.0, 100.0);
|
||||
let r = resize_west(inner0, inner_min, outer_min, ptr0, pos, MIN);
|
||||
assert!((r.inner.x - 320.0).abs() < 0.01);
|
||||
assert!((r.inner.y - 300.0).abs() < 0.01);
|
||||
let o = r.outer.expect("outer");
|
||||
assert!((o.x - 200.0).abs() < 0.01, "outer.x={}", o.x);
|
||||
assert!((o.y - 80.0).abs() < 0.01);
|
||||
let right_before = inner_min.x + inner0.x;
|
||||
let right_after = o.x + r.inner.x;
|
||||
assert!(
|
||||
(right_before - right_after).abs() < 0.01,
|
||||
"right edge drift: {} vs {}",
|
||||
right_before,
|
||||
right_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn west_decorated_outer_offset_from_inner() {
|
||||
let inner0 = Vec2::new(400.0, 300.0);
|
||||
let inner_min = Pos2::new(128.0, 88.0);
|
||||
let outer_min = Pos2::new(120.0, 80.0);
|
||||
let ptr0 = Pos2::new(0.0, 100.0);
|
||||
let pos = Pos2::new(100.0, 100.0);
|
||||
let r = resize_west(inner0, inner_min, outer_min, ptr0, pos, MIN);
|
||||
assert!((r.inner.x - 300.0).abs() < 0.01);
|
||||
let o = r.outer.expect("outer");
|
||||
assert!((o.x - 220.0).abs() < 0.01, "outer.x={}", o.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn south_east_math() {
|
||||
let inner0 = Vec2::new(200.0, 100.0);
|
||||
let ptr0 = Pos2::new(190.0, 90.0);
|
||||
let pos = Pos2::new(210.0, 120.0);
|
||||
let v = resize_south_east(inner0, ptr0, pos);
|
||||
assert!((v.x - 220.0).abs() < 0.01);
|
||||
assert!((v.y - 130.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Syntax highlighting for [`TextEdit`](egui::TextEdit) via `LayoutJob`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use byte_draft::LanguageId;
|
||||
use eframe::egui::text::{LayoutJob, TextFormat};
|
||||
use eframe::egui::{Color32, FontId, TextStyle, Ui};
|
||||
|
||||
fn mono(ui: &Ui) -> FontId {
|
||||
TextStyle::Monospace.resolve(ui.style())
|
||||
}
|
||||
|
||||
fn push(job: &mut LayoutJob, text: &str, color: Color32, font_id: &FontId) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
job.append(text, 0.0, TextFormat::simple(font_id.clone(), color));
|
||||
}
|
||||
|
||||
/// Paint `full_text[abs_range]` using `base_color`, or `find_color` where byte offsets intersect `find_ranges`.
|
||||
fn push_slice_find(
|
||||
job: &mut LayoutJob,
|
||||
full_text: &str,
|
||||
abs_range: std::ops::Range<usize>,
|
||||
base_color: Color32,
|
||||
font_id: &FontId,
|
||||
find_ranges: &[(usize, usize)],
|
||||
find_color: Color32,
|
||||
) {
|
||||
let start = abs_range.start.min(full_text.len());
|
||||
let end = abs_range.end.min(full_text.len());
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
if find_ranges.is_empty() {
|
||||
push(job, &full_text[start..end], base_color, font_id);
|
||||
return;
|
||||
}
|
||||
let mut i = start;
|
||||
while i < end {
|
||||
let in_match = find_ranges
|
||||
.iter()
|
||||
.any(|&(s, e)| i < e && s < end && i >= s);
|
||||
let next = if in_match {
|
||||
find_ranges
|
||||
.iter()
|
||||
.filter(|&&(s, e)| i >= s && i < e)
|
||||
.map(|(_, e)| *e)
|
||||
.min()
|
||||
.unwrap_or(end)
|
||||
} else {
|
||||
find_ranges
|
||||
.iter()
|
||||
.filter_map(|&(s, _)| if s > i && s < end { Some(s) } else { None })
|
||||
.min()
|
||||
.unwrap_or(end)
|
||||
};
|
||||
let next = next.min(end);
|
||||
let color = if in_match { find_color } else { base_color };
|
||||
push(job, &full_text[i..next], color, font_id);
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
|
||||
const FIND_TEXT_COLOR: Color32 = Color32::from_rgb(255, 220, 80);
|
||||
|
||||
fn layout_plain(
|
||||
ui: &Ui,
|
||||
text: &str,
|
||||
wrap_width: f32,
|
||||
color: Color32,
|
||||
find_ranges: &[(usize, usize)],
|
||||
) -> Arc<eframe::egui::Galley> {
|
||||
let font_id = mono(ui);
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap_width;
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
0..text.len(),
|
||||
color,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
ui.fonts(|f| f.layout_job(job))
|
||||
}
|
||||
|
||||
fn layout_json(
|
||||
ui: &Ui,
|
||||
text: &str,
|
||||
wrap_width: f32,
|
||||
find_ranges: &[(usize, usize)],
|
||||
) -> Arc<eframe::egui::Galley> {
|
||||
let font_id = mono(ui);
|
||||
let default_c = Color32::from_rgb(212, 212, 212);
|
||||
let str_c = Color32::from_rgb(206, 145, 120);
|
||||
let num_c = Color32::from_rgb(181, 206, 168);
|
||||
let kw_c = Color32::from_rgb(86, 156, 214);
|
||||
let punct_c = Color32::from_rgb(128, 128, 128);
|
||||
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap_width;
|
||||
|
||||
let mut i = 0usize;
|
||||
let b = text.as_bytes();
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
|
||||
if (c as char).is_whitespace() {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < b.len() && (b[i] as char).is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
start..i,
|
||||
default_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match c {
|
||||
b'{' | b'}' | b'[' | b']' | b':' | b',' => {
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
i..i + 1,
|
||||
punct_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
b'"' => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < b.len() {
|
||||
if b[i] == b'\\' && i + 1 < b.len() {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b[i] == b'"' {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut color = str_c;
|
||||
let slice = &text[start..i];
|
||||
if slice.starts_with('"') && slice.ends_with('"') {
|
||||
let mut j = i;
|
||||
while j < b.len() && (b[j] as char).is_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j < b.len() && b[j] == b':' {
|
||||
color = Color32::from_rgb(156, 220, 254);
|
||||
}
|
||||
}
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
start..i,
|
||||
color,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
}
|
||||
b'-' | b'0'..=b'9' => {
|
||||
let start = i;
|
||||
if c == b'-' {
|
||||
i += 1;
|
||||
}
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-' => i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
start..i,
|
||||
num_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
}
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'_' => {
|
||||
let start = i;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' => i += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let word = &text[start..i];
|
||||
let color = match word {
|
||||
"true" | "false" | "null" => kw_c,
|
||||
_ => default_c,
|
||||
};
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
start..i,
|
||||
color,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let ch = text[i..].chars().next().unwrap();
|
||||
let len = ch.len_utf8();
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
i..i + len,
|
||||
default_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.fonts(|f| f.layout_job(job))
|
||||
}
|
||||
|
||||
/// Minimal XML-ish highlighting: `<`…`>` and `<!-- -->`.
|
||||
fn layout_xml(
|
||||
ui: &Ui,
|
||||
text: &str,
|
||||
wrap_width: f32,
|
||||
find_ranges: &[(usize, usize)],
|
||||
) -> Arc<eframe::egui::Galley> {
|
||||
let font_id = mono(ui);
|
||||
let default_c = Color32::from_rgb(212, 212, 212);
|
||||
let bracket_c = Color32::from_rgb(128, 128, 128);
|
||||
let tag_c = Color32::from_rgb(86, 156, 214);
|
||||
let comment_c = Color32::from_rgb(106, 153, 85);
|
||||
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap_width;
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < text.len() {
|
||||
if text[i..].starts_with("<!--") {
|
||||
if let Some(rel) = text[i..].find("-->") {
|
||||
let end = i + rel + 3;
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
i..end,
|
||||
comment_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if text.as_bytes().get(i) == Some(&b'<') {
|
||||
if let Some(rel) = text[i..].find('>') {
|
||||
let end = i + rel + 1;
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
i..i + 1,
|
||||
bracket_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
if end > i + 1 {
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
i + 1..end - 1,
|
||||
tag_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
}
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
end - 1..end,
|
||||
bracket_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < text.len() {
|
||||
if text[i..].starts_with("<!--") {
|
||||
break;
|
||||
}
|
||||
if text.as_bytes().get(i) == Some(&b'<') {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
push_slice_find(
|
||||
&mut job,
|
||||
text,
|
||||
start..i,
|
||||
default_c,
|
||||
&font_id,
|
||||
find_ranges,
|
||||
FIND_TEXT_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
ui.fonts(|f| f.layout_job(job))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn layout_highlighted(
|
||||
ui: &Ui,
|
||||
text: &str,
|
||||
lang: LanguageId,
|
||||
wrap_width: f32,
|
||||
find_ranges: &[(usize, usize)],
|
||||
) -> Arc<eframe::egui::Galley> {
|
||||
let default = ui.visuals().widgets.inactive.text_color();
|
||||
match lang {
|
||||
LanguageId::Json => layout_json(ui, text, wrap_width, find_ranges),
|
||||
LanguageId::Xml => layout_xml(ui, text, wrap_width, find_ranges),
|
||||
LanguageId::Plain | LanguageId::Yaml | LanguageId::Toml | LanguageId::Unknown => {
|
||||
layout_plain(ui, text, wrap_width, default, find_ranges)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# cargo-deny: advisories (RustSec), license policy, duplicate crate detection.
|
||||
# https://embarkstudios.github.io/cargo-deny/
|
||||
#
|
||||
# Install: cargo install cargo-deny
|
||||
# Check: cargo deny check
|
||||
|
||||
[advisories]
|
||||
version = 2
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
db-urls = ["https://github.com/RustSec/advisory-db"]
|
||||
# Only fail on advisories for your own crates; transitive unmaintained is common in GUI stacks.
|
||||
unmaintained = "workspace"
|
||||
|
||||
[licenses]
|
||||
version = 2
|
||||
confidence-threshold = 0.8
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"Unicode-3.0",
|
||||
"CC0-1.0",
|
||||
"Unlicense",
|
||||
"MPL-2.0",
|
||||
"LGPL-2.1",
|
||||
"LGPL-3.0",
|
||||
"OpenSSL",
|
||||
"OFL-1.1",
|
||||
"LicenseRef-UFL-1.0",
|
||||
]
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "warn"
|
||||
@@ -0,0 +1,754 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run security checks for ByteDraft (Rust SCA/SAST helpers + optional Podman scanners).
|
||||
|
||||
.DESCRIPTION
|
||||
Reads security/security.config.yaml (flat key: value).
|
||||
Rust: cargo deny, cargo audit, clippy, fmt, geiger, outdated, udeps.
|
||||
Optional: Semgrep, Trivy, gitleaks, OSV-Scanner, Qryon (native and/or Podman per config).
|
||||
Qryon CLI is shipped as the crates.io package rma-cli (binary name: qryon); see https://github.com/bumahkib7/qryon
|
||||
Podman steps mount the repo at /src (read-only) and reports at /out (read-write).
|
||||
|
||||
.PARAMETER ConfigPath
|
||||
Override path to security.config.yaml (default: repo security/security.config.yaml).
|
||||
|
||||
.PARAMETER SkipPodman
|
||||
Do not run container-based tools even if enabled in config.
|
||||
|
||||
.PARAMETER SkipClippy
|
||||
Skip cargo clippy (useful when MSVC link.exe / full toolchain is unavailable).
|
||||
|
||||
.PARAMETER SkipNative
|
||||
Skip all native cargo tools (useful for container-only scanning).
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\security-scan.ps1
|
||||
.EXAMPLE
|
||||
.\scripts\security-scan.ps1 -SkipPodman # Native tools only
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = "",
|
||||
[switch]$SkipPodman,
|
||||
[switch]$SkipClippy,
|
||||
[switch]$SkipNative
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-RepoRoot {
|
||||
$here = $PSScriptRoot
|
||||
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||
Resolve-Path (Join-Path $here "..")
|
||||
}
|
||||
|
||||
function Read-FlatSecurityConfig {
|
||||
param([string]$Path)
|
||||
$cfg = @{}
|
||||
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$t = $line.Trim()
|
||||
if ($t -eq "" -or $t.StartsWith("#")) { continue }
|
||||
$parts = $t -split ":", 2
|
||||
if ($parts.Count -lt 2) { continue }
|
||||
$key = $parts[0].Trim()
|
||||
$val = $parts[1].Trim()
|
||||
if ($val -eq "true") { $cfg[$key] = $true }
|
||||
elseif ($val -eq "false") { $cfg[$key] = $false }
|
||||
elseif ($val -match "^\d+$") { $cfg[$key] = [int]$val }
|
||||
else { $cfg[$key] = $val }
|
||||
}
|
||||
$cfg
|
||||
}
|
||||
|
||||
function Test-CommandExists {
|
||||
param([string]$Name)
|
||||
return [bool](Get-Command $Name -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Test-PodmanAvailable {
|
||||
return (-not $SkipPodman) -and (Test-CommandExists "podman")
|
||||
}
|
||||
|
||||
# Podman writes progress to stderr. On Windows PowerShell 5.1, native stderr becomes ErrorRecords; redirecting 2>&1
|
||||
# still yields ErrorRecord objects, which Format-Table as scary red "NativeCommandError" even when the command succeeded.
|
||||
# Normalize to plain strings before Write-Host / log files.
|
||||
function Remove-AnsiEscape {
|
||||
param([string]$Text)
|
||||
if ([string]::IsNullOrEmpty($Text)) { return $Text }
|
||||
return [regex]::Replace($Text, "\x1B\[[0-9;]*[a-zA-Z]", "")
|
||||
}
|
||||
|
||||
# PowerShell 5.1 Set-Content -Encoding UTF8 is inconsistent for tool output; use UTF-8 no BOM for logs.
|
||||
function Write-ReportTextLines {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$LiteralPath,
|
||||
[string[]]$Lines
|
||||
)
|
||||
$enc = New-Object System.Text.UTF8Encoding $false
|
||||
$arr = [System.Collections.ArrayList]@()
|
||||
foreach ($x in $Lines) {
|
||||
if ($null -eq $x) { continue }
|
||||
[void]$arr.Add((Remove-AnsiEscape "$x"))
|
||||
}
|
||||
[System.IO.File]::WriteAllLines($LiteralPath, [string[]]$arr.ToArray(), $enc)
|
||||
}
|
||||
|
||||
function Convert-PodmanOutputLine {
|
||||
param($Object)
|
||||
if ($null -eq $Object) { return $null }
|
||||
if ($Object -is [System.Management.Automation.ErrorRecord]) {
|
||||
$to = $Object.TargetObject
|
||||
if ($null -ne $to -and "$to".Trim() -ne "") { return "$to".TrimEnd() }
|
||||
$em = $Object.Exception.Message
|
||||
if ($em) { return $em.Trim() }
|
||||
return ($Object.ToString())
|
||||
}
|
||||
$s = "$Object"
|
||||
if ($s) { return $s.TrimEnd() }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-PodmanOutputLines {
|
||||
param($Chunks)
|
||||
if ($null -eq $Chunks) { return }
|
||||
if ($Chunks -isnot [System.Array]) { $Chunks = @($Chunks) }
|
||||
foreach ($c in $Chunks) {
|
||||
$line = Convert-PodmanOutputLine $c
|
||||
if ($line) { Write-Host (Remove-AnsiEscape $line) }
|
||||
}
|
||||
}
|
||||
|
||||
# Temporarily relax ErrorAction for native exes; stderr is handled via Convert-PodmanOutputLine above.
|
||||
function Invoke-PodmanCommand {
|
||||
param([scriptblock]$Command)
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $Command
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PodmanExe {
|
||||
param([Parameter(Mandatory)][string[]]$Arguments)
|
||||
Invoke-PodmanCommand {
|
||||
$chunks = & podman @Arguments 2>&1
|
||||
Write-PodmanOutputLines $chunks
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PodmanRun {
|
||||
param([string[]]$PodmanArgs)
|
||||
if (-not (Test-PodmanAvailable)) {
|
||||
return $false
|
||||
}
|
||||
Invoke-PodmanExe $PodmanArgs
|
||||
return $true
|
||||
}
|
||||
|
||||
# Pull failures from `podman run` often do not use exit code 125; pull explicitly and try each candidate.
|
||||
function Resolve-PodmanImage {
|
||||
param([string[]]$Candidates)
|
||||
foreach ($img in ($Candidates | Where-Object { $_ } | Select-Object -Unique)) {
|
||||
Write-Host "podman: pulling $img ..." -ForegroundColor DarkGray
|
||||
Invoke-PodmanExe @("pull", $img)
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
return $img
|
||||
}
|
||||
Write-Host "podman: pull failed for $img (exit $LASTEXITCODE); trying next candidate if any..." -ForegroundColor Yellow
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$RepoRoot = Get-RepoRoot
|
||||
Set-Location $RepoRoot
|
||||
|
||||
if (-not $ConfigPath) {
|
||||
$ConfigPath = Join-Path $RepoRoot "security\security.config.yaml"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
||||
throw "Config not found: $ConfigPath"
|
||||
}
|
||||
$C = Read-FlatSecurityConfig -Path $ConfigPath
|
||||
|
||||
$reportsRel = $C["reports_dir"]
|
||||
if (-not $reportsRel) { $reportsRel = "security/reports" }
|
||||
$reportsDir = Join-Path $RepoRoot $reportsRel
|
||||
New-Item -ItemType Directory -Force -Path $reportsDir | Out-Null
|
||||
|
||||
$failed = $false
|
||||
$scanNotes = New-Object System.Collections.ArrayList
|
||||
|
||||
function Add-ScanNote {
|
||||
param([string]$Message)
|
||||
[void]$scanNotes.Add($Message)
|
||||
}
|
||||
|
||||
function Write-ReportInventory {
|
||||
Write-Host ""
|
||||
Write-Host "=== Report inventory ===" -ForegroundColor Cyan
|
||||
function Write-InvRow {
|
||||
param([string]$Label, [string]$RelPath, [int]$MinBytes)
|
||||
$p = Join-Path $reportsDir $RelPath
|
||||
if (-not (Test-Path -LiteralPath $p)) {
|
||||
Write-Host " [missing] $Label ($RelPath)" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
$len = (Get-Item -LiteralPath $p).Length
|
||||
$ok = ($len -ge $MinBytes)
|
||||
$flag = if ($ok) { "ok" } else { "small" }
|
||||
Write-Host " [$flag] $Label $len bytes $RelPath" -ForegroundColor $(if ($ok) { "DarkGray" } else { "Yellow" })
|
||||
}
|
||||
if ($C["semgrep_enabled"]) { Write-InvRow "Semgrep" "semgrep-report.json" 40 }
|
||||
if ($C["trivy_enabled"]) { Write-InvRow "Trivy" "trivy-fs.txt" 15 }
|
||||
if ($C["gitleaks_enabled"]) {
|
||||
Write-InvRow "gitleaks JSON" "gitleaks-report.json" 2
|
||||
Write-InvRow "gitleaks log" "gitleaks-console.log" 1
|
||||
}
|
||||
if ($C["osv_scanner_enabled"]) { Write-InvRow "OSV-Scanner" "osv-scanner-report.json" 30 }
|
||||
if ($C["qryon_enabled"]) {
|
||||
$qf = $C["qryon_format"]
|
||||
if (-not $qf) { $qf = "json" }
|
||||
$qryonRel = switch ("$qf") {
|
||||
"json" { "qryon-report.json" }
|
||||
"markdown" { "qryon-report.md" }
|
||||
"sarif" { "qryon-report.sarif" }
|
||||
"html" { "qryon-report.html" }
|
||||
default { "qryon-report.txt" }
|
||||
}
|
||||
$qRep = Join-Path $reportsDir $qryonRel
|
||||
$qSk = Join-Path $reportsDir "qryon-skipped.txt"
|
||||
if (Test-Path -LiteralPath $qRep) {
|
||||
Write-InvRow "Qryon" $qryonRel 40
|
||||
} elseif (Test-Path -LiteralPath $qSk) {
|
||||
Write-Host " [skip] Qryon (see qryon-skipped.txt)" -ForegroundColor DarkYellow
|
||||
} else {
|
||||
Write-Host " [missing] Qryon (no $qryonRel or qryon-skipped.txt)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Run-Step {
|
||||
param([string]$Name, [scriptblock]$Action)
|
||||
Write-Host ""
|
||||
Write-Host "=== $Name ===" -ForegroundColor Cyan
|
||||
try {
|
||||
& $Action
|
||||
} catch {
|
||||
Write-Host "FAILED: $_" -ForegroundColor Red
|
||||
$script:failed = $true
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo deny ---
|
||||
if ($C["cargo_deny_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo deny" {
|
||||
if (-not (Test-CommandExists "cargo-deny")) {
|
||||
throw "cargo-deny not on PATH. Install: cargo install cargo-deny"
|
||||
}
|
||||
& cargo-deny check
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo deny exited $LASTEXITCODE" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo audit ---
|
||||
if ($C["cargo_audit_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo audit" {
|
||||
if (-not (Test-CommandExists "cargo-audit")) {
|
||||
throw "cargo-audit not on PATH. Install: cargo install cargo-audit"
|
||||
}
|
||||
& cargo audit
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo audit exited $LASTEXITCODE" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- clippy ---
|
||||
if ($C["cargo_clippy_enabled"] -and -not $SkipClippy -and -not $SkipNative) {
|
||||
Run-Step "cargo clippy" {
|
||||
$extra = @()
|
||||
if ($C["cargo_clippy_deny_warnings"]) {
|
||||
$extra = @("--", "-D", "warnings")
|
||||
}
|
||||
& cargo clippy --all-targets --all-features @extra
|
||||
if ($LASTEXITCODE -ne 0) { throw "clippy exited $LASTEXITCODE" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo fmt --check (readability) ---
|
||||
if ($C["cargo_fmt_check_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo fmt --check" {
|
||||
& cargo fmt --check
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo fmt found formatting issues (run 'cargo fmt' to fix)" }
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo-geiger (unsafe code detection) ---
|
||||
if ($C["cargo_geiger_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo geiger" {
|
||||
if (-not (Test-CommandExists "cargo-geiger")) {
|
||||
Write-Host "cargo-geiger not installed. Install: cargo install cargo-geiger --locked" -ForegroundColor Yellow
|
||||
} else {
|
||||
$report = Join-Path $reportsDir "geiger-report.txt"
|
||||
& cargo geiger 2>&1 | Tee-Object -FilePath $report
|
||||
Write-Host "Geiger report saved to $report"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo-outdated (dependency freshness) ---
|
||||
if ($C["cargo_outdated_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo outdated" {
|
||||
if (-not (Test-CommandExists "cargo-outdated")) {
|
||||
Write-Host "cargo-outdated not installed. Install: cargo install cargo-outdated" -ForegroundColor Yellow
|
||||
} else {
|
||||
$report = Join-Path $reportsDir "outdated-report.txt"
|
||||
& cargo outdated 2>&1 | Tee-Object -FilePath $report
|
||||
Write-Host "Outdated report saved to $report"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- cargo-udeps (unused dependencies, requires nightly) ---
|
||||
if ($C["cargo_udeps_enabled"] -and -not $SkipNative) {
|
||||
Run-Step "cargo udeps" {
|
||||
if (-not (Test-CommandExists "cargo-udeps")) {
|
||||
Write-Host "cargo-udeps not installed. Install: cargo install cargo-udeps --locked" -ForegroundColor Yellow
|
||||
} else {
|
||||
& cargo +nightly udeps --all-targets
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "cargo udeps requires nightly toolchain: rustup install nightly" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Podman volumes: :Z is SELinux (Linux); omit on Windows/WSL if mounts fail—use :ro only.
|
||||
# /out is the same folder as $reportsDir on the host so container exits always leave artifacts to open locally.
|
||||
$mountSrc = "${RepoRoot}:/src:ro"
|
||||
$mountOut = "${reportsDir}:/out:rw"
|
||||
if ((Test-PodmanAvailable) -and ($C["semgrep_enabled"] -or $C["trivy_enabled"] -or $C["gitleaks_enabled"] -or $C["osv_scanner_enabled"] -or $C["qryon_enabled"])) {
|
||||
Write-Host "Podman report output mount: $reportsDir -> /out" -ForegroundColor DarkCyan
|
||||
}
|
||||
|
||||
# --- Semgrep ---
|
||||
if ($C["semgrep_enabled"]) {
|
||||
Run-Step "semgrep" {
|
||||
$cfg = $C["semgrep_config"]
|
||||
if (-not $cfg) { $cfg = "p/rust" }
|
||||
$image = $C["semgrep_image"]
|
||||
if (-not $image) { $image = "docker.io/semgrep/semgrep:latest" }
|
||||
$ran = $false
|
||||
if ($C["semgrep_use_podman"] -and (Test-PodmanAvailable)) {
|
||||
$ran = Invoke-PodmanRun -PodmanArgs @(
|
||||
"run", "--rm",
|
||||
"-v", $mountSrc,
|
||||
"-v", $mountOut,
|
||||
$image,
|
||||
"semgrep", "--error", "--config", $cfg, "--json", "--json-output", "/out/semgrep-report.json", "/src"
|
||||
)
|
||||
if ($ran -and $LASTEXITCODE -ne 0) { throw "semgrep (podman) exited $LASTEXITCODE" }
|
||||
if ($ran) {
|
||||
$semPath = Join-Path $reportsDir "semgrep-report.json"
|
||||
Write-Host "semgrep: JSON report -> $semPath" -ForegroundColor DarkGray
|
||||
if (Test-Path -LiteralPath $semPath) {
|
||||
try {
|
||||
$sj = Get-Content -LiteralPath $semPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$n = @($sj.results).Count
|
||||
Write-Host "semgrep: $n finding(s) in JSON (0 = clean)." -ForegroundColor DarkGray
|
||||
} catch {
|
||||
Write-Host "semgrep: could not parse JSON summary." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $ran) {
|
||||
if (Test-CommandExists "semgrep") {
|
||||
$semOut = Join-Path $reportsDir "semgrep-report.json"
|
||||
& semgrep --error --config $cfg --json --json-output $semOut $RepoRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "semgrep exited $LASTEXITCODE" }
|
||||
Write-Host "semgrep: JSON report -> $semOut" -ForegroundColor DarkGray
|
||||
try {
|
||||
$sj = Get-Content -LiteralPath $semOut -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
Write-Host "semgrep: $(@($sj.results).Count) finding(s) in JSON (0 = clean)." -ForegroundColor DarkGray
|
||||
} catch { }
|
||||
} else {
|
||||
throw "Semgrep not available. Install locally or set semgrep_use_podman with Podman installed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Trivy filesystem ---
|
||||
if ($C["trivy_enabled"]) {
|
||||
Run-Step "trivy fs" {
|
||||
$sev = $C["trivy_severity"]
|
||||
if (-not $sev) { $sev = "HIGH,CRITICAL" }
|
||||
$trivyExitWhenFound = if ($C["trivy_fail_on_findings"] -eq $true) { 1 } else { 0 }
|
||||
$primary = $C["trivy_image"]
|
||||
if (-not $primary) { $primary = "docker.io/aquasec/trivy:0.69.3" }
|
||||
$fallback = $C["trivy_fallback_image"]
|
||||
if (-not $fallback) { $fallback = "ghcr.io/aquasecurity/trivy:0.69.3" }
|
||||
$report = Join-Path $reportsDir "trivy-fs.txt"
|
||||
$trivySkipRaw = $C["trivy_skip_dirs"]
|
||||
if (-not $trivySkipRaw) { $trivySkipRaw = "target" }
|
||||
$trivySkipDirs = [string]$trivySkipRaw -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
$trivySkipArgs = @()
|
||||
foreach ($d in $trivySkipDirs) { $trivySkipArgs += @("--skip-dirs", $d) }
|
||||
$ran = $false
|
||||
if ($C["trivy_use_podman"] -and (Test-PodmanAvailable)) {
|
||||
$candidates = @($primary, $fallback)
|
||||
$chosen = Resolve-PodmanImage -Candidates $candidates
|
||||
if (-not $chosen) {
|
||||
throw "trivy: could not pull any image. Tried: $($candidates -join ', '). Log in: podman login docker.io`n or: podman login ghcr.io"
|
||||
}
|
||||
Write-Host "trivy: scanning with $chosen (skip-dirs: $($trivySkipDirs -join ', '))" -ForegroundColor DarkGray
|
||||
$trivyPodArgs = @(
|
||||
"run", "--rm", "-v", $mountSrc, "-v", $mountOut, $chosen, "fs"
|
||||
) + $trivySkipArgs + @(
|
||||
"--severity", $sev, "--scanners", "vuln", "--exit-code", "$trivyExitWhenFound",
|
||||
"--format", "table", "--output", "/out/trivy-fs.txt", "/src"
|
||||
)
|
||||
Invoke-PodmanExe $trivyPodArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "trivy (podman) exited $LASTEXITCODE" }
|
||||
$ran = $true
|
||||
Write-Host "trivy: report -> $report" -ForegroundColor DarkGray
|
||||
}
|
||||
if (-not $ran) {
|
||||
if (Test-CommandExists "trivy") {
|
||||
Invoke-PodmanCommand {
|
||||
$tc = & trivy fs @trivySkipArgs --severity $sev --scanners vuln --exit-code $trivyExitWhenFound --format table --output $report $RepoRoot 2>&1
|
||||
Write-PodmanOutputLines $tc
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "trivy exited $LASTEXITCODE" }
|
||||
Write-Host "trivy: report -> $report" -ForegroundColor DarkGray
|
||||
} else {
|
||||
throw "Trivy not available. Install locally or set trivy_use_podman with Podman installed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- gitleaks ---
|
||||
if ($C["gitleaks_enabled"]) {
|
||||
Run-Step "gitleaks" {
|
||||
$image = $C["gitleaks_image"]
|
||||
if (-not $image) { $image = "ghcr.io/gitleaks/gitleaks:v8.30.1" }
|
||||
$ran = $false
|
||||
# Podman path first: do not run host `git` here (avoids noise and ordering issues). Official GHCR image + --no-git on /src.
|
||||
if ($C["gitleaks_use_podman"] -and (Test-PodmanAvailable)) {
|
||||
$chosen = Resolve-PodmanImage -Candidates @($image)
|
||||
if (-not $chosen) {
|
||||
throw "gitleaks: could not pull $image. Try: podman login ghcr.io"
|
||||
}
|
||||
Write-Host "gitleaks: detect --no-git --source /src (Podman)" -ForegroundColor DarkGray
|
||||
$inner = @(
|
||||
"detect", "--no-git", "--source", "/src", "--verbose",
|
||||
"--report-format", "json", "--report-path", "/out/gitleaks-report.json"
|
||||
)
|
||||
$gLog = Join-Path $reportsDir "gitleaks-console.log"
|
||||
$gJson = Join-Path $reportsDir "gitleaks-report.json"
|
||||
$glPodArgs = @("run", "--rm", "-e", "NO_COLOR=1", "-v", $mountSrc, "-v", $mountOut, $chosen) + $inner
|
||||
Invoke-PodmanCommand {
|
||||
$chunks = & podman @glPodArgs 2>&1
|
||||
$lines = @($chunks | ForEach-Object { Convert-PodmanOutputLine $_ } | Where-Object { $_ })
|
||||
if ($lines.Count -gt 0) {
|
||||
Write-ReportTextLines -LiteralPath $gLog -Lines $lines
|
||||
} else {
|
||||
Write-ReportTextLines -LiteralPath $gLog -Lines @("(no console output)")
|
||||
}
|
||||
foreach ($ln in $lines) {
|
||||
if ($ln) { Write-Host (Remove-AnsiEscape $ln) }
|
||||
}
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw "gitleaks (podman) exited $LASTEXITCODE" }
|
||||
$ran = $true
|
||||
Write-Host "gitleaks: JSON report -> $gJson" -ForegroundColor DarkGray
|
||||
Write-Host "gitleaks: plain-text log -> $gLog (no secrets; same lines as above)" -ForegroundColor DarkGray
|
||||
if (Test-Path -LiteralPath $gJson) {
|
||||
try {
|
||||
$graw = Get-Content -LiteralPath $gJson -Raw -Encoding UTF8
|
||||
$gj = $graw | ConvertFrom-Json
|
||||
if ($null -eq $gj) { $gc = 0 }
|
||||
elseif ($gj -is [System.Array]) { $gc = $gj.Count }
|
||||
else { $gc = 1 }
|
||||
Write-Host "gitleaks: $gc leak finding(s) in JSON ([] = none)." -ForegroundColor DarkGray
|
||||
} catch {
|
||||
Write-Host "gitleaks: could not parse JSON report." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $ran) {
|
||||
$noGitNative = $false
|
||||
if ($C["gitleaks_no_git"]) {
|
||||
$noGitNative = $true
|
||||
} elseif (Test-Path (Join-Path $RepoRoot ".git")) {
|
||||
Push-Location $RepoRoot
|
||||
try {
|
||||
$null = git rev-parse --verify HEAD 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { $noGitNative = $true }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
$noGitNative = $true
|
||||
}
|
||||
if ($noGitNative) {
|
||||
Write-Host "gitleaks: native scan with --no-git" -ForegroundColor DarkYellow
|
||||
}
|
||||
if (Test-CommandExists "gitleaks") {
|
||||
$gOut = Join-Path $reportsDir "gitleaks-report.json"
|
||||
$local = @(
|
||||
"detect", "--source", $RepoRoot, "--verbose",
|
||||
"--report-format", "json", "--report-path", $gOut
|
||||
)
|
||||
if ($noGitNative) { $local += "--no-git" }
|
||||
& gitleaks @local
|
||||
if ($LASTEXITCODE -ne 0) { throw "gitleaks exited $LASTEXITCODE" }
|
||||
Write-Host "gitleaks: JSON report -> $gOut" -ForegroundColor DarkGray
|
||||
} else {
|
||||
throw "gitleaks not available. Install locally or set gitleaks_use_podman with Podman installed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- OSV-Scanner (V2 CLI: `scan -r`; image is ghcr.io/google/osv-scanner) ---
|
||||
if ($C["osv_scanner_enabled"]) {
|
||||
Run-Step "osv-scanner" {
|
||||
$image = $C["osv_scanner_image"]
|
||||
if (-not $image) { $image = "ghcr.io/google/osv-scanner:stable" }
|
||||
$ran = $false
|
||||
$osvReport = Join-Path $reportsDir "osv-scanner-report.json"
|
||||
if ($C["osv_scanner_use_podman"] -and (Test-PodmanAvailable)) {
|
||||
$ran = Invoke-PodmanRun -PodmanArgs @(
|
||||
"run", "--rm",
|
||||
"-v", $mountSrc,
|
||||
"-v", $mountOut,
|
||||
$image,
|
||||
"scan", "-r", "/src",
|
||||
"--format", "json",
|
||||
"--output-file", "/out/osv-scanner-report.json"
|
||||
)
|
||||
if ($ran) {
|
||||
$oec = $LASTEXITCODE
|
||||
# Exit 1 = vulnerabilities or license findings (not a crash). See https://google.github.io/osv-scanner/output/
|
||||
if ($oec -eq 1 -and $C["osv_scanner_fail_on_findings"] -ne $true) {
|
||||
Write-Host "osv-scanner: exit 1 means findings were reported; scan completed. Set osv_scanner_fail_on_findings: true to fail the script." -ForegroundColor Yellow
|
||||
} elseif ($oec -ne 0) {
|
||||
throw "osv-scanner (podman) exited $oec"
|
||||
}
|
||||
Write-Host "osv-scanner: JSON report -> $osvReport" -ForegroundColor DarkGray
|
||||
if (Test-Path -LiteralPath $osvReport) {
|
||||
try {
|
||||
$oj = Get-Content -LiteralPath $osvReport -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$src = @($oj.results).Count
|
||||
Write-Host "osv-scanner: $src top-level result block(s) in JSON (see file for package/vuln detail)." -ForegroundColor DarkGray
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $ran) {
|
||||
if (Test-CommandExists "osv-scanner") {
|
||||
Invoke-PodmanCommand {
|
||||
$oc = & osv-scanner scan -r $RepoRoot --format json --output-file $osvReport 2>&1
|
||||
Write-PodmanOutputLines $oc
|
||||
}
|
||||
$oec = $LASTEXITCODE
|
||||
if ($oec -eq 1 -and $C["osv_scanner_fail_on_findings"] -ne $true) {
|
||||
Write-Host "osv-scanner: exit 1 = findings reported (native run)." -ForegroundColor Yellow
|
||||
} elseif ($oec -ne 0) {
|
||||
throw "osv-scanner exited $oec"
|
||||
}
|
||||
Write-Host "osv-scanner: JSON report -> $osvReport" -ForegroundColor DarkGray
|
||||
if (Test-Path -LiteralPath $osvReport) {
|
||||
try {
|
||||
$oj = Get-Content -LiteralPath $osvReport -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
Write-Host "osv-scanner: $(@($oj.results).Count) source block(s) in JSON." -ForegroundColor DarkGray
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
throw "osv-scanner not on PATH. Install locally or set osv_scanner_use_podman with Podman installed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Qryon (SAST; https://github.com/bumahkib7/qryon — install: cargo install rma-cli, binary: qryon) ---
|
||||
# Uses current CLI: qryon scan [PATH] --no-color -f <format> -o <file> [-p profile]. NO_COLOR=1 alone breaks clap; we clear env and pass --no-color.
|
||||
# Upstream GHCR images have had glibc/base mismatches; verify in-container before scan when using Podman.
|
||||
if ($C["qryon_enabled"]) {
|
||||
Run-Step "qryon" {
|
||||
$primary = $C["qryon_image"]
|
||||
if (-not $primary) { $primary = "ghcr.io/bumahkib7/qryon:0.20.1" }
|
||||
$candidates = @(
|
||||
$primary,
|
||||
"ghcr.io/bumahkib7/qryon:latest"
|
||||
)
|
||||
$qFmt = $C["qryon_format"]
|
||||
if (-not $qFmt) { $qFmt = "json" }
|
||||
$qProf = $C["qryon_profile"]
|
||||
if (-not $qProf) { $qProf = "balanced" }
|
||||
$qryonRel = switch ("$qFmt") {
|
||||
"json" { "qryon-report.json" }
|
||||
"markdown" { "qryon-report.md" }
|
||||
"sarif" { "qryon-report.sarif" }
|
||||
"html" { "qryon-report.html" }
|
||||
default { "qryon-report.txt" }
|
||||
}
|
||||
$report = Join-Path $reportsDir $qryonRel
|
||||
$outInContainer = "/out/$qryonRel"
|
||||
$ran = $false
|
||||
$qryonStrict = ($C["qryon_fail_on_findings"] -eq $true)
|
||||
if ($C["qryon_use_podman"] -and (Test-PodmanAvailable)) {
|
||||
$prevEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$chosen = $null
|
||||
foreach ($img in ($candidates | Where-Object { $_ } | Select-Object -Unique)) {
|
||||
Write-Host "qryon: pulling $img ..." -ForegroundColor DarkGray
|
||||
Invoke-PodmanExe @("pull", $img)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "qryon: pull failed for $img (exit $LASTEXITCODE); trying next candidate if any..." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
Write-Host "qryon: verifying binary starts in $img ..." -ForegroundColor DarkGray
|
||||
$verifyChunks = & podman run --rm $img --version 2>&1
|
||||
Write-PodmanOutputLines $verifyChunks
|
||||
$vec = $LASTEXITCODE
|
||||
if ($vec -ne 0) {
|
||||
Write-Host "qryon: image pulled but binary does not run in-container (exit $vec). Often GLIBC_* mismatch between binary and base image. Trying next candidate if any." -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
$chosen = $img
|
||||
break
|
||||
}
|
||||
if ($chosen) {
|
||||
Write-Host "qryon: scanning with $chosen (-f $qFmt -p $qProf -> $qryonRel)" -ForegroundColor DarkGray
|
||||
Invoke-PodmanCommand {
|
||||
$qc = & podman run --rm -v $mountSrc -v $mountOut $chosen scan /src --no-color -p $qProf -f $qFmt -o $outInContainer 2>&1
|
||||
Write-PodmanOutputLines $qc
|
||||
}
|
||||
$qec = $LASTEXITCODE
|
||||
if ($qec -eq 0) {
|
||||
$ran = $true
|
||||
} elseif ($qec -eq 1 -and -not $qryonStrict) {
|
||||
Write-Host "qryon: exit 1 likely means findings; continuing. Set qryon_fail_on_findings: true to fail the script." -ForegroundColor Yellow
|
||||
$ran = $true
|
||||
} else {
|
||||
throw "qryon (podman) exited $qec (image: $chosen)"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $report)) {
|
||||
throw "qryon (podman) did not write report at $report"
|
||||
}
|
||||
Remove-Item (Join-Path $reportsDir "qryon-skipped.txt") -ErrorAction SilentlyContinue
|
||||
Write-Host "qryon: report -> $report" -ForegroundColor DarkGray
|
||||
if ("$qFmt" -eq "json") {
|
||||
try {
|
||||
$qj = Get-Content -LiteralPath $report -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$qFiles = @($qj.results).Count
|
||||
$qFind = 0
|
||||
foreach ($row in @($qj.results)) {
|
||||
if ($row.findings) { $qFind += @($row.findings).Count }
|
||||
}
|
||||
Write-Host "qryon: $qFiles file row(s) in JSON; $qFind security finding(s) across results[].findings." -ForegroundColor DarkGray
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
Write-Host "qryon: no usable container image (pull or in-container start failed). Tried: $(($candidates | Select-Object -Unique) -join ', ')" -ForegroundColor Yellow
|
||||
Write-Host "qryon: use native CLI: cargo install rma-cli (or set qryon_use_podman: false)" -ForegroundColor Yellow
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEap
|
||||
}
|
||||
}
|
||||
if (-not $ran) {
|
||||
if (Test-CommandExists "qryon") {
|
||||
# clap maps NO_COLOR to --no-color; value "1" is invalid. Clear for this invocation; use explicit --no-color.
|
||||
$noColorSaved = $env:NO_COLOR
|
||||
Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue
|
||||
try {
|
||||
Invoke-PodmanCommand {
|
||||
$chunks = & qryon scan $RepoRoot --no-color -p $qProf -f $qFmt -o $report 2>&1
|
||||
Write-PodmanOutputLines $chunks
|
||||
}
|
||||
} finally {
|
||||
if ($null -ne $noColorSaved) { $env:NO_COLOR = $noColorSaved }
|
||||
}
|
||||
$qec = $LASTEXITCODE
|
||||
if ($qec -eq 1 -and -not $qryonStrict) {
|
||||
Write-Host "qryon: exit 1 likely means findings (native run)." -ForegroundColor Yellow
|
||||
} elseif ($qec -ne 0) {
|
||||
throw "qryon exited $qec"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $report)) {
|
||||
throw "qryon did not write report at $report"
|
||||
}
|
||||
Remove-Item (Join-Path $reportsDir "qryon-skipped.txt") -ErrorAction SilentlyContinue
|
||||
Write-Host "qryon: report -> $report" -ForegroundColor DarkGray
|
||||
if ("$qFmt" -eq "json") {
|
||||
try {
|
||||
$qj = Get-Content -LiteralPath $report -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$qFiles = @($qj.results).Count
|
||||
$qFind = 0
|
||||
foreach ($row in @($qj.results)) {
|
||||
if ($row.findings) { $qFind += @($row.findings).Count }
|
||||
}
|
||||
Write-Host "qryon: $qFiles file row(s) in JSON; $qFind security finding(s) across results[].findings." -ForegroundColor DarkGray
|
||||
} catch {
|
||||
Write-Host "qryon: could not parse JSON summary." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "Qryon not on PATH and no working Podman image. Install: cargo install rma-cli (binary: qryon)" -ForegroundColor Yellow
|
||||
$skipPath = Join-Path $reportsDir "qryon-skipped.txt"
|
||||
Write-ReportTextLines -LiteralPath $skipPath -Lines @(
|
||||
"Qryon did not run.",
|
||||
"",
|
||||
"Causes:",
|
||||
" - Container images ghcr.io/bumahkib7/qryon:* fail in-container: binary requires GLIBC 2.38+ but the image base is older.",
|
||||
" - Native CLI 'qryon' is not on PATH (install with: cargo install rma-cli).",
|
||||
"",
|
||||
"Options:",
|
||||
" - cargo install rma-cli then ensure ~/.cargo/bin is on PATH; set qryon_use_podman: false.",
|
||||
" - Or set qryon_enabled: false until upstream fixes the image.",
|
||||
""
|
||||
)
|
||||
Write-Host "qryon: wrote skip summary -> $skipPath" -ForegroundColor DarkGray
|
||||
Add-ScanNote "Qryon did not run (see qryon-skipped.txt)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- DAST placeholder ---
|
||||
if ($C["dast_enabled"]) {
|
||||
Write-Host ""
|
||||
Write-Host "=== DAST (manual) ===" -ForegroundColor Yellow
|
||||
$base = $C["dast_base_url"]
|
||||
$zapPort = $C["zap_port"]
|
||||
Write-Host "dast_enabled is true. Run OWASP ZAP or Nuclei against: $base"
|
||||
$zh = $C["zap_host"]
|
||||
if (-not $zh) { $zh = "127.0.0.1" }
|
||||
Write-Host "Example ZAP API/daemon port (config): ${zh}:$zapPort"
|
||||
Write-Host "Podman (ZAP): podman run -u zap -p ${zapPort}:8080 docker.io/zaproxy/zap-stable zap.sh -daemon -host 0.0.0.0 -port 8080"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-ReportInventory
|
||||
Write-Host ""
|
||||
if ($failed) {
|
||||
Write-Host "Security scan finished with failures." -ForegroundColor Red
|
||||
Write-Host "Partial logs and reports may still be under: $reportsDir" -ForegroundColor DarkYellow
|
||||
exit 1
|
||||
}
|
||||
if ($scanNotes.Count -gt 0) {
|
||||
Write-Host "Security scan finished; all configured steps ran without hard failures." -ForegroundColor Green
|
||||
Write-Host "Notes:" -ForegroundColor Yellow
|
||||
foreach ($n in $scanNotes) { Write-Host " - $n" -ForegroundColor DarkYellow }
|
||||
} else {
|
||||
Write-Host "Security scan completed successfully." -ForegroundColor Green
|
||||
}
|
||||
Write-Host "Reports directory: $reportsDir" -ForegroundColor DarkGray
|
||||
exit 0
|
||||
@@ -0,0 +1,65 @@
|
||||
# ByteDraft security scan settings (read by scripts/security-scan.ps1).
|
||||
# Flat key: value lines only (no nested YAML). Lines starting with # are comments.
|
||||
#
|
||||
# --- Rust (native cargo tools) ---
|
||||
cargo_deny_enabled: true
|
||||
cargo_audit_enabled: true
|
||||
cargo_clippy_enabled: true
|
||||
cargo_clippy_deny_warnings: true
|
||||
cargo_fmt_check_enabled: true
|
||||
cargo_geiger_enabled: true
|
||||
cargo_outdated_enabled: true
|
||||
cargo_udeps_enabled: false
|
||||
|
||||
# --- Container-based scanners via Podman ---
|
||||
# Run: podman machine start (Windows/Mac) before enabling these.
|
||||
semgrep_enabled: true
|
||||
semgrep_use_podman: true
|
||||
semgrep_image: docker.io/semgrep/semgrep:latest
|
||||
semgrep_config: p/rust
|
||||
# Semgrep CE scans only git-tracked files; add/commit paths you want included.
|
||||
|
||||
trivy_enabled: true
|
||||
trivy_use_podman: true
|
||||
# Prefer Docker Hub if you use `podman login docker.io` (script pulls each candidate until one succeeds).
|
||||
trivy_image: docker.io/aquasec/trivy:0.69.3
|
||||
trivy_fallback_image: ghcr.io/aquasecurity/trivy:0.69.3
|
||||
trivy_severity: HIGH,CRITICAL
|
||||
# Comma-separated names under the scan root (e.g. target). Skipping target avoids walk errors on Windows+Podman bind mounts over Rust build dirs.
|
||||
trivy_skip_dirs: target
|
||||
# When false, Trivy uses --exit-code 0 so findings still print but the script step does not fail.
|
||||
trivy_fail_on_findings: false
|
||||
|
||||
gitleaks_enabled: true
|
||||
gitleaks_use_podman: true
|
||||
# Official image (zricethezav/* on Docker Hub is legacy; avoids old entrypoints / git quirks).
|
||||
gitleaks_image: ghcr.io/gitleaks/gitleaks:v8.30.1
|
||||
gitleaks_no_git: false
|
||||
# Podman runs always use --no-git (Windows bind-mount + git often breaks). Native gitleaks: --no-git if no HEAD or if true here.
|
||||
|
||||
osv_scanner_enabled: true
|
||||
osv_scanner_use_podman: true
|
||||
# Official image is on GHCR only (docker.io/google/osv-scanner is not published).
|
||||
osv_scanner_image: ghcr.io/google/osv-scanner:stable
|
||||
# Exit 1 means vulnerabilities/licenses were reported, not a crash. When false, the script continues (see output).
|
||||
osv_scanner_fail_on_findings: false
|
||||
|
||||
# Qryon - SAST (https://github.com/bumahkib7/qryon). CLI from crates.io: `cargo install rma-cli` (binary: qryon).
|
||||
# Script runs: qryon scan <repo> --no-color -p <profile> -f <format> -o <reports_dir>/qryon-report.<ext>
|
||||
qryon_enabled: true
|
||||
qryon_use_podman: false
|
||||
qryon_profile: balanced
|
||||
qryon_format: json
|
||||
# When qryon_use_podman is true: same flags inside the container; output to /out/qryon-report.<ext>
|
||||
qryon_image: ghcr.io/bumahkib7/qryon:0.20.1
|
||||
qryon_fail_on_findings: false
|
||||
|
||||
# --- DAST (OWASP ZAP, Nuclei): only when you run a reachable HTTP service ---
|
||||
dast_enabled: false
|
||||
dast_base_url: https://localhost:8443
|
||||
zap_host: 127.0.0.1
|
||||
zap_port: 8090
|
||||
|
||||
# --- Output ---
|
||||
# Podman mounts this directory at /out (rw). Expect semgrep-report.json, trivy-fs.txt, gitleaks-report.json (+ gitleaks-console.log), osv-scanner-report.json, qryon-report.<ext per qryon_format> or qryon-skipped.txt.
|
||||
reports_dir: security/reports
|
||||
Reference in New Issue
Block a user