Skip to content

Commit e6a17d9

Browse files
committed
fix(tsort): achieve GNU compatibility with alphabetical DFS ordering and reverse successor processing
should fix tests/misc/tsort.pl
1 parent 5ffc01c commit e6a17d9

File tree

3 files changed

+133
-26
lines changed

3 files changed

+133
-26
lines changed

src/uu/tsort/src/tsort.rs

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use uucore::translate;
1717

1818
mod options {
1919
pub const FILE: &str = "file";
20+
pub const WARN: &str = "warn";
2021
}
2122

2223
#[derive(Debug, Error)]
@@ -49,11 +50,28 @@ impl UError for LoopNode<'_> {}
4950
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
5051
let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
5152

52-
let input = matches
53-
.get_one::<OsString>(options::FILE)
54-
.expect("Value is required by clap");
53+
// Check for extra operands
54+
let files: Vec<_> = matches
55+
.get_many::<OsString>(options::FILE)
56+
.map(|v| v.collect())
57+
.unwrap_or_default();
58+
59+
if files.len() > 1 {
60+
return Err(uucore::error::USimpleError::new(
61+
1,
62+
format!(
63+
"extra operand {}\nTry '{} --help' for more information.",
64+
files[1].to_string_lossy().quote(),
65+
uucore::util_name()
66+
),
67+
));
68+
}
69+
70+
let input = files.first().expect("Value is required by clap");
71+
// Accept -w/--warn flag for GNU compatibility (doesn't change behavior)
72+
let _warn = matches.get_flag(options::WARN);
5573

