Skip to content

Commit d693fdd

Browse files
committed
smart copy, language updates
1 parent a163539 commit d693fdd

34 files changed

Lines changed: 992 additions & 128 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [4.1.0] - 2026-01-10
9+
10+
### Fixes
11+
12+
* fix a few misleading error messages
13+
* fix an issue where some unlikely Applesoft syntax errors could be passed over
14+
* Applesoft minifier handles removing REM statements at the end of a program
15+
* eliminate a panic that could happen during Merlin macro expansion
16+
17+
### New Behaviors
18+
19+
* the `-t auto` option works in more cases
20+
21+
### New Features
22+
23+
* "smart copy" subcommand `cp`
24+
* Merlin language support is improved
25+
- hovering on an ENT label will list the modules that use it
26+
- `go to references` on an ENT finds corresponding EXT labels in other modules
27+
- error checking of macro arguments allows for conditionals
28+
829
## [4.0.1] - 2025-10-04
930

1031
### Fixes

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a2kit"
3-
version = "4.0.1"
3+
version = "4.1.0"
44
edition = "2024"
55
readme = "README.md"
66
license = "MIT"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Command line interface and library for retro disk images, file systems, and lang
55
* Designed to be scriptable
66
* Language Servers - Applesoft, Integer BASIC, Merlin Assembly
77
- deep analysis, tokenization, disassembly, adheres to [LSP](https://microsoft.github.io/language-server-protocol/)
8-
* File Systems - Apple DOS 3.x, ProDOS, CP/M, Pascal, FAT (such as MS-DOS)
8+
* File Systems - Apple DOS 3.2, Apple DOS 3.3, ProDOS, CP/M, Pascal, FAT (such as MS-DOS)
99
- full read and write access
1010
- high or low level manipulations
1111
- interface for handling sparse and random access files

src/bin/server-merlin/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,10 @@ fn main() -> Result<(), Box<dyn Error + Sync + Send>> {
376376
if let Some(chkpt) = tools.doc_chkpts.get_mut(&result.uri.to_string()) {
377377
update_client_toolbar(&connection, &result.symbols).expect("toolbar update failed");
378378
chkpt.update_symbols(result.symbols);
379+
chkpt.update_ws_symbols(tools.workspace.entries.clone());
379380
chkpt.update_folding_ranges(result.folding);
380381
tools.hover_provider.use_shared_symbols(chkpt.shared_symbols());
382+
tools.hover_provider.use_shared_ws_symbols(chkpt.shared_ws_symbols());
381383
tools.completion_provider.use_shared_symbols(chkpt.shared_symbols());
382384
tools.tokenizer.use_shared_symbols(chkpt.shared_symbols());
383385
tools.formatter.use_shared_symbols(chkpt.shared_symbols());

src/cli.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ Detokenize from image: `a2kit get -f prog -t atok -d myimg.dsk | a2kit detokeniz
149149
.after_long_help(long_help)
150150
.version(crate_version!());
151151

152+
main_cmd = main_cmd.subcommand(
153+
Command::new("cp")
154+
.arg(Arg::new("paths").num_args(2..=1000).help("sequence of paths, last path is the destination").value_name("PATHS").required(true))
155+
.arg(Arg::new("addr").long("addr").short('a').help("load-address if applicable").value_name("ADDRESS").required(false))
156+
.arg(pro_arg())
157+
.arg(method_arg())
158+
.about("smart copy that formats for the target")
159+
.after_help("Disk images can appear midway through a path,
160+
the path then continues inside the image (e.g. /path/to/mydisk.woz/startup).
161+
Glob patterns outside a disk image are only expanded if the shell expands them,
162+
whereas glob patterns inside a disk image are expanded by a2kit.
163+
Delimiters like quotes are sometimes needed for the latter to work.
164+
This subcommand is meant as a convenience in non-critical situations.
165+
If you need to be precise the get/put syntax may be more appropriate.")
166+
);
152167
main_cmd = main_cmd.subcommand(
153168
Command::new("get")
154169
.arg(file_arg("path, key, or address, maybe inside disk image",false,true).long_help(F_LONG_HELP))

src/commands/ezcopy.rs

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
//! # Provide the `cp` subcommand
2+
//!
3+
//! This subcommand is specially designed to provide *convenient* syntax and semantics for shuttling
4+
//! files back and forth between disk images and the host file system.
5+
//! It works for image-to-image copying if the file systems are the same.
6+
//! It will reject a host-to-host copy request since that should be handled by native commands.
7+
//! The get and put subcommands, while less convenient, are more precise, and might be a better
8+
//! choice for mission critical scripts.
9+
10+
use clap;
11+
use regex::Regex;
12+
use std::str::FromStr;
13+
use std::path::PathBuf;
14+
15+
use super::CommandError;
16+
use crate::fs::{DiskFS,FileImage,UnpackedData,dos3x,prodos};
17+
use crate::img::tracks::Method;
18+
use crate::{DYNERR,STDRESULT};
19+
use crate::lang::{applesoft,integer,merlin,is_lang};
20+
21+
enum Destination {
22+
/// disk object, path to image, path inside image
23+
Dimg(Box<dyn DiskFS>,String,String),
24+
/// path on the host system
25+
Host(String)
26+
}
27+
28+
/// Parse a path the starts with a disk image and ends with a path inside the disk image.
29+
/// Returns a tuple with (path_to_disk_image, path_inside_disk_image), where the second part can be an empty string.
30+
/// Panics if `fused` does not match `dimg_patt`
31+
fn parse_fused_path(fused: &str,dimg_patt: &Regex) -> Result<(String,String),DYNERR> {
32+
let mut locs = dimg_patt.capture_locations();
33+
dimg_patt.captures_read(&mut locs,fused);
34+
let (_,end) = locs.get(0).unwrap();
35+
let path_to_dimg = fused[0..end].to_owned();
36+
if fused.len() > end && &fused[end..end+1] != "/" {
37+
log::error!("{} is not formatted correctly",fused);
38+
return Err(Box::new(CommandError::InvalidCommand));
39+
}
40+
// We will always throw out the leading `/` from the path inside.
41+
// If ProDOS users want to specify the volume name (perhaps as a check) they can use `//`.
42+
let path_in_dimg = match fused.len() - end {
43+
0 => String::new(),
44+
1 => String::new(),
45+
_ => fused[end+1..].to_owned()
46+
};
47+
Ok((path_to_dimg,path_in_dimg))
48+
}
49+
50+
/// Combine src_path and dst_path using logic that the user likely expects.
51+
/// If there is 1 source and the destination is not null, and not an existing directory,
52+
/// then the destination is used as is. Otherwise the source filename is joined to the destination path.
53+
fn revise_destination_path(src_path: &str, dst_path: &str, src_count: usize, dst_dir_exists: bool) -> Result<String,DYNERR> {
54+
let new_path = match (src_count,dst_dir_exists,dst_path.is_empty()) {
55+
(1,false,false) => PathBuf::from(dst_path),
56+
_ => {
57+
match PathBuf::from(src_path).file_name() {
58+
Some(fname) => PathBuf::from(dst_path).join(fname),
59+
None => return Err(Box::new(CommandError::FileNotFound))
60+
}
61+
}
62+
};
63+
match new_path.as_os_str().to_str() {
64+
Some(s) => Ok(s.to_owned()),
65+
None => Err(Box::new(CommandError::FileNotFound))
66+
}
67+
}
68+
69+
/// Pack up data into a file image after transforming to the emulated system's format.
70+
/// The input slice may be fully parsed for identification purposes.
71+
fn smart_pack(fimg: &mut FileImage, dat: &[u8], load_addr: Option<usize>) -> STDRESULT {
72+
match str::from_utf8(dat) {
73+
Ok(program) => {
74+
if is_lang(tree_sitter_applesoft::LANGUAGE.into(),program) {
75+
log::info!("detected Applesoft");
76+
let start_addr = match load_addr {
77+
Some(addr) => u16::try_from(addr)?,
78+
None => 2049
79+
};
80+
let mut tokenizer = applesoft::tokenizer::Tokenizer::new();
81+
let tok = tokenizer.tokenize(&program,start_addr)?;
82+
fimg.pack_tok(&tok,super::ItemType::ApplesoftTokens,None)
83+
} else if is_lang(tree_sitter_integerbasic::LANGUAGE.into(), program) {
84+
log::info!("detected Integer BASIC");
85+
let mut tokenizer = integer::tokenizer::Tokenizer::new();
86+
let tok = tokenizer.tokenize(program.to_string())?;
87+
fimg.pack_tok(&tok,super::ItemType::IntegerTokens,None)
88+
} else if is_lang(tree_sitter_merlin6502::LANGUAGE.into(), program) {
89+
log::info!("detected Merlin");
90+
let mut tokenizer = merlin::tokenizer::Tokenizer::new();
91+
let tok = tokenizer.tokenize(program.to_string())?;
92+
fimg.pack_raw(&tok)
93+
} else {
94+
// this will take care of either records, or the case where
95+
// the data is already a file image
96+
fimg.pack(&dat,load_addr)
97+
}
98+
},
99+
Err(_) => {
100+
fimg.pack(&dat,load_addr)
101+
}
102+
}
103+
}
104+
105+
/// Unpack a file image and transform the data to a string that is readable and invertible on the host system.
106+
/// In this direction there is a chance file system hints can be used for identification, while in some
107+
/// cases it is still necessary to parse the whole slice.
108+
fn smart_unpack(fimg: &FileImage) -> Result<UnpackedData,DYNERR> {
109+
// Coerce DOS types to ProDOS types so we can handle all at once via packing trait.
110+
// For pascal, FAT, and CP/M we only have the generic unpacking.
111+
let maybe_file_type = match fimg.file_system.as_str() {
112+
"prodos" => prodos::Packer::get_prodos_type(fimg),
113+
"a2 dos" => match dos3x::Packer::get_dos3x_type(fimg) {
114+
Some(dos3x::types::FileType::Applesoft) => Some(prodos::types::FileType::ApplesoftCode),
115+
Some(dos3x::types::FileType::Integer) => Some(prodos::types::FileType::IntegerCode),
116+
Some(dos3x::types::FileType::Text) => Some(prodos::types::FileType::Text),
117+
_ => None
118+
},
119+
_ => None
120+
};
121+
match maybe_file_type {
122+
Some(prodos::types::FileType::ApplesoftCode) => {
123+
log::info!("detected Applesoft");
124+
let toks = fimg.unpack_tok()?;
125+
let tokenizer = applesoft::tokenizer::Tokenizer::new();
126+
Ok(UnpackedData::Text(tokenizer.detokenize(&toks)?))
127+
},
128+
Some(prodos::types::FileType::IntegerCode) => {
129+
log::info!("detected Integer BASIC");
130+
let toks = fimg.unpack_tok()?;
131+
let tokenizer = integer::tokenizer::Tokenizer::new();
132+
Ok(UnpackedData::Text(tokenizer.detokenize(&toks)?))
133+
},
134+
Some(prodos::types::FileType::Text) => {
135+
// some processing to see if this is Merlin
136+
let merlin_code = fimg.unpack_raw(true)?;
137+
let mut tokenizer = merlin::tokenizer::Tokenizer::new();
138+
tokenizer.set_err_log(false);
139+
match tokenizer.detokenize(&merlin_code) {
140+
Ok(src) => {
141+
match is_lang(tree_sitter_merlin6502::LANGUAGE.into(), &src) {
142+
true => {
143+
log::info!("detected Merlin");
144+
Ok(UnpackedData::Text(src))
145+
},
146+
false => fimg.unpack()
147+
}
148+
},
149+
Err(_) => fimg.unpack()
150+
}
151+
},
152+
_ => fimg.unpack()
153+
}
154+
}
155+
156+
fn gather(src: Vec<String>,dst: &Destination,dimg_patt: &Regex,cmd: &clap::ArgMatches) -> Result<Vec<FileImage>,DYNERR> {
157+
let mut ans = Vec::new();
158+
let fmt = super::get_fmt(cmd)?;
159+
let load_addr: Option<usize> = match cmd.get_one::<String>("addr") {
160+
Some(a) => Some(usize::from_str(a)?),
161+
_ => None
162+
};
163+
164+
for src_path in src {
165+
166+
match dimg_patt.is_match(&src_path) {
167+
true => {
168+
let (path_to,path_in) = parse_fused_path(&src_path,dimg_patt)?;
169+
let mut src_disk = crate::create_fs_from_file(&path_to,fmt.as_ref())?;
170+
src_disk.get_img().change_method(Method::from_str(cmd.get_one::<String>("method").unwrap())?);
171+
match src_disk.glob(&path_in,false) {
172+
Ok(vlist) => {
173+
for v in vlist {
174+
ans.push(src_disk.get(&v)?);
175+
}
176+
},
177+
Err(_) => ans.push(src_disk.get(&path_in)?)
178+
}
179+
},
180+
false => {
181+
match (dst,std::fs::read(&src_path)) {
182+
(Destination::Host(_),_) => {
183+
log::error!("refusing host-to-host copy");
184+
return Err(Box::new(CommandError::InvalidCommand))
185+
},
186+
(Destination::Dimg(dst_disk,_,_),Ok(dat)) => {
187+
let pbuf = PathBuf::from(src_path);
188+
let filename = match pbuf.file_name() {
189+
Some(p) => p.to_string_lossy(),
190+
None => return Err(Box::new(CommandError::FileNotFound))
191+
};
192+
let mut fimg = dst_disk.new_fimg(None,true,&filename.to_string())?;
193+
smart_pack(&mut fimg,&dat,load_addr)?;
194+
ans.push(fimg);
195+
},
196+
(_,Err(e)) => return Err(Box::new(e))
197+
}
198+
}
199+
}
200+
}
201+
Ok(ans)
202+
}
203+
204+
pub fn ezcopy(cmd: &clap::ArgMatches) -> STDRESULT {
205+
206+
// let rec_len = match cmd.get_one::<String>("len") {
207+
// Some(s) => Some(usize::from_str(s)?),
208+
// None => None
209+
// };
210+
211+
// First stage, setup and gather sources
212+
213+
let dimg_patt = Regex::new(r"(?i)\.(2mg|d13|dsk|do|dsk|ima|imd|img|nib|po|td0|woz)").expect("failed to parse regex");
214+
let mut path_list: Vec<String> = cmd.get_many::<String>("paths").expect("no paths").map(|x| x.to_owned()).collect();
215+
let fused = path_list.pop().unwrap();
216+
let fmt = super::get_fmt(cmd)?;
217+
let mut dst = match dimg_patt.is_match(&fused) {
218+
true => {
219+
let (path_to,path_inside) = parse_fused_path(&fused,&dimg_patt)?;
220+
let mut dimg = crate::create_fs_from_file(&path_to,fmt.as_ref())?;
221+
dimg.get_img().change_method(Method::from_str(cmd.get_one::<String>("method").unwrap())?);
222+
Destination::Dimg(dimg,path_to,path_inside)
223+
},
224+
false => {
225+
Destination::Host(fused.clone())
226+
}
227+
};
228+
let dst_dir_exists = match &mut dst {
229+
Destination::Dimg(disk, _, path_inside) => {
230+
log::info!("image destination {}",path_inside);
231+
match disk.catalog_to_vec(path_inside) {
232+
Ok(_) => true,
233+
Err(_) => false
234+
}
235+
},
236+
Destination::Host(target_path) => {
237+
log::info!("host destination {}",target_path);
238+
if PathBuf::from(target_path.as_str()).is_file() {
239+
log::error!("destination already exists as a file");
240+
return Err(Box::new(CommandError::InvalidCommand));
241+
}
242+
PathBuf::from(target_path.as_str()).is_dir()
243+
}
244+
};
245+
let mut src_list = gather(path_list,&dst,&dimg_patt,cmd)?;
246+
247+
// Second stage, write to destination
248+
249+
let src_count = src_list.len();
250+
for src in &mut src_list {
251+
match &mut dst {
252+
Destination::Dimg(dst_disk, _, raw_dst_path) => {
253+
let dst_path = revise_destination_path(&src.full_path, &raw_dst_path, src_count, dst_dir_exists)?;
254+
log::info!("copy {} -> {}",src.full_path,dst_path);
255+
dst_disk.put_at(&dst_path,src)?;
256+
},
257+
Destination::Host(raw_dst_path) => {
258+
let dst_path = revise_destination_path(&src.full_path, raw_dst_path, src_count, dst_dir_exists)?;
259+
if PathBuf::from(dst_path.as_str()).is_file() {
260+
log::error!("destination {} already exists as a file",dst_path);
261+
return Err(Box::new(CommandError::InvalidCommand));
262+
}
263+
log::info!("copy {} -> {}",src.full_path,dst_path);
264+
match smart_unpack(src)? {
265+
UnpackedData::Binary(dat) => std::fs::write(&dst_path,&dat).expect("host file system error"),
266+
UnpackedData::Text(s) => std::fs::write(&dst_path,s.as_bytes()).expect("host file system error"),
267+
UnpackedData::Records(r) => {
268+
let rec_str = r.to_json(None);
269+
std::fs::write(&dst_path,rec_str.as_bytes()).expect("host file system error")
270+
}
271+
}
272+
}
273+
}
274+
}
275+
match &mut dst {
276+
Destination::Dimg(dst_disk,dimg_path,_) => crate::save_img(dst_disk,&dimg_path),
277+
Destination::Host(_) => Ok(())
278+
}
279+
}

src/commands/get.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ fn unpack_primitive(fimg: &FileImage,typ: ItemType,rec_len: Option<usize>,trunc:
4343

4444
pub fn unpack(cmd: &clap::ArgMatches) -> STDRESULT {
4545
if atty::is(atty::Stream::Stdin) {
46-
log::error!("cannot use `put` with console input, please pipe something in");
46+
log::error!("cannot use `unpack` with console input, please pipe something in");
4747
return Err(Box::new(CommandError::InvalidCommand));
4848
}
4949
let mut dat = Vec::new();
5050
std::io::stdin().read_to_end(&mut dat).expect("failed to read input stream");
5151
if dat.len()==0 {
52-
log::error!("put did not receive any data from previous node");
52+
log::error!("unpack did not receive any data from previous node");
5353
return Err(Box::new(CommandError::InvalidCommand));
5454
}
5555
let trunc = cmd.get_flag("trunc");

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod stat;
1111
pub mod modify;
1212
pub mod langx;
1313
pub mod completions;
14+
pub mod ezcopy;
1415

1516
use std::str::FromStr;
1617
use std::io::Read;

src/commands/put.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ fn pack_primitive(fimg: &mut FileImage, dat: &[u8], load_addr: Option<usize>, ty
3232

3333
pub fn pack(cmd: &clap::ArgMatches) -> STDRESULT {
3434
if atty::is(atty::Stream::Stdin) {
35-
log::error!("cannot use `put` with console input, please pipe something in");
35+
log::error!("cannot use `pack` with console input, please pipe something in");
3636
return Err(Box::new(CommandError::InvalidCommand));
3737
}
3838
let mut dat = Vec::new();
3939
std::io::stdin().read_to_end(&mut dat).expect("failed to read input stream");
4040
if dat.len()==0 {
41-
log::error!("put did not receive any data from previous node");
41+
log::error!("pack did not receive any data from previous node");
4242
return Err(Box::new(CommandError::InvalidCommand));
4343
}
4444
let path = cmd.get_one::<String>("file").unwrap();

0 commit comments

Comments
 (0)