practicode 0.1.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/Cargo.lock +767 -0
- package/Cargo.toml +20 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/assets/practicode-terminal.svg +24 -0
- package/bin/practicode.js +39 -0
- package/docs/problem-authoring-notes.md +33 -0
- package/package.json +44 -0
- package/scripts/npm-postinstall.js +20 -0
- package/src/ai.rs +213 -0
- package/src/core.rs +831 -0
- package/src/lib.rs +31 -0
- package/src/main.rs +3 -0
- package/src/process.rs +82 -0
- package/src/text.rs +263 -0
- package/src/tui.rs +1173 -0
package/src/lib.rs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use crossterm::{cursor::SetCursorStyle, execute};
|
|
3
|
+
use std::{env, io::stdout};
|
|
4
|
+
|
|
5
|
+
pub mod ai;
|
|
6
|
+
pub mod core;
|
|
7
|
+
pub mod process;
|
|
8
|
+
pub mod text;
|
|
9
|
+
pub mod tui;
|
|
10
|
+
|
|
11
|
+
pub fn run_cli() -> Result<()> {
|
|
12
|
+
let root = env::current_dir().context("read current directory")?;
|
|
13
|
+
if env::args().any(|arg| arg == "--smoke") {
|
|
14
|
+
let bank = core::load_bank(&root)?;
|
|
15
|
+
let state = core::load_state(&root, &bank)?;
|
|
16
|
+
let problem = core::problem_by_id(&bank, &state.current_problem).unwrap_or(&bank[0]);
|
|
17
|
+
println!(
|
|
18
|
+
"{}",
|
|
19
|
+
core::localized(&problem.title, &state.settings.ui_language)
|
|
20
|
+
);
|
|
21
|
+
return Ok(());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let mut app = tui::PracticodeApp::new(root)?;
|
|
25
|
+
let mut terminal = ratatui::init();
|
|
26
|
+
let _ = execute!(stdout(), SetCursorStyle::SteadyBar);
|
|
27
|
+
let result = app.run(&mut terminal);
|
|
28
|
+
ratatui::restore();
|
|
29
|
+
let _ = execute!(stdout(), SetCursorStyle::DefaultUserShape);
|
|
30
|
+
result
|
|
31
|
+
}
|
package/src/main.rs
ADDED
package/src/process.rs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use std::{
|
|
3
|
+
env,
|
|
4
|
+
io::Write,
|
|
5
|
+
path::PathBuf,
|
|
6
|
+
process::{Command, Stdio},
|
|
7
|
+
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
8
|
+
};
|
|
9
|
+
use wait_timeout::ChildExt;
|
|
10
|
+
|
|
11
|
+
#[derive(Clone, Debug)]
|
|
12
|
+
pub struct CommandSpec {
|
|
13
|
+
pub program: PathBuf,
|
|
14
|
+
pub args: Vec<String>,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#[derive(Clone, Debug)]
|
|
18
|
+
pub struct RunOutput {
|
|
19
|
+
pub code: Option<i32>,
|
|
20
|
+
pub stdout: String,
|
|
21
|
+
pub stderr: String,
|
|
22
|
+
pub timed_out: bool,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub fn run_capture(command: &mut Command, input: &str, timeout: Duration) -> Result<RunOutput> {
|
|
26
|
+
command
|
|
27
|
+
.stdin(Stdio::piped())
|
|
28
|
+
.stdout(Stdio::piped())
|
|
29
|
+
.stderr(Stdio::piped());
|
|
30
|
+
let mut child = command.spawn().context("spawn command")?;
|
|
31
|
+
if let Some(stdin) = child.stdin.as_mut() {
|
|
32
|
+
stdin.write_all(input.as_bytes()).context("write stdin")?;
|
|
33
|
+
}
|
|
34
|
+
drop(child.stdin.take());
|
|
35
|
+
|
|
36
|
+
let timed_out = match child.wait_timeout(timeout).context("wait for command")? {
|
|
37
|
+
Some(_) => false,
|
|
38
|
+
None => {
|
|
39
|
+
let _ = child.kill();
|
|
40
|
+
true
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
let output = child.wait_with_output().context("read command output")?;
|
|
44
|
+
Ok(RunOutput {
|
|
45
|
+
code: output.status.code(),
|
|
46
|
+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
|
47
|
+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
|
48
|
+
timed_out,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub fn which(name: &str) -> Option<PathBuf> {
|
|
53
|
+
let paths = env::var_os("PATH")?;
|
|
54
|
+
env::split_paths(&paths).find_map(|dir| {
|
|
55
|
+
let path = dir.join(name);
|
|
56
|
+
if path.is_file() { Some(path) } else { None }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn shell_process(command: &str) -> Command {
|
|
61
|
+
if cfg!(windows) {
|
|
62
|
+
let mut process = Command::new("cmd");
|
|
63
|
+
process.args(["/C", command]);
|
|
64
|
+
process
|
|
65
|
+
} else {
|
|
66
|
+
let mut process = Command::new("sh");
|
|
67
|
+
process.args(["-c", command]);
|
|
68
|
+
process
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pub fn sh_quote(value: &str) -> String {
|
|
73
|
+
format!("'{}'", value.replace('\'', "'\\''"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pub fn unique_temp_path(prefix: &str, ext: &str) -> PathBuf {
|
|
77
|
+
let nanos = SystemTime::now()
|
|
78
|
+
.duration_since(UNIX_EPOCH)
|
|
79
|
+
.unwrap_or_default()
|
|
80
|
+
.as_nanos();
|
|
81
|
+
env::temp_dir().join(format!("{prefix}-{}-{nanos}.{ext}", std::process::id()))
|
|
82
|
+
}
|
package/src/text.rs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
use unicode_width::UnicodeWidthStr;
|
|
2
|
+
|
|
3
|
+
pub fn render_markdown_plain(markdown: &str) -> String {
|
|
4
|
+
let mut out = Vec::new();
|
|
5
|
+
let mut in_fence = false;
|
|
6
|
+
for line in markdown.lines() {
|
|
7
|
+
if line.trim_start().starts_with("```") {
|
|
8
|
+
in_fence = !in_fence;
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if in_fence {
|
|
12
|
+
out.push(line.to_string());
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
let trimmed = line.trim_start();
|
|
16
|
+
if trimmed.starts_with('#') {
|
|
17
|
+
out.push(trimmed.trim_start_matches('#').trim_start().to_string());
|
|
18
|
+
} else {
|
|
19
|
+
out.push(line.replace('`', ""));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
out.join("\n").trim_end().to_string()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
pub fn byte_index(value: &str, char_index: usize) -> usize {
|
|
26
|
+
value
|
|
27
|
+
.char_indices()
|
|
28
|
+
.nth(char_index)
|
|
29
|
+
.map(|(index, _)| index)
|
|
30
|
+
.unwrap_or(value.len())
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn char_len(value: &str) -> usize {
|
|
34
|
+
value.chars().count()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub fn prefix(value: &str, char_index: usize) -> String {
|
|
38
|
+
value.chars().take(char_index).collect()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub fn display_width(value: &str) -> usize {
|
|
42
|
+
UnicodeWidthStr::width(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CHO: &[char] = &[
|
|
46
|
+
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ',
|
|
47
|
+
'ㅌ', 'ㅍ', 'ㅎ',
|
|
48
|
+
];
|
|
49
|
+
const JUNG: &[char] = &[
|
|
50
|
+
'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ',
|
|
51
|
+
'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ',
|
|
52
|
+
];
|
|
53
|
+
const JONG: &[char] = &[
|
|
54
|
+
'\0', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ',
|
|
55
|
+
'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
pub fn compose_hangul_jamo(value: &str) -> String {
|
|
59
|
+
let mut out = String::new();
|
|
60
|
+
let mut run = Vec::new();
|
|
61
|
+
for char in decompose_hangul(value).chars() {
|
|
62
|
+
if is_hangul_jamo(char) {
|
|
63
|
+
run.push(char);
|
|
64
|
+
} else {
|
|
65
|
+
if !run.is_empty() {
|
|
66
|
+
out.push_str(&compose_hangul_run(&run));
|
|
67
|
+
run.clear();
|
|
68
|
+
}
|
|
69
|
+
out.push(char);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if !run.is_empty() {
|
|
73
|
+
out.push_str(&compose_hangul_run(&run));
|
|
74
|
+
}
|
|
75
|
+
out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fn decompose_hangul(value: &str) -> String {
|
|
79
|
+
let mut chars = String::new();
|
|
80
|
+
for char in value.chars() {
|
|
81
|
+
let code = char as u32;
|
|
82
|
+
if (0xAC00..=0xD7A3).contains(&code) {
|
|
83
|
+
let offset = code - 0xAC00;
|
|
84
|
+
let lead = (offset / 588) as usize;
|
|
85
|
+
let vowel = ((offset % 588) / 28) as usize;
|
|
86
|
+
let tail = (offset % 28) as usize;
|
|
87
|
+
chars.push(CHO[lead]);
|
|
88
|
+
chars.push(JUNG[vowel]);
|
|
89
|
+
if tail != 0 {
|
|
90
|
+
chars.push(JONG[tail]);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
chars.push(char);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
chars
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn compose_hangul_run(chars: &[char]) -> String {
|
|
100
|
+
let mut out = String::new();
|
|
101
|
+
let mut lead = None;
|
|
102
|
+
let mut vowel = None;
|
|
103
|
+
let mut tail = None;
|
|
104
|
+
|
|
105
|
+
fn emit(
|
|
106
|
+
out: &mut String,
|
|
107
|
+
lead: &mut Option<char>,
|
|
108
|
+
vowel: &mut Option<char>,
|
|
109
|
+
tail: &mut Option<char>,
|
|
110
|
+
) {
|
|
111
|
+
match (*lead, *vowel) {
|
|
112
|
+
(Some(l), Some(v)) => {
|
|
113
|
+
if let (Some(l_index), Some(v_index)) = (cho_index(l), jung_index(v)) {
|
|
114
|
+
let code = 0xAC00
|
|
115
|
+
+ ((l_index * 21 + v_index) * 28 + tail.and_then(jong_index).unwrap_or(0))
|
|
116
|
+
as u32;
|
|
117
|
+
if let Some(char) = char::from_u32(code) {
|
|
118
|
+
out.push(char);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
for part in [*lead, *vowel, *tail].into_iter().flatten() {
|
|
122
|
+
out.push(part);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
_ => {
|
|
127
|
+
for part in [*lead, *vowel, *tail].into_iter().flatten() {
|
|
128
|
+
out.push(part);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
*lead = None;
|
|
133
|
+
*vowel = None;
|
|
134
|
+
*tail = None;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for &char in chars {
|
|
138
|
+
if jung_index(char).is_some() {
|
|
139
|
+
if lead.is_none() {
|
|
140
|
+
out.push(char);
|
|
141
|
+
} else if vowel.is_none() {
|
|
142
|
+
vowel = Some(char);
|
|
143
|
+
} else if tail.is_none() {
|
|
144
|
+
if let Some(combined) = combine_jung(vowel.unwrap(), char) {
|
|
145
|
+
vowel = Some(combined);
|
|
146
|
+
} else {
|
|
147
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
148
|
+
out.push(char);
|
|
149
|
+
}
|
|
150
|
+
} else if let Some((first, second)) = split_jong(tail.unwrap()) {
|
|
151
|
+
tail = Some(first);
|
|
152
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
153
|
+
lead = Some(second);
|
|
154
|
+
vowel = Some(char);
|
|
155
|
+
} else {
|
|
156
|
+
let next_lead = tail;
|
|
157
|
+
tail = None;
|
|
158
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
159
|
+
lead = next_lead;
|
|
160
|
+
vowel = Some(char);
|
|
161
|
+
}
|
|
162
|
+
} else if lead.is_none() {
|
|
163
|
+
lead = Some(char);
|
|
164
|
+
} else if vowel.is_none() {
|
|
165
|
+
if let Some(combined) = combine_cho(lead.unwrap(), char) {
|
|
166
|
+
lead = Some(combined);
|
|
167
|
+
} else {
|
|
168
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
169
|
+
lead = Some(char);
|
|
170
|
+
}
|
|
171
|
+
} else if tail.is_none() && jong_index(char).is_some() {
|
|
172
|
+
tail = Some(char);
|
|
173
|
+
} else if tail.is_some() {
|
|
174
|
+
if let Some(combined) = combine_jong(tail.unwrap(), char) {
|
|
175
|
+
tail = Some(combined);
|
|
176
|
+
} else {
|
|
177
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
178
|
+
lead = Some(char);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
182
|
+
lead = Some(char);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
emit(&mut out, &mut lead, &mut vowel, &mut tail);
|
|
186
|
+
out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fn is_hangul_jamo(char: char) -> bool {
|
|
190
|
+
cho_index(char).is_some() || jung_index(char).is_some() || jong_index(char).is_some()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fn cho_index(char: char) -> Option<usize> {
|
|
194
|
+
CHO.iter().position(|value| *value == char)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn jung_index(char: char) -> Option<usize> {
|
|
198
|
+
JUNG.iter().position(|value| *value == char)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fn jong_index(char: char) -> Option<usize> {
|
|
202
|
+
JONG.iter()
|
|
203
|
+
.position(|value| *value == char)
|
|
204
|
+
.filter(|index| *index != 0)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fn combine_cho(a: char, b: char) -> Option<char> {
|
|
208
|
+
match (a, b) {
|
|
209
|
+
('ㄱ', 'ㄱ') => Some('ㄲ'),
|
|
210
|
+
('ㄷ', 'ㄷ') => Some('ㄸ'),
|
|
211
|
+
('ㅂ', 'ㅂ') => Some('ㅃ'),
|
|
212
|
+
('ㅅ', 'ㅅ') => Some('ㅆ'),
|
|
213
|
+
('ㅈ', 'ㅈ') => Some('ㅉ'),
|
|
214
|
+
_ => None,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fn combine_jung(a: char, b: char) -> Option<char> {
|
|
219
|
+
match (a, b) {
|
|
220
|
+
('ㅗ', 'ㅏ') => Some('ㅘ'),
|
|
221
|
+
('ㅗ', 'ㅐ') => Some('ㅙ'),
|
|
222
|
+
('ㅗ', 'ㅣ') => Some('ㅚ'),
|
|
223
|
+
('ㅜ', 'ㅓ') => Some('ㅝ'),
|
|
224
|
+
('ㅜ', 'ㅔ') => Some('ㅞ'),
|
|
225
|
+
('ㅜ', 'ㅣ') => Some('ㅟ'),
|
|
226
|
+
('ㅡ', 'ㅣ') => Some('ㅢ'),
|
|
227
|
+
_ => None,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fn combine_jong(a: char, b: char) -> Option<char> {
|
|
232
|
+
match (a, b) {
|
|
233
|
+
('ㄱ', 'ㅅ') => Some('ㄳ'),
|
|
234
|
+
('ㄴ', 'ㅈ') => Some('ㄵ'),
|
|
235
|
+
('ㄴ', 'ㅎ') => Some('ㄶ'),
|
|
236
|
+
('ㄹ', 'ㄱ') => Some('ㄺ'),
|
|
237
|
+
('ㄹ', 'ㅁ') => Some('ㄻ'),
|
|
238
|
+
('ㄹ', 'ㅂ') => Some('ㄼ'),
|
|
239
|
+
('ㄹ', 'ㅅ') => Some('ㄽ'),
|
|
240
|
+
('ㄹ', 'ㅌ') => Some('ㄾ'),
|
|
241
|
+
('ㄹ', 'ㅍ') => Some('ㄿ'),
|
|
242
|
+
('ㄹ', 'ㅎ') => Some('ㅀ'),
|
|
243
|
+
('ㅂ', 'ㅅ') => Some('ㅄ'),
|
|
244
|
+
_ => None,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fn split_jong(char: char) -> Option<(char, char)> {
|
|
249
|
+
match char {
|
|
250
|
+
'ㄳ' => Some(('ㄱ', 'ㅅ')),
|
|
251
|
+
'ㄵ' => Some(('ㄴ', 'ㅈ')),
|
|
252
|
+
'ㄶ' => Some(('ㄴ', 'ㅎ')),
|
|
253
|
+
'ㄺ' => Some(('ㄹ', 'ㄱ')),
|
|
254
|
+
'ㄻ' => Some(('ㄹ', 'ㅁ')),
|
|
255
|
+
'ㄼ' => Some(('ㄹ', 'ㅂ')),
|
|
256
|
+
'ㄽ' => Some(('ㄹ', 'ㅅ')),
|
|
257
|
+
'ㄾ' => Some(('ㄹ', 'ㅌ')),
|
|
258
|
+
'ㄿ' => Some(('ㄹ', 'ㅍ')),
|
|
259
|
+
'ㅀ' => Some(('ㄹ', 'ㅎ')),
|
|
260
|
+
'ㅄ' => Some(('ㅂ', 'ㅅ')),
|
|
261
|
+
_ => None,
|
|
262
|
+
}
|
|
263
|
+
}
|