56-
let data = if input == "-" {
74+
let data = if input.to_string_lossy() == "-" {
5775
let stdin = std::io::stdin();
5876
std::io::read_to_string(stdin)?
5977
} else {
@@ -96,12 +114,20 @@ pub fn uu_app() -> Command {
96114
.override_usage(format_usage(&translate!("tsort-usage")))
97115
.about(translate!("tsort-about"))
98116
.infer_long_args(true)
117+
.arg(
118+
Arg::new(options::WARN)
119+
.short('w')
120+
.long("warn")
121+
.help("warn about cycles, but continue")
122+
.action(clap::ArgAction::SetTrue),
123+
)
99124
.arg(
100125
Arg::new(options::FILE)
101126
.default_value("-")
102127
.hide(true)
103128
.value_parser(clap::value_parser!(OsString))
104-
.value_hint(clap::ValueHint::FilePath),
129+
.value_hint(clap::ValueHint::FilePath)
130+
.num_args(0..),
105131
)
106132
}
107133

@@ -132,6 +158,7 @@ impl<'input> Node<'input> {
132158
struct Graph<'input> {
133159
name: String,
134160
nodes: HashMap<&'input str, Node<'input>>,
161+
insertion_order: Vec<&'input str>,
135162
}
136163

137164
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -145,14 +172,26 @@ impl<'input> Graph<'input> {
145172
Self {
146173
name,
147174
nodes: HashMap::default(),
175+
insertion_order: Vec::new(),
148176
}
149177
}
150178

151179
fn add_edge(&mut self, from: &'input str, to: &'input str) {
152-
let from_node = self.nodes.entry(from).or_default();
180+
// Track insertion order for deterministic output
181+
if let Entry::Vacant(e) = self.nodes.entry(from) {
182+
self.insertion_order.push(from);
183+
e.insert(Node::default());
184+
}
185+
186+
let from_node = self.nodes.get_mut(from).unwrap();
153187
if from != to {
154188
from_node.add_successor(to);
155-
let to_node = self.nodes.entry(to).or_default();
189+
190+
if let Entry::Vacant(e) = self.nodes.entry(to) {
191+
self.insertion_order.push(to);
192+
e.insert(Node::default());
193+
}
194+
let to_node = self.nodes.get_mut(to).unwrap();
156195
to_node.predecessor_count += 1;
157196
}
158197
}
@@ -178,23 +217,20 @@ impl<'input> Graph<'input> {
178217
})
179218
.collect();
180219

181-
// To make sure the resulting ordering is deterministic we
182-
// need to order independent nodes.
183-
//
184-
// FIXME: this doesn't comply entirely with the GNU coreutils
185-
// implementation.
220+
// Sort independent nodes alphabetically to match GNU coreutils behavior
186221
independent_nodes_queue.make_contiguous().sort_unstable();
187222

188223
while !self.nodes.is_empty() {
189224
// Get the next node (breaking any cycles necessary to do so).
190225
let v = self.find_next_node(&mut independent_nodes_queue);
191226
println!("{v}");
192227
if let Some(node_to_process) = self.nodes.remove(v) {
193-
for successor_name in node_to_process.successor_names {
228+
// Process successors in reverse order and add freed nodes to the back
229+
for successor_name in node_to_process.successor_names.iter().rev() {
194230
let successor_node = self.nodes.get_mut(successor_name).unwrap();
195231
successor_node.predecessor_count -= 1;
196232
if successor_node.predecessor_count == 0 {
197-
// If we find nodes without any other prerequisites, we add them to the queue.
233+
// Add to the back of the queue (FIFO)
198234
independent_nodes_queue.push_back(successor_name);
199235
}
200236
}
@@ -245,6 +281,8 @@ impl<'input> Graph<'input> {
245281
}
246282

247283
fn detect_cycle(&self) -> Vec<&'input str> {
284+
// Sort the nodes alphabetically to make this function deterministic
285+
// and match GNU coreutils behavior.
248286
let mut nodes: Vec<_> = self.nodes.keys().collect();
249287
nodes.sort_unstable();
250288

tests/by-util/test_tsort.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ fn test_multiple_arguments() {
7777
.arg("call_graph.txt")
7878
.arg("invalid_file")
7979
.fails()
80-
.stderr_contains("unexpected argument 'invalid_file' found");
80+
.stderr_contains("extra operand 'invalid_file'");
8181
}
8282

8383
#[test]
@@ -119,7 +119,7 @@ fn test_two_cycles() {
119119
new_ucmd!()
120120
.pipe_in("a b b c c b b d d b")
121121
.fails_with_code(1)
122-
.stdout_is("a\nb\nc\nd\n")
122+
.stdout_is("a\nb\nd\nc\n")
123123
.stderr_is("tsort: -: input contains a loop:\ntsort: b\ntsort: c\ntsort: -: input contains a loop:\ntsort: b\ntsort: d\n");
124124
}
125125

@@ -153,3 +153,72 @@ fn test_loop_for_iterative_dfs_correctness() {
153153
.fails_with_code(1)
154154
.stderr_contains("tsort: -: input contains a loop:\ntsort: B\ntsort: C");
155155
}
156+
157+
#[test]
158+
fn test_warn_flag_accepted() {
159+
// Test that -w/--warn flag is accepted without error
160+
// This is for GNU test suite compatibility (cycle-3 test uses -w)
161+
// The flag doesn't change behavior, just needs to be parseable
162+
new_ucmd!()
163+
.arg("-w")
164+
.pipe_in("a b\nb c\nc a")
165+
.fails_with_code(1)
166+
.stderr_contains("input contains a loop:");
167+
}
168+
169+
#[test]
170+
fn test_warn_flag_long() {
171+
// Test that --warn is also accepted
172+
new_ucmd!()
173+
.arg("--warn")
174+
.pipe_in("a b\nb c")
175+
.succeeds()
176+
.stdout_is("a\nb\nc\n");
177+
}
178+
179+
#[test]
180+
fn test_cycle_detection_alphabetical_order() {
181+
// Verify cycles are reported starting from alphabetically first node
182+
// Input: t->b, t->s, s->t creates cycle [s,t]
183+
// Cycle should be detected starting from 's' (alphabetically first)
184+
new_ucmd!()
185+
.pipe_in("t b t s s t")
186+
.fails_with_code(1)
187+
.stdout_is("s\nt\nb\n")
188+
.stderr_contains("tsort: s\ntsort: t");
189+
}
190+
191+
#[test]
192+
fn test_tree_branching_order() {
193+
// Test the interleaving behavior when dependency chains branch
194+
// Input creates: a→b→c→d→e→f→g
195+
// ↓
196+
// x→y→z
197+
// Should output: a, b, c, x, d, y, e, z, f, g
198+
new_ucmd!()
199+
.pipe_in("a b b c c d d e e f f g c x x y y z")
200+
.succeeds()
201+
.stdout_is("a\nb\nc\nx\nd\ny\ne\nz\nf\ng\n");
202+
}
203+
204+
#[test]
205+
fn test_multiple_cycles_alphabetical() {
206+
// Test multiple cycles are detected in alphabetical order
207+
// Input: a→b→a (cycle 1), a→c→a (cycle 2)
208+
// Both cycles should start from 'a' (alphabetically first in each)
209+
new_ucmd!()
210+
.pipe_in("a a a b a c c a b a")
211+
.fails_with_code(1)
212+
.stderr_contains("tsort: a\ntsort: b")
213+
.stderr_contains("tsort: a\ntsort: c");
214+
}
215+
216+
#[test]
217+
fn test_alphabetical_frontier_ordering() {
218+
// Test that initial independent nodes are processed alphabetically
219+
// All nodes have no dependencies, should output alphabetically
220+
new_ucmd!()
221+
.pipe_in("d d c c b b a a")
222+
.succeeds()
223+
.stdout_is("a\nb\nc\nd\n");
224+
}
Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
main
2-
parse_options
3-
tail_file
42
tail_forever
5-
tail
3+
tail_file
4+
parse_options
65
recheck
6+
tail
77
write_header
8-
tail_lines
9-
tail_bytes
108
pretty_name
11-
start_lines
12-
file_lines
13-
pipe_lines
14-
xlseek
15-
start_bytes
9+
tail_bytes
10+
tail_lines
1611
pipe_bytes
12+
start_bytes
13+
xlseek
14+
pipe_lines
15+
file_lines
16+
start_lines
1717
dump_remainder

0 commit comments

Comments
 (0)