cobolx 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.devcontainer/devcontainer.json +26 -0
- package/.dockerignore +4 -0
- package/.github/workflows/ci.yml +157 -0
- package/Cargo.lock +2245 -0
- package/Cargo.toml +39 -0
- package/bin/check-update.js +44 -0
- package/bin/cobolx.js +81 -0
- package/docker-compose.yml +33 -0
- package/dockerfile +18 -0
- package/dockerfile.test +39 -0
- package/package.json +27 -0
- package/scripts/install.js +145 -0
- package/src/agent/client.rs +1345 -0
- package/src/agent.rs +1 -0
- package/src/cobol/copybook.rs +71 -0
- package/src/cobol/data_parser.rs +290 -0
- package/src/cobol/indexer.rs +256 -0
- package/src/cobol/layout.rs +278 -0
- package/src/cobol/lexer.rs +135 -0
- package/src/cobol/model.rs +196 -0
- package/src/cobol/scanner.rs +72 -0
- package/src/cobol/source_parser.rs +91 -0
- package/src/cobol.rs +8 -0
- package/src/config/config.rs +64 -0
- package/src/config.rs +3 -0
- package/src/lib.rs +6 -0
- package/src/main.rs +20 -0
- package/src/memory/files.rs +155 -0
- package/src/memory/store.rs +406 -0
- package/src/memory.rs +5 -0
- package/src/ui/draw.rs +519 -0
- package/src/ui/tui.rs +812 -0
- package/src/ui.rs +2 -0
- package/tests/indexer_tests.rs +192 -0
- package/tests/memory_store_tests.rs +21 -0
- package/tests/project_files_tests.rs +72 -0
- package/tests/sandbox_tests.rs +178 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
use crate::cobol::lexer::{clean_name, logical_lines, tokenize};
|
|
2
|
+
use crate::cobol::model::{CallKind, ParsedCall, ParsedCopy, ParsedFile, ParsedProgram, Token};
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
pub(crate) fn parse_source_file(path: &Path) -> std::io::Result<ParsedFile> {
|
|
6
|
+
let content = std::fs::read_to_string(path)?;
|
|
7
|
+
let mut programs = Vec::new();
|
|
8
|
+
let mut copies = Vec::new();
|
|
9
|
+
let mut calls = Vec::new();
|
|
10
|
+
let mut current_program = None::<String>;
|
|
11
|
+
|
|
12
|
+
for line in logical_lines(&content) {
|
|
13
|
+
let tokens = tokenize(&line.text);
|
|
14
|
+
if tokens.is_empty() {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for idx in 0..tokens.len() {
|
|
19
|
+
match tokens[idx].text.as_str() {
|
|
20
|
+
"PROGRAM-ID" => {
|
|
21
|
+
if let Some(name) = tokens.get(idx + 1).map(|t| clean_name(&t.text)) {
|
|
22
|
+
if !name.is_empty() {
|
|
23
|
+
current_program = Some(name.clone());
|
|
24
|
+
programs.push(ParsedProgram {
|
|
25
|
+
name,
|
|
26
|
+
start_offset: line.start_offset + tokens[idx].start,
|
|
27
|
+
byte_len: line.byte_len,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
"COPY" => {
|
|
33
|
+
if let Some(name) = tokens.get(idx + 1).map(|t| clean_name(&t.text)) {
|
|
34
|
+
if !name.is_empty() {
|
|
35
|
+
let replacing_text = tokens
|
|
36
|
+
.iter()
|
|
37
|
+
.find(|t| t.text == "REPLACING")
|
|
38
|
+
.map(|t| line.text[t.start..].trim().to_string());
|
|
39
|
+
copies.push(ParsedCopy {
|
|
40
|
+
name,
|
|
41
|
+
start_offset: line.start_offset + tokens[idx].start,
|
|
42
|
+
byte_len: line.byte_len,
|
|
43
|
+
replacing_text,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
"CALL" => {
|
|
49
|
+
if let Some(target) = tokens.get(idx + 1) {
|
|
50
|
+
let name = clean_name(&target.text);
|
|
51
|
+
if !name.is_empty() {
|
|
52
|
+
let kind = if target.quoted {
|
|
53
|
+
CallKind::Static
|
|
54
|
+
} else {
|
|
55
|
+
CallKind::Dynamic
|
|
56
|
+
};
|
|
57
|
+
calls.push(ParsedCall {
|
|
58
|
+
caller_name: current_program.clone(),
|
|
59
|
+
target: name,
|
|
60
|
+
kind,
|
|
61
|
+
start_offset: line.start_offset + tokens[idx].start,
|
|
62
|
+
byte_len: line.byte_len,
|
|
63
|
+
using_count: count_using_args(&tokens[idx + 2..]),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
_ => {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Ok(ParsedFile {
|
|
74
|
+
path: path.to_path_buf(),
|
|
75
|
+
programs,
|
|
76
|
+
copies,
|
|
77
|
+
calls,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn count_using_args(tokens: &[Token]) -> usize {
|
|
82
|
+
let Some(using_idx) = tokens.iter().position(|t| t.text == "USING") else {
|
|
83
|
+
return 0;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
tokens[using_idx + 1..]
|
|
87
|
+
.iter()
|
|
88
|
+
.take_while(|t| !matches!(t.text.as_str(), "END-CALL" | "RETURNING" | "GIVING"))
|
|
89
|
+
.filter(|t| !matches!(t.text.as_str(), "BY" | "REFERENCE" | "CONTENT" | "VALUE"))
|
|
90
|
+
.count()
|
|
91
|
+
}
|
package/src/cobol.rs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
|
|
3
|
+
#[derive(Serialize, Deserialize, Clone, Default)]
|
|
4
|
+
pub struct ConfigData {
|
|
5
|
+
pub deepseek_api_key: String,
|
|
6
|
+
pub glm_api_key: String,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
pub struct ConfigManager;
|
|
10
|
+
|
|
11
|
+
impl ConfigManager {
|
|
12
|
+
/// Loads the configuration data from the standard location.
|
|
13
|
+
/// Automatically generates directories and standard config file if they do not exist.
|
|
14
|
+
pub fn load_or_create() -> (Option<String>, ConfigData) {
|
|
15
|
+
let mut config_path_str = None;
|
|
16
|
+
let mut config_data = ConfigData::default();
|
|
17
|
+
|
|
18
|
+
if let Some(proj_dirs) = directories::ProjectDirs::from("com", "cobolx", "rdo") {
|
|
19
|
+
let config_dir = proj_dirs.config_dir();
|
|
20
|
+
let config_file_path = config_dir.join("config.json");
|
|
21
|
+
config_path_str = Some(config_file_path.to_string_lossy().to_string());
|
|
22
|
+
|
|
23
|
+
if !config_dir.exists() {
|
|
24
|
+
let _ = std::fs::create_dir_all(config_dir);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if !config_file_path.exists() {
|
|
28
|
+
let template = ConfigData {
|
|
29
|
+
deepseek_api_key: "".to_string(),
|
|
30
|
+
glm_api_key: "".to_string(),
|
|
31
|
+
};
|
|
32
|
+
if let Ok(serialized) = serde_json::to_string_pretty(&template) {
|
|
33
|
+
let _ = std::fs::write(&config_file_path, serialized);
|
|
34
|
+
}
|
|
35
|
+
} else if let Ok(content) = std::fs::read_to_string(&config_file_path) {
|
|
36
|
+
if let Ok(parsed) = serde_json::from_str::<ConfigData>(&content) {
|
|
37
|
+
config_data = parsed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
(config_path_str, config_data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Saves the configuration data back to the file.
|
|
46
|
+
pub fn save(data: &ConfigData) -> Result<(), std::io::Error> {
|
|
47
|
+
if let Some(proj_dirs) = directories::ProjectDirs::from("com", "cobolx", "rdo") {
|
|
48
|
+
let config_dir = proj_dirs.config_dir();
|
|
49
|
+
let config_file_path = config_dir.join("config.json");
|
|
50
|
+
if !config_dir.exists() {
|
|
51
|
+
std::fs::create_dir_all(config_dir)?;
|
|
52
|
+
}
|
|
53
|
+
let serialized = serde_json::to_string_pretty(data)
|
|
54
|
+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
55
|
+
std::fs::write(config_file_path, serialized)?;
|
|
56
|
+
} else {
|
|
57
|
+
return Err(std::io::Error::new(
|
|
58
|
+
std::io::ErrorKind::NotFound,
|
|
59
|
+
"Could not locate project directories",
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
Ok(())
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/config.rs
ADDED
package/src/lib.rs
ADDED
package/src/main.rs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
mod agent;
|
|
2
|
+
mod cobol;
|
|
3
|
+
mod config;
|
|
4
|
+
mod memory;
|
|
5
|
+
mod ui;
|
|
6
|
+
|
|
7
|
+
#[tokio::main]
|
|
8
|
+
async fn main() {
|
|
9
|
+
if let Ok(root) = std::env::current_dir() {
|
|
10
|
+
if let Err(e) = memory::MemoryStore::open_or_create(root) {
|
|
11
|
+
eprintln!("Error initializing memory store: {}", e);
|
|
12
|
+
std::process::exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if let Err(e) = ui::tui::run_tui() {
|
|
17
|
+
eprintln!("Error running TUI: {}", e);
|
|
18
|
+
std::process::exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
use crate::memory::MemoryStore;
|
|
2
|
+
use std::error::Error;
|
|
3
|
+
use std::path::{Path, PathBuf};
|
|
4
|
+
|
|
5
|
+
type FileResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|
6
|
+
|
|
7
|
+
#[allow(dead_code)]
|
|
8
|
+
impl MemoryStore {
|
|
9
|
+
pub fn write_markdown(
|
|
10
|
+
&self,
|
|
11
|
+
relative_path: impl AsRef<Path>,
|
|
12
|
+
content: &str,
|
|
13
|
+
) -> FileResult<PathBuf> {
|
|
14
|
+
let path = resolve_markdown(self.docs_dir(), relative_path.as_ref())?;
|
|
15
|
+
write_file(&path, content)?;
|
|
16
|
+
Ok(path)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub fn append_markdown(
|
|
20
|
+
&self,
|
|
21
|
+
relative_path: impl AsRef<Path>,
|
|
22
|
+
content: &str,
|
|
23
|
+
) -> FileResult<PathBuf> {
|
|
24
|
+
let path = resolve_markdown(self.docs_dir(), relative_path.as_ref())?;
|
|
25
|
+
append_file(&path, content)?;
|
|
26
|
+
Ok(path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub fn read_markdown(&self, relative_path: impl AsRef<Path>) -> FileResult<String> {
|
|
30
|
+
let path = resolve_markdown(self.docs_dir(), relative_path.as_ref())?;
|
|
31
|
+
Ok(std::fs::read_to_string(path)?)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
pub fn write_skill_file(
|
|
35
|
+
&self,
|
|
36
|
+
relative_path: impl AsRef<Path>,
|
|
37
|
+
content: &str,
|
|
38
|
+
) -> FileResult<PathBuf> {
|
|
39
|
+
let path = resolve_under(self.skills_dir(), relative_path.as_ref())?;
|
|
40
|
+
write_file(&path, content)?;
|
|
41
|
+
Ok(path)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pub fn read_skill_file(&self, relative_path: impl AsRef<Path>) -> FileResult<String> {
|
|
45
|
+
let path = resolve_under(self.skills_dir(), relative_path.as_ref())?;
|
|
46
|
+
Ok(std::fs::read_to_string(path)?)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn resolve_markdown(base: &Path, relative_path: &Path) -> FileResult<PathBuf> {
|
|
51
|
+
let raw = relative_path.as_os_str().to_string_lossy();
|
|
52
|
+
if !raw.to_ascii_lowercase().ends_with(".md") {
|
|
53
|
+
return Err("markdown file path must end with .md".into());
|
|
54
|
+
}
|
|
55
|
+
resolve_under(base, relative_path)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn resolve_under(base: &Path, relative_path: &Path) -> FileResult<PathBuf> {
|
|
59
|
+
let raw = relative_path.as_os_str().to_string_lossy();
|
|
60
|
+
let normalized = normalize_relative_path(&raw)?;
|
|
61
|
+
let mut sanitized = PathBuf::new();
|
|
62
|
+
for part in normalized.split('/') {
|
|
63
|
+
sanitized.push(part);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if sanitized.as_os_str().is_empty() {
|
|
67
|
+
return Err("file path must not be empty".into());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Ok(base.join(sanitized))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fn normalize_relative_path(raw: &str) -> FileResult<String> {
|
|
74
|
+
if raw.is_empty() {
|
|
75
|
+
return Err("file path must not be empty".into());
|
|
76
|
+
}
|
|
77
|
+
if raw.as_bytes().contains(&0) {
|
|
78
|
+
return Err("file path must not contain NUL".into());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let normalized = raw.replace('\\', "/");
|
|
82
|
+
if normalized.starts_with('/') {
|
|
83
|
+
return Err("file path must be relative".into());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let mut parts = Vec::new();
|
|
87
|
+
for part in normalized.split('/') {
|
|
88
|
+
if part.is_empty() || part == "." {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if part == ".." {
|
|
92
|
+
return Err("file path must not contain parent components".into());
|
|
93
|
+
}
|
|
94
|
+
if has_windows_forbidden_chars(part) {
|
|
95
|
+
return Err("file path contains characters invalid on Windows".into());
|
|
96
|
+
}
|
|
97
|
+
if is_windows_reserved_name(part) {
|
|
98
|
+
return Err("file path contains a Windows reserved name".into());
|
|
99
|
+
}
|
|
100
|
+
parts.push(part);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if parts.is_empty() {
|
|
104
|
+
return Err("file path must not be empty".into());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
Ok(parts.join("/"))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn has_windows_forbidden_chars(part: &str) -> bool {
|
|
111
|
+
part.contains(':')
|
|
112
|
+
|| part
|
|
113
|
+
.chars()
|
|
114
|
+
.any(|c| matches!(c, '<' | '>' | '"' | '|' | '?' | '*'))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn is_windows_reserved_name(part: &str) -> bool {
|
|
118
|
+
let stem = part
|
|
119
|
+
.split('.')
|
|
120
|
+
.next()
|
|
121
|
+
.unwrap_or(part)
|
|
122
|
+
.trim_end_matches(' ')
|
|
123
|
+
.to_ascii_uppercase();
|
|
124
|
+
|
|
125
|
+
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
|
126
|
+
|| stem
|
|
127
|
+
.strip_prefix("COM")
|
|
128
|
+
.and_then(|n| n.parse::<u8>().ok())
|
|
129
|
+
.is_some_and(|n| (1..=9).contains(&n))
|
|
130
|
+
|| stem
|
|
131
|
+
.strip_prefix("LPT")
|
|
132
|
+
.and_then(|n| n.parse::<u8>().ok())
|
|
133
|
+
.is_some_and(|n| (1..=9).contains(&n))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
fn write_file(path: &Path, content: &str) -> FileResult<()> {
|
|
137
|
+
if let Some(parent) = path.parent() {
|
|
138
|
+
std::fs::create_dir_all(parent)?;
|
|
139
|
+
}
|
|
140
|
+
std::fs::write(path, content)?;
|
|
141
|
+
Ok(())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fn append_file(path: &Path, content: &str) -> FileResult<()> {
|
|
145
|
+
if let Some(parent) = path.parent() {
|
|
146
|
+
std::fs::create_dir_all(parent)?;
|
|
147
|
+
}
|
|
148
|
+
use std::io::Write;
|
|
149
|
+
let mut file = std::fs::OpenOptions::new()
|
|
150
|
+
.create(true)
|
|
151
|
+
.append(true)
|
|
152
|
+
.open(path)?;
|
|
153
|
+
file.write_all(content.as_bytes())?;
|
|
154
|
+
Ok(())
|
|
155
|
+
}
|