1use std::time::{SystemTime, UNIX_EPOCH};
2
3use crate::{
4 cmd::glob::{GlobOptions, expand_path},
5 error::to_shell_err,
6 globals::{get_pwd, get_vfs},
7};
8use jacquard::chrono;
9use nu_engine::CallExt;
10use nu_protocol::{
11 Category, ListStream, PipelineData, Record, ShellError, Signature, SyntaxShape, Type, Value,
12 engine::{Command, EngineState, Stack},
13};
14use std::sync::Arc;
15
16#[derive(Clone)]
17pub struct Ls;
18
19impl Command for Ls {
20 fn name(&self) -> &str {
21 "ls"
22 }
23
24 fn signature(&self) -> Signature {
25 Signature::build("ls")
26 .optional(
27 "path",
28 SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::GlobPattern]),
29 "the path to list",
30 )
31 .switch(
32 "all",
33 "include hidden paths (that start with a dot)",
34 Some('a'),
35 )
36 .switch(
37 "long",
38 "show detailed information about each file",
39 Some('l'),
40 )
41 .switch("full-paths", "display paths as absolute paths", Some('f'))
42 .input_output_type(Type::Nothing, Type::table())
43 .category(Category::FileSystem)
44 }
45
46 fn description(&self) -> &str {
47 "list the files in the virtual filesystem."
48 }
49
50 fn run(
51 &self,
52 engine_state: &EngineState,
53 stack: &mut Stack,
54 call: &nu_protocol::engine::Call,
55 _input: PipelineData,
56 ) -> Result<PipelineData, ShellError> {
57 let path_arg: Option<Value> = call.opt(engine_state, stack, 0)?;
58 let all = call.has_flag(engine_state, stack, "all")?;
59 let long = call.has_flag(engine_state, stack, "long")?;
60 let full_paths = call.has_flag(engine_state, stack, "full-paths")?;
61
62 let pwd = get_pwd();
63 let span = call.head;
64
65 // If no path provided, list current directory
66 let (matches, base_path) = if let Some(path_val) = &path_arg {
67 let path_str = match path_val {
68 Value::String { val, .. } | Value::Glob { val, .. } => val,
69 _ => {
70 return Err(ShellError::GenericError {
71 error: "invalid path".into(),
72 msg: "path must be a string or glob pattern".into(),
73 span: Some(call.arguments_span()),
74 help: None,
75 inner: vec![],
76 });
77 }
78 };
79
80 let is_absolute = path_str.starts_with('/');
81 let base_path: Arc<vfs::VfsPath> = if is_absolute { get_vfs() } else { pwd.clone() };
82
83 // Check if it's a glob pattern
84 let is_glob = path_str.contains('*')
85 || path_str.contains('?')
86 || path_str.contains('[')
87 || path_str.contains("**");
88
89 if is_glob {
90 // Glob pattern: expand and list matching paths
91 let options = GlobOptions {
92 max_depth: None,
93 no_dirs: false,
94 no_files: false,
95 };
96 let matches = expand_path(path_str, base_path.clone(), options)?;
97 (matches, base_path)
98 } else {
99 // Non-glob path: check if it's a directory and list its contents
100 let normalized_path = path_str.trim_start_matches('/').trim_end_matches('/');
101 let target_path = base_path
102 .join(normalized_path)
103 .map_err(to_shell_err(call.arguments_span()))?;
104
105 let metadata = target_path.metadata().map_err(to_shell_err(span))?;
106 match metadata.file_type {
107 vfs::VfsFileType::Directory => {
108 // List directory contents
109 let entries = target_path.read_dir().map_err(to_shell_err(span))?;
110 let matches: Vec<String> = entries
111 .map(|e| {
112 // Build relative path from base_path
113 let entry_name = e.filename();
114 if normalized_path.is_empty() || normalized_path == "." {
115 entry_name
116 } else {
117 format!("{}/{}", normalized_path, entry_name)
118 }
119 })
120 .collect();
121 (matches, base_path)
122 }
123 vfs::VfsFileType::File => {
124 // Single file: return just this file (normalized, relative to base_path)
125 (vec![normalized_path.to_string()], base_path)
126 }
127 }
128 }
129 } else {
130 // No path: list current directory entries
131 let entries = pwd.read_dir().map_err(to_shell_err(span))?;
132 let matches: Vec<String> = entries.map(|e| e.filename()).collect();
133 (matches, pwd.clone())
134 };
135
136 let make_record = move |rel_path: &str| {
137 let full_path = base_path.join(rel_path).map_err(to_shell_err(span))?;
138 let metadata = full_path.metadata().map_err(to_shell_err(span))?;
139
140 // Filter hidden files if --all is not set
141 let filename = rel_path.split('/').last().unwrap_or(rel_path);
142 if filename.starts_with('.') && !all {
143 return Ok(None);
144 }
145
146 let type_str = match metadata.file_type {
147 vfs::VfsFileType::Directory => "dir",
148 vfs::VfsFileType::File => "file",
149 };
150
151 let mut record = Record::new();
152 let display_name = if full_paths {
153 full_path.as_str().to_string()
154 } else {
155 rel_path.to_string()
156 };
157 record.push("name", Value::string(display_name, span));
158 record.push("type", Value::string(type_str, span));
159 record.push("size", Value::filesize(metadata.len as i64, span));
160 let mut add_timestamp = |field: &str, timestamp: Option<SystemTime>| {
161 if let Some(dur) = timestamp.and_then(|s| s.duration_since(UNIX_EPOCH).ok()) {
162 record.push(
163 field,
164 Value::date(
165 chrono::DateTime::from_timestamp_nanos(dur.as_nanos() as i64)
166 .fixed_offset(),
167 span,
168 ),
169 );
170 } else {
171 record.push(field, Value::nothing(span));
172 }
173 };
174 add_timestamp("modified", metadata.modified);
175 if long {
176 add_timestamp("created", metadata.created);
177 add_timestamp("accessed", metadata.accessed);
178 }
179 Ok(Some(Value::record(record, span)))
180 };
181
182 let entries = matches.into_iter().flat_map(move |rel_path| {
183 make_record(&rel_path)
184 .transpose()
185 .map(|res| res.unwrap_or_else(|err| Value::error(err, span)))
186 });
187
188 let signals = engine_state.signals().clone();
189 Ok(PipelineData::list_stream(
190 ListStream::new(entries, span, signals.clone()),
191 Some(nu_protocol::PipelineMetadata {
192 data_source: nu_protocol::DataSource::Ls,
193 content_type: None,
194 custom: Record::new(),
195 }),
196 ))
197 }
198}